From 14cdafbec93377540e4f3454c02b24591d77032a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Wed, 26 Nov 2025 21:23:48 -0300 Subject: [PATCH] wip emails --- api.saladeaula.digital/app/app.py | 1 + .../app/routes/courses/__init__.py | 27 ++- .../app/routes/register/__init__.py | 13 -- .../app/routes/users/emails.py | 180 +++++++++++++++++- .../app/routes/users/orgs.py | 12 +- api.saladeaula.digital/template.yaml | 8 + .../tests/routes/test_users.py | 25 ++- api.saladeaula.digital/tests/seeds.jsonl | 3 +- api.saladeaula.digital/uv.lock | 68 ++++++- 9 files changed, 296 insertions(+), 41 deletions(-) delete mode 100644 api.saladeaula.digital/app/routes/register/__init__.py diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index 2deed8c..5607577 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -32,6 +32,7 @@ app = APIGatewayHttpResolver( debug=debug, serializer=serializer, ) +app.enable_swagger(path='/swagger') app.include_router(courses.router, prefix='/courses') app.include_router(enrollments.router, prefix='/enrollments') app.include_router(enrollments.cancel, prefix='/enrollments') diff --git a/api.saladeaula.digital/app/routes/courses/__init__.py b/api.saladeaula.digital/app/routes/courses/__init__.py index eb53708..0f83777 100644 --- a/api.saladeaula.digital/app/routes/courses/__init__.py +++ b/api.saladeaula.digital/app/routes/courses/__init__.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from http import HTTPStatus from io import BytesIO @@ -114,21 +113,19 @@ def sample(course_id: str, s3_uri: Annotated[str, Body(embed=True)]): # Send template URI and data to Paperforge API to generate a PDF r = requests.post( PAPERFORGE_API, - data=json.dumps( - { - 'template_uri': s3_uri, - 'args': { - 'name': 'Juscelino Kubitschek', - 'cpf': '***.810.132-**', - 'score': 100, - 'started_at': now_.strftime('%d/%m/%Y'), - 'completed_at': now_.strftime('%d/%m/%Y'), - 'today': _datefmt(now_), - 'year': now_.strftime('%Y'), - 'expires_at': now_.strftime('%d/%m/%Y'), - }, + json={ + 'template_uri': s3_uri, + 'args': { + 'name': 'Juscelino Kubitschek', + 'cpf': '***.810.132-**', + 'score': 100, + 'started_at': now_.strftime('%d/%m/%Y'), + 'completed_at': now_.strftime('%d/%m/%Y'), + 'today': _datefmt(now_), + 'year': now_.strftime('%Y'), + 'expires_at': now_.strftime('%d/%m/%Y'), }, - ), + }, ) r.raise_for_status() diff --git a/api.saladeaula.digital/app/routes/register/__init__.py b/api.saladeaula.digital/app/routes/register/__init__.py deleted file mode 100644 index 9a565e0..0000000 --- a/api.saladeaula.digital/app/routes/register/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from aws_lambda_powertools.event_handler.api_gateway import Router -from layercake.dynamodb import DynamoDBPersistenceLayer - -from boto3clients import dynamodb_client -from config import USER_TABLE - -router = Router() -dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) - - -@router.post('/register') -def register(): - return {} diff --git a/api.saladeaula.digital/app/routes/users/emails.py b/api.saladeaula.digital/app/routes/users/emails.py index e9c29bc..302216f 100644 --- a/api.saladeaula.digital/app/routes/users/emails.py +++ b/api.saladeaula.digital/app/routes/users/emails.py @@ -1,6 +1,18 @@ -from aws_lambda_powertools.event_handler.api_gateway import Router -from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair +from http import HTTPStatus +from uuid import uuid4 +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.exceptions import ( + NotFoundError, + ServiceError, +) +from aws_lambda_powertools.event_handler.openapi.params import Body, Path, Query +from layercake.dateutils import now, ttl +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, SortKey +from pydantic import EmailStr +from typing_extensions import Annotated + +from api_gateway import JSONResponse from boto3clients import dynamodb_client from config import USER_TABLE @@ -9,11 +21,169 @@ dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) @router.get('//emails') -def get_emails(user_id: str): - start_key = router.current_event.get_query_string_value('start_key', None) - +def get_emails(user_id: str, start_key: Annotated[str | None, Query] = None): return dyn.collection.query( # Post-migration (users): rename `emails` to `EMAIL` key=KeyPair(user_id, 'emails'), start_key=start_key, ) + + +class EmailConflictError(ServiceError): + def __init__(self, msg: str | dict): + super().__init__(HTTPStatus.CONFLICT, msg) + + +@router.post('//emails') +def add( + user_id: str, + email: Annotated[EmailStr, Body(embed=True)], +): + now_ = now() + name = dyn.collection.get_item( + KeyPair(user_id, SortKey('0', path_spec='name')), + raise_on_error=False, + ) + + with dyn.transact_writer() as transact: + transact.put( + item={ + 'id': user_id, + # Post-migration (users): rename `emails` to `EMAIL` + 'sk': f'emails#{email}', + 'email_verified': False, + 'email_primary': True, + 'created_at': now_, + } + ) + transact.put( + item={ + # Post-migration (users): rename `email` to `EMAIL` + 'id': 'email', + 'sk': email, + 'created_at': now_, + }, + cond_expr='attribute_not_exists(sk)', + exc_cls=EmailConflictError, + ) + transact.put( + item={ + 'id': 'EMAIL_VERIFICATION', + 'sk': uuid4(), + 'name': name, + 'email': email, + 'user_id': user_id, + 'ttl': ttl(start_dt=now_, days=30), + 'created_at': now_, + } + ) + + return JSONResponse(status_code=HTTPStatus.CREATED) + + +class EmailVerificationNotFoundError(NotFoundError): ... + + +@router.post('//emails//verify') +def verify(user_id: str, hash: str): + email = dyn.collection.get_item( + KeyPair( + pk='EMAIL_VERIFICATION', + sk=SortKey(hash, path_spec='email'), + ), + exc_cls=EmailVerificationNotFoundError, + ) + + with dyn.transact_writer() as transact: + transact.delete(key=KeyPair('EMAIL_VERIFICATION', hash)) + transact.update( + key=KeyPair(user_id, f'emails#{email}'), + update_expr='SET email_verified = :true, updated_at = :now', + expr_attr_values={ + ':true': True, + ':now': now(), + }, + ) + + return JSONResponse(status_code=HTTPStatus.NO_CONTENT) + + +@router.patch('//emails/primary') +def primary( + user_id: str, + old_email: Annotated[EmailStr, Body(embed=True)], + new_email: Annotated[EmailStr, Body(embed=True)], + email_verified: Annotated[bool, Body(embed=True)], +): + now_ = now() + expr = 'SET email_primary = :email_primary, updated_at = :updated_at' + + with dyn.transact_writer() as transact: + # Set the old email as non-primary + transact.update( + # Post-migration (users): rename `emails` to `EMAIL` + key=KeyPair(user_id, f'emails#{old_email}'), + update_expr=expr, + expr_attr_values={ + ':email_primary': False, + ':updated_at': now_, + }, + cond_expr='attribute_exists(sk)', + ) + # Set the new email as primary + transact.update( + # Post-migration (users): rename `emails` to `EMAIL` + key=KeyPair(user_id, f'emails#{new_email}'), + update_expr=expr, + expr_attr_values={ + ':email_primary': True, + ':updated_at': now_, + }, + cond_expr='attribute_exists(sk)', + ) + transact.update( + key=KeyPair(user_id, '0'), + update_expr='DELETE emails :email_set \ + SET email = :email, \ + email_verified = :email_verified, \ + updated_at = :updated_at', + expr_attr_values={ + ':email': new_email, + ':email_set': {new_email}, + ':email_verified': email_verified, + ':updated_at': now_, + }, + ) + + return JSONResponse(status_code=HTTPStatus.NO_CONTENT) + + +@router.delete('//emails/') +def remove( + user_id: str, + email: Annotated[EmailStr, Path], +): + with dyn.transact_writer() as transact: + transact.delete( + # Post-migration (users): rename `email` to `EMAIL` + key=KeyPair('email', email), + ) + transact.delete( + # Post-migration (users): rename `emails` to `EMAIL` + key=KeyPair(user_id, f'emails#{email}'), + # Delete any email except the primary email + cond_expr='email_primary <> :email_primary', + expr_attr_values={ + ':email_primary': True, + }, + exc_cls=EmailConflictError, + ) + transact.update( + key=KeyPair(user_id, '0'), + update_expr='DELETE emails :email', + expr_attr_values={ + ':email': {email}, + }, + ) + + return JSONResponse(status_code=HTTPStatus.NO_CONTENT) diff --git a/api.saladeaula.digital/app/routes/users/orgs.py b/api.saladeaula.digital/app/routes/users/orgs.py index 3114234..ce94bbd 100644 --- a/api.saladeaula.digital/app/routes/users/orgs.py +++ b/api.saladeaula.digital/app/routes/users/orgs.py @@ -1,4 +1,7 @@ +from typing import Annotated + from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.openapi.params import Query from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client @@ -9,10 +12,11 @@ dyn = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) @router.get('//orgs') -def get_orgs(user_id: str): - start_key = router.current_event.get_query_string_value('start_key', None) - limit = int(router.current_event.get_query_string_value('limit', '25')) - +def get_orgs( + user_id: str, + start_key: Annotated[str | None, Query] = None, + limit: Annotated[int, Query(ge=25)] = 25, +): return dyn.collection.query( # Post-migration (users): rename `orgs` to `ORG` key=KeyPair(user_id, 'orgs#'), diff --git a/api.saladeaula.digital/template.yaml b/api.saladeaula.digital/template.yaml index 5e1ae16..9ca5639 100644 --- a/api.saladeaula.digital/template.yaml +++ b/api.saladeaula.digital/template.yaml @@ -111,6 +111,14 @@ Resources: ApiId: !Ref HttpApi Auth: Authorizer: NONE + Swagger: + Type: HttpApi + Properties: + Path: /swagger + Method: GET + ApiId: !Ref HttpApi + Auth: + Authorizer: NONE EventKeepWarmScheduledFunction: Type: AWS::Serverless::Function diff --git a/api.saladeaula.digital/tests/routes/test_users.py b/api.saladeaula.digital/tests/routes/test_users.py index 7c42ab8..48fef6c 100644 --- a/api.saladeaula.digital/tests/routes/test_users.py +++ b/api.saladeaula.digital/tests/routes/test_users.py @@ -20,7 +20,29 @@ def test_get_emails( assert r['statusCode'] == HTTPStatus.OK body = json.loads(r['body']) - assert len(body['items']) == 1 + assert len(body['items']) == 2 + + +def test_email_as_primary( + app, + seeds, + http_api_proxy: HttpApiProxy, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/users/15bacf02-1535-4bee-9022-19d106fd7518/emails/primary', + method=HTTPMethod.PATCH, + body={ + 'old_email': 'sergio@somosbeta.com.br', + 'new_email': 'osergiosiqueira@gmail.com', + 'email_verified': 'false', + }, + ), + lambda_context, + ) + + assert r['statusCode'] == HTTPStatus.NO_CONTENT def test_get_orgs( @@ -36,4 +58,5 @@ def test_get_orgs( ), lambda_context, ) + assert r['statusCode'] == HTTPStatus.OK diff --git a/api.saladeaula.digital/tests/seeds.jsonl b/api.saladeaula.digital/tests/seeds.jsonl index 1a45add..d48b328 100644 --- a/api.saladeaula.digital/tests/seeds.jsonl +++ b/api.saladeaula.digital/tests/seeds.jsonl @@ -2,6 +2,7 @@ {"id": "213a6682-2c59-4404-9189-12eec0a846d4", "sk": "orgs#f6000f79-6e5c-49a0-952f-3bda330ef278", "name": "Banco do Brasil", "cnpj": "00000000000191"} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "0", "name": "Sérgio R Siqueira", "email": "sergio@somosbeta.com.br", "cpf": "07879819908"} {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#sergio@somosbeta.com.br", "email_primary": true, "mx_record_exists": true} +{"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "emails#osergiosiqueira@gmail.com", "mx_record_exists": true} // User orgs {"id": "15bacf02-1535-4bee-9022-19d106fd7518", "sk": "orgs#f6000f79-6e5c-49a0-952f-3bda330ef278", "name": "Banco do Brasil", "cnpj": "00000000000191"} @@ -32,4 +33,4 @@ {"id": "email", "sk": "sergio@somosbeta.com.br", "user_id": "15bacf02-1535-4bee-9022-19d106fd7518"} // Course scormset -{"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "sk": "SCORMSET#76c75561-d972-43ef-9818-497d8fc6edbe", "packages": [{"version": "1.2", "scormdriver": "s3://saladeaula.digital/scorm/nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html"}]} \ No newline at end of file +{"id": "c27d1b4f-575c-4b6b-82a1-9b91ff369e0b", "sk": "SCORMSET#76c75561-d972-43ef-9818-497d8fc6edbe", "packages": [{"version": "1.2", "scormdriver": "s3://saladeaula.digital/scorm/nr-33-espacos-confinados-conteudo-de-demonstracao-scorm12/scormdriver/indexAPI.html"}]} diff --git a/api.saladeaula.digital/uv.lock b/api.saladeaula.digital/uv.lock index 2d063c6..83591d8 100644 --- a/api.saladeaula.digital/uv.lock +++ b/api.saladeaula.digital/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" [[package]] @@ -592,7 +592,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.11.1" +version = "0.11.2" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, @@ -609,6 +609,7 @@ dependencies = [ { name = "pycpfcnpj" }, { name = "pydantic", extra = ["email"] }, { name = "pydantic-extra-types" }, + { name = "python-calamine" }, { name = "python-multipart" }, { name = "pytz" }, { name = "requests" }, @@ -633,6 +634,7 @@ requires-dist = [ { name = "pycpfcnpj", specifier = ">=1.8" }, { name = "pydantic", extras = ["email"], specifier = ">=2.10.6" }, { name = "pydantic-extra-types", specifier = ">=2.10.3" }, + { name = "python-calamine", specifier = ">=0.5.4" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "pytz", specifier = ">=2025.1" }, { name = "requests", specifier = ">=2.32.3" }, @@ -972,6 +974,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, ] +[[package]] +name = "python-calamine" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/32/99a794a1ca7b654cecdb76d4d61f21658b6f76574321341eb47df4365807/python_calamine-0.6.1.tar.gz", hash = "sha256:5974989919aa0bb55a136c1822d6f8b967d13c0fd0f245e3293abb4e63ab0f4b", size = 138354, upload-time = "2025-11-26T10:48:35.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b6/d9b1a6432d33d43ded44ca01dff2c2a41f68a169413bdbe7677fc6598bfc/python_calamine-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:44dcffccbc3d9d258848d84ed685803ecb196f6b44bff271418283a0d015a6ea", size = 877262, upload-time = "2025-11-26T10:46:49.271Z" }, + { url = "https://files.pythonhosted.org/packages/4d/09/29a113debc6c389065057c9f72e8837760b36ae86a6363a31c18b699adfb/python_calamine-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:66373ae273ef356a55b53c2348335274b6d25c08d75a399a3f167d93e13aa1b6", size = 854634, upload-time = "2025-11-26T10:46:50.716Z" }, + { url = "https://files.pythonhosted.org/packages/89/c4/0a68314336b8b1d04ae1cda98cc8c191829547d652394f34e5360d9563c9/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02482677cea6d3c2a09008469b7f5544d4d8c79af8fc7d49edcc669cfc75f640", size = 927779, upload-time = "2025-11-26T10:46:52.146Z" }, + { url = "https://files.pythonhosted.org/packages/29/ab/ce23029f808e31e12fe9ca26b038b67c8f065b9c666a1e73aacaa086d177/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6794c55fa3d3dc88deda7377fc721b6506d186ec149e04b38109b1f58cc0b61f", size = 912282, upload-time = "2025-11-26T10:46:53.875Z" }, + { url = "https://files.pythonhosted.org/packages/90/d9/e4bfad521a92ebb330f16a0ab7ad57da35ded14d90e9e395e97aacd63bef/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79c33a5384221d8ab7d4b91b83374317b403ef945b5aa18f6e6ea6cbba661393", size = 1071785, upload-time = "2025-11-26T10:46:55.735Z" }, + { url = "https://files.pythonhosted.org/packages/ee/e8/18894883669644da9d14f8c6db0db00b793eaac3cd7268bcafb4a73b9837/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e36211a7feaa56d12e8ea1ddeeae6c4887764c351c275b034c07c9b7d66455e", size = 964443, upload-time = "2025-11-26T10:46:57.208Z" }, + { url = "https://files.pythonhosted.org/packages/0c/0d/7482fcded940d1adc4c8eaf47488a69ef1e3fd86eb8c6d33a981ddf5f82a/python_calamine-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7c3e6aeedeb289311907f8d59b2a32a404433d1af4dfce0ba4e3badd30f9775d", size = 932682, upload-time = "2025-11-26T10:46:59.006Z" }, + { url = "https://files.pythonhosted.org/packages/ee/88/4898de6ce811c936168b48c92d310bba0e8f4ab6e56059b537d9d6d72c05/python_calamine-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a2aa2989e5231cda2a15d21fd6e7cf3fc4ce09535756bdb7b2f32197fd6a566a", size = 975624, upload-time = "2025-11-26T10:47:00.844Z" }, + { url = "https://files.pythonhosted.org/packages/10/1e/85ef4693452cc21cb912e32e33c8aa4add399b3fb0c1af8036692fd33f61/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e5761dc896446d6e9dd40c7e781908c1ae919d31bdd00b5dedc033525f440dec", size = 1110373, upload-time = "2025-11-26T10:47:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/2f/18/67aaa61c4bea9fd99ed44ff50e93fac70096b992275bae3552f98f6a1229/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:d1118d9d4f626f62663bfd5c83de07bc8455463081de6bc3b4264414e81a56a9", size = 1179486, upload-time = "2025-11-26T10:47:04.067Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/73baef823b41f7b50a86ddb36d1ea2c19882414568aaa2d8ed7afb96dc71/python_calamine-0.6.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7e5500a8769bdf0efaef10bcce2613d5240823891172d1a943b776f18977c2f1", size = 1108067, upload-time = "2025-11-26T10:47:05.873Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f2/db7fc4d14ff0bf8a8bf3ee43daad2e63fc2f46605e5972d97543e0f95e62/python_calamine-0.6.1-cp313-cp313-win32.whl", hash = "sha256:ec7928740519a8471ad8f1ec429301fb8a31a9c6adbfea51d7ff6ef2cb116835", size = 695391, upload-time = "2025-11-26T10:47:07.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c9/2e6b5d073885051ee7b5947156678c0cf5dfedf0dd10c5f23b694dcef824/python_calamine-0.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8f24740645a773cefae8507a13d03981867fa3dbd7fad1c3c667a1a3cd43235b", size = 747094, upload-time = "2025-11-26T10:47:08.69Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c4/8ff9ecfe3b9b2bf556474e8ee8de541edfd650fd3e77752fa5705cbee3dc/python_calamine-0.6.1-cp313-cp313-win_arm64.whl", hash = "sha256:8e4ac2732aadc98bee412b59770dc6f4a6a886b5308cb57bfea53e877ae1a913", size = 716857, upload-time = "2025-11-26T10:47:11.062Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0d/83e44b3cbc7712ffac7750b14a817e34637904bcaa435626799506bf998b/python_calamine-0.6.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:caab3bafa99b62d0aed0abf261a9f9df045eef11c5410ed91aa1b25f8381a087", size = 873582, upload-time = "2025-11-26T10:47:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/1f/7e/b47cfe737f885b139dae63f4139cb2ed1515994b465cf0370e25ce8d0065/python_calamine-0.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3aefcdea5bdd2972e999264435b97e71855f02481688d213a4473d372b8288b0", size = 850739, upload-time = "2025-11-26T10:47:13.989Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ea/6aa2f277271323c5fbbde8718a7cad5ecf1fed9f637f648b0f6ae2c240cd/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2e9d10c91308eacfc1f76ff08bb7a8316c61f8f47619f9e4e254dd888fb3e9b", size = 923053, upload-time = "2025-11-26T10:47:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/00/2a/bf6ff24816fa60646d61a00f8a69113239a6a97207cdb2d541936003d030/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:71eb5069b3d3639594a4fdccb3cb95a1b8f650e12def39a752ad8ff19eea620f", size = 907953, upload-time = "2025-11-26T10:47:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/54bb664dc9cc1252207bf5512d9870be23fdba2e5b94300d7e32e8c39a82/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:def9e6be95496c660b6dc55b37eac3c6a479a71522e849f3a1ed4435788c6599", size = 1071663, upload-time = "2025-11-26T10:47:18.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b7/4e2e5c8fd00ee7d80d272cb5e3cf170615a99911b515a2b4347995df0aa8/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c4740797c7e794acd907c7fa84ec09931ed2dfc3c9d1c689f7c7d236498d74cc", size = 961235, upload-time = "2025-11-26T10:47:21.117Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/25193d600bf0e48513d275a69e5cdb158c27d11573bed74a28eb88d88592/python_calamine-0.6.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b67f1a9f7452fa6ee736ac5a59349bbfc66087b96402051656c9b5a54a111ef", size = 930561, upload-time = "2025-11-26T10:47:22.904Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/b0f434622c31182b64bd2e0e6c81cf35cf240ccee38cfb8074fbde9add98/python_calamine-0.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1f369ebb8d6bf2ac66fbe38f5e6adf7b6a81fa71c1b5e2e7b2bb4a5c9667711", size = 971200, upload-time = "2025-11-26T10:47:24.837Z" }, + { url = "https://files.pythonhosted.org/packages/39/8e/502bbb06fa70f1f52f4f46efc0b331b31124110986a5378c1be711ad05e9/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:99bf12605466097219ebb133df54e41e479cb2559359d2dbad624dc301d4286b", size = 1106302, upload-time = "2025-11-26T10:47:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/c7/63/6fbda3f58aa5907cdfb628fc96e26e10820000858a9dd4fe6053e05a9310/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:96a44d48b9c4b05fb70396674ca7c90e4b4286845b5937606b60babe90f1fa4c", size = 1174437, upload-time = "2025-11-26T10:47:28.229Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/9e027e79de13424844ab33b6e2ad2b2be9ac40b653040bc8459bbfe4b48f/python_calamine-0.6.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f7bfaf556391841ea59d0d0a63c5af7b5285ab260103656e65f55384b31b2010", size = 1105843, upload-time = "2025-11-26T10:47:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/cd/80/231c1f02d3d5adfde8c1f324da2c7907b63adb6f9ef36c3fd7db5b5fe083/python_calamine-0.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a46ff8aa52ea3ef7851d6b5fd496be72a10db4f2d8942b42ecb0634ff9c1e441", size = 746797, upload-time = "2025-11-26T10:47:31.333Z" }, + { url = "https://files.pythonhosted.org/packages/88/2d/8c18519847dd53227c472231bcca37086027dd54b40ae13c48da7bacea53/python_calamine-0.6.1-cp313-cp313t-win_arm64.whl", hash = "sha256:7ac72743c3b2398ed55b9130482db097da8cb80d61b4b7aaf4008c7831ac11d3", size = 711966, upload-time = "2025-11-26T10:47:32.995Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/974515fe4e871fc8ff2495ebd1a59585fe56956b83096bd8f17c76716951/python_calamine-0.6.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:957412de027ef6c05da0ad687c7a5111229108c1c81780a94ea64ca6afa10074", size = 874587, upload-time = "2025-11-26T10:47:34.823Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1c/185a871429bcd19a00d0df8a5f5a6469dfd5d5e86039d43df6d98b913cd1/python_calamine-0.6.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5393b60b81e2c7d6f54b26bca8fb47c032bc35531ea3bb38ae5ffdefd6ba2b6d", size = 851804, upload-time = "2025-11-26T10:47:36.809Z" }, + { url = "https://files.pythonhosted.org/packages/16/f0/a1b18653d621efac176ae63b3b4b4fdcf2b9d8706ffec75b0d4dbf02c1d2/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efdf70f647fe51638f4a2d0efb0644f132eb2bc32b0268f2c8477e23d56302f4", size = 925164, upload-time = "2025-11-26T10:47:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/e4/4e/1ad2bcea9bbd9e5eed89626391d63759c800cd9064e13dd8f17d9084ddbf/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bf893d3526af30d0e4152de54621cf440d5d9fe99882adac02803a9f870da76", size = 908880, upload-time = "2025-11-26T10:47:40.239Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bb/bd5fe13c89f2e39f439f6f3535f34c3d29fb5280fa7e6a6b9f101547a1eb/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2905f241beff9945b1c4a3622ddc9cf604b1825a26683b35a8f97533c983b228", size = 1077935, upload-time = "2025-11-26T10:47:41.738Z" }, + { url = "https://files.pythonhosted.org/packages/98/8d/fde8575220ecbbf1a3a3eeb6c9fd96288bfadf1eb9fca4eb89ebfb81ce8e/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39a722be084690516e0bf6260cc452cf783ef72f01a18c0d1daf428dc88cf090", size = 961729, upload-time = "2025-11-26T10:47:43.238Z" }, + { url = "https://files.pythonhosted.org/packages/a7/75/d6da93f82e07359710bb472822e4e4f964bc712a16a86b009f97679ea0c0/python_calamine-0.6.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33e9180c7018ecaf5d8648b6a9c54381d467bf622dccc5d8fa90ae727b21ca46", size = 931109, upload-time = "2025-11-26T10:47:44.855Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/abdacdf1ffec109ebb52eae3edbb110de3350d54c2a6232e3d88acabc8ec/python_calamine-0.6.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18d00677bd9f2fad3924d1101d95ac0057f98ebde406034d5782c1f14d4f6c64", size = 972567, upload-time = "2025-11-26T10:47:46.424Z" }, + { url = "https://files.pythonhosted.org/packages/56/36/b7aa35eab36515216759be0fa2f6702ec1ac20168f239d220a0027c3c2f4/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:df8c6bdfc6eefbeae35a8f9fdfbf85d954f944b9c8aea8e43e1cdde1d50eb686", size = 1108588, upload-time = "2025-11-26T10:47:48.019Z" }, + { url = "https://files.pythonhosted.org/packages/19/d1/33c947f2541006f6d196bf7b9f1d5211592c36398027381b27c69dea8a6f/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:96107062d9e5f696e5b15b4c67b40acc136607bc880c2368797051e26478bd9e", size = 1175173, upload-time = "2025-11-26T10:47:49.631Z" }, + { url = "https://files.pythonhosted.org/packages/cf/84/46ca9e32572ea0c8ba0fbe489c7a15dc0af0d266331e3e0ae44a7d841767/python_calamine-0.6.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c3d9f2f5f5712dc5c59707a1211781339738b9ede7611c049995327e26e99f6d", size = 1107963, upload-time = "2025-11-26T10:47:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d7/043fbe723313ab52d3e7f81465287d507a3237d442ac913ed168172dc9f2/python_calamine-0.6.1-cp314-cp314-win32.whl", hash = "sha256:46563dd5424a7e0e6d8845bf4263455364749517493690a7af8c98c7803d7348", size = 694668, upload-time = "2025-11-26T10:47:54.028Z" }, + { url = "https://files.pythonhosted.org/packages/e9/93/5690f52c267dbcde420a2db0e39158eb78ae85083137db2bda3387232116/python_calamine-0.6.1-cp314-cp314-win_amd64.whl", hash = "sha256:8fdff080b3c46527d90f8d8c593400d39f02c126bd4ed477b845603f86524b52", size = 744792, upload-time = "2025-11-26T10:47:55.488Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/360c6cfd78bee2707d1f294bd74ecb2662abfc9ee9786a373869403c5737/python_calamine-0.6.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8d7a18a2385d7302f4d82ff2789765e725afa95339f35e33b27d43ef7914e91", size = 714327, upload-time = "2025-11-26T10:47:57.035Z" }, + { url = "https://files.pythonhosted.org/packages/18/26/d0f619823b511606490359d8b7f2090f17233373eac5fd9ad7bb5bab01a8/python_calamine-0.6.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c863c5f447fab38d72f272ab388e9e38552a1e034446c97a358008397d290fca", size = 874069, upload-time = "2025-11-26T10:47:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/a0687797d3ee024611fb4ba9e3d658742bcfed10ab979c6ba8cb7028c225/python_calamine-0.6.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a20b752042ab833724d4118ae107072b9b575142dc7e9c142989c3613c0b7094", size = 852456, upload-time = "2025-11-26T10:48:00.325Z" }, + { url = "https://files.pythonhosted.org/packages/01/09/6ebea8e51791fb2fe6d9651f0de54adae20fdb7eb9b9654897c855b7a939/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:350b02f2101132e9faf04784849370eabfc4d65b070fe76f07cbe46deee67850", size = 923253, upload-time = "2025-11-26T10:48:01.894Z" }, + { url = "https://files.pythonhosted.org/packages/54/63/a32eaca9cb65608109ec393a2ebcef5e9fad7c6cfc7b464a5f6cf1b595ba/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec23faed1922a1e1c966fe1f09a573de4921303b97304bda82f5d764c55f905b", size = 909063, upload-time = "2025-11-26T10:48:03.759Z" }, + { url = "https://files.pythonhosted.org/packages/90/cc/64a81e3ebd0d8fe79b2120f748db7dcd733abe11a9d97d00921ab60c02c4/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd14ea56bf194d6da8103d5b3c16fcafed666843d3ad4ae77d1efbb04912de5", size = 1070734, upload-time = "2025-11-26T10:48:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a9/04c29089240763f559ab69be6794fe4209acf16306c051fe0fc4afb40f8a/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e06444e75411a7a5cff3ee5b4c7f831897b549cc720b9a66740be1045980e634", size = 960622, upload-time = "2025-11-26T10:48:06.935Z" }, + { url = "https://files.pythonhosted.org/packages/19/3e/9659b179b9e28b7895f32d0b0f0a09474b263fe001abaf1009b51b1b7b9c/python_calamine-0.6.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb4e4277b94d3e07d6045de2b2b1995cd093399f54dacc441acdb86ec4e6a4f", size = 929758, upload-time = "2025-11-26T10:48:08.56Z" }, + { url = "https://files.pythonhosted.org/packages/45/43/4cb1603b1452ecb3b1a34863b193fce54dc2b048b961a51652d2116a5998/python_calamine-0.6.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1f722f72abb43fc2eabf2e74472ec2a30a6fbcf90836927da430d36a0fe26c83", size = 971930, upload-time = "2025-11-26T10:48:10.212Z" }, + { url = "https://files.pythonhosted.org/packages/80/d8/939fb61b1a085a8f96a2e3e86872c23f23377070dc582ba0d1066cbc973b/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ac3e464ab5df1ef1a2eff0932a2c5431a35c41b4c7dd8030fd76b4abba53a11c", size = 1106265, upload-time = "2025-11-26T10:48:12.107Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/22103aab600f89ab99d8b9538e92b37f4e6e520a8caceb73e421cb6b996b/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:ee671cb13e1e68f4669e85ca8cc365dcc62a1a023d288c1b3feeab98512a63f5", size = 1175335, upload-time = "2025-11-26T10:48:13.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/950bf18c38964f84639fe530162c40aea23f1473eeb78668096211984e56/python_calamine-0.6.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3019d81aea47e8fea6c08a2c5310faeef1d3119e2b11409f1aae86b4dc5aaff3", size = 1104826, upload-time = "2025-11-26T10:48:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/ea8e77509b9ca8ea1e70f4660b854e4d38b84c76aba4ee7c973423a613ba/python_calamine-0.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89d11e9022bc1aec124d5a5bc5a34e703a6b7e22171558231e05c84ac56ec79b", size = 745873, upload-time = "2025-11-26T10:48:17.028Z" }, + { url = "https://files.pythonhosted.org/packages/f4/99/6a2be914635f50ccd9296fcb39f7566f354d28ca20acc93085ce610e9d23/python_calamine-0.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a57ad2e1feb443ef0b197b7717200f786c3e3a3412bf88a9bfef0792ab848f58", size = 711796, upload-time = "2025-11-26T10:48:18.57Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"