diff --git a/.gitignore b/.gitignore index c92f186..226d6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules/ .env vendor/ -.idea/ \ No newline at end of file +.idea/ +*.csv \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a20824f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "[PYTEST] Debugger", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "args": ["app/python/src/tests/main.py"], + "console": "integratedTerminal", + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, + "purpose": ["debug-test"] + } + ] +} diff --git a/Makefile b/Makefile index 45a0024..0db7051 100755 --- a/Makefile +++ b/Makefile @@ -1,3 +1,4 @@ + deploy-env: scripts/deploy-env.sh > /dev/null @@ -8,4 +9,20 @@ deploy-python: scripts/deploy-python.sh > /dev/null deploy-node: - scripts/deploy-node.sh > /dev/null \ No newline at end of file + scripts/deploy-node.sh > /dev/null + +set_env_variables: + @echo "Setting AWS Credentials..." + export $(grep -v '^#' .env) + +# Python only +export_serverless_requirements: + @echo "Exporting requirements..." + uv pip compile pyproject.toml -o app/python/requirements.txt + +check_lambda_status: + @echo "Checking Lambda function status..." + aws lambda get-function-configuration \ + --profile "fender" \ + --function-name fender_digital_code_exercise \ + --query '{State:State, LastUpdateStatus:LastUpdateStatus, LastUpdateStatusReason:LastUpdateStatusReason}' \ No newline at end of file diff --git a/app/python/requirements.txt b/app/python/requirements.txt old mode 100755 new mode 100644 index 1db657b..42b78e4 --- a/app/python/requirements.txt +++ b/app/python/requirements.txt @@ -1 +1,21 @@ -boto3 \ No newline at end of file +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml -o app/python/requirements.txt +annotated-types==0.7.0 + # via pydantic +faker==37.12.0 + # via fds-aws-coding-exercise (pyproject.toml) +pydantic==2.12.4 + # via fds-aws-coding-exercise (pyproject.toml) +pydantic-core==2.41.5 + # via pydantic +python-dotenv==1.2.1 + # via fds-aws-coding-exercise (pyproject.toml) +typing-extensions==4.15.0 + # via + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +tzdata==2025.2 + # via faker diff --git a/app/python/src/__init__.py b/app/python/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/config.py b/app/python/src/config.py new file mode 100644 index 0000000..5578e5a --- /dev/null +++ b/app/python/src/config.py @@ -0,0 +1,15 @@ +import os + +from dotenv import load_dotenv + +load_dotenv(override=True) + + +class Config: + ENVIRONMENT = os.getenv("ENVIRONMENT", "staging") + AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "") + AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "") + AWS_REGION_NAME = os.getenv("AWS_REGION_NAME", "us-east-1") + + +IS_DEVELOPMENT = Config.ENVIRONMENT == "development" diff --git a/app/python/src/db/dynamo.py b/app/python/src/db/dynamo.py new file mode 100644 index 0000000..c28b53e --- /dev/null +++ b/app/python/src/db/dynamo.py @@ -0,0 +1,163 @@ +import json +from decimal import Decimal + +import boto3 +from boto3.dynamodb.conditions import And, Key + +try: + # For local development + from ..config import IS_DEVELOPMENT, Config +except: + from config import IS_DEVELOPMENT, Config + + +def serialize_dynamo(dict): + return json.loads(json.dumps(dict, default=str_dynamo_data)) + + +def str_dynamo_data(obj): + # Coerce every object into string + return str(obj) + + +def dynamo_write_serializer(dict: dict) -> dict: + """ + Serialize data before writing into DynamoDB + """ + for key, value in dict.items(): + if isinstance(value, float): + dict[key] = Decimal(str(value)) + + return dict + + +PK_FIELD = "pk" +SK_FIELD = "sk" + + +class DynamoFender: + """ + DynamoDB connection handler for Fender application. + """ + + def __init__(self, tablename) -> None: + if IS_DEVELOPMENT: + auth_params = { + "aws_access_key_id": Config.AWS_ACCESS_KEY_ID, + "aws_secret_access_key": Config.AWS_SECRET_ACCESS_KEY, + "region_name": Config.AWS_REGION_NAME, + } + self.dynamodb = boto3.resource("dynamodb", **auth_params) + self.client = boto3.client("dynamodb", **auth_params) + else: + self.dynamodb = boto3.resource("dynamodb") + self.client = boto3.client("dynamodb") + try: + self.table = self.dynamodb.Table(tablename) + except Exception as error: + raise ValueError( + f"Could't make connection to `{tablename}` table due `{error}`" + ) + + def write(self, data: list | dict) -> bool: + """ + Writes data into table + """ + + if isinstance(data, dict): + data = [data] + + with self.table.batch_writer() as batch: + for values in data: + serialized_values = dynamo_write_serializer(values) + batch.put_item(Item=serialized_values) + + return True + + def _convert_updatable_dict(self, payload: dict) -> dict: + """ + Convert payload to corresponding format to update + """ + + final_dict = {} + + for key, value in payload.items(): + if key == PK_FIELD or key == SK_FIELD: + continue + + final_dict.update({key: {"Value": value}}) + + return final_dict + + def update(self, data: dict) -> None: + """ + Updates DB in dynamo + """ + pk_value = data.get(PK_FIELD) + sk_value = data.get(SK_FIELD) + + if PK_FIELD not in data.keys() or not pk_value: + raise ValueError(f"`pk` is mandatory") + + if SK_FIELD not in data.keys() or not sk_value: + raise ValueError(f"`sk` is mandatory") + + # Convert dict to value field + attributes = self._convert_updatable_dict(data) + + return self.table.update_item( + Key={ + PK_FIELD: pk_value, + SK_FIELD: sk_value, + }, + AttributeUpdates=attributes, + ) + + def get_by_pk(self, pk: str) -> dict: + response = self.table.query(KeyConditionExpression=Key("pk").eq(pk)) + return serialize_dynamo(response["Items"]) + + def get_or_create(self, pk: str, sk: str) -> dict: + response = self.table.query( + KeyConditionExpression=And(Key("pk").eq(pk), Key("sk").eq(sk)) + ) + items = serialize_dynamo(response["Items"]) + if items: + return items[0] + return {} + + +class SubscriptionTable(DynamoFender): + """ + DynamoDB handler for Subscription table. + """ + + __tablename__ = "FenderSubscriptions" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) + + +class PlanTable(DynamoFender): + """ + DynamoDB handler for Plan table. + """ + + __tablename__ = "FenderPlans" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) + + +class SubscriptionsAndPlansTable(DynamoFender): + """ + DynamoDB handler for Plan table. + """ + + __tablename__ = "fender_digital_code_exercise" + + def __init__(self, tablename: str = None) -> None: + _tablename = tablename or self.__tablename__ + super().__init__(_tablename) diff --git a/app/python/src/db/tables.py b/app/python/src/db/tables.py new file mode 100644 index 0000000..3c5052d --- /dev/null +++ b/app/python/src/db/tables.py @@ -0,0 +1,12 @@ +try: + # For local development + from .dynamo import PlanTable, SubscriptionsAndPlansTable, SubscriptionTable +except: + # For AWS Lambda deployment + from dynamo import PlanTable, SubscriptionsAndPlansTable, SubscriptionTable + + +class DynamoFenderTables: + SUBSCRIPTION = SubscriptionTable() + PLAN = PlanTable() + SUBSCRIPTIONS_AND_PLANS = SubscriptionsAndPlansTable() diff --git a/app/python/src/main.py b/app/python/src/main.py index e9fdd3b..ac74d23 100755 --- a/app/python/src/main.py +++ b/app/python/src/main.py @@ -1,5 +1,15 @@ +try: + # For local development + from .routes import Router + from .schemas.schemas import EventSchema +except: + # For AWS Lambda deployment + from routes import Router + from schemas.schemas import EventSchema + + def handler(event, context): - return { - 'statusCode': 200, - 'body': 'Hello from Python Lambda!' - } \ No newline at end of file + event = EventSchema(**event) + router = Router(event=event) + + return router.process_event() diff --git a/app/python/src/models/models.py b/app/python/src/models/models.py new file mode 100644 index 0000000..c56a3c3 --- /dev/null +++ b/app/python/src/models/models.py @@ -0,0 +1,321 @@ +import random +from datetime import datetime +from enum import StrEnum +from typing import Literal, Optional + +from faker import Faker +from pydantic import BaseModel + +try: + # For local development + from ..db.tables import DynamoFenderTables + from ..schemas.schemas import SubscriptionEventPayload + from ..utils.response import success_response, validation_wrapper + from ..utils.utils import parse_iso8601 +except: + # For AWS Lambda deployment + from db.tables import DynamoFenderTables + from schemas.schemas import SubscriptionEventPayload + from utils.response import success_response, validation_wrapper + from utils.utils import parse_iso8601 + +fake = Faker("es_MX") + + +class SubscriptionStatus(StrEnum): + ACTIVE = "active" + PENDING = "pending" + CANCELLED = "cancelled" + + +class BillingCycle(StrEnum): + MONTHLY = "monthly" + YEARLY = "yearly" + + +class PlanStatus(StrEnum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class SubscriptionAttributes(BaseModel): + # Adding as optional in case some attributes are missing + provider: Optional[str] = None + paymentId: Optional[str] = None + customerId: Optional[str] = None + autoRenew: Optional[bool] = None + paymentMethod: Optional[str] = None + + +class SubscriptionModel(BaseModel): + pk: str + sk: str + type: str = "sub" + planSku: str + startDate: str + expiresAt: str + cancelledAt: str | None = None + lastModified: str + attributes: SubscriptionAttributes | None = None + + @property + def plan_pk(self) -> str: + return f"{self.planSku}" + + def create(self) -> None: + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.write( + self.model_dump(exclude_none=True) + ) + + @property + def last_date_modified(self) -> datetime: + return parse_iso8601(self.lastModified) + + def parse_cancelled_at(self) -> str: + if not self.cancelledAt: + raise ValueError("cancelledAt is None") + return parse_iso8601(self.expiresAt) + + @property + def is_pending(self) -> bool: + return self.last_date_modified < self.parse_cancelled_at() + + @property + def is_cancelled(self) -> bool: + return self.last_date_modified >= self.parse_cancelled_at() + + def compute_status(self) -> SubscriptionStatus: + if not self.cancelledAt: + return SubscriptionStatus.ACTIVE + if self.is_pending: + return SubscriptionStatus.PENDING + if self.is_cancelled: + return SubscriptionStatus.CANCELLED + + +class PlanModel(BaseModel): + pk: str + sk: str + type: str + name: str + price: float + currency: str + billingCycle: Literal[BillingCycle.MONTHLY, BillingCycle.YEARLY] + features: list[str] + status: Literal[PlanStatus.ACTIVE, PlanStatus.INACTIVE] + lastModified: str | None = None + + def create(self) -> None: + DynamoFenderTables.PLAN.write(self.model_dump()) + + @property + def is_active(self) -> bool: + return self.status == PlanStatus.ACTIVE + + @property + def is_inactive(self) -> bool: + return self.status == PlanStatus.INACTIVE + + +class SubscriptionDetailsSchema: + """ + Schema to transform SubscriptionEventPayload into SubscriptionModel data. + """ + + DEFAULT_TYPE = "sub" + + def __init__(self, payload: SubscriptionEventPayload) -> None: + self.pk = payload.sub_pk + self.sk = payload.sub_sk + self.type = self.DEFAULT_TYPE + self.planSku = payload.metadata.planSku + self.startDate = payload.timestamp + self.expiresAt = payload.expiresAt + self.lastModified = payload.timestamp + self.attributes = { + "provider": payload.provider, + "paymentId": payload.paymentId, + "customerId": payload.customerId, + "autoRenew": payload.metadata.autoRenew, + "paymentMethod": payload.metadata.paymentMethod, + } + if payload.is_cancelled: + self.cancelledAt = payload.cancelledAt + + def to_dict(self) -> dict: + return self.__dict__ + + +class SubscriptionAdapter(BaseModel): + payload: SubscriptionEventPayload + + def get_sub_by_pk(self) -> dict: + return DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(self.payload.sub_pk) + + def get_plan_by_pk(self) -> PlanModel | None: + if plan := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk( + self.payload.plan_pk + ): + return PlanModel(**plan[0]) + + def create_sub(self) -> None: + """ + Process subscription event payload and create subscription record. + """ + data = SubscriptionDetailsSchema(self.payload).to_dict() + + subscription_model = SubscriptionModel(**data) + subscription_model.create() + + def _update_renewal(self) -> None: + """ + Update plan on renewal if needed. + """ + update_dict = { + "pk": self.payload.sub_pk, + "sk": self.payload.sub_sk, + "lastModified": self.payload.timestamp, + "expiresAt": self.payload.expiresAt, + "internalStatus": SubscriptionStatus.ACTIVE, + } + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.update(update_dict) + + def _update_cancelled(self) -> None: + """ + Update plan on renewal if needed. + """ + update_dict = { + "pk": self.payload.sub_pk, + "sk": self.payload.sub_sk, + "lastModified": self.payload.timestamp, + "expiresAt": self.payload.expiresAt, + "cancelledAt": self.payload.cancelledAt, + "internalStatus": SubscriptionStatus.CANCELLED, + } + DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.update(update_dict) + + def process(self) -> None: + """ + Process subscription event payload. + """ + if not (plan := self.get_plan_by_pk()) or plan.is_inactive: + raise ValueError("Plan is inactive or does not exist") + + if not self.get_sub_by_pk(): + return self.create_sub() + + if self.payload.is_renewal: + # Add logic to update plan if needed on renewal + return self._update_renewal() + elif self.payload.is_cancelled: + # Add logic to update plan if needed on cancellation + return self._update_cancelled() + + +class PlanAdapter(BaseModel): + payload: SubscriptionEventPayload + + def _get_by_pk(self) -> dict: + return DynamoFenderTables.PLAN.get_by_pk(self.payload.metadata.planSku) + + def _create(self) -> None: + """ + Process subscription event payload and create plan record. + """ + DEFAULT_TYPE = "plan" + DEFAULT_CURRENCY = "USD" + # Random price and currency for demonstration purposes + random_billing_cycle = random.choice( + [BillingCycle.MONTHLY, BillingCycle.YEARLY] + ) + random_features = [f"Feature {el}" for el in fake.random_sample()] + data = { + "pk": self.payload.metadata.planSku, + "sk": self.payload.metadata.paymentMethod, + "type": DEFAULT_TYPE, + "name": self.payload.plan_name, + "price": float(fake.numerify()), + "currency": DEFAULT_CURRENCY, + "billingCycle": random_billing_cycle, + "features": random_features, + "status": PlanStatus.ACTIVE, + "lastModified": self.payload.timestamp, + } + plan_model = PlanModel(**data) + plan_model.create() + + def process(self) -> None: + if not self._get_by_pk(): + return self._create() + + +class SubscriptionAndPlanAdapter(BaseModel): + user_id: str + + @property + def sub_pk(self) -> str: + return f"user:{self.user_id}" + + def _get_sub_by_pk(self) -> SubscriptionModel: + if not ( + subscription := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk( + self.sub_pk + ) + ): + raise ValueError("Subscription not found") + + return SubscriptionModel(**subscription[0]) + + def _get_plan_by_pk(self, plan_pk: str) -> PlanModel: + if not (plan := DynamoFenderTables.SUBSCRIPTIONS_AND_PLANS.get_by_pk(plan_pk)): + raise ValueError("Plan not found") + + return PlanModel(**plan[0]) + + def process(self) -> dict: + subscription = self._get_sub_by_pk() + plan = self._get_plan_by_pk(subscription.plan_pk) + + data = { + "userId": subscription.pk, + "subscriptionId": subscription.sk, + "plan": { + "sku": plan.pk, + "name": plan.name, + "price": plan.price, + "currency": plan.currency, + "billingCycle": plan.billingCycle, + "features": plan.features, + }, + "startDate": subscription.startDate, + "expiresAt": subscription.expiresAt, + "status": subscription.compute_status(), + "attributes": { + "autoRenew": subscription.attributes.autoRenew, + "paymentMethod": subscription.attributes.paymentMethod, + }, + } + + if subscription.cancelledAt: + data["cancelledAt"] = subscription.cancelledAt + + return data + + +@validation_wrapper +def process_subscription_and_plan(payload: SubscriptionEventPayload) -> None: + subscription_adapter = SubscriptionAdapter(payload=payload) + subscription_adapter.process() + + return success_response("Subscription and Plan processed successfully") + + +@validation_wrapper +def process_user_id(user_id: str) -> dict: + """ + Fetch user subscription by user ID. + """ + subscription_adapter = SubscriptionAndPlanAdapter(user_id=user_id) + data = subscription_adapter.process() + + return success_response("User subscription retrieved successfully", data=data) diff --git a/app/python/src/routes.py b/app/python/src/routes.py new file mode 100644 index 0000000..1521100 --- /dev/null +++ b/app/python/src/routes.py @@ -0,0 +1,50 @@ +from http import HTTPStatus + +from pydantic import BaseModel + +try: + # For local development + from .models.models import process_subscription_and_plan, process_user_id + from .schemas.schemas import EventSchema, SubscriptionEventPayload + from .utils.response import error_response, validation_wrapper +except: + # For AWS Lambda deployment + from models.models import process_subscription_and_plan, process_user_id + from schemas.schemas import EventSchema, SubscriptionEventPayload + from utils.response import error_response, validation_wrapper + + +# /api/v1/subscriptions/{userId} +def router_get_user_subscription(user_id: str) -> dict: + """ + Router function to handle GET /api/v1/subscriptions/{userId} requests. + """ + return process_user_id(user_id=user_id) + + +# /api/v1/webhooks/subscriptions +def router_post_user_subscription(body: SubscriptionEventPayload) -> dict: + """ + Router function to handle POST /api/v1/webhooks/subscriptions requests. + """ + return process_subscription_and_plan(payload=body) + + +class Router(BaseModel): + event: EventSchema + + @validation_wrapper + def process_event(self) -> dict: + + if self.event.is_get: + user_id = self.event.pathParameters.userId + return router_get_user_subscription(user_id=user_id) + + elif self.event.is_post: + body = SubscriptionEventPayload(**self.event.parse_body()) + return router_post_user_subscription(body=body) + + else: + return error_response( + "Method not allowed", status_code=HTTPStatus.METHOD_NOT_ALLOWED + ) diff --git a/app/python/src/schemas/__init__.py b/app/python/src/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/schemas/schemas.py b/app/python/src/schemas/schemas.py new file mode 100644 index 0000000..2925664 --- /dev/null +++ b/app/python/src/schemas/schemas.py @@ -0,0 +1,89 @@ +import json +from enum import StrEnum +from typing import Optional + +from pydantic import BaseModel + + +class SubscriptionType(StrEnum): + RENEWAL = "subscription.renewed" + CREATED = "subscription.created" + CANCELLED = "subscription.cancelled" + + +class SupportedMethods(StrEnum): + GET = "GET" + POST = "POST" + + +class UserParamsSchema(BaseModel): + userId: str + + +class EventSchema(BaseModel): + httpMethod: str + path: str + body: Optional[str] = None + pathParameters: Optional[UserParamsSchema] = None + + @property + def is_get(self) -> bool: + return self.httpMethod == SupportedMethods.GET + + @property + def is_post(self) -> bool: + return self.httpMethod == SupportedMethods.POST + + def parse_body(self) -> dict: + if self.body: + return json.loads(self.body) + return {} + + +class MetadataSchema(BaseModel): + planSku: str + autoRenew: bool + paymentMethod: str + cancelReason: Optional[str] = None + + +class SubscriptionEventPayload(BaseModel): + eventId: str + eventType: str + timestamp: str + provider: str + subscriptionId: str + paymentId: Optional[str] = None + userId: str + customerId: str + expiresAt: str + cancelledAt: Optional[str] = None + metadata: MetadataSchema + + @property + def sub_pk(self) -> str: + return f"user:{self.userId}" + + @property + def sub_sk(self) -> str: + return f"sub:{self.subscriptionId}" + + @property + def plan_pk(self) -> str: + return f"{self.metadata.planSku}" + + @property + def plan_name(self) -> str: + return self.metadata.planSku.replace("_", " ").title() + + @property + def is_renewal(self) -> bool: + return self.eventType == SubscriptionType.RENEWAL + + @property + def is_created(self) -> bool: + return self.eventType == SubscriptionType.CREATED + + @property + def is_cancelled(self) -> bool: + return self.eventType == SubscriptionType.CANCELLED diff --git a/app/python/src/tests/__init__.py b/app/python/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/python/src/tests/main.py b/app/python/src/tests/main.py new file mode 100644 index 0000000..3c86382 --- /dev/null +++ b/app/python/src/tests/main.py @@ -0,0 +1,286 @@ +import json + +import pytest + +try: + # For local development + from ..db.tables import DynamoFenderTables + from ..main import handler + from ..models.models import SubscriptionAdapter + from ..schemas.schemas import EventSchema, SubscriptionEventPayload +except: + # For AWS Lambda deployment + from db.tables import DynamoFenderTables + from main import handler + from models.models import SubscriptionAdapter + from schemas.schemas import EventSchema, SubscriptionEventPayload + +CREATED_SUBSCRIPTION_EVENT = { + "eventId": "evt_123456789", + "eventType": "subscription.created", + "timestamp": "2024-03-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_123456", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-04-20T10:00:00Z", + "metadata": { + "planSku": "plan:XYZ123", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} +CREATED_SUBSCRIPTION_EVENT_INACTIVE_PLAN = { + "eventId": "evt_123456789", + "eventType": "subscription.created", + "timestamp": "2024-03-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_123456", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-04-20T10:00:00Z", + "metadata": { + "planSku": "plan:ABC456", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} +RENEWED_SUBSCRIPTION_EVENT = { + "eventId": "evt_987654321", + "eventType": "subscription.renewed", + "timestamp": "2024-04-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": "pm_654321", + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-05-20T10:00:00Z", + "metadata": { + "planSku": "plan:DEF789", + "autoRenew": True, + "paymentMethod": "CREDIT_CARD", + }, +} + +CANCELLED_SUBSCRIPTION_EVENT = { + "eventId": "evt_456789123", + "eventType": "subscription.cancelled", + "timestamp": "2024-05-20T10:00:00Z", + "provider": "STRIPE", + "subscriptionId": "sub_456789", + "paymentId": None, + "userId": "123", + "customerId": "cus_789012", + "expiresAt": "2024-05-20T10:00:00Z", + "cancelledAt": "2024-05-20T10:00:00Z", + "metadata": { + "planSku": "plan:GHI012", + "autoRenew": False, + "paymentMethod": "CREDIT_CARD", + "cancelReason": "USER_REQUESTED", + }, +} + + +AWS_GET_EVENT_SUBSCRIPTION = { + "resource": "/api/v1/subscriptions/{userId}", + "path": "/api/v1/subscriptions/123", + "httpMethod": "GET", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", + "User-Agent": "PostmanRuntime/7.49.1", + "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", + "X-Forwarded-For": "189.179.129.75", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", + }, + "multiValueHeaders": { + "Accept": ["*/*"], + "Accept-Encoding": ["gzip, deflate, br"], + "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], + "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], + "User-Agent": ["PostmanRuntime/7.49.1"], + "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], + "X-Forwarded-For": ["189.179.129.75"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"], + }, + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + "pathParameters": {"userId": "123"}, + "stageVariables": None, + "requestContext": { + "resourceId": "xxxxyyy", + "resourcePath": "/api/v1/subscriptions/{userId}", + "httpMethod": "GET", + "extendedRequestId": "Tp_ajGsBoAMEdCw=", + "requestTime": "07/Nov/2025:04:43:59 +0000", + "path": "/dev/api/v1/subscriptions/dummy_user", + "accountId": "929676127859", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "xxxxxyyy", + "requestTimeEpoch": 1762490639902, + "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": None, + "cognitoIdentityId": None, + "caller": None, + "sourceIp": "189.179.129.75", + "principalOrgId": None, + "accessKey": None, + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": None, + "userAgent": "PostmanRuntime/7.49.1", + "user": None, + }, + "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "deploymentId": "w3dwr7", + "apiId": "0s2r8aurv7", + }, + "body": None, + "isBase64Encoded": False, +} + + +def base_aws_post_event(event_body: dict) -> dict: + return { + "resource": "/api/v1/subscriptions/{userId}", + "path": "/api/v1/subscriptions/dummy_user", + "httpMethod": "POST", + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate, br", + "Host": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "Postman-Token": "fa1c9874-89e9-4277-a5b1-e65017947519", + "User-Agent": "PostmanRuntime/7.49.1", + "X-Amzn-Trace-Id": "Root=1-690d790f-52e7f1510ea0af3242a5d1d6", + "X-Forwarded-For": "189.179.129.75", + "X-Forwarded-Port": "443", + "X-Forwarded-Proto": "https", + }, + "multiValueHeaders": { + "Accept": ["*/*"], + "Accept-Encoding": ["gzip, deflate, br"], + "Host": ["0s2r8aurv7.execute-api.us-east-1.amazonaws.com"], + "Postman-Token": ["fa1c9874-89e9-4277-a5b1-e65017947519"], + "User-Agent": ["PostmanRuntime/7.49.1"], + "X-Amzn-Trace-Id": ["Root=1-690d790f-52e7f1510ea0af3242a5d1d6"], + "X-Forwarded-For": ["189.179.129.75"], + "X-Forwarded-Port": ["443"], + "X-Forwarded-Proto": ["https"], + }, + "queryStringParameters": None, + "multiValueQueryStringParameters": None, + "pathParameters": None, + "stageVariables": None, + "requestContext": { + "resourceId": "xxxxyyy", + "resourcePath": "/api/v1/subscriptions/{userId}", + "httpMethod": "GET", + "extendedRequestId": "Tp_ajGsBoAMEdCw=", + "requestTime": "07/Nov/2025:04:43:59 +0000", + "path": "/dev/api/v1/subscriptions/dummy_user", + "accountId": "929676127859", + "protocol": "HTTP/1.1", + "stage": "dev", + "domainPrefix": "xxxxxyyy", + "requestTimeEpoch": 1762490639902, + "requestId": "fb9a86bd-d6cf-4812-b69d-21f3ce009155", + "identity": { + "cognitoIdentityPoolId": None, + "accountId": None, + "cognitoIdentityId": None, + "caller": None, + "sourceIp": "189.179.129.75", + "principalOrgId": None, + "accessKey": None, + "cognitoAuthenticationType": None, + "cognitoAuthenticationProvider": None, + "userArn": None, + "userAgent": "PostmanRuntime/7.49.1", + "user": None, + }, + "domainName": "0s2r8aurv7.execute-api.us-east-1.amazonaws.com", + "deploymentId": "w3dwr7", + "apiId": "0s2r8aurv7", + }, + "body": json.dumps(event_body), + "isBase64Encoded": False, + } + + +AWS_POST_EVENT_CREATE_SUBSCRIPTION = base_aws_post_event(CREATED_SUBSCRIPTION_EVENT) +AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN = base_aws_post_event( + CREATED_SUBSCRIPTION_EVENT_INACTIVE_PLAN +) +AWS_POST_EVENT_RENEW_SUBSCRIPTION = base_aws_post_event(RENEWED_SUBSCRIPTION_EVENT) +AWS_POST_EVENT_CANCEL_SUBSCRIPTION = base_aws_post_event(CANCELLED_SUBSCRIPTION_EVENT) + + +# @pytest.mark.skip(reason="Skipping this test for now") +def test_handler_event(): + context = {} + event_get = AWS_GET_EVENT_SUBSCRIPTION + + event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION + response_created = handler(event_created, context) + response_get = handler(event_get, context) + + event_created = AWS_POST_EVENT_CREATE_SUBSCRIPTION_INACTIVE_PLAN + response_renewed = handler(event_created, context) + response_get = handler(event_get, context) + + event_renewed = AWS_POST_EVENT_RENEW_SUBSCRIPTION + response_renewed = handler(event_renewed, context) + response_get = handler(event_get, context) + + event_cancelled = AWS_POST_EVENT_CANCEL_SUBSCRIPTION + response_cancelled = handler(event_cancelled, context) + response_get = handler(event_get, context) + + print(response_get) + + +@pytest.mark.skip(reason="Skipping this test for now") +def test_handler_get_event(): + event = AWS_GET_EVENT_SUBSCRIPTION + context = {} + + response = handler(event, context) + print(response) + + +@pytest.mark.skip(reason="Skipping this test for now") +def test_event_schema_get_method(): + event = EventSchema(**AWS_GET_EVENT_SUBSCRIPTION) + assert event.is_get is True + assert event.is_post is False + + event = EventSchema(**AWS_POST_EVENT_CREATE_SUBSCRIPTION) + assert event.is_get is False + assert event.is_post is True + + +@pytest.mark.skip(reason="Skipping this test for now") +def test_subscription_event_creation(): + subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) + + assert subscription_payload.is_created is True + assert subscription_payload.metadata.planSku == "PREMIUM_MONTHLY" + assert subscription_payload.metadata.autoRenew is True + assert subscription_payload.metadata.paymentMethod == "CREDIT_CARD" + + +@pytest.mark.skip(reason="Skipping this test for now") +def test_subscription_adapter(): + subscription_payload = SubscriptionEventPayload(**CREATED_SUBSCRIPTION_EVENT) + adapter = SubscriptionAdapter(payload=subscription_payload) + adapter.process() diff --git a/app/python/src/utils/response.py b/app/python/src/utils/response.py new file mode 100644 index 0000000..5727ade --- /dev/null +++ b/app/python/src/utils/response.py @@ -0,0 +1,60 @@ +import json +from functools import wraps +from http import HTTPStatus + +from pydantic import ValidationError + + +def success_response(body: str, data: dict = None) -> dict: + + body = {"message": body} + + if data: + body["data"] = data + + return { + "statusCode": HTTPStatus.OK, + "body": json.dumps(body), + } + + +def error_response(body: str, status_code: HTTPStatus = HTTPStatus.BAD_REQUEST) -> dict: + return { + "statusCode": status_code, + "body": json.dumps({"error": body}), + } + + +def process_pydantic_error(e): + fields = [] + + for error in e.errors(): + if error.get("loc"): + location = error["loc"][0] + message = error["msg"] + error_message = f"'{location}': `{message}`" + fields.append(error_message) + else: + fields.append(error["msg"]) + + fields_error = f", ".join(fields) + + return fields_error + + +def validation_wrapper(f): + """ + Decorator to catch validation errors + """ + + @wraps(f) + def decorator(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValidationError as e: + message = process_pydantic_error(e) + return False, error_response(message) + except Exception as e: + return False, error_response(str(e)) + + return decorator diff --git a/app/python/src/utils/utils.py b/app/python/src/utils/utils.py new file mode 100644 index 0000000..2e22413 --- /dev/null +++ b/app/python/src/utils/utils.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +def parse_iso8601(date_string: str) -> datetime: + """ + Parse an ISO 8601 date string and return a timezone-aware datetime object. + """ + return datetime.fromisoformat(date_string.replace("Z", "+00:00")).astimezone( + timezone.utc + ) + + +def format_iso8601(dt: datetime) -> str: + """ + Format a timezone-aware datetime object as an ISO 8601 date string. + """ + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/e2e/Fender.postman_collection.json b/e2e/Fender.postman_collection.json new file mode 100644 index 0000000..f1e87b7 --- /dev/null +++ b/e2e/Fender.postman_collection.json @@ -0,0 +1,165 @@ +{ + "info": { + "_postman_id": "6bd79d8a-2b00-4d2b-bd09-e46941f91906", + "name": "Fender", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "9398736" + }, + "item": [ + { + "name": "subscriptions", + "item": [ + { + "name": "userId", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + } + ] + }, + { + "name": "webhooks", + "item": [ + { + "name": "New Request", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"content\": \"dummy\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + } + ] + }, + { + "name": "E2E", + "item": [ + { + "name": "createNewSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.created\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getNewSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + }, + { + "name": "renewSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.renewed\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getRenewSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + }, + { + "name": "cancelSubscription", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"eventId\": \"evt_123456789\",\n \"eventType\": \"subscription.cancelled\",\n \"timestamp\": \"2024-03-20T10:00:00Z\",\n \"provider\": \"STRIPE\",\n \"subscriptionId\": \"sub_456789\",\n \"paymentId\": \"pm_123456\",\n \"userId\": \"123\",\n \"customerId\": \"cus_789012\",\n \"expiresAt\": \"2024-04-20T10:00:00Z\",\n \"metadata\": {\n \"planSku\": \"PREMIUM_MONTHLY\",\n \"autoRenew\": true,\n \"paymentMethod\": \"CREDIT_CARD\"\n }\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{BASE_URL}}/api/v1/webhooks/subscriptions", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "webhooks", "subscriptions"] + } + }, + "response": [] + }, + { + "name": "getCancelSubscription", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{BASE_URL}}/api/v1/subscriptions/123", + "host": ["{{BASE_URL}}"], + "path": ["api", "v1", "subscriptions", "123"] + } + }, + "response": [] + } + ] + } + ] +} diff --git a/e2e/plansSample.json b/e2e/plansSample.json new file mode 100644 index 0000000..2d4039a --- /dev/null +++ b/e2e/plansSample.json @@ -0,0 +1,58 @@ +[ + { + "pk": "plan:XYZ123", + "sk": "metadata", + "type": "plan", + "name": "Basic Monthly Plan", + "price": 9.99, + "currency": "USD", + "billingCycle": "monthly", + "features": ["Access to basic features", "Email support"], + "status": "active", + "lastDateModified": "2024-06-10T12:00:00Z" + }, + { + "pk": "plan:ABC456", + "sk": "metadata", + "type": "plan", + "name": "Pro Yearly Plan", + "price": 99.99, + "currency": "USD", + "billingCycle": "yearly", + "features": [ + "Access to all features", + "Priority support", + "Monthly webinars" + ], + "status": "inactive", + "lastDateModified": "2024-05-15T08:30:00Z" + }, + { + "pk": "plan:DEF789", + "sk": "metadata", + "type": "plan", + "name": "Enterprise Plan", + "price": 499.99, + "currency": "USD", + "billingCycle": "yearly", + "features": [ + "Custom integrations", + "Dedicated account manager", + "24/7 support" + ], + "status": "active", + "lastDateModified": "2024-06-01T14:45:00Z" + }, + { + "pk": "plan:GHI012", + "sk": "metadata", + "type": "plan", + "name": "Starter Plan", + "price": 4.99, + "currency": "USD", + "billingCycle": "monthly", + "features": ["Limited feature access", "Community support"], + "status": "active", + "lastDateModified": "2024-06-12T09:15:00Z" + } +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..63820c8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "fds-aws-coding-exercise" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "faker>=37.12.0", + "pydantic>=2.12.4", + "python-dotenv>=1.2.1", +] + +[dependency-groups] +dev = [ + "boto3>=1.40.67", + "ipython>=9.7.0", + "pytest>=8.4.2", +] diff --git a/scripts/deploy-python.sh b/scripts/deploy-python.sh index 45eb394..8c0c615 100755 --- a/scripts/deploy-python.sh +++ b/scripts/deploy-python.sh @@ -3,7 +3,20 @@ mkdir .temp mkdir .temp/package -pip install -r app/python/requirements.txt -t .temp/package +# User `uv` for faster installs +uv pip install -r app/python/requirements.txt --target .temp/package +#! NOTE: Since we're using pydantic v2, please watch step in how to deploy +#! pydantic with AWS Lambda: +#! https://docs.pydantic.dev/latest/integrations/aws_lambda/#installing-pydantic-for-aws-lambda-functions +#! Commente out if not using pydantic v2 +pip install \ + --platform manylinux2014_x86_64 \ + --implementation cp \ + --python-version 3.13 \ + --only-binary=:all: \ + --target .temp/package \ + --upgrade pydantic + cp -r app/python/src/. .temp/package cd .temp/package diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7eb314a --- /dev/null +++ b/uv.lock @@ -0,0 +1,487 @@ +version = 1 +revision = 1 +requires-python = ">=3.12" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + +[[package]] +name = "boto3" +version = "1.40.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/a5/0e87ff413d2ca57500b1ec9e583a83589ed56fc27af8bacf8f0681c28672/boto3-1.40.67.tar.gz", hash = "sha256:3e4317139ace6d44658b8e1f2b5b6612f05b45720721841c90cdee45b02aa514", size = 111587 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/2d/f4896b59ff21d1bf228bde2973b5c5af2debc71e11137533dd088f094846/boto3-1.40.67-py3-none-any.whl", hash = "sha256:3d06e9b3c7abedb8253c7d75b9ab27005480ca1e6e448d1f3c3cc3e209673ca0", size = 139362 }, +] + +[[package]] +name = "botocore" +version = "1.40.67" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/aa/4d3d04e3fb2f497fbe574051d50180a6326ffef481caea80837605a0016d/botocore-1.40.67.tar.gz", hash = "sha256:cc086f39c877aee0ea8dc88ef69062c9f395b9d30d49bfcfac7b8b7e61864b3a", size = 14417097 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/65/2b50bb0112d6e2c171c8e07cc7f2a0581d39b850921d4defdf5421098fc9/botocore-1.40.67-py3-none-any.whl", hash = "sha256:e49e61f6718e8bc8b34e9bb8a97f16c8dc560485faef4981b55d76f825c9d78a", size = 14081807 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, +] + +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461 }, +] + +[[package]] +name = "fds-aws-coding-exercise" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "faker" }, + { name = "pydantic" }, + { name = "python-dotenv" }, +] + +[package.dev-dependencies] +dev = [ + { name = "boto3" }, + { name = "ipython" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "faker", specifier = ">=37.12.0" }, + { name = "pydantic", specifier = ">=2.12.4" }, + { name = "python-dotenv", specifier = ">=1.2.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "boto3", specifier = ">=1.40.67" }, + { name = "ipython", specifier = ">=9.7.0" }, + { name = "pytest", specifier = ">=8.4.2" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "ipython" +version = "9.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/e6/48c74d54039241a456add616464ea28c6ebf782e4110d419411b83dae06f/ipython-9.7.0.tar.gz", hash = "sha256:5f6de88c905a566c6a9d6c400a8fed54a638e1f7543d17aae2551133216b1e4e", size = 4422115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/aa/62893d6a591d337aa59dcc4c6f6c842f1fe20cd72c8c5c1f980255243252/ipython-9.7.0-py3-none-any.whl", hash = "sha256:bce8ac85eb9521adc94e1845b4c03d88365fd6ac2f4908ec4ed1eb1b0a065f9f", size = 618911 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jmespath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "parso" +version = "0.8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/de/53e0bcf53d13e005bd8c92e7855142494f41171b34c2536b86187474184d/parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a", size = 401205 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668 }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pydantic" +version = "2.12.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400 }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495 }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388 }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879 }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + +[[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 } +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 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, +] + +[[package]] +name = "s3transfer" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +] + +[[package]] +name = "urllib3" +version = "2.5.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286 }, +]