diff --git a/alembic/versions/40e4b59f754d_add_stripe_payment_table.py b/alembic/versions/40e4b59f754d_add_stripe_payment_table.py new file mode 100644 index 0000000..1b0d37e --- /dev/null +++ b/alembic/versions/40e4b59f754d_add_stripe_payment_table.py @@ -0,0 +1,34 @@ +"""add stripe payment table + +Revision ID: 40e4b59f754d +Revises: a58395ea1b22 +Create Date: 2025-09-02 20:52:29.183031 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime, UTC + + +# revision identifiers, used by Alembic. +revision = '40e4b59f754d' +down_revision = 'a58395ea1b22' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table('stripe_payment', + sa.Column('id', sa.String, primary_key=True), + sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('amount', sa.Integer, nullable=False), + sa.Column('currency', sa.String(3), nullable=False), + sa.Column('status', sa.String, nullable=False), + sa.Column('raw_data', sa.JSON, nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), default=datetime.now(UTC)), + sa.Column('updated_at', sa.DateTime(timezone=True), default=datetime.now(UTC), onupdate=datetime.now(UTC)), + ) + + +def downgrade() -> None: + op.drop_table('stripe_payment') diff --git a/app/api/routes/stripe.py b/app/api/routes/stripe.py new file mode 100644 index 0000000..3321595 --- /dev/null +++ b/app/api/routes/stripe.py @@ -0,0 +1,78 @@ +import os +from fastapi import APIRouter, Depends, Request +from app.api.dependencies import get_current_active_user_from_clerk, get_current_active_user +from app.api.schemas.stripe import CreateCheckoutSessionRequest +from app.models.user import User +from app.models.stripe import StripePayment +from app.core.database import get_async_db +from sqlalchemy.ext.asyncio import AsyncSession +import stripe +from app.core.logger import get_logger +from sqlalchemy import select +from fastapi import HTTPException + +logger = get_logger(name="stripe") + +STRIPE_API_KEY = os.getenv("STRIPE_API_KEY") +stripe.api_key = STRIPE_API_KEY + +router = APIRouter() + +@router.post("/create-checkout-session/clerk") +async def stripe_create_checkout_session_clerk(request: Request, create_checkout_session_request: CreateCheckoutSessionRequest, user: User = Depends(get_current_active_user_from_clerk), db: AsyncSession = Depends(get_async_db)): + return await stripe_create_checkout_session(request, create_checkout_session_request, user, db) + + +@router.post("/create-checkout-session") +async def stripe_create_checkout_session(request: Request, create_checkout_session_request: CreateCheckoutSessionRequest, user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db)): + """ + Create a checkout session for a user. + """ + logger.info(f"Creating checkout session for user {user.id}") + session = await stripe.checkout.Session.create_async( + metadata={ + "user_id": user.id, + }, + **create_checkout_session_request.model_dump(exclude_none=True), + ) + stripe_payment = StripePayment( + id=session.id, + user_id=user.id, + status=session.status, + currency=session.currency.upper(), + amount=session.amount_total, + # store the whole session as raw_data + raw_data=dict(session), + ) + db.add(stripe_payment) + await db.commit() + + return { + 'session_id': session.id, + 'url': session.url, + } + +@router.get("/checkout-session") +async def stripe_get_checkout_session(session_id: str, user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_async_db)): + result = await db.execute( + select( + StripePayment + ) + .where(StripePayment.id == session_id, StripePayment.user_id == user.id) + ) + stripe_payment = result.scalar_one_or_none() + if not stripe_payment: + raise HTTPException(status_code=404, detail="Stripe payment not found") + + return { + 'id': stripe_payment.id, + 'status': stripe_payment.status, + 'currency': stripe_payment.currency, + 'amount': stripe_payment.amount / 100.0 if stripe_payment.currency == "USD" else stripe_payment.amount, + 'created_at': stripe_payment.created_at, + 'updated_at': stripe_payment.updated_at, + } + +@router.get("/checkout-session/clerk") +async def stripe_get_checkout_session_clerk(session_id: str, user: User = Depends(get_current_active_user_from_clerk), db: AsyncSession = Depends(get_async_db)): + return await stripe_get_checkout_session(session_id, user, db) diff --git a/app/api/routes/wallet.py b/app/api/routes/wallet.py index 70c4ada..6886aca 100644 --- a/app/api/routes/wallet.py +++ b/app/api/routes/wallet.py @@ -1,12 +1,17 @@ from decimal import Decimal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from typing import List +from datetime import datetime from app.api.dependencies import get_current_active_user, get_current_active_user_from_clerk from app.core.database import get_async_db from app.models.user import User +from app.models.stripe import StripePayment +from app.models.usage_tracker import UsageTracker from app.services.wallet_service import WalletService +from sqlalchemy import select, desc, func router = APIRouter() @@ -14,6 +19,8 @@ class WalletResponse(BaseModel): balance: Decimal blocked: bool currency: str + total_spent: Decimal + total_earned: Decimal @router.get("/balance", response_model=WalletResponse) async def get_wallet_balance( @@ -25,9 +32,14 @@ async def get_wallet_balance( if not wallet: await WalletService.ensure_wallet(db, user.id) - return WalletResponse(balance=Decimal("0"), blocked=False, currency="USD") + return WalletResponse(balance=Decimal("0"), blocked=False, currency="USD", total_spent=Decimal("0"), total_earned=Decimal("0")) - return WalletResponse(**wallet) + result = await db.execute(select(func.sum(UsageTracker.cost)).where(UsageTracker.user_id == user.id, UsageTracker.updated_at.is_not(None))) + total_spent = result.scalar_one_or_none() or "0" + result = await db.execute(select(func.sum(StripePayment.amount)).where(StripePayment.user_id == user.id, StripePayment.status == "completed")) + total_earned = result.scalar_one_or_none() or "0" + + return WalletResponse(**wallet, total_spent=Decimal(total_spent), total_earned=Decimal(total_earned)) @router.get("/balance/clerk", response_model=WalletResponse) async def get_wallet_balance_clerk( @@ -35,3 +47,69 @@ async def get_wallet_balance_clerk( db: AsyncSession = Depends(get_async_db) ): return await get_wallet_balance(user, db) + +class TransactionHistoryItem(BaseModel): + currency: str + amount: Decimal + status: str + created_at: datetime + updated_at: datetime + +class TransactionHistoryResponse(BaseModel): + items: List[TransactionHistoryItem] + total: int + page_size: int + page_index: int + +@router.get("/transactions/history", response_model=TransactionHistoryResponse) +async def get_wallet_transactions_history( + user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_async_db), + page_size: int = Query(10, ge=1), + page_index: int = Query(0, ge=0), + status: str = Query(None, min_length=1), + started_at: datetime = Query(None), +): + # I would also want to get the total count of the transactions within one sql query + query = ( + select( + StripePayment.currency, + StripePayment.amount, + StripePayment.status, + StripePayment.created_at, + StripePayment.updated_at, + func.count().over().label("total"), + ) + .where(StripePayment.user_id == user.id, status is None or StripePayment.status == status, started_at is None or StripePayment.created_at >= started_at) + .order_by(desc(StripePayment.updated_at)) + .offset(page_index * page_size) + .limit(page_size) + ) + result = await db.execute(query) + transactions = result.fetchall() + return TransactionHistoryResponse( + items=[ + TransactionHistoryItem( + currency=transaction.currency, + # Convert cents to dollars for USD + amount=transaction.amount / 100.0 if transaction.currency == "USD" else transaction.amount, + status=transaction.status, + created_at=transaction.created_at, + updated_at=transaction.updated_at, + ) + for transaction in transactions], + total=transactions[0].total if transactions else 0, + page_size=page_size, + page_index=page_index, + ) + +@router.get("/transactions/history/clerk", response_model=TransactionHistoryResponse) +async def get_wallet_transactions_history_clerk( + user: User = Depends(get_current_active_user_from_clerk), + db: AsyncSession = Depends(get_async_db), + page_size: int = Query(10, ge=1), + page_index: int = Query(0, ge=0), + status: str = Query(None, min_length=1), + started_at: datetime = Query(None), +): + return await get_wallet_transactions_history(user, db, page_size, page_index, status, started_at) diff --git a/app/api/routes/webhooks.py b/app/api/routes/webhooks.py index f234872..8a4d071 100644 --- a/app/api/routes/webhooks.py +++ b/app/api/routes/webhooks.py @@ -3,15 +3,15 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy import select -from sqlalchemy.exc import IntegrityError +import stripe +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from svix import Webhook, WebhookVerificationError from app.core.database import get_async_db from app.core.logger import get_logger -from app.core.security import generate_forge_api_key from app.models.user import User +from app.models.stripe import StripePayment from app.services.provider_service import create_default_tensorblock_provider_for_user from app.services.wallet_service import WalletService @@ -248,9 +248,9 @@ async def stripe_webhook_handler(request: Request, db: AsyncSession = Depends(ge Handle Stripe webhooks for payment events. Key events to handle: - - payment_intent.succeeded: Credit wallet balance - - payment_intent.payment_failed: Log failed payment - - invoice.payment_failed: Handle subscription payment failure + - checkout.session.async_payment_succeeded / checkout.session.completed: Credit wallet balance + - checkout.session.async_payment_failed: Log failed payment + - checkout.session.expired: Log expired payment """ # Get the request body and signature payload = await request.body() @@ -261,44 +261,33 @@ async def stripe_webhook_handler(request: Request, db: AsyncSession = Depends(ge status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Stripe signature header" ) - - # NOTE: For production, you would verify the webhook signature here - # This is a placeholder for the Stripe webhook verification - # Example: - # import stripe - # try: - # event = stripe.Webhook.construct_event( - # payload, sig_header, STRIPE_WEBHOOK_SECRET - # ) - # except ValueError: - # raise HTTPException(status_code=400, detail="Invalid payload") - # except stripe.error.SignatureVerificationError: - # raise HTTPException(status_code=400, detail="Invalid signature") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_WEBHOOK_SECRET + ) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid payload") + except stripe.error.SignatureVerificationError: + raise HTTPException(status_code=400, detail="Invalid signature") try: # For now, parse as JSON (would use verified event in production) - event_data = json.loads(payload) - event_type = event_data.get("type") + event_type = event.get("type") logger.info(f"Received Stripe webhook: {event_type}") # Handle different event types - if event_type == "payment_intent.succeeded": - await handle_payment_succeeded(event_data, db) - elif event_type == "payment_intent.payment_failed": - await handle_payment_failed(event_data, db) - elif event_type == "invoice.payment_failed": - await handle_invoice_payment_failed(event_data, db) + if event_type in ["checkout.session.async_payment_succeeded", "checkout.session.completed"]: + await handle_payment_succeeded(event, db) + elif event_type == "checkout.session.async_payment_failed": + await handle_payment_failed(event, db) + elif event_type == "checkout.session.expired": + await handle_payment_expired(event, db) else: logger.info(f"Unhandled Stripe event type: {event_type}") return {"status": "success", "message": f"Event {event_type} processed"} - - except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON payload" - ) except Exception as e: logger.error(f"Error processing Stripe webhook: {e}", exc_info=True) raise HTTPException( @@ -307,70 +296,119 @@ async def stripe_webhook_handler(request: Request, db: AsyncSession = Depends(ge ) -async def handle_payment_succeeded(event_data: dict, db: AsyncSession): +async def handle_payment_succeeded(event: dict, db: AsyncSession): """Handle successful payment - credit wallet balance""" try: - payment_intent = event_data.get("data", {}).get("object", {}) - amount = payment_intent.get("amount", 0) # Amount in cents - currency = payment_intent.get("currency", "usd").upper() - customer_id = payment_intent.get("customer") + session = event.get("data", {}).get("object", {}) + if not session: + logger.warning("Checkout payment intent not found in event") + return + + session_id = session['id'] + amount = session['amount_total'] + currency = session['currency'].upper() + status = session['status'] + payment_status = session['payment_status'] + + if status == "complete" and payment_status == "paid": + status = "completed" + else: + status = payment_status or "failed" + + # update the corresponding StripePayment db record and return the user id + result = await db.execute( + update(StripePayment).where(StripePayment.id == session_id).values( + status = status, + amount = amount, + currency = currency, + raw_data = session, + ).returning(StripePayment.user_id) + ) + user_id = result.scalar_one_or_none() + if not user_id: + logger.warning(f"Stripe payment not found for id {id}") + return + if status != "completed": + logger.warning(f"Received payment success event for non-completed session: {session_id}") + return + # Convert cents to dollars for USD if currency == "USD": amount_decimal = amount / 100.0 else: amount_decimal = amount # Handle other currencies as needed - # TODO: Map customer_id to user_id - # For now, this is a placeholder - you'd need to implement customer mapping - # user_id = get_user_id_from_stripe_customer(customer_id) - - logger.info(f"Payment succeeded: {amount_decimal} {currency} for customer {customer_id}") + logger.info(f"Payment succeeded: {amount_decimal} {currency} for customer {user_id}") - # Uncomment when customer mapping is implemented: - # await WalletService.adjust( - # db, - # user_id, - # amount_decimal, - # f"deposit:stripe:{payment_intent.get('id')}", - # currency - # ) + await WalletService.adjust( + db, + user_id, + amount_decimal, + f"deposit:stripe:{session_id}", + currency + ) except Exception as e: logger.error(f"Failed to process payment success: {e}", exc_info=True) raise - -async def handle_payment_failed(event_data: dict, db: AsyncSession): +async def handle_payment_failed(event: dict, db: AsyncSession): """Handle failed payment""" try: - payment_intent = event_data.get("data", {}).get("object", {}) - customer_id = payment_intent.get("customer") + session = event.get("data", {}).get("object", {}) + if not session: + logger.warning("Checkout session not found in event") + return + + session_id = session['id'] + status = session['status'] + payment_status = session['payment_status'] - logger.warning(f"Payment failed for customer {customer_id}") + # update the corresponding StripePayment db record and return the user id + result = await db.execute( + update(StripePayment).where(StripePayment.id == session_id).values( + status = payment_status or status, + raw_data = session, + ).returning(StripePayment.user_id) + ) + user_id = result.scalar_one_or_none() + if not user_id: + logger.warning(f"Stripe payment not found for id {session_id}") + return - # TODO: Implement failure handling logic - # - Notify user - # - Update payment status - # - Handle retry logic + logger.warning(f"Payment failed: {session_id} for customer {user_id}") except Exception as e: - logger.error(f"Failed to process payment failure: {e}", exc_info=True) + logger.error(f"Failed to process payment failed: {e}", exc_info=True) raise + - -async def handle_invoice_payment_failed(event_data: dict, db: AsyncSession): - """Handle failed invoice payment - may need to block account""" +async def handle_payment_expired(event: dict, db: AsyncSession): + """Handle expired payment""" try: - invoice = event_data.get("data", {}).get("object", {}) - customer_id = invoice.get("customer") + session = event.get("data", {}).get("object", {}) + if not session: + logger.warning("Checkout session not found in event") + return - logger.warning(f"Invoice payment failed for customer {customer_id}") + session_id = session['id'] + status = session['status'] - # TODO: Map customer_id to user_id and potentially block account - # user_id = get_user_id_from_stripe_customer(customer_id) - # await WalletService.set_blocked(db, user_id, True) + # update the corresponding StripePayment db record and return the user id + result = await db.execute( + update(StripePayment).where(StripePayment.id == session_id).values( + status = status, + raw_data = session, + ).returning(StripePayment.user_id) + ) + user_id = result.scalar_one_or_none() + if not user_id: + logger.warning(f"Stripe payment not found for id {session_id}") + return + logger.warning(f"Payment expired: {session_id} for customer {user_id}") + except Exception as e: - logger.error(f"Failed to process invoice payment failure: {e}", exc_info=True) - raise + logger.error(f"Failed to process payment expired: {e}", exc_info=True) + raise \ No newline at end of file diff --git a/app/api/schemas/stripe.py b/app/api/schemas/stripe.py new file mode 100644 index 0000000..4f30d61 --- /dev/null +++ b/app/api/schemas/stripe.py @@ -0,0 +1,27 @@ +from pydantic import BaseModel +from typing import List, Literal + +# https://docs.stripe.com/api/checkout/sessions/create +class StripeCheckoutSessionLineItemPriceDataProductData(BaseModel): + name: str + description: str | None = None + images: List[str] | None = None + +class StripeCheckoutSessionLineItemPriceData(BaseModel): + currency: str + product_data: StripeCheckoutSessionLineItemPriceDataProductData + tax_behavior: str = "inclusive" + unit_amount: int + +class StripeCheckoutSessionLineItem(BaseModel): + price_data: StripeCheckoutSessionLineItemPriceData + quantity: int + +class CreateCheckoutSessionRequest(BaseModel): + line_items: List[StripeCheckoutSessionLineItem] + # Only allow payment mode for now + mode: Literal["payment"] = "payment" + success_url: str | None = None + return_url: str | None = None + cancel_url: str | None = None + ui_mode: str = "hosted" \ No newline at end of file diff --git a/app/main.py b/app/main.py index 40d1b72..f63da19 100644 --- a/app/main.py +++ b/app/main.py @@ -20,6 +20,7 @@ users, wallet, webhooks, + stripe, ) from app.core.database import engine from app.core.logger import get_logger @@ -171,6 +172,7 @@ def create_app() -> FastAPI: v1_router.include_router(wallet.router, prefix="/wallet", tags=["wallet"]) v1_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"]) v1_router.include_router(statistic.router, prefix='/statistic', tags=["statistic"]) + v1_router.include_router(stripe.router, prefix='/stripe', tags=["stripe"]) # Claude Code compatible API endpoints v1_router.include_router(claude_code.router, tags=["Claude Code API"]) diff --git a/app/models/stripe.py b/app/models/stripe.py new file mode 100644 index 0000000..cc9b373 --- /dev/null +++ b/app/models/stripe.py @@ -0,0 +1,15 @@ +from app.models.base import Base +from sqlalchemy import Column, String, Integer, ForeignKey, JSON, DateTime +from datetime import datetime, UTC + +class StripePayment(Base): + __tablename__ = "stripe_payment" + + id = Column(String, primary_key=True) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + amount = Column(Integer, nullable=False) + currency = Column(String(3), nullable=False) + status = Column(String, nullable=False) + raw_data = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), default=datetime.now(UTC)) + updated_at = Column(DateTime(timezone=True), default=datetime.now(UTC), onupdate=datetime.now(UTC)) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e843410..83baf7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "uvicorn>=0.22.0", "pydantic>=2.0.0", "python-jose>=3.3.0", + "stripe", "passlib>=1.7.4", "python-multipart>=0.0.5", "sqlalchemy[asyncio]>=2.0.0", diff --git a/uv.lock b/uv.lock index 7f71753..00f8d7f 100644 --- a/uv.lock +++ b/uv.lock @@ -548,6 +548,7 @@ dependencies = [ { name = "redis" }, { name = "requests" }, { name = "sqlalchemy", extra = ["asyncio"] }, + { name = "stripe" }, { name = "svix" }, { name = "tiktoken" }, { name = "uvicorn" }, @@ -599,6 +600,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.28.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.0" }, + { name = "stripe" }, { name = "svix", specifier = ">=1.13.0" }, { name = "tiktoken", specifier = ">=0.5.0" }, { name = "uvicorn", specifier = ">=0.22.0" }, @@ -1698,6 +1700,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, ] +[[package]] +name = "stripe" +version = "12.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/2b/953b5305ccc771d139861a3d51d463024eea74ae2e854950245a2ebb3198/stripe-12.5.0.tar.gz", hash = "sha256:cd2b8e71216b6aed5dc1e9b5e6f658b5e21d69a60d44fb8c08f41e1f7bddedd0", size = 1432368 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/57/97b0a2680103b4fe662c31ed0035d8340fca180e806ce6dea5595dcb3f5f/stripe-12.5.0-py2.py3-none-any.whl", hash = "sha256:9256226ed5c64282045a025687b34279af6227af21c954d0ddd9ff9d4b69a335", size = 1664060 }, +] + [[package]] name = "svix" version = "1.67.0" @@ -1725,18 +1740,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165 }, ] -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, -] - [[package]] name = "tiktoken" version = "0.9.0" @@ -1761,6 +1764,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669 }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, +] + [[package]] name = "types-deprecated" version = "1.2.15.20250304"