From 86bdb41216baf60b377a3659abda81e7ee9f645d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Sat, 12 Apr 2025 21:04:02 -0300 Subject: [PATCH] add docs --- http-api/boto3clients.py | 1 + http-api/cli/seeds.py | 13 +++--- http-api/cognito.py | 26 +++++++++++ http-api/course.py | 10 ++--- http-api/routes/enrollments/__init__.py | 59 +++++++++++++++++++++---- http-api/routes/settings/__init__.py | 4 +- http-api/routes/users/__init__.py | 35 +++++++-------- http-api/seeds/test-users.jsonl | 5 +++ http-api/settings.py | 2 + http-api/template.yaml | 11 ++++- http-api/tests/routes/test_courses.py | 2 +- http-api/tests/seeds.jsonl | 5 ++- http-api/uv.lock | 24 +++++++++- layercake/docs/reference.md | 1 - layercake/layercake/dynamodb.py | 52 +++++++++++++++++++++- layercake/layercake/extra_types.py | 6 ++- layercake/layercake/funcs.py | 58 ++++++++++++++++++++---- layercake/mkdocs.yml | 3 +- layercake/uv.lock | 2 +- 19 files changed, 259 insertions(+), 60 deletions(-) delete mode 100644 layercake/docs/reference.md diff --git a/http-api/boto3clients.py b/http-api/boto3clients.py index d2f66a3..4ce8bbd 100644 --- a/http-api/boto3clients.py +++ b/http-api/boto3clients.py @@ -13,3 +13,4 @@ if 'PYTEST_VERSION' in os.environ: DYNAMODB_ENDPOINT_URL = 'http://127.0.0.1:8000' dynamodb_client = boto3.client('dynamodb', endpoint_url=DYNAMODB_ENDPOINT_URL) +idp_client = boto3.client('cognito-idp') diff --git a/http-api/cli/seeds.py b/http-api/cli/seeds.py index 4bdbc16..950cd7e 100644 --- a/http-api/cli/seeds.py +++ b/http-api/cli/seeds.py @@ -57,10 +57,11 @@ class Elastic: index: str, doc: dict, ): - return self.client.index( + return self.client.update( index=index, id=id, - document=_serialize_to_basic_types(doc), + doc=_serialize_to_basic_types(doc), + doc_as_upsert=True, ) def delete_index(self, index: str) -> bool: @@ -90,12 +91,12 @@ if __name__ == '__main__': # Populate DynamoDB tables with data from JSONL files for file in tqdm(jsonl_files, desc='Processing files'): - with jsonlines.open(f'seeds/{file}') as lines: + with open(f'seeds/{file}') as fp: table_name = file.removesuffix('.jsonl') - reader = jsonlines.Reader(fp) + reader = jsonlines.Reader(fp).iter(skip_invalid=True) - for line in tqdm(lines, desc=f'Processing lines in {file}'): - put_item(line, table_name, dynamodb_client) + for line in tqdm(reader, desc=f'Processing lines in {file}'): + put_item(line, table_name, dynamodb_client) # type: ignore # Scan DynamoDB tables and index the data into Elasticsearch for file in tqdm(jsonl_files, desc='Scanning tables'): diff --git a/http-api/cognito.py b/http-api/cognito.py index 2870bd7..b9f96d0 100644 --- a/http-api/cognito.py +++ b/http-api/cognito.py @@ -1,3 +1,8 @@ +from aws_lambda_powertools import Logger + +logger = Logger(__name__) + + class UnauthorizedError(Exception): pass @@ -10,3 +15,24 @@ def get_user(access_token: str, /, idp_client) -> dict[str, str]: raise UnauthorizedError() else: return {attr['Name']: attr['Value'] for attr in user['UserAttributes']} + + +def admin_get_user( + sub: str, + user_pool_id: str, + *, + idp_client, +) -> dict[str, str] | None: + """Gets the specified user by user name in a user pool as an administrator. + Works on any user. + + - https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp/client/admin_get_user.html + - https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AdminGetUser.html + """ + try: + user = idp_client.admin_get_user(Username=sub, UserPoolId=user_pool_id) + except idp_client.exceptions as err: + logger.exception(err) + return None + else: + return user diff --git a/http-api/course.py b/http-api/course.py index efb83c1..c00b543 100644 --- a/http-api/course.py +++ b/http-api/course.py @@ -10,13 +10,13 @@ def create_course( /, persistence_layer: DynamoDBPersistenceLayer, ): - current_time = now() + now_ = now() transact = TransactItems(persistence_layer.table_name) transact.put( item={ 'sk': '0', 'tenant__org_id': {org.id}, - 'create_date': current_time, + 'create_date': now_, **course.model_dump(), } ) @@ -26,7 +26,7 @@ def create_course( 'sk': 'tenant', 'org_id': org.id, 'name': org.name, - 'create_date': current_time, + 'create_date': now_, } ) return persistence_layer.transact_write_items(transact) @@ -38,7 +38,7 @@ def update_course( /, persistence_layer: DynamoDBPersistenceLayer, ): - current_time = now() + now_ = now() transact = TransactItems(persistence_layer.table_name) transact.update( key=KeyPair(id, '0'), @@ -50,7 +50,7 @@ def update_course( ':name': course.name, ':cert': course.cert.model_dump() if course.cert else None, ':access_period': course.access_period, - ':update_date': current_time, + ':update_date': now_, }, cond_expr='attribute_exists(sk)', ) diff --git a/http-api/routes/enrollments/__init__.py b/http-api/routes/enrollments/__init__.py index 5999161..8e289a1 100644 --- a/http-api/routes/enrollments/__init__.py +++ b/http-api/routes/enrollments/__init__.py @@ -1,15 +1,28 @@ import json -from typing import Literal +from typing import TypedDict from aws_lambda_powertools.event_handler.api_gateway import Router from elasticsearch import Elasticsearch -from pydantic import BaseModel +from layercake.dynamodb import ( + DynamoDBCollection, + DynamoDBPersistenceLayer, + SortKey, + TransactKey, +) +from pydantic import UUID4, BaseModel import elastic -from settings import ELASTIC_CONN, ENROLLMENT_TABLE +from boto3clients import dynamodb_client +from middlewares.audit_log_middleware import AuditLogMiddleware +from middlewares.authorizer_middleware import User +from settings import ELASTIC_CONN, ENROLLMENT_TABLE, USER_TABLE router = Router() elastic_client = Elasticsearch(**ELASTIC_CONN) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) +user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client) +enrollment_collect = DynamoDBCollection(enrollment_layer) +user_collect = DynamoDBCollection(user_layer) @router.get('/', compress=True, tags=['Enrollment']) @@ -28,16 +41,44 @@ def get_enrollments(): @router.get('/', compress=True, tags=['Enrollment']) def get_enrollment(id: str): - return {} + return enrollment_collect.get_items( + TransactKey(id) + + SortKey('0') + + SortKey('started_date') + + SortKey('finished_date') + + SortKey('failed_date') + + SortKey('canceled_date') + + SortKey('archived_date') + + SortKey('cancel_policy') + + SortKey('parent_vacancy') + + SortKey('lock', path_spec='hash') + + SortKey('author') + + SortKey('tenant') + + SortKey('cert') + ) -class CancelPayload(BaseModel): - status: Literal['CANCELED'] = 'CANCELED' +class Course(TypedDict): + id: str + name: str -@router.patch('/', compress=True, tags=['Enrollment']) -def cancel(id: str, payload: CancelPayload): - return {} +class Cancel(BaseModel): + id: UUID4 | str + course: Course + + +@router.patch( + '//cancel', + compress=True, + tags=['Enrollment'], + middlewares=[ + AuditLogMiddleware('ENROLLMENT_CANCEL', user_collect, ('id', 'course')) + ], +) +def cancel(id: str, payload: Cancel): + user: User = router.context['user'] + return payload @router.post('/', compress=True, tags=['Enrollment']) diff --git a/http-api/routes/settings/__init__.py b/http-api/routes/settings/__init__.py index 3ec95b3..b813a73 100644 --- a/http-api/routes/settings/__init__.py +++ b/http-api/routes/settings/__init__.py @@ -22,11 +22,11 @@ LIMIT = 25 @router.get('/', include_in_schema=False) def settings(): user: User = router.context['user'] - acls = user_collect.get_items( + acls = user_collect.query( KeyPair(user.id, PrefixKey('acls')), limit=LIMIT, ) - tenants = user_collect.get_items( + tenants = user_collect.query( KeyPair(user.id, PrefixKey('orgs')), limit=LIMIT, ) diff --git a/http-api/routes/users/__init__.py b/http-api/routes/users/__init__.py index c98361c..1a26d5b 100644 --- a/http-api/routes/users/__init__.py +++ b/http-api/routes/users/__init__.py @@ -21,11 +21,12 @@ from layercake.dynamodb import ( ) from pydantic import UUID4, BaseModel, StringConstraints +import cognito import elastic -from boto3clients import dynamodb_client +from boto3clients import dynamodb_client, idp_client from middlewares import AuditLogMiddleware from models import User -from settings import ELASTIC_CONN, USER_TABLE +from settings import ELASTIC_CONN, USER_POOOL_ID, USER_TABLE class BadRequestError(MissingError, PowertoolsBadRequestError): ... @@ -37,12 +38,7 @@ user_collect = DynamoDBCollection(user_layer, exception_cls=BadRequestError) elastic_client = Elasticsearch(**ELASTIC_CONN) -@router.get( - '/', - compress=True, - tags=['User'], - summary='Get users', -) +@router.get('/', compress=True, tags=['User'], summary='Get users') def get_users(): event = router.current_event query = event.get_query_string_value('query', '{}') @@ -67,29 +63,28 @@ def post_user(payload: User): return Response(status_code=HTTPStatus.CREATED) -class NewPasswordPayload(BaseModel): +class NewPassword(BaseModel): cognito_sub: UUID4 new_password: Annotated[str, StringConstraints(min_length=6)] @router.patch('/', compress=True, tags=['User']) -def patch_reset(id: str, payload: NewPasswordPayload): +def patch_newpassword(id: str, payload: NewPassword): return Response(status_code=HTTPStatus.OK) -@router.get( - '/', - compress=True, - tags=['User'], - summary='Get user', -) +@router.get('/', compress=True, tags=['User'], summary='Get user') def get_user(id: str): return user_collect.get_item(KeyPair(id, '0')) @router.get('//idp', compress=True, include_in_schema=False) def get_idp(id: str): - return [] + return cognito.admin_get_user( + sub=id, + user_pool_id=USER_POOOL_ID, + idp_client=idp_client, + ) @router.get( @@ -99,7 +94,7 @@ def get_idp(id: str): summary='Get user emails', ) def get_emails(id: str): - return user_collect.get_items( + return user_collect.query( KeyPair(id, PrefixKey('emails')), start_key=router.current_event.get_query_string_value('start_key', None), ) @@ -112,7 +107,7 @@ def get_emails(id: str): summary='Get user logs', ) def get_logs(id: str): - return user_collect.get_items( + return user_collect.query( # Post-migration: uncomment to enable PartitionKey with a composite key (id with `logs` prefix). # PartitionKey(ComposeKey(id, prefix='logs')), PartitionKey(ComposeKey(id, prefix='log', delimiter=':')), @@ -127,7 +122,7 @@ def get_logs(id: str): summary='Get user orgs', ) def get_orgs(id: str): - return user_collect.get_items( + return user_collect.query( KeyPair(id, PrefixKey('orgs')), start_key=router.current_event.get_query_string_value('start_key', None), ) diff --git a/http-api/seeds/test-users.jsonl b/http-api/seeds/test-users.jsonl index b39fa13..2aa1472 100644 --- a/http-api/seeds/test-users.jsonl +++ b/http-api/seeds/test-users.jsonl @@ -13,6 +13,8 @@ {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#MqiRQWy9cSBEci93aKBvTd"}, "tag": {"S": "Test"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#NQ3YmmTesfoSyHCkPKGKEF"}, "tag": {"S": "F\u00e1brica"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "tags#bg45io8igarjsA4BzPQyrz"}, "tag": {"S": "Escrit\u00f3rio"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}} +{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policy"}, "due_days": {"N": "90"}} +{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "billing_policy"}, "billing_day": {"N": "2"}, "payment_method": {"S": "PIX"}} {"sk": {"S": "admin#18606"}, "create_date": {"S": "2023-09-22T18:31:11.475449-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "mateushickmann@petrobras.com.br"}, "name": {"S": "MATEUS LATTIK HICKMANN"}} {"sk": {"S": "admin#21425"}, "create_date": {"S": "2023-09-22T18:34:06.480230-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "sergio@inbep.com.br"}, "name": {"S": "S\u00e9rgio Siqueira"}} {"sk": {"S": "admin#2338"}, "create_date": {"S": "2023-09-22T18:28:10.121068-03:00"}, "id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "email": {"S": "empresa@inbep.com.br"}, "name": {"S": "Empresa Teste"}} @@ -68,3 +70,6 @@ {"id": {"S": "webhook_requests#*#0e7f4d2e62ec525fc94465a6dd7299d2"}, "sk": {"S": "2025-03-03T15:47:42.039256-03:00"}, "request": {"M": {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "headers": {"M": {"Accept": {"S": "*/*"}, "Accept-Encoding": {"S": "gzip, deflate"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "108"}, "Content-Type": {"S": "application/json"}, "User-Agent": {"S": "eduseg/python-requests/2.32.3"}}}, "payload": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "email": {"S": "sergio@somosbeta.com.br"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}}}, "response": {"M": {"body": {"S": "{\"message\":\"Workflow was started\"}"}, "elapsed_time": {"S": "0.14"}, "headers": {"M": {"alt-svc": {"S": "h3=\":443\"; ma=86400"}, "cf-cache-status": {"S": "DYNAMIC"}, "CF-RAY": {"S": "8fc528b57b62a3f0-GRU"}, "Connection": {"S": "keep-alive"}, "Content-Length": {"S": "34"}, "Content-Type": {"S": "application/json; charset=utf-8"}, "Date": {"S": "Fri, 03 Jan 2025 18:47:44 GMT"}, "etag": {"S": "W/\"22-6OS7cK0FzqnV2NeDHdOSGS1bVUs\""}, "NEL": {"S": "{\"success_fraction\":0,\"report_to\":\"cf-nel\",\"max_age\":604800}"}, "Report-To": {"S": "{\"endpoints\":[{\"url\":\"https:\\/\\/a.nel.cloudflare.com\\/report\\/v4?s=FMkgKCTOnFvNgtE30yiYnM4XtK8q99O62s7Ep57KDNc3YGiy3W1j%2BzfS0vqJNylSAn5viU3MMGJvTIwPsYQ6jQ298t1p0hYd1KPwURhxchME4hc%2BsO0NNAv%2FFEpXv1ZYWw%3D%3D\"}],\"group\":\"cf-nel\",\"max_age\":604800}"}, "Server": {"S": "cloudflare"}, "server-timing": {"S": "cfL4;desc=\"?proto=TCP&rtt=1016&min_rtt=998&rtt_var=411&sent=4&recv=7&lost=0&retrans=0&sent_bytes=2836&recv_bytes=999&delivery_rate=2522648&cwnd=251&unsent_bytes=0&cid=1b26053952909423&ts=93&x=0\""}, "Strict-Transport-Security": {"S": "max-age=0; includeSubDomains"}, "vary": {"S": "Accept-Encoding"}}}, "status_code": {"S": "200"}}}, "update_date": {"S": "2025-01-03T15:47:44.537597-03:00"}, "url": {"S": "https://n8n.sergio.run/webhook/56bb43b8-533c-4e8b-bdaa-3f7c2b0e548f"}} {"id": {"S": "webhooks#*"}, "sk": {"S": "1e3759c9c4cfa7aaf86ac281bdb8fd6f"}, "author": {"M": {"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "name": {"S": "S\u00e9rgio Rafael de Siqueira"}}}, "create_date": {"S": "2025-01-06T15:00:39.363207-03:00"}, "event_type": {"S": "insert"}, "resource": {"S": "users"}, "url": {"S": "https://hook.us2.make.com/hgkc5oj5dbpfld5qvu1xmsu22ond93l9"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"},"sk": {"S": "2024-02-26T11:48:17.605911-03:00"},"action": {"S": "OPEN_EMAIL"},"data": {"M": {"ip_address": {"S": "66.249.83.73"},"message_id": {"S": "0103018de5de75cf-670bf01f-7ccf-4ce0-88b4-7c85cd0d06d0-000000"},"recipients": {"L": [{"S": "sergio@somosbeta.com.br"}]},"subject": {"S": "Re: (sem assunto)"},"timestamp": {"S": "2024-02-26T14:48:16.976Z"},"user_agent": {"S": "Mozilla/5.0 (Windows NT 5.1; rv:11.0) Gecko Firefox/11.0 (via ggpht.com GoogleImageProxy)"}}},"ttl": {"N": "1772030897"}} +{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "0"},"cognito:sub": {"S": "58efed8d-d276-41a8-8502-4ab8b5a6415e"},"cpf": {"S": "04330965275"},"createDate": {"S": "2025-04-08T10:24:46.493980-03:00"},"email": {"S": "barbara.gomes@sinobras.com.br"},"email_verified": {"BOOL": true},"konviva:id": {"N": "199205"},"lastLogin": {"S": "2025-04-10T12:01:48.380215-03:00"},"name": {"S": "Barbara Kamyla Vasconcelos Gomes"},"tenant__org_id": {"SS": ["EkvQwpmmL6vzWtJunM5dCJ"]},"update_date": {"S": "2025-04-10T08:50:22.530758-03:00"}} +{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "acls#EkvQwpmmL6vzWtJunM5dCJ"},"create_date": {"S": "2025-04-10T09:36:41.133157-03:00"},"roles": {"L": [{"S": "ADMIN"}]}} +{"id": {"S": "7zf52CWrTS3csRBFWU5rkq"},"sk": {"S": "orgs#EkvQwpmmL6vzWtJunM5dCJ"},"cnpj": {"S": "07933914000154"},"create_date": {"S": "2025-04-08T10:24:46.493980-03:00"},"name": {"S": "SIDERURGICA NORTE BRASIL S.A"}} diff --git a/http-api/settings.py b/http-api/settings.py index 5293d36..282c1b9 100644 --- a/http-api/settings.py +++ b/http-api/settings.py @@ -32,3 +32,5 @@ match os.getenv('AWS_SAM_LOCAL'), os.getenv('PYTEST_VERSION'): 'cloud_id': ELASTIC_CLOUD_ID, 'basic_auth': ('elastic', ELASTIC_AUTH_PASS), } + +USER_POOOL_ID = 'sa-east-1_s6YmVSfXj' diff --git a/http-api/template.yaml b/http-api/template.yaml index e973b2d..3fbd3af 100644 --- a/http-api/template.yaml +++ b/http-api/template.yaml @@ -23,7 +23,7 @@ Globals: Architectures: - x86_64 Layers: - - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:35 + - !Sub arn:aws:lambda:sa-east-1:336641857101:layer:layercake:42 Environment: Variables: TZ: America/Sao_Paulo @@ -82,6 +82,15 @@ Resources: TableName: !Ref CourseTable - DynamoDBCrudPolicy: TableName: !Ref OrderTable + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + - Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - cognito-idp:AdminGetUser + - cognito-idp:AdminSetUserPassword + Resource: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* Events: Preflight: Type: HttpApi diff --git a/http-api/tests/routes/test_courses.py b/http-api/tests/routes/test_courses.py index 5277596..01403bf 100644 --- a/http-api/tests/routes/test_courses.py +++ b/http-api/tests/routes/test_courses.py @@ -59,7 +59,7 @@ def test_post_course( assert r['statusCode'] == HTTPStatus.CREATED collect = DynamoDBCollection(dynamodb_persistence_layer) - logs = collect.get_items( + logs = collect.query( PartitionKey( ComposeKey('5OxmMjL-ujoR5IMGegQz', prefix='log', delimiter=':'), ) diff --git a/http-api/tests/seeds.jsonl b/http-api/tests/seeds.jsonl index d8c004e..5d364df 100644 --- a/http-api/tests/seeds.jsonl +++ b/http-api/tests/seeds.jsonl @@ -8,5 +8,8 @@ {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2024-02-08T16:42:33.776409-03:00"}, "action": {"S": "OPEN_EMAIL"}} {"id": {"S": "log:5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "2019-03-25T00:00:00-03:00"}, "action": {"S": "CLICK_EMAIL"}} {"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "0"}, "name": {"S": "EDUSEG"}, "cnpj": {"S": "15608435000190"}, "email": {"S": "org+15608435000190@users.noreply.betaeducacao.com.br"}} -{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policies"}, "due_days": {"N": "90"}} +{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "payment_policy"}, "due_days": {"N": "90"}} +{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "billing_policy"}, "billing_day": {"N": "1"}, "payment_method": {"S": "PIX"}} {"id": {"S": "90d7f0d2-d9a4-4467-a31c-f9a7955964cf"}, "sk": {"S": "0"}, "access_period": {"N": "720"}, "create_date": {"S": "2024-12-30T00:00:33.088916-03:00"},"konviva__class_id": {"N": "266"},"name": {"S": "Reciclagem em NR-18 Básico"},"tenant__org_id": {"SS": ["cJtK9SsnJhKPyxESe7g3DG"]}} +{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "0"}, "status": {"S": "PENDING"}} +{"id": {"S": "43ea4475-c369-4f90-b576-135b7df5106b"}, "sk": {"S": "cancel_policy"}} diff --git a/http-api/uv.lock b/http-api/uv.lock index 4c3de6d..08dd0d9 100644 --- a/http-api/uv.lock +++ b/http-api/uv.lock @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.1.16" +version = "0.2.5" source = { directory = "../layercake" } dependencies = [ { name = "arnparse" }, @@ -538,6 +538,7 @@ dependencies = [ { name = "pydantic-extra-types" }, { name = "pytz" }, { name = "requests" }, + { name = "uuid-utils" }, { name = "weasyprint" }, ] @@ -557,6 +558,7 @@ requires-dist = [ { name = "pydantic-extra-types", specifier = ">=2.10.3" }, { name = "pytz", specifier = ">=2025.1" }, { name = "requests", specifier = ">=2.32.3" }, + { name = "uuid-utils", specifier = ">=0.10.0" }, { name = "weasyprint", specifier = ">=65.0" }, ] @@ -938,6 +940,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] +[[package]] +name = "uuid-utils" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0a/cbdb2eb4845dafeb632d02a18f47b02f87f2ce4f25266f5e3c017976ce89/uuid_utils-0.10.0.tar.gz", hash = "sha256:5db0e1890e8f008657ffe6ded4d9459af724ab114cfe82af1557c87545301539", size = 18828 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/54/9d22fa16b19e5d1676eba510f08a9c458d96e2a62ff2c8ebad64251afb18/uuid_utils-0.10.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d5a4508feefec62456cd6a41bcdde458d56827d908f226803b886d22a3d5e63", size = 573006 }, + { url = "https://files.pythonhosted.org/packages/08/8e/f895c6e52aa603e521fbc13b8626ba5dd99b6e2f5a55aa96ba5b232f4c53/uuid_utils-0.10.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dbefc2b9113f9dfe56bdae58301a2b3c53792221410d422826f3d1e3e6555fe7", size = 292543 }, + { url = "https://files.pythonhosted.org/packages/b6/58/cc4834f377a5e97d6e184408ad96d13042308de56643b6e24afe1f6f34df/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffc49c33edf87d1ec8112a9b43e4cf55326877716f929c165a2cc307d31c73d5", size = 323340 }, + { url = "https://files.pythonhosted.org/packages/37/e3/6aeddf148f6a7dd7759621b000e8c85382ec83f52ae79b60842d1dc3ab6b/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0636b6208f69d5a4e629707ad2a89a04dfa8d1023e1999181f6830646ca048a1", size = 329653 }, + { url = "https://files.pythonhosted.org/packages/0c/00/dd6c2164ace70b7b1671d9129267df331481d7d1e5f9c5e6a564f07953f6/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7bc06452856b724df9dedfc161c3582199547da54aeb81915ec2ed54f92d19b0", size = 365471 }, + { url = "https://files.pythonhosted.org/packages/b4/e7/0ab8080fcae5462a7b5e555c1cef3d63457baffb97a59b9bc7b005a3ecb1/uuid_utils-0.10.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:263b2589111c61decdd74a762e8f850c9e4386fb78d2cf7cb4dfc537054cda1b", size = 325844 }, + { url = "https://files.pythonhosted.org/packages/73/39/52d94e9ef75b03f44b39ffc6ac3167e93e74ef4d010a93d25589d9f48540/uuid_utils-0.10.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a558db48b7096de6b4d2d2210d82bba8586a6d55f99106b03bb7d01dc5c5bcd6", size = 344389 }, + { url = "https://files.pythonhosted.org/packages/7c/29/4824566f62666238290d99c62a58e4ab2a8b9cf2eccf94cebd9b3359131e/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:807465067f3c892514230326ac71a79b28a8dfe2c88ecd2d5675fc844f3c76b5", size = 510078 }, + { url = "https://files.pythonhosted.org/packages/5e/8f/bbcc7130d652462c685f0d3bd26bb214b754215b476340885a4cb50fb89a/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:57423d4a2b9d7b916de6dbd75ba85465a28f9578a89a97f7d3e098d9aa4e5d4a", size = 515937 }, + { url = "https://files.pythonhosted.org/packages/23/f8/34e0c00f5f188604d336713e6a020fcf53b10998e8ab24735a39ab076740/uuid_utils-0.10.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:76d8d660f18ff6b767e319b1b5f927350cd92eafa4831d7ef5b57fdd1d91f974", size = 494111 }, + { url = "https://files.pythonhosted.org/packages/1a/52/b7f0066cc90a7a9c28d54061ed195cd617fde822e5d6ac3ccc88509c3c44/uuid_utils-0.10.0-cp39-abi3-win32.whl", hash = "sha256:6c11a71489338837db0b902b75e1ba7618d5d29f05fde4f68b3f909177dbc226", size = 173520 }, + { url = "https://files.pythonhosted.org/packages/8b/15/f04f58094674d333974243fb45d2c740cf4b79186fb707168e57943c84a3/uuid_utils-0.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:11c55ae64f6c0a7a0c741deae8ca2a4eaa11e9c09dbb7bec2099635696034cf7", size = 182965 }, +] + [[package]] name = "wcwidth" version = "0.2.13" diff --git a/layercake/docs/reference.md b/layercake/docs/reference.md deleted file mode 100644 index eddd993..0000000 --- a/layercake/docs/reference.md +++ /dev/null @@ -1 +0,0 @@ -::: layercake.dynamodb diff --git a/layercake/layercake/dynamodb.py b/layercake/layercake/dynamodb.py index 50caa81..e2098dd 100644 --- a/layercake/layercake/dynamodb.py +++ b/layercake/layercake/dynamodb.py @@ -12,7 +12,6 @@ from uuid import UUID from aws_lambda_powertools import Logger from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from botocore.exceptions import ClientError -from glom import glom from .dateutils import now, timestamp from .funcs import omit @@ -129,12 +128,40 @@ if TYPE_CHECKING: @dataclass class SortKey(str): + """ + SortKey encapsulates the sort key value and optionally stores additional attributes + for nested data extraction. + + Parameters + ---------- + sk: str + The sort key value. + table_name: str, optional + Optional name of the table associated with the sort key. + path_spec: str, optional + Optional specification for nested data extraction. + """ + sk: str table_name: str | None = None path_spec: str | None = None else: class SortKey(str): + """ + SortKey encapsulates the sort key value and optionally stores additional attributes + for nested data extraction. + + Parameters + ---------- + sk: str + The sort key value. + table_name: str, optional + Optional name of the table associated with the sort key. + path_spec: str, optional + Optional specification for nested data extraction. + """ + def __new__( cls, sk: str, @@ -574,6 +601,27 @@ class PaginatedResult(TypedDict): class DynamoDBCollection: + """ + DynamoDBCollection provides a high-level abstraction for performing common CRUD operations + and queries on a DynamoDB table. It leverages an underlying persistence layer to handle + serialization and deserialization of data, key composition, transaction operations, and TTL management. + + This collection class simplifies interaction with DynamoDB items, allowing users to: + - Retrieve a single item or multiple items via transactions. + - Insert (put) items with optional TTL (time-to-live) settings. + - Delete items based on keys and conditions. + - Query items using partition keys or composite key pairs with optional filtering and pagination. + + Parameters + ---------- + persistence_layer: DynamoDBPersistenceLayer + The persistence layer instance responsible for direct DynamoDB operations. + exception_cls: Type[Exception], optional + The exception class to be raised when a requested item is not found. + tz: str, optional + The timezone identifier used for date/time operations. + """ + def __init__( self, persistence_layer: DynamoDBPersistenceLayer, @@ -781,6 +829,8 @@ class DynamoDBCollection: head, tail = {}, items def _getin(sk: SortKey, v: dict) -> dict: + from glom import glom + v = omit((PK, SK), v) return glom(v, sk.path_spec) if sk.path_spec else v diff --git a/layercake/layercake/extra_types.py b/layercake/layercake/extra_types.py index 3e9f393..2d54e5c 100644 --- a/layercake/layercake/extra_types.py +++ b/layercake/layercake/extra_types.py @@ -165,9 +165,11 @@ if TYPE_CHECKING: CnpjStr = Annotated[str, ...] else: - class CpfStr(CpfCnpj): ... + class CpfStr(CpfCnpj): + pass - class CnpjStr(CpfCnpj): ... + class CnpjStr(CpfCnpj): + pass if __name__ == '__main__': diff --git a/layercake/layercake/funcs.py b/layercake/layercake/funcs.py index d636c46..ed3df17 100644 --- a/layercake/layercake/funcs.py +++ b/layercake/layercake/funcs.py @@ -7,14 +7,56 @@ def pick( exclude_none: bool = True, default: Any = None, ) -> dict[str, Any]: - """Returns a partial copy of an object containing only the keys specified.""" - return { - k: dct.get(k, default) for k in keys if k in dct or not exclude_none - } + """ + Return a partial dict with only the specified keys. + + Parameters + ---------- + keys: list[str] or tuple[str, ...] + Keys to select. + dct: dict[str, Any] + Source dict. + exclude_none: bool, optional + If True, omit keys not in dct; if False, include them with `default`. + default: Any, optional + Value for keys not in dct when exclude_none is False. + + Returns + ------- + dict[str, Any] + A dict with the picked key/value pairs. + + Examples + -------- + >>> d = {'a': 1, 'b': 2, 'c': 3} + >>> pick(['a', 'c'], d) + {'a': 1, 'c': 3} + >>> pick(['a', 'd'], d, exclude_none=False, default='missing') + {'a': 1, 'd': 'missing'} + """ + return {k: dct.get(k, default) for k in keys if k in dct or not exclude_none} -def omit( - keys: list[str] | tuple[str, ...], dct: dict[str, Any] -) -> dict[str, Any]: - """Returns a partial copy of an object omitting the keys specified.""" +def omit(keys: list[str] | tuple[str, ...], dct: dict[str, Any]) -> dict[str, Any]: + """ + Return a partial dict omitting the specified keys. + + Parameters + ---------- + keys: list[str] or tuple[str, ...] + Keys to omit. + dct: dict[str, Any] + Source dict. + + Returns + ------- + dict[str, Any] + A dict without the omitted key/value pairs. + + Examples + -------- + >>> d = {'a': 1, 'b': 2, 'c': 3} + >>> omit(['b'], d) + {'a': 1, 'c': 3} + """ return {k: dct[k] for k in dct.keys() if k not in keys} diff --git a/layercake/mkdocs.yml b/layercake/mkdocs.yml index 3f6f699..d3aa9d2 100644 --- a/layercake/mkdocs.yml +++ b/layercake/mkdocs.yml @@ -11,4 +11,5 @@ plugins: docstring_style: numpy nav: - index.md - - API reference: reference.md + - DynamoDB: dynamodb.md + - API: api.md diff --git a/layercake/uv.lock b/layercake/uv.lock index e685946..26152b6 100644 --- a/layercake/uv.lock +++ b/layercake/uv.lock @@ -600,7 +600,7 @@ wheels = [ [[package]] name = "layercake" -version = "0.2.4" +version = "0.2.5" source = { editable = "." } dependencies = [ { name = "arnparse" },