diff --git a/.env.example b/.env.example index f372984..0660afd 100644 --- a/.env.example +++ b/.env.example @@ -21,9 +21,15 @@ GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback # Frontend FRONTEND_ORIGIN=http://localhost:3000 -# S3 (Mock implementation - update when S3 is ready) -AWS_S3_BUCKET=waffice-uploads -AWS_S3_REGION=ap-northeast-2 +# OCI Object Storage +OCI_NAMESPACE= +OCI_BUCKET= +OCI_REGION=ap-chuncheon-1 +# Use instance_principal in OKE. Use config_file for local development. +OCI_OBJECT_STORAGE_AUTH=config_file +OCI_CONFIG_FILE= +OCI_CONFIG_PROFILE=DEFAULT +OCI_PUBLIC_BASE_URL= # AWS Secrets Manager (for dev/prod environments) # AWS_SECRET_NAME=waffice-db-credentials diff --git a/app/exceptions.py b/app/exceptions.py index 67b623f..1c35e19 100644 --- a/app/exceptions.py +++ b/app/exceptions.py @@ -155,3 +155,24 @@ class RequestAlreadyProcessedError(AppError): def __init__(self, message: str = "Request has already been processed"): super().__init__("REQUEST_ALREADY_PROCESSED", message, 400) + + +class InvalidProfileImageError(AppError): + """Invalid profile image content type""" + + def __init__(self, message: str = "Only jpeg, png, or webp images are allowed"): + super().__init__("INVALID_PROFILE_IMAGE", message, 400) + + +class ProfileImageTooLargeError(AppError): + """Profile image is too large""" + + def __init__(self, message: str = "Profile image must be 5MB or smaller"): + super().__init__("PROFILE_IMAGE_TOO_LARGE", message, 413) + + +class ObjectStorageError(AppError): + """Object storage operation failed""" + + def __init__(self, message: str = "Object storage operation failed"): + super().__init__("OBJECT_STORAGE_ERROR", message, 502) diff --git a/app/main.py b/app/main.py index b483f32..be994a2 100644 --- a/app/main.py +++ b/app/main.py @@ -75,13 +75,15 @@ async def app_error_handler(request: Request, exc: AppError): # ============================== # ROUTERS # ============================== -from app.routes import auth, projects, requests, upload, users +from app.routes import auth, profile_image, projects, requests, users app.include_router(auth.router, prefix="/auth", tags=["Auth"]) app.include_router(users.router, prefix="/users", tags=["Users"]) app.include_router(projects.router, prefix="/projects", tags=["Projects"]) app.include_router(requests.router, prefix="/requests", tags=["Requests"]) -app.include_router(upload.router, prefix="/upload", tags=["Upload"]) +app.include_router( + profile_image.router, prefix="/profile-image", tags=["Profile Image"] +) # Dev-only auth endpoints (only included in local/dev environments) if ENV in ("local", "dev"): diff --git a/app/routes/__init__.py b/app/routes/__init__.py index ec2a398..dd2832e 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,3 +1,3 @@ -from app.routes import auth, projects, upload, users +from app.routes import auth, profile_image, projects, users -__all__ = ["auth", "users", "projects", "upload"] +__all__ = ["auth", "users", "projects", "profile_image"] diff --git a/app/routes/profile_image.py b/app/routes/profile_image.py new file mode 100644 index 0000000..ee14ae8 --- /dev/null +++ b/app/routes/profile_image.py @@ -0,0 +1,36 @@ +from fastapi import APIRouter, Depends, File, UploadFile +from sqlalchemy.orm import Session + +from app.config.database import get_db +from app.deps.auth import require_associate +from app.exceptions import InvalidProfileImageError, ProfileImageTooLargeError +from app.models import User +from app.schemas import Response, UserDetail +from app.services import OCIObjectStorageService, UserService + +router = APIRouter() + +MAX_PROFILE_IMAGE_SIZE = 5 * 1024 * 1024 +PROFILE_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} + + +@router.post("/upload", response_model=Response[UserDetail]) +async def upload_profile_image( + file: UploadFile = File(...), + current_user: User = Depends(require_associate), + db: Session = Depends(get_db), +): + if file.content_type not in PROFILE_IMAGE_TYPES: + raise InvalidProfileImageError() + + body = await file.read() + if len(body) > MAX_PROFILE_IMAGE_SIZE: + raise ProfileImageTooLargeError() + + storage = OCIObjectStorageService() + old_url = current_user.avatar_url + avatar_url = storage.upload_profile_image(current_user.id, file, body) + updated_user = UserService.update(db, current_user, avatar_url=avatar_url) + storage.delete_profile_image(current_user.id, old_url) + + return Response(ok=True, data=updated_user) diff --git a/app/routes/upload.py b/app/routes/upload.py deleted file mode 100644 index 197c741..0000000 --- a/app/routes/upload.py +++ /dev/null @@ -1,28 +0,0 @@ -from fastapi import APIRouter, Depends - -from app.deps.auth import require_associate -from app.models import User -from app.schemas import PresignedUrlRequest, PresignedUrlResponse, Response -from app.services import S3Service - -router = APIRouter() - - -@router.post("/presigned-url", response_model=Response[PresignedUrlResponse]) -async def get_presigned_url( - request: PresignedUrlRequest, - _user: User = Depends(require_associate), -): - """ - Get presigned URL for S3 upload (requires associate or higher). - Currently returns mock URLs - actual S3 integration pending. - """ - s3_service = S3Service() - urls = s3_service.generate_presigned_url(request.filename, request.content_type) - - return Response( - ok=True, - data=PresignedUrlResponse( - upload_url=urls["upload_url"], file_url=urls["file_url"] - ), - ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index be80cb5..e30342a 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -40,7 +40,6 @@ RequestScope, RequestStatusFilter, ) -from app.schemas.upload import PresignedUrlRequest, PresignedUrlResponse from app.schemas.user import ( ApproveRequest, ProfileUpdateRequest, @@ -78,8 +77,6 @@ "ProjectBrief", "ProjectDetail", "AuditLogDetail", - "PresignedUrlRequest", - "PresignedUrlResponse", "ActivityCreateRequest", "ActivityUpdateRequest", "ActivityDetail", diff --git a/app/schemas/upload.py b/app/schemas/upload.py deleted file mode 100644 index d56a116..0000000 --- a/app/schemas/upload.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import BaseModel - - -class PresignedUrlRequest(BaseModel): - filename: str - content_type: str - - -class PresignedUrlResponse(BaseModel): - upload_url: str - file_url: str diff --git a/app/services/__init__.py b/app/services/__init__.py index f26dac7..2aa7701 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,9 +1,9 @@ from app.services.activity import ActivityService from app.services.audit_log import AuditLogService from app.services.member import CannotRemoveSelfError, LastLeaderError, MemberService +from app.services.object_storage import OCIObjectStorageService from app.services.project import ProjectService from app.services.request import RequestService -from app.services.s3 import S3Service from app.services.user import UserService __all__ = [ @@ -11,7 +11,7 @@ "AuditLogService", "ProjectService", "MemberService", - "S3Service", + "OCIObjectStorageService", "ActivityService", "RequestService", "LastLeaderError", diff --git a/app/services/object_storage.py b/app/services/object_storage.py new file mode 100644 index 0000000..5238262 --- /dev/null +++ b/app/services/object_storage.py @@ -0,0 +1,98 @@ +import os +from urllib.parse import quote, unquote, urlparse +from uuid import uuid4 + +from fastapi import UploadFile + +from app.exceptions import ObjectStorageError + +PROFILE_IMAGE_EXTENSIONS = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +class OCIObjectStorageService: + def __init__(self): + try: + import oci + except ImportError: + raise ObjectStorageError("oci package is not installed") + + self.oci = oci + self.namespace = os.getenv("OCI_NAMESPACE", "") + self.bucket = os.getenv("OCI_BUCKET", "") + self.region = os.getenv("OCI_REGION", "") + if not all((self.namespace, self.bucket, self.region)): + raise ObjectStorageError("OCI object storage is not configured") + + auth_mode = os.getenv("OCI_OBJECT_STORAGE_AUTH", "instance_principal") + try: + if auth_mode == "config_file": + config_file = os.getenv("OCI_CONFIG_FILE", "~/.oci/config") + profile = os.getenv("OCI_CONFIG_PROFILE", "DEFAULT") + config = ( + oci.config.from_file(config_file, profile) + if config_file + else oci.config.from_file(profile_name=profile) + ) + self.client = oci.object_storage.ObjectStorageClient(config) + elif auth_mode == "resource_principal": + signer = oci.auth.signers.get_resource_principals_signer() + self.client = oci.object_storage.ObjectStorageClient( + {"region": self.region}, signer=signer + ) + elif auth_mode == "instance_principal": + signer = oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + self.client = oci.object_storage.ObjectStorageClient( + {"region": self.region}, signer=signer + ) + else: + raise ValueError(f"Unsupported OCI authentication mode: {auth_mode}") + except Exception as exc: + raise ObjectStorageError("OCI object storage is not configured") from exc + + def upload_profile_image(self, user_id: int, file: UploadFile, body: bytes) -> str: + object_name = ( + f"profiles/{user_id}/{uuid4()}{PROFILE_IMAGE_EXTENSIONS[file.content_type]}" + ) + try: + self.client.put_object( + self.namespace, + self.bucket, + object_name, + body, + content_type=file.content_type, + ) + except Exception as exc: + raise ObjectStorageError("Failed to upload profile image") from exc + return self.public_url(object_name) + + def delete_profile_image(self, user_id: int, url: str | None) -> None: + object_name = self.object_name_from_url(url) + if not object_name or not object_name.startswith(f"profiles/{user_id}/"): + return + try: + self.client.delete_object(self.namespace, self.bucket, object_name) + except Exception: + pass + + def public_url(self, object_name: str) -> str: + base_url = ( + os.getenv("OCI_PUBLIC_BASE_URL") + or f"https://objectstorage.{self.region}.oraclecloud.com/n/{self.namespace}/b/{self.bucket}/o" + ).rstrip("/") + return f"{base_url}/{quote(object_name, safe='')}" + + def object_name_from_url(self, url: str | None) -> str | None: + if not url: + return None + path = urlparse(url).path + marker = f"/n/{self.namespace}/b/{self.bucket}/o/" + if marker in path: + return unquote(path.split(marker, 1)[1]) + base_url = os.getenv("OCI_PUBLIC_BASE_URL", "").rstrip("/") + if base_url and url.startswith(f"{base_url}/"): + return unquote(url[len(base_url) + 1 :]) + return None diff --git a/app/services/s3.py b/app/services/s3.py deleted file mode 100644 index 8e7e5ac..0000000 --- a/app/services/s3.py +++ /dev/null @@ -1,30 +0,0 @@ -from uuid import uuid4 - - -class S3Service: - def __init__(self, bucket: str = "waffice-uploads", region: str = "ap-northeast-2"): - """ - Mock S3 Service for generating presigned URLs. - - TODO: Replace with actual boto3 implementation when S3 is ready. - """ - self.bucket = bucket - self.region = region - - def generate_presigned_url( - self, filename: str, content_type: str - ) -> dict[str, str]: - """ - Mock implementation for presigned URL generation. - - Returns: - dict with 'upload_url' and 'file_url' - - Note: The upload_url is a placeholder and won't actually work. - Actual S3 integration will require boto3 and proper AWS credentials. - """ - mock_key = f"profiles/{uuid4()}/{filename}" - return { - "upload_url": f"https://mock-s3-upload.example.com/{mock_key}", - "file_url": f"https://{self.bucket}.s3.{self.region}.amazonaws.com/{mock_key}", - } diff --git a/pyproject.toml b/pyproject.toml index 4b24795..cdf5863 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "fastapi[standard]>=0.117.1", "google-auth>=2.48.0", "itsdangerous>=2.2.0", + "oci>=2.164.0", "openpyxl>=3.1.0", "pydantic>=2.11.9", "pymysql>=1.1.2", diff --git a/tests/test_member_detail.py b/tests/test_member_detail.py index 19c9b2c..9360474 100644 --- a/tests/test_member_detail.py +++ b/tests/test_member_detail.py @@ -461,3 +461,102 @@ def test_invalid_notification_channel_returns_422( headers={"Authorization": f"Bearer {regular_token}"}, ) assert response.status_code == 422 + + +class TestProfileImageUpload: + def test_public_url_uses_default_when_base_url_is_empty( + self, monkeypatch: pytest.MonkeyPatch + ): + """Empty OCI_PUBLIC_BASE_URL should not produce a relative URL.""" + from app.services.object_storage import OCIObjectStorageService + + monkeypatch.setenv("OCI_PUBLIC_BASE_URL", "") + storage = OCIObjectStorageService.__new__(OCIObjectStorageService) + storage.region = "ap-chuncheon-1" + storage.namespace = "namespace" + storage.bucket = "bucket" + + assert storage.public_url("profiles/1/avatar.png") == ( + "https://objectstorage.ap-chuncheon-1.oraclecloud.com/n/namespace/b/bucket/o/" + "profiles%2F1%2Favatar.png" + ) + + def test_user_can_upload_profile_image( + self, + client: TestClient, + regular_token: str, + regular_user: User, + monkeypatch: pytest.MonkeyPatch, + ): + """User can upload a profile image.""" + deleted = [] + + class FakeStorage: + def upload_profile_image(self, user_id, file, body): + assert user_id == regular_user.id + assert file.content_type == "image/png" + assert body == b"image" + return "https://cdn.example.com/profiles/1/new.png" + + def delete_profile_image(self, user_id, url): + deleted.append((user_id, url)) + + monkeypatch.setattr( + "app.routes.profile_image.OCIObjectStorageService", FakeStorage + ) + + response = client.post( + "/profile-image/upload", + files={"file": ("avatar.png", b"image", "image/png")}, + headers={"Authorization": f"Bearer {regular_token}"}, + ) + + assert response.status_code == 200 + assert response.json()["data"]["avatar_url"] == ( + "https://cdn.example.com/profiles/1/new.png" + ) + assert deleted == [(regular_user.id, None)] + + def test_upload_rejects_non_image( + self, + client: TestClient, + regular_token: str, + ): + """Profile image upload accepts only configured image types.""" + response = client.post( + "/profile-image/upload", + files={"file": ("avatar.txt", b"text", "text/plain")}, + headers={"Authorization": f"Bearer {regular_token}"}, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "INVALID_PROFILE_IMAGE" + + def test_upload_rejects_large_image( + self, + client: TestClient, + regular_token: str, + ): + """Profile image upload rejects files larger than 5MB.""" + response = client.post( + "/profile-image/upload", + files={"file": ("avatar.png", b"0" * (5 * 1024 * 1024 + 1), "image/png")}, + headers={"Authorization": f"Bearer {regular_token}"}, + ) + + assert response.status_code == 413 + assert response.json()["error"] == "PROFILE_IMAGE_TOO_LARGE" + + def test_pending_user_cannot_upload_profile_image( + self, + client: TestClient, + pending_token: str, + ): + """Pending users cannot upload profile images.""" + response = client.post( + "/profile-image/upload", + files={"file": ("avatar.png", b"image", "image/png")}, + headers={"Authorization": f"Bearer {pending_token}"}, + ) + + assert response.status_code == 403 diff --git a/tests/test_object_storage.py b/tests/test_object_storage.py new file mode 100644 index 0000000..943359c --- /dev/null +++ b/tests/test_object_storage.py @@ -0,0 +1,121 @@ +import sys +from types import SimpleNamespace + +import pytest + +from app.exceptions import ObjectStorageError +from app.services.object_storage import OCIObjectStorageService + + +@pytest.fixture +def configured_oci_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("OCI_NAMESPACE", "namespace") + monkeypatch.setenv("OCI_BUCKET", "bucket") + monkeypatch.setenv("OCI_REGION", "ap-chuncheon-1") + + +def test_instance_principal_authentication( + monkeypatch: pytest.MonkeyPatch, configured_oci_env +): + signer = object() + calls = {} + + class FakeObjectStorageClient: + def __init__(self, config, signer=None): + calls["config"] = config + calls["signer"] = signer + + fake_oci = SimpleNamespace( + auth=SimpleNamespace( + signers=SimpleNamespace( + InstancePrincipalsSecurityTokenSigner=lambda: signer, + get_resource_principals_signer=lambda: None, + ) + ), + config=SimpleNamespace(from_file=lambda *args, **kwargs: {}), + object_storage=SimpleNamespace(ObjectStorageClient=FakeObjectStorageClient), + ) + monkeypatch.setitem(sys.modules, "oci", fake_oci) + monkeypatch.setenv("OCI_OBJECT_STORAGE_AUTH", "instance_principal") + + OCIObjectStorageService() + + assert calls == { + "config": {"region": "ap-chuncheon-1"}, + "signer": signer, + } + + +def test_config_file_authentication( + monkeypatch: pytest.MonkeyPatch, configured_oci_env +): + config = {"region": "ap-chuncheon-1"} + calls = {} + + def from_file(config_file, profile): + calls["from_file"] = (config_file, profile) + return config + + class FakeObjectStorageClient: + def __init__(self, client_config): + calls["client_config"] = client_config + + fake_oci = SimpleNamespace( + auth=SimpleNamespace(signers=SimpleNamespace()), + config=SimpleNamespace(from_file=from_file), + object_storage=SimpleNamespace(ObjectStorageClient=FakeObjectStorageClient), + ) + monkeypatch.setitem(sys.modules, "oci", fake_oci) + monkeypatch.setenv("OCI_OBJECT_STORAGE_AUTH", "config_file") + monkeypatch.setenv("OCI_CONFIG_FILE", "/tmp/oci-config") + monkeypatch.setenv("OCI_CONFIG_PROFILE", "WAFFICE") + + OCIObjectStorageService() + + assert calls == { + "from_file": ("/tmp/oci-config", "WAFFICE"), + "client_config": config, + } + + +def test_resource_principal_authentication( + monkeypatch: pytest.MonkeyPatch, configured_oci_env +): + signer = object() + calls = {} + + class FakeObjectStorageClient: + def __init__(self, config, signer=None): + calls["config"] = config + calls["signer"] = signer + + fake_oci = SimpleNamespace( + auth=SimpleNamespace( + signers=SimpleNamespace( + get_resource_principals_signer=lambda: signer, + ) + ), + object_storage=SimpleNamespace(ObjectStorageClient=FakeObjectStorageClient), + ) + monkeypatch.setitem(sys.modules, "oci", fake_oci) + monkeypatch.setenv("OCI_OBJECT_STORAGE_AUTH", "resource_principal") + + OCIObjectStorageService() + + assert calls == { + "config": {"region": "ap-chuncheon-1"}, + "signer": signer, + } + + +def test_unsupported_authentication_mode( + monkeypatch: pytest.MonkeyPatch, configured_oci_env +): + fake_oci = SimpleNamespace() + monkeypatch.setitem(sys.modules, "oci", fake_oci) + monkeypatch.setenv("OCI_OBJECT_STORAGE_AUTH", "unsupported") + + with pytest.raises(ObjectStorageError) as exc_info: + OCIObjectStorageService() + + assert isinstance(exc_info.value.__cause__, ValueError) diff --git a/uv.lock b/uv.lock index a6e2e82..5502008 100644 --- a/uv.lock +++ b/uv.lock @@ -269,6 +269,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] +[[package]] +name = "circuitbreaker" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/ac/de7a92c4ed39cba31fe5ad9203b76a25ca67c530797f6bb420fff5f65ccb/circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084", size = 10787, upload-time = "2025-03-31T08:12:08.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/34/15f08edd4628f65217de1fc3c1a27c82e46fe357d60c217fc9881e12ebcc/circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1", size = 7737, upload-time = "2025-03-31T08:12:07.802Z" }, +] + [[package]] name = "click" version = "8.3.0" @@ -290,6 +299,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "crc32c" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/4c/4e40cc26347ac8254d3f25b9f94710b8e8df24ee4dddc1ba41907a88a94d/crc32c-2.7.1.tar.gz", hash = "sha256:f91b144a21eef834d64178e01982bb9179c354b3e9e5f4c803b0e5096384968c", size = 45712, upload-time = "2024-09-24T06:20:17.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/8e/2f37f46368bbfd50edfc11b96f0aa135699034b1b020966c70ebaff3463b/crc32c-2.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19e03a50545a3ef400bd41667d5525f71030488629c57d819e2dd45064f16192", size = 49672, upload-time = "2024-09-24T06:18:18.032Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b8/e52f7c4b045b871c2984d70f37c31d4861b533a8082912dfd107a96cf7c1/crc32c-2.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c03286b1e5ce9bed7090084f206aacd87c5146b4b10de56fe9e86cbbbf851cf", size = 37155, upload-time = "2024-09-24T06:18:19.373Z" }, + { url = "https://files.pythonhosted.org/packages/25/ee/0cfa82a68736697f3c7e435ba658c2ef8c997f42b89f6ab4545efe1b2649/crc32c-2.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ebbf144a1a56a532b353e81fa0f3edca4f4baa1bf92b1dde2c663a32bb6a15", size = 35372, upload-time = "2024-09-24T06:18:20.983Z" }, + { url = "https://files.pythonhosted.org/packages/aa/92/c878aaba81c431fcd93a059e9f6c90db397c585742793f0bf6e0c531cc67/crc32c-2.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96b794fd11945298fdd5eb1290a812efb497c14bc42592c5c992ca077458eeba", size = 54879, upload-time = "2024-09-24T06:18:23.085Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f5/ab828ab3907095e06b18918408748950a9f726ee2b37be1b0839fb925ee1/crc32c-2.7.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df7194dd3c0efb5a21f5d70595b7a8b4fd9921fbbd597d6d8e7a11eca3e2d27", size = 52588, upload-time = "2024-09-24T06:18:24.463Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2b/9e29e9ac4c4213d60491db09487125db358cd9263490fbadbd55e48fbe03/crc32c-2.7.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d698eec444b18e296a104d0b9bb6c596c38bdcb79d24eba49604636e9d747305", size = 53674, upload-time = "2024-09-24T06:18:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/79/ed/df3c4c14bf1b29f5c9b52d51fb6793e39efcffd80b2941d994e8f7f5f688/crc32c-2.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e07cf10ef852d219d179333fd706d1c415626f1f05e60bd75acf0143a4d8b225", size = 54691, upload-time = "2024-09-24T06:18:26.578Z" }, + { url = "https://files.pythonhosted.org/packages/0c/47/4917af3c9c1df2fff28bbfa6492673c9adeae5599dcc207bbe209847489c/crc32c-2.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d2a051f296e6e92e13efee3b41db388931cdb4a2800656cd1ed1d9fe4f13a086", size = 52896, upload-time = "2024-09-24T06:18:28.174Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6f/26fc3dda5835cda8f6cd9d856afe62bdeae428de4c34fea200b0888e8835/crc32c-2.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a1738259802978cdf428f74156175da6a5fdfb7256f647fdc0c9de1bc6cd7173", size = 53554, upload-time = "2024-09-24T06:18:29.104Z" }, + { url = "https://files.pythonhosted.org/packages/56/3e/6f39127f7027c75d130c0ba348d86a6150dff23761fbc6a5f71659f4521e/crc32c-2.7.1-cp311-cp311-win32.whl", hash = "sha256:f7786d219a1a1bf27d0aa1869821d11a6f8e90415cfffc1e37791690d4a848a1", size = 38370, upload-time = "2024-09-24T06:18:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/c9/fb/1587c2705a3a47a3d0067eecf9a6fec510761c96dec45c7b038fb5c8ff46/crc32c-2.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:887f6844bb3ad35f0778cd10793ad217f7123a5422e40041231b8c4c7329649d", size = 39795, upload-time = "2024-09-24T06:18:31.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/02/998dc21333413ce63fe4c1ca70eafe61ca26afc7eb353f20cecdb77d614e/crc32c-2.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f7d1c4e761fe42bf856130daf8b2658df33fe0ced3c43dadafdfeaa42b57b950", size = 49568, upload-time = "2024-09-24T06:18:32.425Z" }, + { url = "https://files.pythonhosted.org/packages/9c/3e/e3656bfa76e50ef87b7136fef2dbf3c46e225629432fc9184fdd7fd187ff/crc32c-2.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:73361c79a6e4605204457f19fda18b042a94508a52e53d10a4239da5fb0f6a34", size = 37019, upload-time = "2024-09-24T06:18:34.097Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7d/5ff9904046ad15a08772515db19df43107bf5e3901a89c36a577b5f40ba0/crc32c-2.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:afd778fc8ac0ed2ffbfb122a9aa6a0e409a8019b894a1799cda12c01534493e0", size = 35373, upload-time = "2024-09-24T06:18:35.02Z" }, + { url = "https://files.pythonhosted.org/packages/4d/41/4aedc961893f26858ab89fc772d0eaba91f9870f19eaa933999dcacb94ec/crc32c-2.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ef661b34e9f25991fface7f9ad85e81bbc1b3fe3b916fd58c893eabe2fa0b8", size = 54675, upload-time = "2024-09-24T06:18:35.954Z" }, + { url = "https://files.pythonhosted.org/packages/d6/63/8cabf09b7e39b9fec8f7010646c8b33057fc8d67e6093b3cc15563d23533/crc32c-2.7.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:571aa4429444b5d7f588e4377663592145d2d25eb1635abb530f1281794fc7c9", size = 52386, upload-time = "2024-09-24T06:18:36.896Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/13576941bf7cf95026abae43d8427c812c0054408212bf8ed490eda846b0/crc32c-2.7.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c02a3bd67dea95cdb25844aaf44ca2e1b0c1fd70b287ad08c874a95ef4bb38db", size = 53495, upload-time = "2024-09-24T06:18:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/55ffb26d0517d2d6c6f430ce2ad36ae7647c995c5bfd7abce7f32bb2bad1/crc32c-2.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99d17637c4867672cb8adeea007294e3c3df9d43964369516cfe2c1f47ce500a", size = 54456, upload-time = "2024-09-24T06:18:39.051Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1a/5562e54cb629ecc5543d3604dba86ddfc7c7b7bf31d64005b38a00d31d31/crc32c-2.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4a400ac3c69a32e180d8753fd7ec7bccb80ade7ab0812855dce8a208e72495f", size = 52647, upload-time = "2024-09-24T06:18:40.021Z" }, + { url = "https://files.pythonhosted.org/packages/48/ec/ce4138eaf356cd9aae60bbe931755e5e0151b3eca5f491fce6c01b97fd59/crc32c-2.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:588587772e55624dd9c7a906ec9e8773ae0b6ac5e270fc0bc84ee2758eba90d5", size = 53332, upload-time = "2024-09-24T06:18:40.925Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/144b42cd838a901175a916078781cb2c3c9f977151c9ba085aebd6d15b22/crc32c-2.7.1-cp312-cp312-win32.whl", hash = "sha256:9f14b60e5a14206e8173dd617fa0c4df35e098a305594082f930dae5488da428", size = 38371, upload-time = "2024-09-24T06:18:42.711Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c4/7929dcd5d9b57db0cce4fe6f6c191049380fc6d8c9b9f5581967f4ec018e/crc32c-2.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:7c810a246660a24dc818047dc5f89c7ce7b2814e1e08a8e99993f4103f7219e8", size = 39805, upload-time = "2024-09-24T06:18:43.6Z" }, + { url = "https://files.pythonhosted.org/packages/bf/98/1a6d60d5b3b5edc8382777b64100343cb4aa6a7e172fae4a6cfcb8ebbbd9/crc32c-2.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:24949bffb06fc411cc18188d33357923cb935273642164d0bb37a5f375654169", size = 49567, upload-time = "2024-09-24T06:18:44.485Z" }, + { url = "https://files.pythonhosted.org/packages/4f/56/0dd652d4e950e6348bbf16b964b3325e4ad8220470774128fc0b0dd069cb/crc32c-2.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d5d326e7e118d4fa60187770d86b66af2fdfc63ce9eeb265f0d3e7d49bebe0b", size = 37018, upload-time = "2024-09-24T06:18:45.434Z" }, + { url = "https://files.pythonhosted.org/packages/47/02/2bd65fdef10139b6a802d83a7f966b7750fe5ffb1042f7cbe5dbb6403869/crc32c-2.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba110df60c64c8e2d77a9425b982a520ccdb7abe42f06604f4d98a45bb1fff62", size = 35374, upload-time = "2024-09-24T06:18:46.304Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0d/3e797d1ed92d357a6a4c5b41cea15a538b27a8fdf18c7863747eb50b73ad/crc32c-2.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c277f9d16a3283e064d54854af0976b72abaa89824955579b2b3f37444f89aae", size = 54641, upload-time = "2024-09-24T06:18:47.207Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d3/4ddeef755caaa75680c559562b6c71f5910fee4c4f3a2eb5ea8b57f0e48c/crc32c-2.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881af0478a01331244e27197356929edbdeaef6a9f81b5c6bacfea18d2139289", size = 52338, upload-time = "2024-09-24T06:18:49.31Z" }, + { url = "https://files.pythonhosted.org/packages/01/cf/32f019be5de9f6e180926a50ee5f08648e686c7d9a59f2c5d0806a77b1c7/crc32c-2.7.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:724d5ff4d29ff093a983ae656be3307093706d850ea2a233bf29fcacc335d945", size = 53447, upload-time = "2024-09-24T06:18:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/92f3f62f3bafe8f7ab4af7bfb7246dc683fd11ec0d6dfb73f91e09079f69/crc32c-2.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2416c4d88696ac322632555c0f81ab35e15f154bc96055da6cf110d642dbc10", size = 54484, upload-time = "2024-09-24T06:18:51.311Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/113a50f8781f76af5ac65ffdb907e72bddbe974de8e02247f0d58bc48040/crc32c-2.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:60254251b88ec9b9795215f0f9ec015a6b5eef8b2c5fba1267c672d83c78fc02", size = 52703, upload-time = "2024-09-24T06:18:52.488Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/309229e9acda8cf36a8ff4061d70b54d905f79b7037e16883ce6590a24ab/crc32c-2.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:edefc0e46f3c37372183f70338e5bdee42f6789b62fcd36ec53aa933e9dfbeaf", size = 53367, upload-time = "2024-09-24T06:18:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/6c6324d920396e1bd9f3efbe8753da071be0ca52bd22d6c82d446b8d6975/crc32c-2.7.1-cp313-cp313-win32.whl", hash = "sha256:813af8111218970fe2adb833c5e5239f091b9c9e76f03b4dd91aaba86e99b499", size = 38377, upload-time = "2024-09-24T06:18:54.487Z" }, + { url = "https://files.pythonhosted.org/packages/db/a0/f01ccfab538db07ef3f6b4ede46357ff147a81dd4f3c59ca6a34c791a549/crc32c-2.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:7d9ede7be8e4ec1c9e90aaf6884decbeef10e3473e6ddac032706d710cab5888", size = 39803, upload-time = "2024-09-24T06:18:55.419Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/61dcae7568b33acfde70c9d651c7d891c0c578c39cc049107c1cf61f1367/crc32c-2.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db9ac92294284b22521356715784b91cc9094eee42a5282ab281b872510d1831", size = 49386, upload-time = "2024-09-24T06:18:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f1/80f17c089799ab2b4c247443bdd101d6ceda30c46d7f193e16b5ca29c5a0/crc32c-2.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8fcd7f2f29a30dc92af64a9ee3d38bde0c82bd20ad939999427aac94bbd87373", size = 36937, upload-time = "2024-09-24T06:18:57.77Z" }, + { url = "https://files.pythonhosted.org/packages/63/42/5fcfc71a3de493d920fd2590843762a2749981ea56b802b380e5df82309d/crc32c-2.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5c056ef043393085523e149276a7ce0cb534b872e04f3e20d74d9a94a75c0ad7", size = 35292, upload-time = "2024-09-24T06:18:58.676Z" }, + { url = "https://files.pythonhosted.org/packages/03/de/fef962e898a953558fe1c55141644553e84ef4190693a31244c59a0856c7/crc32c-2.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03a92551a343702629af91f78d205801219692b6909f8fa126b830e332bfb0e0", size = 54223, upload-time = "2024-09-24T06:18:59.675Z" }, + { url = "https://files.pythonhosted.org/packages/21/14/fceca1a6f45c0a1814fe8602a65657b75c27425162445925ba87438cad6b/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb9424ec1a8ca54763155a703e763bcede82e6569fe94762614bb2de1412d4e1", size = 51588, upload-time = "2024-09-24T06:19:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/13/3b/13d40a7dfbf9ef05c84a0da45544ee72080dca4ce090679e5105689984bd/crc32c-2.7.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88732070f6175530db04e0bb36880ac45c33d49f8ac43fa0e50cfb1830049d23", size = 52678, upload-time = "2024-09-24T06:19:02.661Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/65ffc4fb9fa60ff6714eeb50a92284a4525e5943f0b040b572c0c76368c1/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57a20dfc27995f568f64775eea2bbb58ae269f1a1144561df5e4a4955f79db32", size = 53847, upload-time = "2024-09-24T06:19:03.705Z" }, + { url = "https://files.pythonhosted.org/packages/24/71/938e926085b7288da052db7c84416f3ce25e71baf7ab5b63824c7bcb6f22/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f7186d098bfd2cff25eac6880b7c7ad80431b90610036131c1c7dd0eab42a332", size = 51860, upload-time = "2024-09-24T06:19:04.726Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d8/4526d5380189d6f2fa27256c204100f30214fe402f47cf6e9fb9a91ab890/crc32c-2.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:55a77e29a265418fa34bef15bd0f2c60afae5348988aaf35ed163b4bbf93cf37", size = 52508, upload-time = "2024-09-24T06:19:05.731Z" }, + { url = "https://files.pythonhosted.org/packages/19/30/15f7e35176488b77e5b88751947d321d603fccac273099ace27c7b2d50a6/crc32c-2.7.1-cp313-cp313t-win32.whl", hash = "sha256:ae38a4b6aa361595d81cab441405fbee905c72273e80a1c010fb878ae77ac769", size = 38319, upload-time = "2024-09-24T06:19:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/19/c4/0b3eee04dac195f4730d102d7a9fbea894ae7a32ce075f84336df96a385d/crc32c-2.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:eee2a43b663feb6c79a6c1c6e5eae339c2b72cfac31ee54ec0209fa736cf7ee5", size = 39781, upload-time = "2024-09-24T06:19:08.182Z" }, +] + [[package]] name = "cryptography" version = "46.0.3" @@ -512,7 +573,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -523,7 +583,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -534,7 +593,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -545,7 +603,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" }, @@ -784,6 +841,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "oci" +version = "2.181.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "circuitbreaker" }, + { name = "crc32c" }, + { name = "cryptography" }, + { name = "pyjwt" }, + { name = "pyopenssl" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/b3/4f80ae248dc9872bcc2b0e677548f86c2a3fa4df0082bbe00e17058bc988/oci-2.181.1.tar.gz", hash = "sha256:05587d120014238c6d5459328517eb02862f20f10e085a14f2ed48e5ca082672", size = 17489251, upload-time = "2026-07-07T06:10:28.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/bd/7141e3f3b2ae7041a883d97bf3d0d9d9d4745c61b659743ebe3afe5e7624/oci-2.181.1-py3-none-any.whl", hash = "sha256:a7e4424e1c1f917afedfdf1a7b1bc0b99b75683a774e2270afba8ed09fcce95a", size = 35749804, upload-time = "2026-07-07T06:10:20.315Z" }, +] + [[package]] name = "openpyxl" version = "3.1.5" @@ -972,6 +1049,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + [[package]] name = "pymysql" version = "1.1.2" @@ -986,6 +1072,19 @@ rsa = [ { name = "cryptography" }, ] +[[package]] +name = "pyopenssl" +version = "26.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1a/51/27a5ad5f939d08f690a326ef9582cda7140555180db71695f6fb747d6a36/pyopenssl-26.2.0.tar.gz", hash = "sha256:8c6fcecd1183a7fc897548dfe388b0cdb7f37e018200d8409cf33959dbe35387", size = 182195, upload-time = "2026-05-04T23:06:09.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/b8/a0e2790ae249d6f38c9f66de7a211621a7ab2650217bcd04e1262f578a56/pyopenssl-26.2.0-py3-none-any.whl", hash = "sha256:4f9d971bc5298b8bc1fab282803da04bf000c755d4ad9d99b52de2569ca19a70", size = 55823, upload-time = "2026-05-04T23:06:08.395Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1060,6 +1159,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/e5/63bed382f6a7a5ba70e7e132b8b7b8abbcf4888ffa6be4877698dcfbed7d/pytokens-0.1.10-py3-none-any.whl", hash = "sha256:db7b72284e480e69fb085d9f251f66b3d2df8b7166059261258ff35f50fb711b", size = 12046, upload-time = "2025-02-19T14:51:18.694Z" }, ] +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -1400,11 +1508,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -1482,6 +1590,7 @@ dependencies = [ { name = "fastapi", extra = ["standard"] }, { name = "google-auth" }, { name = "itsdangerous" }, + { name = "oci" }, { name = "openpyxl" }, { name = "pydantic" }, { name = "pymysql" }, @@ -1511,6 +1620,7 @@ requires-dist = [ { name = "fastapi", extras = ["standard"], specifier = ">=0.117.1" }, { name = "google-auth", specifier = ">=2.48.0" }, { name = "itsdangerous", specifier = ">=2.2.0" }, + { name = "oci", specifier = ">=2.164.0" }, { name = "openpyxl", specifier = ">=3.1.0" }, { name = "pydantic", specifier = ">=2.11.9" }, { name = "pymysql", specifier = ">=1.1.2" },