diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d232cd7..d748e355 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,11 +6,11 @@ jobs: publish-pypi: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2.4.0 - - name: Set up Python 3.8 - uses: actions/setup-python@v2.3.1 + - uses: actions/checkout@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.13 - name: Install dependencies run: pip install -qU setuptools wheel twine - name: Generating distribution archives diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4a75f5f..e53e421c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,10 +7,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5.1.0 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.13 - name: Install dependencies run: make install-test - name: Lint @@ -20,11 +20,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5.1.0 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -36,16 +36,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5.1.0 + - name: Setup Python 3.13 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: 3.13 - name: Install dependencies run: make install-test - name: Generate coverage report run: pytest --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4.1.1 + uses: codecov/codecov-action@v5 with: file: ./coverage.xml flags: unittests diff --git a/Makefile b/Makefile index f62d6cf1..2bc05db5 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,9 @@ SHELL := bash PATH := ./venv/bin:${PATH} -PYTHON = python3.8 +PYTHON = python3.13 PROJECT = cuenca isort = isort $(PROJECT) tests setup.py examples -black = black -S -l 79 --target-version py38 $(PROJECT) tests setup.py examples +black = black -S -l 79 --target-version py313 $(PROJECT) tests setup.py examples all: test diff --git a/cuenca/resources/api_keys.py b/cuenca/resources/api_keys.py index 1675b78c..6c85beea 100644 --- a/cuenca/resources/api_keys.py +++ b/cuenca/resources/api_keys.py @@ -2,6 +2,7 @@ from typing import ClassVar, Optional, cast from cuenca_validations.types import ApiKeyQuery, ApiKeyUpdateRequest +from pydantic import ConfigDict from ..http import Session, session as global_session from .base import Creatable, Queryable, Retrievable, Updateable @@ -12,11 +13,10 @@ class ApiKey(Creatable, Queryable, Retrievable, Updateable): _query_params: ClassVar = ApiKeyQuery secret: str - deactivated_at: Optional[dt.datetime] - user_id: Optional[str] - - class Config: - schema_extra = { + deactivated_at: Optional[dt.datetime] = None + user_id: Optional[str] = None + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'AKNEUInh69SuKXXmK95sROwQ', 'updated_at': '2021-08-24T14:15:22Z', @@ -26,6 +26,7 @@ class Config: 'user_id': 'USWqY5cvkISJOxHyEKjAKf8w', } } + ) @property def active(self) -> bool: @@ -74,5 +75,5 @@ def update( req = ApiKeyUpdateRequest( metadata=metadata, user_id=user_id, platform_id=platform_id ) - resp = cls._update(api_key_id, **req.dict(), session=session) + resp = cls._update(api_key_id, **req.model_dump(), session=session) return cast('ApiKey', resp) diff --git a/cuenca/resources/arpc.py b/cuenca/resources/arpc.py index 78efd9c5..6b1eb450 100644 --- a/cuenca/resources/arpc.py +++ b/cuenca/resources/arpc.py @@ -1,7 +1,9 @@ import datetime as dt from typing import ClassVar, Optional, cast +from cuenca_validations.types.enums import TrackDataMethod from cuenca_validations.types.requests import ARPCRequest +from pydantic_extra_types.payment import PaymentCardNumber from ..http import Session, session as global_session from .base import Creatable @@ -23,13 +25,13 @@ class Arpc(Creatable): created_at: dt.datetime card_uri: str - is_valid_arqc: Optional[bool] - arpc: Optional[str] + is_valid_arqc: Optional[bool] = None + arpc: Optional[str] = None @classmethod def create( cls, - number: str, + number: PaymentCardNumber, arqc: str, arpc_method: str, transaction_data: str, @@ -50,6 +52,6 @@ def create( transaction_counter=transaction_counter, pan_sequence=pan_sequence, unique_number=unique_number, - track_data_method=track_data_method, + track_data_method=cast(TrackDataMethod, track_data_method), ) - return cast('Arpc', cls._create(session=session, **req.dict())) + return cast('Arpc', cls._create(session=session, **req.model_dump())) diff --git a/cuenca/resources/balance_entries.py b/cuenca/resources/balance_entries.py index 9104f38d..e160f6bb 100644 --- a/cuenca/resources/balance_entries.py +++ b/cuenca/resources/balance_entries.py @@ -1,4 +1,4 @@ -from typing import ClassVar, TypeVar, cast +from typing import ClassVar, Union, cast from cuenca_validations.types import BalanceEntryQuery, EntryType @@ -8,9 +8,7 @@ from .resources import retrieve_uri from .service_providers import ServiceProvider -FundingInstrument = TypeVar( - 'FundingInstrument', Account, ServiceProvider, Card -) +FundingInstrument = Union[Account, ServiceProvider, Card] class BalanceEntry(Retrievable, Queryable): diff --git a/cuenca/resources/base.py b/cuenca/resources/base.py index 7a34dece..f4d87138 100644 --- a/cuenca/resources/base.py +++ b/cuenca/resources/base.py @@ -2,7 +2,7 @@ import datetime as dt import json from io import BytesIO -from typing import ClassVar, Dict, Generator, Optional, Union +from typing import Any, ClassVar, Generator, Optional from urllib.parse import urlencode from cuenca_validations.types import ( @@ -24,7 +24,7 @@ class Resource(BaseModel): id: str @classmethod - def _from_dict(cls, obj_dict: Dict[str, Union[str, int]]) -> 'Resource': + def _from_dict(cls, obj_dict: dict[str, Any]) -> 'Resource': cls._filter_excess_fields(obj_dict) return cls(**obj_dict) @@ -42,7 +42,7 @@ def _filter_excess_fields(cls, obj_dict): del obj_dict[f] def to_dict(self): - return SantizedDict(self.dict()) + return SantizedDict(self.model_dump()) class Retrievable(Resource): @@ -79,7 +79,7 @@ def _update( class Deactivable(Resource): - deactivated_at: Optional[dt.datetime] + deactivated_at: Optional[dt.datetime] = None @classmethod def deactivate( @@ -151,7 +151,7 @@ def one( cls, *, session: Session = global_session, **query_params ) -> Resource: q = cls._query_params(limit=2, **query_params) - resp = session.get(cls._resource, q.dict()) + resp = session.get(cls._resource, q.model_dump()) items = resp['items'] len_items = len(items) if not len_items: @@ -165,7 +165,7 @@ def first( cls, *, session: Session = global_session, **query_params ) -> Optional[Resource]: q = cls._query_params(limit=1, **query_params) - resp = session.get(cls._resource, q.dict()) + resp = session.get(cls._resource, q.model_dump()) try: item = resp['items'][0] except IndexError: @@ -179,7 +179,7 @@ def count( cls, *, session: Session = global_session, **query_params ) -> int: q = cls._query_params(count=True, **query_params) - resp = session.get(cls._resource, q.dict()) + resp = session.get(cls._resource, q.model_dump()) return resp['count'] @classmethod @@ -188,7 +188,7 @@ def all( ) -> Generator[Resource, None, None]: session = session or global_session q = cls._query_params(**query_params) - next_page_uri = f'{cls._resource}?{urlencode(q.dict())}' + next_page_uri = f'{cls._resource}?{urlencode(q.model_dump())}' while next_page_uri: page = session.get(next_page_uri) yield from (cls._from_dict(item) for item in page['items']) diff --git a/cuenca/resources/card_activations.py b/cuenca/resources/card_activations.py index 6523e253..853c9c1b 100644 --- a/cuenca/resources/card_activations.py +++ b/cuenca/resources/card_activations.py @@ -2,6 +2,7 @@ from typing import ClassVar, Optional, cast from cuenca_validations.types.requests import CardActivationRequest +from pydantic_extra_types.payment import PaymentCardNumber from ..http import Session, session as global_session from .base import Creatable @@ -15,13 +16,13 @@ class CardActivation(Creatable): created_at: dt.datetime user_id: str ip_address: str - card_uri: Optional[str] + card_uri: Optional[str] = None success: bool @classmethod def create( cls, - number: str, + number: PaymentCardNumber, exp_month: int, exp_year: int, cvv2: str, @@ -43,7 +44,7 @@ def create( cvv2=cvv2, ) return cast( - 'CardActivation', cls._create(session=session, **req.dict()) + 'CardActivation', cls._create(session=session, **req.model_dump()) ) @property diff --git a/cuenca/resources/card_transactions.py b/cuenca/resources/card_transactions.py index dae0a674..cfcbfe09 100644 --- a/cuenca/resources/card_transactions.py +++ b/cuenca/resources/card_transactions.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List, Optional, cast +from typing import ClassVar, Optional, cast from cuenca_validations.types import ( CardErrorType, @@ -14,24 +14,25 @@ class CardTransaction(Transaction): + _resource: ClassVar = 'card_transactions' _query_params: ClassVar = CardTransactionQuery type: CardTransactionType network: CardNetwork - related_card_transaction_uris: List[str] + related_card_transaction_uris: list[str] card_uri: str card_last4: str card_type: CardType metadata: dict - error_type: Optional[CardErrorType] + error_type: Optional[CardErrorType] = None @property # type: ignore - def related_card_transactions(self) -> Optional[List['CardTransaction']]: + def related_card_transactions(self) -> Optional[list['CardTransaction']]: if not self.related_card_transaction_uris: return [] return cast( - List['CardTransaction'], + list['CardTransaction'], retrieve_uris(self.related_card_transaction_uris), ) diff --git a/cuenca/resources/card_validations.py b/cuenca/resources/card_validations.py index 858febd9..344ad594 100644 --- a/cuenca/resources/card_validations.py +++ b/cuenca/resources/card_validations.py @@ -3,6 +3,7 @@ from cuenca_validations.types import CardStatus, CardType from cuenca_validations.types.requests import CardValidationRequest +from pydantic_extra_types.payment import PaymentCardNumber from ..http import Session, session as global_session from .base import Creatable @@ -18,11 +19,11 @@ class CardValidation(Creatable): user_id: str card_status: CardStatus card_type: CardType - is_valid_cvv: Optional[bool] - is_valid_cvv2: Optional[bool] - is_valid_icvv: Optional[bool] - is_valid_pin_block: Optional[bool] - is_valid_exp_date: Optional[bool] + is_valid_cvv: Optional[bool] = None + is_valid_cvv2: Optional[bool] = None + is_valid_icvv: Optional[bool] = None + is_valid_pin_block: Optional[bool] = None + is_valid_exp_date: Optional[bool] = None is_pin_attempts_exceeded: bool is_expired: bool platform_id: Optional[str] = None @@ -30,14 +31,14 @@ class CardValidation(Creatable): @classmethod def create( cls, - number: str, + number: PaymentCardNumber, cvv: Optional[str] = None, cvv2: Optional[str] = None, icvv: Optional[str] = None, exp_month: Optional[int] = None, exp_year: Optional[int] = None, pin_block: Optional[str] = None, - pin_attempts_exceeded: Optional[str] = None, + pin_attempts_exceeded: Optional[bool] = None, *, session: Session = global_session, ) -> 'CardValidation': @@ -52,7 +53,7 @@ def create( pin_attempts_exceeded=pin_attempts_exceeded, ) return cast( - 'CardValidation', cls._create(session=session, **req.dict()) + 'CardValidation', cls._create(session=session, **req.model_dump()) ) @property diff --git a/cuenca/resources/cards.py b/cuenca/resources/cards.py index ff739615..d239375d 100644 --- a/cuenca/resources/cards.py +++ b/cuenca/resources/cards.py @@ -21,12 +21,12 @@ class Card(Retrievable, Queryable, Creatable, Updateable): _resource: ClassVar = 'cards' _query_params: ClassVar = CardQuery - user_id: Optional[str] + user_id: Optional[str] = None number: str exp_month: int exp_year: int cvv2: str - pin: Optional[str] + pin: Optional[str] = None type: CardType status: CardStatus issuer: CardIssuer @@ -81,7 +81,7 @@ def create( card_holder_user_id=card_holder_user_id, is_dynamic_cvv=is_dynamic_cvv, ) - return cast('Card', cls._create(session=session, **req.dict())) + return cast('Card', cls._create(session=session, **req.model_dump())) @classmethod def update( @@ -106,7 +106,7 @@ def update( req = CardUpdateRequest( status=status, pin_block=pin_block, is_dynamic_cvv=is_dynamic_cvv ) - resp = cls._update(card_id, session=session, **req.dict()) + resp = cls._update(card_id, session=session, **req.model_dump()) return cast('Card', resp) @classmethod diff --git a/cuenca/resources/curp_validations.py b/cuenca/resources/curp_validations.py index 5c466503..98bac949 100644 --- a/cuenca/resources/curp_validations.py +++ b/cuenca/resources/curp_validations.py @@ -8,6 +8,7 @@ State, ) from cuenca_validations.types.identities import CurpField +from pydantic import ConfigDict, Field from ..http import Session, session as global_session from .base import Creatable, Retrievable @@ -17,44 +18,42 @@ class CurpValidation(Creatable, Retrievable): _resource: ClassVar = 'curp_validations' created_at: dt.datetime - names: Optional[str] = None - first_surname: Optional[str] = None - second_surname: Optional[str] = None - date_of_birth: Optional[dt.date] = None - country_of_birth: Optional[Country] = None - state_of_birth: Optional[State] = None - gender: Optional[Gender] = None - nationality: Optional[Country] = None - manual_curp: Optional[CurpField] = None - calculated_curp: CurpField - validated_curp: Optional[CurpField] = None - renapo_curp_match: bool - renapo_full_match: bool - - class Config: - fields = { - 'names': {'description': 'Official name from Renapo'}, - 'first_surname': {'description': 'Official surname from Renapo'}, - 'second_surname': {'description': 'Official surname from Renapo'}, - 'country_of_birth': {'description': 'In format ISO 3166 Alpha-2'}, - 'state_of_birth': {'description': 'In format ISO 3166 Alpha-2'}, - 'nationality': {'description': 'In format ISO 3166 Alpha-2'}, - 'manual_curp': {'description': 'curp provided in request'}, - 'calculated_curp': { - 'description': 'Calculated CURP based on request data' - }, - 'validated_curp': { - 'description': 'CURP validated in Renapo, null if not exists' - }, - 'renapo_curp_match': { - 'description': 'True if CURP exists and is valid' - }, - 'renapo_full_match': { - 'description': 'True if all fields provided match the response' - ' from RENAPO. Accents in names are ignored' - }, - } - schema_extra = { + names: Optional[str] = Field(None, description='Official name from Renapo') + first_surname: Optional[str] = Field( + None, description='Official surname from Renapo' + ) + second_surname: Optional[str] = Field( + None, description='Official surname from Renapo' + ) + date_of_birth: Optional[dt.date] = Field( + None, description='In format ISO 3166 Alpha-2' + ) + country_of_birth: Optional[Country] = Field( + None, description='In format ISO 3166 Alpha-2' + ) + state_of_birth: Optional[State] = Field(None, description='State of birth') + gender: Optional[Gender] = Field(None, description='Gender') + nationality: Optional[Country] = Field( + None, description='In format ISO 3166 Alpha-2' + ) + manual_curp: Optional[CurpField] = Field( + None, description='curp provided in request' + ) + calculated_curp: CurpField = Field( + description='Calculated CURP based on request data' + ) + validated_curp: Optional[CurpField] = Field( + None, description='CURP validated in Renapo, null if not exists' + ) + renapo_curp_match: bool = Field( + description='True if CURP exists and is valid' + ) + renapo_full_match: bool = Field( + description='True if all fields provided match the response from ' + 'RENAPO. Accents in names are ignored', + ) + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'CVNEUInh69SuKXXmK95sROwQ', 'created_at': '2019-08-24T14:15:22Z', @@ -72,7 +71,8 @@ class Config: 'renapo_curp_match': True, 'renapo_full_match': True, } - } + }, + ) @classmethod def create( @@ -94,11 +94,11 @@ def create( second_surname=second_surname, date_of_birth=date_of_birth, state_of_birth=state_of_birth, - country_of_birth=country_of_birth, + country_of_birth=cast(Country, country_of_birth), gender=gender, manual_curp=manual_curp, ) return cast( 'CurpValidation', - cls._create(session=session, **req.dict()), + cls._create(session=session, **req.model_dump()), ) diff --git a/cuenca/resources/deposits.py b/cuenca/resources/deposits.py index eccdce8a..5e8f9192 100644 --- a/cuenca/resources/deposits.py +++ b/cuenca/resources/deposits.py @@ -13,7 +13,7 @@ class Deposit(Transaction): network: DepositNetwork source_uri: str - tracking_key: Optional[str] # clave rastreo if network is SPEI + tracking_key: Optional[str] = None # clave rastreo if network is SPEI @property # type: ignore def source(self) -> Account: diff --git a/cuenca/resources/endpoints.py b/cuenca/resources/endpoints.py index 43fb79c9..d05ad8e0 100644 --- a/cuenca/resources/endpoints.py +++ b/cuenca/resources/endpoints.py @@ -1,11 +1,11 @@ -from typing import ClassVar, List, Optional, cast +from typing import ClassVar, Optional, cast from cuenca_validations.types.enums import WebhookEvent from cuenca_validations.types.requests import ( EndpointRequest, EndpointUpdateRequest, ) -from pydantic import HttpUrl +from pydantic import ConfigDict, Field, HttpUrl from ..http import Session, session as global_session from .base import Creatable, Deactivable, Queryable, Retrievable, Updateable @@ -14,28 +14,21 @@ class Endpoint(Creatable, Deactivable, Retrievable, Queryable, Updateable): _resource: ClassVar = 'endpoints' - url: HttpUrl - secret: str - is_enable: bool - events: List[WebhookEvent] - - class Config: - fields = { - 'url': {'description': 'HTTPS url to send webhooks'}, - 'secret': { - 'description': 'token to verify the webhook is sent by Cuenca ' - 'using HMAC algorithm' - }, - 'is_enable': { - 'description': 'Allows user to turn-off the endpoint ' - 'without the need of deleting it' - }, - 'events': { - 'description': 'list of enabled events. If None, ' - 'all events will be enabled for this Endpoint' - }, - } - schema_extra = { + url: HttpUrl = Field(description='HTTPS url to send webhooks') + secret: str = Field( + description='token to verify the webhook is sent by Cuenca ' + 'using HMAC algorithm', + ) + is_enable: bool = Field( + description='Allows user to turn-off the endpoint without the ' + 'need of deleting it', + ) + events: list[WebhookEvent] = Field( + description='list of enabled events. If None, all events will ' + 'be enabled for this Endpoint', + ) + model_config = ConfigDict( + json_schema_extra={ 'example': { '_id': 'ENxxne2Z5VSTKZm_w8Hzffcw', 'platform_id': 'PTZoPrrPT6Ts-9myamq5h1bA', @@ -52,13 +45,14 @@ class Config: ], 'is_enable': True, } - } + }, + ) @classmethod def create( cls, url: HttpUrl, - events: Optional[List[WebhookEvent]] = None, + events: Optional[list[WebhookEvent]] = None, *, session: Session = global_session, ) -> 'Endpoint': @@ -72,14 +66,16 @@ def create( :return: New active endpoint """ req = EndpointRequest(url=url, events=events) - return cast('Endpoint', cls._create(session=session, **req.dict())) + return cast( + 'Endpoint', cls._create(session=session, **req.model_dump()) + ) @classmethod def update( cls, endpoint_id: str, url: Optional[HttpUrl] = None, - events: Optional[List[WebhookEvent]] = None, + events: Optional[list[WebhookEvent]] = None, is_enable: Optional[bool] = None, *, session: Session = global_session, @@ -96,5 +92,5 @@ def update( req = EndpointUpdateRequest( url=url, is_enable=is_enable, events=events ) - resp = cls._update(endpoint_id, session=session, **req.dict()) + resp = cls._update(endpoint_id, session=session, **req.model_dump()) return cast('Endpoint', resp) diff --git a/cuenca/resources/file_batches.py b/cuenca/resources/file_batches.py index 68123898..b846d308 100644 --- a/cuenca/resources/file_batches.py +++ b/cuenca/resources/file_batches.py @@ -1,6 +1,10 @@ -from typing import ClassVar, Dict, List, cast +from typing import ClassVar, cast -from cuenca_validations.types import BatchFileMetadata, FileBatchUploadRequest +from cuenca_validations.types import ( + BatchFileMetadata, + FileBatchUploadRequest, + FileRequest, +) from ..http import Session, session as global_session from .base import Creatable, Queryable @@ -9,17 +13,21 @@ class FileBatch(Creatable, Queryable): _resource: ClassVar = 'file_batches' - received_files: List[BatchFileMetadata] - uploaded_files: List[BatchFileMetadata] + received_files: list[BatchFileMetadata] + uploaded_files: list[BatchFileMetadata] user_id: str @classmethod def create( cls, - files: List[Dict], + files: list[dict], user_id: str, *, session: Session = global_session, ) -> 'FileBatch': - req = FileBatchUploadRequest(files=files, user_id=user_id) - return cast('FileBatch', cls._create(session=session, **req.dict())) + req = FileBatchUploadRequest( + files=cast(list[FileRequest], files), user_id=user_id + ) + return cast( + 'FileBatch', cls._create(session=session, **req.model_dump()) + ) diff --git a/cuenca/resources/files.py b/cuenca/resources/files.py index b56bb1a5..38c0f14c 100644 --- a/cuenca/resources/files.py +++ b/cuenca/resources/files.py @@ -1,7 +1,12 @@ from io import BytesIO from typing import ClassVar, Optional, cast -from cuenca_validations.types import FileQuery, FileUploadRequest, KYCFileType +from cuenca_validations.types import ( + FileExtension, + FileQuery, + FileUploadRequest, + KYCFileType, +) from pydantic import HttpUrl from ..http import Session, session as global_session @@ -40,7 +45,7 @@ def upload( req = FileUploadRequest( file=file.read(), type=file_type, - extension=extension, + extension=cast(FileExtension, extension), is_back=is_back, user_id=user_id, ) @@ -48,7 +53,7 @@ def upload( 'File', cls._upload( session=session, - **req.dict(), + **req.model_dump(), ), ) diff --git a/cuenca/resources/identities.py b/cuenca/resources/identities.py index d59f1f6d..6cb4738a 100644 --- a/cuenca/resources/identities.py +++ b/cuenca/resources/identities.py @@ -23,17 +23,17 @@ class Identity(Retrievable, Queryable): created_at: dt.datetime names: str first_surname: str - second_surname: Optional[str] - curp: Optional[CurpField] - rfc: Optional[str] + second_surname: Optional[str] = None + curp: Optional[CurpField] = None + rfc: Optional[str] = None gender: Gender - date_of_birth: Optional[dt.date] - state_of_birth: Optional[State] - country_of_birth: Optional[str] - status: Optional[UserStatus] - tos_agreement: Optional[TOSAgreement] - blacklist_validation_status: Optional[VerificationStatus] - address: Optional[Address] - govt_id: Optional[KYCFile] - proof_of_address: Optional[KYCFile] - proof_of_life: Optional[KYCFile] + date_of_birth: Optional[dt.date] = None + state_of_birth: Optional[State] = None + country_of_birth: Optional[str] = None + status: Optional[UserStatus] = None + tos_agreement: Optional[TOSAgreement] = None + blacklist_validation_status: Optional[VerificationStatus] = None + address: Optional[Address] = None + govt_id: Optional[KYCFile] = None + proof_of_address: Optional[KYCFile] = None + proof_of_life: Optional[KYCFile] = None diff --git a/cuenca/resources/kyc_validations.py b/cuenca/resources/kyc_validations.py index 1ab0bc47..2f215a9b 100644 --- a/cuenca/resources/kyc_validations.py +++ b/cuenca/resources/kyc_validations.py @@ -1,6 +1,7 @@ -from typing import ClassVar, List, Optional, cast +from typing import ClassVar, Optional, cast from cuenca_validations.types import KYCFile, KYCValidationRequest +from pydantic import ConfigDict from ..http import Session, session as global_session from .base import Creatable, Queryable, Retrievable @@ -9,12 +10,11 @@ class KYCValidation(Creatable, Retrievable, Queryable): _resource: ClassVar = 'kyc_validations' platform_id: str - attemps: Optional[int] - verification_id: Optional[str] - files_uri: Optional[List[str]] - - class Config: - schema_extra = { + attemps: Optional[int] = None + verification_id: Optional[str] = None + files_uri: Optional[list[str]] = None + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'KVNEUInh69SuKXXmK95sROwQ', 'platform_id': 'PT8UEv02zBTcymd4Kd3MO6pg', @@ -24,13 +24,14 @@ class Config: 'attemps': '1', } } + ) @classmethod def create( cls, user_id: str, force: bool = False, - documents: List[KYCFile] = [], + documents: list[KYCFile] = [], session: Session = global_session, ) -> 'KYCValidation': req = KYCValidationRequest( @@ -39,5 +40,5 @@ def create( documents=documents, ) return cast( - 'KYCValidation', cls._create(**req.dict(), session=session) + 'KYCValidation', cls._create(**req.model_dump(), session=session) ) diff --git a/cuenca/resources/kyc_verifications.py b/cuenca/resources/kyc_verifications.py index 106b77b3..acccd46f 100644 --- a/cuenca/resources/kyc_verifications.py +++ b/cuenca/resources/kyc_verifications.py @@ -7,6 +7,7 @@ KYCVerificationUpdateRequest, Rfc, ) +from pydantic import ConfigDict from ..http import Session, session as global_session from .base import Creatable, Retrievable, Updateable @@ -17,14 +18,13 @@ class KYCVerification(Creatable, Retrievable, Updateable): platform_id: str created_at: dt.datetime - deactivated_at: Optional[dt.datetime] - verification_id: Optional[str] + deactivated_at: Optional[dt.datetime] = None + verification_id: Optional[str] = None curp: Optional[CurpField] = None rfc: Optional[Rfc] = None address: Optional[Address] = None - - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'KVNEUInh69SuKXXmK95sROwQ', 'updated_at': '2020-05-24T14:15:22Z', @@ -36,6 +36,7 @@ class Config: 'address': Address.schema().get('example'), } } + ) @classmethod def create(cls, session: Session = global_session) -> 'KYCVerification': @@ -45,7 +46,9 @@ def create(cls, session: Session = global_session) -> 'KYCVerification': def update( cls, kyc_id: str, - curp: Optional[CurpField] = None, + curp: CurpField, ) -> 'KYCVerification': req = KYCVerificationUpdateRequest(curp=curp) - return cast('KYCVerification', cls._update(id=kyc_id, **req.dict())) + return cast( + 'KYCVerification', cls._update(id=kyc_id, **req.model_dump()) + ) diff --git a/cuenca/resources/limited_wallets.py b/cuenca/resources/limited_wallets.py index 8ea03865..5180781e 100644 --- a/cuenca/resources/limited_wallets.py +++ b/cuenca/resources/limited_wallets.py @@ -15,13 +15,13 @@ class LimitedWallet(Wallet): _resource: ClassVar = 'limited_wallets' _query_params: ClassVar = AccountQuery account_number: Clabe - allowed_rfc: Optional[Rfc] + allowed_rfc: Optional[Rfc] = None allowed_curp: CurpField @classmethod def create( cls, - allowed_curp: Optional[CurpField] = None, + allowed_curp: CurpField, allowed_rfc: Optional[Rfc] = None, ) -> 'LimitedWallet': """ @@ -37,4 +37,4 @@ def create( allowed_curp=allowed_curp, allowed_rfc=allowed_rfc, ) - return cast('LimitedWallet', cls._create(**request.dict())) + return cast('LimitedWallet', cls._create(**request.model_dump())) diff --git a/cuenca/resources/login_tokens.py b/cuenca/resources/login_tokens.py index 7bb3828b..0982744c 100644 --- a/cuenca/resources/login_tokens.py +++ b/cuenca/resources/login_tokens.py @@ -1,14 +1,22 @@ from typing import ClassVar, cast +from pydantic import ConfigDict, SecretStr + from ..http import Session, session as global_session from .base import Creatable +# mypy: disable-error-code=override class LoginToken(Creatable): _resource: ClassVar = 'login_tokens' - class Config: - schema_extra = {'example': {'id': 'LTNEUInh69SuKXXmK95sROwQ'}} + # Override the `id` field to be a `SecretStr` + # To ensure sensitive data is not exposed in logs. + id: SecretStr # type: ignore + + model_config = ConfigDict( + json_schema_extra={'example': {'id': 'LTNEUInh69SuKXXmK95sROwQ'}} + ) @classmethod def create(cls, session: Session = global_session) -> 'LoginToken': diff --git a/cuenca/resources/otps.py b/cuenca/resources/otps.py index 03450c1b..da2d214b 100644 --- a/cuenca/resources/otps.py +++ b/cuenca/resources/otps.py @@ -1,20 +1,22 @@ from typing import ClassVar, cast +from pydantic import ConfigDict, SecretStr + from ..http import Session, session as global_session from .base import Creatable class Otp(Creatable): _resource: ClassVar = 'otps' - secret: str - - class Config: - schema_extra = { + secret: SecretStr + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'OTNEUInh69SuKXXmK95sROwQ', 'secret': 'somesecret', } } + ) @classmethod def create(cls, session: Session = global_session) -> 'Otp': diff --git a/cuenca/resources/platforms.py b/cuenca/resources/platforms.py index e867fcdb..3cdde42b 100644 --- a/cuenca/resources/platforms.py +++ b/cuenca/resources/platforms.py @@ -2,6 +2,7 @@ from typing import ClassVar, Optional, cast from cuenca_validations.types import Country, PlatformRequest, State +from pydantic import ConfigDict, Field from ..http import Session, session as global_session from .base import Creatable @@ -11,33 +12,28 @@ class Platform(Creatable): _resource: ClassVar = 'platforms' created_at: dt.datetime - name: str - rfc: Optional[str] = None - establishment_date: Optional[dt.date] = None - country: Optional[Country] = None - state: Optional[State] = None - economic_activity: Optional[str] = None - email_address: Optional[str] = None - phone_number: Optional[str] = None - - class Config: - fields = { - 'name': {'description': 'name of the platform being created'}, - 'rfc': {'description': 'RFC or CURP of the platform'}, - 'establishment_date': { - 'description': 'when the platform was established' - }, - 'country': {'description': 'country where the platform resides'}, - 'state': {'description': 'state where the platform resides'}, - 'economic_activity': {'description': 'what the platform does'}, - 'phone_number': { - 'description': 'phone number to contact the platform' - }, - 'email_address': { - 'description': 'email address to contact the platform' - }, - } - schema_extra = { + name: str = Field(description='name of the platform being created') + rfc: Optional[str] = Field(None, description='RFC or CURP of the platform') + establishment_date: Optional[dt.date] = Field( + None, description='when the platform was established' + ) + country: Optional[Country] = Field( + None, description='country where the platform resides' + ) + state: Optional[State] = Field( + None, description='state where the platform resides' + ) + economic_activity: Optional[str] = Field( + None, description='what the platform does' + ) + email_address: Optional[str] = Field( + None, description='email address to contact the platform' + ) + phone_number: Optional[str] = Field( + None, description='phone number to contact the platform' + ) + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'PT0123456789', 'name': 'Arteria', @@ -50,14 +46,15 @@ class Config: 'phone_number': '+525555555555', 'email_address': 'art@eria.com', } - } + }, + ) @classmethod def create( cls, name: str, rfc: Optional[str] = None, - establishment_date: Optional[str] = None, + establishment_date: Optional[dt.date] = None, country: Optional[Country] = None, state: Optional[State] = None, economic_activity: Optional[str] = None, @@ -76,4 +73,6 @@ def create( phone_number=phone_number, email_address=email_address, ) - return cast('Platform', cls._create(session=session, **req.dict())) + return cast( + 'Platform', cls._create(session=session, **req.model_dump()) + ) diff --git a/cuenca/resources/questionnaires.py b/cuenca/resources/questionnaires.py index 5e19c9b6..906c4f40 100644 --- a/cuenca/resources/questionnaires.py +++ b/cuenca/resources/questionnaires.py @@ -2,6 +2,7 @@ from typing import ClassVar, cast from cuenca_validations.types import QuestionnairesRequest +from pydantic import ConfigDict from ..http import Session, session as global_session from .base import Creatable, Retrievable @@ -14,15 +15,15 @@ class Questionnaires(Creatable, Retrievable): token: str form_id: str user_id: str - - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ 'example': { 'user_id': 'US234i23jh23h4h23', 'token': '3223j23ij23ij3', 'alert_id': 'ALewifjwiejf', } } + ) @classmethod def create( @@ -39,5 +40,5 @@ def create( form_id=form_id, ) return cast( - 'Questionnaires', cls._create(session=session, **req.dict()) + 'Questionnaires', cls._create(session=session, **req.model_dump()) ) diff --git a/cuenca/resources/resources.py b/cuenca/resources/resources.py index eeac433b..82388a59 100644 --- a/cuenca/resources/resources.py +++ b/cuenca/resources/resources.py @@ -1,11 +1,10 @@ import re -from concurrent.futures import ThreadPoolExecutor -from typing import Dict, List, cast +from typing import cast from .base import Retrievable ENDPOINT_RE = re.compile(r'.*/(?P[a-z_]+)/(?P.+)$') -RESOURCES: Dict[str, Retrievable] = {} # set in ./__init__.py after imports +RESOURCES: dict[str, Retrievable] = {} # set in ./__init__.py after imports def retrieve_uri(uri: str) -> Retrievable: @@ -16,6 +15,12 @@ def retrieve_uri(uri: str) -> Retrievable: return cast(Retrievable, RESOURCES[resource].retrieve(id_)) -def retrieve_uris(uris: List[str]) -> List[Retrievable]: - with ThreadPoolExecutor(max_workers=len(uris)) as executor: - return [obj for obj in executor.map(retrieve_uri, uris)] +def retrieve_uris(uris: list[str]) -> list[Retrievable]: + # Changed the implementation to use a simple for loop instead of + # ThreadPoolExecutor. The list of URIs is small, so the performance + # difference is negligible. Additionally, using ThreadPoolExecutor + # caused issues with VCR tests, as the recordings were not retrieved + # in the correct order, leading to unexpected HTTP calls instead of + # using the mocked recordings. + + return [retrieve_uri(uri) for uri in uris] diff --git a/cuenca/resources/savings.py b/cuenca/resources/savings.py index 2a857097..10320046 100644 --- a/cuenca/resources/savings.py +++ b/cuenca/resources/savings.py @@ -17,8 +17,8 @@ class Saving(Wallet, Updateable): _query_params: ClassVar = WalletQuery name: str category: SavingCategory - goal_amount: Optional[StrictPositiveInt] - goal_date: Optional[dt.datetime] + goal_amount: Optional[StrictPositiveInt] = None + goal_date: Optional[dt.datetime] = None @classmethod def create( @@ -34,7 +34,7 @@ def create( goal_amount=goal_amount, goal_date=goal_date, ) - return cast('Saving', cls._create(**request.dict())) + return cast('Saving', cls._create(**request.model_dump())) @classmethod def update( @@ -51,4 +51,6 @@ def update( goal_amount=goal_amount, goal_date=goal_date, ) - return cast('Saving', cls._update(id=saving_id, **request.dict())) + return cast( + 'Saving', cls._update(id=saving_id, **request.model_dump()) + ) diff --git a/cuenca/resources/service_providers.py b/cuenca/resources/service_providers.py index 9935ed63..4401d6a0 100644 --- a/cuenca/resources/service_providers.py +++ b/cuenca/resources/service_providers.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List +from typing import ClassVar from cuenca_validations.types import ServiceProviderCategory @@ -10,4 +10,4 @@ class ServiceProvider(Retrievable, Queryable): name: str provider_key: str - categories: List[ServiceProviderCategory] + categories: list[ServiceProviderCategory] diff --git a/cuenca/resources/sessions.py b/cuenca/resources/sessions.py index 227e3b15..9f1def08 100644 --- a/cuenca/resources/sessions.py +++ b/cuenca/resources/sessions.py @@ -2,26 +2,28 @@ from typing import ClassVar, Optional, cast from cuenca_validations.types import SessionRequest, SessionType -from pydantic import AnyUrl +from pydantic import AnyUrl, ConfigDict, SecretStr from .. import http from .base import Creatable, Queryable, Retrievable +# mypy: disable-error-code=override class Session(Creatable, Retrievable, Queryable): _resource: ClassVar = 'sessions' - id: str + # Override the `id` field to be a `SecretStr` + # To ensure sensitive data is not exposed in logs. + id: SecretStr = None # type: ignore created_at: dt.datetime user_id: str platform_id: str expires_at: dt.datetime - success_url: Optional[AnyUrl] - failure_url: Optional[AnyUrl] - type: Optional[SessionType] - - class Config: - schema_extra = { + success_url: Optional[AnyUrl] = None + failure_url: Optional[AnyUrl] = None + type: Optional[SessionType] = None + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'SENEUInh69SuKXXmK95sROwQ', 'created_at': '2022-08-24T14:15:22Z', @@ -33,6 +35,7 @@ class Config: 'type': 'session.registration', } } + ) @classmethod def create( @@ -47,7 +50,9 @@ def create( req = SessionRequest( user_id=user_id, type=type, - success_url=success_url, - failure_url=failure_url, + success_url=success_url, # type: ignore + failure_url=failure_url, # type: ignore + ) + return cast( + 'Session', cls._create(session=session, **req.model_dump()) ) - return cast('Session', cls._create(session=session, **req.dict())) diff --git a/cuenca/resources/transfers.py b/cuenca/resources/transfers.py index 97b0cb9e..780d3cfb 100644 --- a/cuenca/resources/transfers.py +++ b/cuenca/resources/transfers.py @@ -1,6 +1,7 @@ import datetime as dt -from typing import ClassVar, List, Optional, cast +from typing import ClassVar, Optional, cast +from clabe import Clabe from cuenca_validations.types import ( TransferNetwork, TransferQuery, @@ -25,7 +26,7 @@ class Transfer(Transaction, Creatable): idempotency_key: str network: TransferNetwork destination_uri: str - tracking_key: Optional[str] # clave rastreo if network is SPEI + tracking_key: Optional[str] = None # clave rastreo if network is SPEI @property # type: ignore def destination(self) -> Account: @@ -62,21 +63,21 @@ def create( if not idempotency_key: idempotency_key = cls._gen_idempotency_key(account_number, amount) req = TransferRequest( - account_number=account_number, + account_number=cast(Clabe, account_number), amount=amount, descriptor=descriptor, recipient_name=recipient_name, idempotency_key=idempotency_key, user_id=user_id, ) - return cast('Transfer', cls._create(**req.dict())) + return cast('Transfer', cls._create(**req.model_dump())) @classmethod - def create_many(cls, requests: List[TransferRequest]) -> DictStrAny: + def create_many(cls, requests: list[TransferRequest]) -> DictStrAny: transfers: DictStrAny = dict(submitted=[], errors=[]) for req in requests: try: - transfer = cls._create(**req.dict()) + transfer = cls._create(**req.model_dump()) except (CuencaException, HTTPError) as e: transfers['errors'].append(dict(request=req, error=e)) else: @@ -90,4 +91,4 @@ def _gen_idempotency_key(account_number: str, amount: int) -> str: idempotency_key, but this provides some level of protection against submitting duplicate transfers """ - return f'{dt.datetime.utcnow().date()}:{account_number}:{amount}' + return f'{dt.datetime.utcnow().date()}: {account_number}: {amount}' diff --git a/cuenca/resources/user_credentials.py b/cuenca/resources/user_credentials.py index 097dd6ff..005a0c4c 100644 --- a/cuenca/resources/user_credentials.py +++ b/cuenca/resources/user_credentials.py @@ -26,7 +26,7 @@ def create( ) -> 'UserCredential': req = UserCredentialRequest(password=password, user_id=user_id) return cast( - 'UserCredential', cls._create(**req.dict(), session=session) + 'UserCredential', cls._create(**req.model_dump(), session=session) ) @classmethod @@ -44,5 +44,5 @@ def update( ) return cast( 'UserCredential', - cls._update(id=user_id, **req.dict(), session=session), + cls._update(id=user_id, **req.model_dump(), session=session), ) diff --git a/cuenca/resources/user_events.py b/cuenca/resources/user_events.py index 8a6de5fa..ba565d9b 100644 --- a/cuenca/resources/user_events.py +++ b/cuenca/resources/user_events.py @@ -1,5 +1,7 @@ from typing import ClassVar +from pydantic import ConfigDict + from .identity_events import IdentityEvent from .users import User @@ -9,9 +11,8 @@ class UserEvent(IdentityEvent): user_id: str platform_id: str - - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'UEYE4qnWs3Sm68tbgqkx_d5Q', 'created_at': '2022-05-24T14:15:22Z', @@ -22,3 +23,4 @@ class Config: 'new_model': User.schema().get('example'), } } + ) diff --git a/cuenca/resources/user_lists_validation.py b/cuenca/resources/user_lists_validation.py index 8578071b..072e3420 100644 --- a/cuenca/resources/user_lists_validation.py +++ b/cuenca/resources/user_lists_validation.py @@ -1,6 +1,7 @@ import datetime as dt from typing import ClassVar, Optional, cast +from clabe import Clabe from cuenca_validations.types import UserListsRequest, VerificationStatus from cuenca_validations.types.identities import CurpField @@ -34,9 +35,9 @@ def create( first_surname=first_surname, second_surname=second_surname, curp=curp, - account_number=account_number, + account_number=cast(Clabe, account_number), ) return cast( 'UserListsValidation', - cls._create(session=session, **req.dict()), + cls._create(session=session, **req.model_dump()), ) diff --git a/cuenca/resources/user_logins.py b/cuenca/resources/user_logins.py index 703f1d81..1111ce95 100644 --- a/cuenca/resources/user_logins.py +++ b/cuenca/resources/user_logins.py @@ -2,6 +2,7 @@ from typing import ClassVar, Optional, cast from cuenca_validations.types.requests import UserLoginRequest +from pydantic import ConfigDict from ..http import Session, session as global_session from .base import Creatable @@ -10,17 +11,17 @@ class UserLogin(Creatable): _resource: ClassVar = 'user_logins' - last_login_at: Optional[dt.datetime] + last_login_at: Optional[dt.datetime] = None success: bool - - class Config: - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'ULNEUInh69SuKXXmK95sROwQ', 'last_login_at': '2022-01-01T14:15:22Z', 'success': True, } } + ) @classmethod def create( @@ -31,7 +32,9 @@ def create( session: Session = global_session, ) -> 'UserLogin': req = UserLoginRequest(password=password, user_id=user_id) - login = cast('UserLogin', cls._create(session=session, **req.dict())) + login = cast( + 'UserLogin', cls._create(session=session, **req.model_dump()) + ) if login.success: session.headers['X-Cuenca-LoginId'] = login.id return login diff --git a/cuenca/resources/users.py b/cuenca/resources/users.py index 0a0d5816..4b119686 100644 --- a/cuenca/resources/users.py +++ b/cuenca/resources/users.py @@ -1,5 +1,5 @@ import datetime as dt -from typing import ClassVar, List, Optional, cast +from typing import ClassVar, Optional, cast from clabe import Clabe from cuenca_validations.types import ( @@ -16,7 +16,7 @@ ) from cuenca_validations.types.enums import Country, Gender, State from cuenca_validations.types.identities import CurpField -from pydantic import EmailStr, HttpUrl +from pydantic import ConfigDict, EmailStr, Field, HttpUrl from ..http import Session, session as global_session from .balance_entries import BalanceEntry @@ -30,60 +30,52 @@ class User(Creatable, Retrievable, Updateable, Queryable): _query_params: ClassVar = UserQuery identity_uri: str - level: int - required_level: int + level: int = Field( + description='Account level according to KYC information' + ) + required_level: int = Field( + description='Maximum level User can reach. Set by platform' + ) created_at: dt.datetime - phone_number: Optional[PhoneNumber] - email_address: Optional[EmailStr] - profession: Optional[str] - terms_of_service: Optional[TOSAgreement] - status: Optional[UserStatus] - address: Optional[Address] - govt_id: Optional[KYCFile] - proof_of_address: Optional[KYCFile] - proof_of_life: Optional[KYCFile] - beneficiaries: Optional[List[Beneficiary]] + phone_number: Optional[PhoneNumber] = None + email_address: Optional[EmailStr] = None + profession: Optional[str] = None + terms_of_service: Optional[TOSAgreement] = None + status: Optional[UserStatus] = None + address: Optional[Address] = None + govt_id: Optional[KYCFile] = Field( + None, description='Government ID document validation' + ) + proof_of_address: Optional[KYCFile] = Field( + None, description='Detail of proof of address document validation' + ) + proof_of_life: Optional[KYCFile] = Field( + None, description='Detail of selfie video validation' + ) + beneficiaries: Optional[list[Beneficiary]] = Field( + None, description='Beneficiaries of account in case of death' + ) platform_id: Optional[str] = None clabe: Optional[Clabe] = None # These fields are added by identify when retrieving a User: - names: Optional[str] - first_surname: Optional[str] - second_surname: Optional[str] - curp: Optional[str] - rfc: Optional[str] - gender: Optional[Gender] - date_of_birth: Optional[dt.date] - state_of_birth: Optional[State] - nationality: Optional[Country] - country_of_birth: Optional[Country] + names: Optional[str] = None + first_surname: Optional[str] = None + second_surname: Optional[str] = None + curp: Optional[str] = None + rfc: Optional[str] = None + gender: Optional[Gender] = None + date_of_birth: Optional[dt.date] = None + state_of_birth: Optional[State] = None + nationality: Optional[Country] = None + country_of_birth: Optional[Country] = None @property def balance(self) -> int: be = cast(BalanceEntry, BalanceEntry.first(user_id=self.id)) return be.rolling_balance if be else 0 - class Config: - fields = { - 'level': { - 'description': 'Account level according to KYC information' - }, - 'required_level': { - 'description': 'Maximum level User can reach. Set by platform' - }, - 'govt_id': { - 'description': 'Detail of government id document validation' - }, - 'proof_of_address': { - 'description': 'Detail of proof of address document validation' - }, - 'proof_of_life': { - 'description': 'Detail of selfie video validation' - }, - 'beneficiaries': { - 'description': 'Beneficiaries of account in case of death' - }, - } - schema_extra = { + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'USWqY5cvkISJOxHyEKjAKf8w', 'created_at': '2019-08-24T14:15:22Z', @@ -105,12 +97,14 @@ class Config: ], 'platform_id': 'PT8UEv02zBTcymd4Kd3MO6pg', } - } + }, + ) @classmethod def create( cls, curp: CurpField, + id: Optional[str] = None, phone_number: Optional[PhoneNumber] = None, email_address: Optional[EmailStr] = None, profession: Optional[str] = None, @@ -124,6 +118,7 @@ def create( session: Session = global_session, ) -> 'User': req = UserRequest( + id=id, curp=curp, phone_number=phone_number, email_address=email_address, @@ -135,7 +130,7 @@ def create( status=status, terms_of_service=terms_of_service, ) - return cast('User', cls._create(session=session, **req.dict())) + return cast('User', cls._create(session=session, **req.model_dump())) @classmethod def update( @@ -145,7 +140,7 @@ def update( email_address: Optional[str] = None, profession: Optional[str] = None, address: Optional[Address] = None, - beneficiaries: Optional[List[Beneficiary]] = None, + beneficiaries: Optional[list[Beneficiary]] = None, govt_id: Optional[KYCFile] = None, proof_of_address: Optional[KYCFile] = None, proof_of_life: Optional[KYCFile] = None, @@ -154,7 +149,7 @@ def update( status: Optional[UserStatus] = None, email_verification_id: Optional[str] = None, phone_verification_id: Optional[str] = None, - curp_document: Optional[HttpUrl] = None, + curp_document_uri: Optional[HttpUrl] = None, *, session: Session = global_session, ): @@ -171,12 +166,12 @@ def update( verification_id=verification_id, email_verification_id=email_verification_id, phone_verification_id=phone_verification_id, - curp_document=curp_document, + curp_document_uri=curp_document_uri, status=status, ) return cast( 'User', - cls._update(id=user_id, **request.dict(), session=session), + cls._update(id=user_id, **request.model_dump(), session=session), ) @property diff --git a/cuenca/resources/verifications.py b/cuenca/resources/verifications.py index bf59b1f7..2f5c2de4 100644 --- a/cuenca/resources/verifications.py +++ b/cuenca/resources/verifications.py @@ -7,7 +7,7 @@ VerificationType, ) from cuenca_validations.types.identities import PhoneNumber -from pydantic import EmailStr +from pydantic import ConfigDict, EmailStr, Field from ..http import Session, session as global_session from .base import Creatable, Updateable @@ -16,14 +16,14 @@ class Verification(Creatable, Updateable): _resource: ClassVar = 'verifications' - recipient: Union[EmailStr, PhoneNumber] + recipient: Union[EmailStr, PhoneNumber] = Field( + description='Phone or email to validate' + ) type: VerificationType created_at: dt.datetime - deactivated_at: Optional[dt.datetime] - - class Config: - fields = {'recipient': {'description': 'Phone or email to validate'}} - schema_extra = { + deactivated_at: Optional[dt.datetime] = None + model_config = ConfigDict( + json_schema_extra={ 'example': { 'id': 'VENEUInh69SuKXXmK95sROwQ', 'recipient': 'user@example.com', @@ -31,7 +31,8 @@ class Config: 'created_at': '2022-05-24T14:15:22Z', 'deactivated_at': None, } - } + }, + ) @classmethod def create( @@ -44,7 +45,9 @@ def create( req = VerificationRequest( recipient=recipient, type=type, platform_id=platform_id ) - return cast('Verification', cls._create(**req.dict(), session=session)) + return cast( + 'Verification', cls._create(**req.model_dump(), session=session) + ) @classmethod def verify( @@ -56,5 +59,5 @@ def verify( req = VerificationAttemptRequest(code=code) return cast( 'Verification', - cls._update(id=id, **req.dict(), session=session), + cls._update(id=id, **req.model_dump(), session=session), ) diff --git a/cuenca/resources/wallet_transactions.py b/cuenca/resources/wallet_transactions.py index c745d007..ed9e8d72 100644 --- a/cuenca/resources/wallet_transactions.py +++ b/cuenca/resources/wallet_transactions.py @@ -35,4 +35,4 @@ def create( transaction_type=transaction_type, amount=amount, ) - return cast('WalletTransaction', cls._create(**request.dict())) + return cast('WalletTransaction', cls._create(**request.model_dump())) diff --git a/cuenca/resources/webhooks.py b/cuenca/resources/webhooks.py index 257e8b68..580ebc29 100644 --- a/cuenca/resources/webhooks.py +++ b/cuenca/resources/webhooks.py @@ -1,6 +1,7 @@ -from typing import Any, ClassVar, Dict +from typing import Any, ClassVar from cuenca_validations.types.enums import WebhookEvent +from pydantic import Field from .base import Queryable, Retrievable @@ -8,11 +9,5 @@ class Webhook(Retrievable, Queryable): _resource: ClassVar = 'webhooks' - payload: Dict[str, Any] - event: WebhookEvent - - class Config: - fields = { - 'payload': {'description': 'object sent by the webhook'}, - 'event': {'description': 'type of event being reported'}, - } + payload: dict[str, Any] = Field(description='object sent by the webhook') + event: WebhookEvent = Field(description='type of event being reported') diff --git a/cuenca/resources/whatsapp_transfers.py b/cuenca/resources/whatsapp_transfers.py index 7a067599..5be24966 100644 --- a/cuenca/resources/whatsapp_transfers.py +++ b/cuenca/resources/whatsapp_transfers.py @@ -14,12 +14,12 @@ class WhatsappTransfer(Transaction): updated_at: dt.datetime recipient_name: str phone_number: str - claim_url: Optional[str] + claim_url: Optional[str] = None expires_at: dt.datetime # defined after the transfer has been claimed - destination_uri: Optional[str] - network: Optional[TransferNetwork] - tracking_key: Optional[str] # clave rastreo if network is SPEI + destination_uri: Optional[str] = None + network: Optional[TransferNetwork] = None + tracking_key: Optional[str] = None # clave rastreo if network is SPEI @property # type: ignore def destination(self) -> Optional[Account]: diff --git a/cuenca/version.py b/cuenca/version.py index 2d56c88c..b7fc5d71 100644 --- a/cuenca/version.py +++ b/cuenca/version.py @@ -1,3 +1,3 @@ -__version__ = '1.0.2' +__version__ = '2.0.0' CLIENT_VERSION = __version__ API_VERSION = '2020-03-19' diff --git a/requirements-test.txt b/requirements-test.txt index 22487320..01d92536 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,12 +1,12 @@ -black==24.3.0 -flake8==4.0.* -freezegun==1.1.* -isort==5.10.* -mypy==0.931 -pytest==6.2.* -pytest-cov==3.0.* -pytest-vcr==1.0.* -requests-mock==1.9.* -types-freezegun -types-requests -vcrpy==4.3.1 +black==24.10.0 +flake8==7.1.1 +freezegun==1.5.1 +isort==5.13.2 +mypy==1.14.1 +pytest==8.3.4 +pytest-cov==6.0.0 +pytest-vcr==1.0.2 +requests-mock==1.12.1 +types-freezegun==1.1.10 +types-requests==2.31.0.6 +vcrpy==7.0.0 diff --git a/requirements.txt b/requirements.txt index 7cd483b3..2f33d1be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -requests==2.31.0 -cuenca-validations==0.11.30 -dataclasses>=0.7;python_version<"3.7" +requests==2.32.3 +cuenca-validations==2.0.0.dev14 +pydantic-extra-types==2.10.* diff --git a/setup.py b/setup.py index 36c728e9..e769025c 100644 --- a/setup.py +++ b/setup.py @@ -21,14 +21,18 @@ packages=find_packages(), include_package_data=True, package_data=dict(cuenca=['py.typed']), - python_requires='>=3.8', + python_requires='>=3.9', install_requires=[ - 'requests>=2.24,<28', - 'dataclasses>=0.7;python_version<"3.8"', - 'cuenca-validations>= 0.11.3,<0.12.0', + 'requests>=2.32.0', + 'cuenca-validations>=2.0.0', + 'pydantic-extra-types>=2.10.0', ], classifiers=[ - 'Programming Language :: Python :: 3.8', + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', ], diff --git a/tests/conftest.py b/tests/conftest.py index d61b02cc..c659e32e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import datetime as dt from io import BytesIO -from typing import Dict import pytest from cuenca_validations.types import Country, Gender, State @@ -31,7 +30,7 @@ def transfer(): @pytest.fixture -def curp_validation_request() -> Dict: +def curp_validation_request() -> dict: curp_validation = dict( names='José', first_surname='López', @@ -45,7 +44,7 @@ def curp_validation_request() -> Dict: @pytest.fixture -def user_request() -> Dict: +def user_request() -> dict: user_dict = dict( curp='LOHJ660606HDFPRS02', phone_number='+525511223344', @@ -64,7 +63,7 @@ def user_request() -> Dict: @pytest.fixture -def user_lists_request() -> Dict: +def user_lists_request() -> dict: user_dict = dict( curp='LOHJ660606HDFPRS02', names='Alejandro', diff --git a/tests/resources/cassettes/test_card_activation.yaml b/tests/resources/cassettes/test_card_activation.yaml index 268c2a3b..1861b4e6 100644 --- a/tests/resources/cassettes/test_card_activation.yaml +++ b/tests/resources/cassettes/test_card_activation.yaml @@ -1,111 +1,90 @@ interactions: - request: - body: '{"number": "4122943400023502", "exp_month": 11, "exp_year": 24, "cvv2": - "123"}' + body: '{"number": "5448750001621241", "exp_month": 11, "exp_year": 24, "cvv2": + "111"}' headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate Authorization: - DUMMY - Connection: - - keep-alive Content-Length: - '78' Content-Type: - application/json User-Agent: - - cuenca-python/0.7.1 + - cuenca-python/2.0.0.dev3 X-Cuenca-Api-Version: - '2020-03-19' - X-Cuenca-LoginId: - - ULj4BvmWfnRkGk62g321ikiQ + X-Cuenca-LoginToken: + - LTtwn7EyHrRyG7dBe927XWew method: POST uri: https://sandbox.cuenca.com/card_activations response: body: - string: '{"id":"CAAmnPW-msFSUK0V0fpjnC_UQ","created_at":"2021-03-26T19:25:36.504000","user_id":"US1237","ip_address":"200.56.74.39, - 10.5.52.181","card_uri":"/cards/CA2XIurUccQIcqvPGNpdTm7k","success":true}' + string: '{"id":"CAAEkTme9YJRQGmZgTJhSpBdg","created_at":"2025-01-08T22:40:46.834250","user_id":"US1w9BJ0DZ9kSdac39ur14Nf","ip_address":"10.0.2.68","card_uri":"/cards/CAPpdShtSGR0m__EmAmH7dWg","success":true,"bin":"544875","deactivated_at":null}' headers: Connection: - keep-alive Content-Length: - - '214' + - '235' Content-Type: - application/json Date: - - Fri, 26 Mar 2021 19:25:36 GMT - X-Amzn-Trace-Id: - - Root=1-605e352f-728c710217fdaeb646ffeb5a;Sampled=0 + - Wed, 08 Jan 2025 22:40:47 GMT X-Request-Time: - - 'value: 1.241' + - 'value: 1.156' x-amz-apigw-id: - - cz0_aGfEiYcFkHw= + - EFzFPFpRCYcEk8Q= x-amzn-Remapped-Connection: - keep-alive x-amzn-Remapped-Content-Length: - - '214' + - '235' x-amzn-Remapped-Date: - - Fri, 26 Mar 2021 19:25:36 GMT + - Wed, 08 Jan 2025 22:40:47 GMT x-amzn-Remapped-Server: - - nginx/1.18.0 - x-amzn-Remapped-x-amzn-RequestId: - - d825e840-021f-4ea3-885b-47136dc4b951 + - nginx/1.26.2 x-amzn-RequestId: - - b7e4b2a6-e501-44be-adfe-ab8d1437915b + - 0dfcf94e-9d46-447c-96d7-4fbaf1668ce0 status: code: 201 message: Created - request: body: null headers: - Accept: - - '*/*' - Accept-Encoding: - - gzip, deflate Authorization: - DUMMY - Connection: - - keep-alive User-Agent: - - cuenca-python/0.7.1 + - cuenca-python/2.0.0.dev3 X-Cuenca-Api-Version: - '2020-03-19' - X-Cuenca-LoginId: - - ULj4BvmWfnRkGk62g321ikiQ + X-Cuenca-LoginToken: + - LTtwn7EyHrRyG7dBe927XWew method: GET - uri: https://sandbox.cuenca.com/cards/CA2XIurUccQIcqvPGNpdTm7k + uri: https://sandbox.cuenca.com/cards/CAPpdShtSGR0m__EmAmH7dWg response: body: - string: '{"id":"CA2XIurUccQIcqvPGNpdTm7k","created_at":"2019-11-13T21:15:44.877000","updated_at":"2019-11-13T21:15:44.877000","user_id":"US1237","number":"4122943400023502","exp_month":11,"exp_year":24,"cvv2":"123","type":"physical","status":"active","pin":"1234","issuer":"accendo","funding_type":"debit","pin_block":""}' + string: '{"id":"CAPpdShtSGR0m__EmAmH7dWg","created_at":"2021-02-26T06:56:36.285000","updated_at":"2025-01-08T22:40:46.233000","user_id":"US1w9BJ0DZ9kSdac39ur14Nf","platform_id":"PTZbBlk__kQt-wfwzP5nwA9A","number":"5448750001621241","exp_month":11,"exp_year":24,"cvv2":"111","type":"physical","status":"active","pin":"1111","issuer":"cuenca","funding_type":"credit","card_holder_user_id":null,"deactivated_at":null,"manufacturer":"manufacturer","cvv":"111","icvv":"111","is_dynamic_cvv":false,"first_validation":null,"pin_block":null,"pin_block_switch":null,"pin_attempts_failed":0}' headers: Connection: - keep-alive Content-Length: - - '330' + - '572' Content-Type: - application/json Date: - - Fri, 26 Mar 2021 19:25:37 GMT - X-Amzn-Trace-Id: - - Root=1-605e3530-41588b232c2acbdb55635076;Sampled=0 + - Wed, 08 Jan 2025 22:40:48 GMT X-Request-Time: - - 'value: 0.423' + - 'value: 0.716' x-amz-apigw-id: - - cz0_pGfNCYcFVlA= + - EFzFdFIeCYcEs9g= x-amzn-Remapped-Connection: - keep-alive x-amzn-Remapped-Content-Length: - - '330' + - '572' x-amzn-Remapped-Date: - - Fri, 26 Mar 2021 19:25:37 GMT + - Wed, 08 Jan 2025 22:40:48 GMT x-amzn-Remapped-Server: - - nginx/1.18.0 - x-amzn-Remapped-x-amzn-RequestId: - - 5e9c4391-baff-4bc8-a71a-d515803b6c99 + - nginx/1.26.2 x-amzn-RequestId: - - 52340184-a7f4-4ecf-b905-51e7a1a38805 + - 48813814-edea-47dc-b7f8-5ef6069da98b status: code: 200 message: OK diff --git a/tests/resources/cassettes/test_user_beneficiaries_update.yaml b/tests/resources/cassettes/test_user_beneficiaries_update.yaml index 3e4cb2e0..8ecaf994 100644 --- a/tests/resources/cassettes/test_user_beneficiaries_update.yaml +++ b/tests/resources/cassettes/test_user_beneficiaries_update.yaml @@ -29,7 +29,7 @@ interactions: uri: https://sandbox.cuenca.com/users/USw182B9fVTxK3J1A2ElKV7g response: body: - string: "{\"id\":\"USw182B9fVTxK3J1A2ElKV7g\",\"identity_uri\":\"/identities/IDYsENkazRRuuZ46KnlN0x1Q\",\"created_at\":\"2022-07-04T20:36:35.007000\",\"updated_at\":\"2022-07-05T18:32:38.470895\",\"platform_id\":\"PTk5UC6RWyQjmDR74oiHlFng\",\"level\":0,\"required_level\":4,\"phone_number\":\"+5299887766\",\"email_address\":\"danisan@mail.com\",\"profession\":null,\"clabe\":null,\"status\":\"active\",\"terms_of_service\":null,\"blacklist_validation_status\":\"not_verified\",\"address\":null,\"govt_id\":null,\"proof_of_address\":null,\"proof_of_life\":null,\"beneficiaries\":[{\"name\":\"Pedro + string: "{\"id\":\"USw182B9fVTxK3J1A2ElKV7g\",\"identity_uri\":\"/identities/IDYsENkazRRuuZ46KnlN0x1Q\",\"created_at\":\"2022-07-04T20:36:35.007000\",\"updated_at\":\"2022-07-05T18:32:38.470895\",\"platform_id\":\"PTk5UC6RWyQjmDR74oiHlFng\",\"level\":0,\"required_level\":4,\"phone_number\":\"+529988776666\",\"email_address\":\"danisan@mail.com\",\"profession\":null,\"clabe\":null,\"status\":\"active\",\"terms_of_service\":null,\"blacklist_validation_status\":\"not_verified\",\"address\":null,\"govt_id\":null,\"proof_of_address\":null,\"proof_of_life\":null,\"beneficiaries\":[{\"name\":\"Pedro P\xE9rez\",\"birth_date\":\"2020-01-01\",\"phone_number\":\"+525555555555\",\"user_relationship\":\"brother\",\"percentage\":50,\"created_at\":\"2022-07-05T18:32:38.470632\"},{\"name\":\"Jos\xE9 P\xE9rez\",\"birth_date\":\"2020-01-02\",\"phone_number\":\"+525544444444\",\"user_relationship\":\"brother\",\"percentage\":50,\"created_at\":\"2022-07-05T18:32:38.470703\"}],\"names\":\"Daniel\",\"first_surname\":\"Sanchez\",\"second_surname\":\"Chavez\",\"curp\":\"LOHJ660606HDFPRS02\",\"rfc\":\"LOHJ660606HDF\"}" headers: diff --git a/tests/resources/test_card_activations.py b/tests/resources/test_card_activations.py index eb14e0db..f7dbb10b 100644 --- a/tests/resources/test_card_activations.py +++ b/tests/resources/test_card_activations.py @@ -8,17 +8,17 @@ @pytest.mark.vcr def test_card_activation(): values = dict( - number='4122943400023502', + number='5448750001621241', exp_month=11, exp_year=24, - cvv2='123', + cvv2='111', ) card_activation = CardActivation.create(**values) assert card_activation.success - assert card_activation.user_id == 'US1237' + assert card_activation.user_id == 'US1w9BJ0DZ9kSdac39ur14Nf' card = card_activation.card assert all(getattr(card, key) == value for key, value in values.items()) - assert card.user_id == 'US1237' + assert card.user_id == 'US1w9BJ0DZ9kSdac39ur14Nf' assert card.status is CardStatus.active diff --git a/tests/resources/test_cards.py b/tests/resources/test_cards.py index ada1c5e9..8d51eede 100644 --- a/tests/resources/test_cards.py +++ b/tests/resources/test_cards.py @@ -79,14 +79,6 @@ def test_card_not_found(): assert exc.value.json['Code'] == 'NotFoundError' -@pytest.mark.vcr -def test_card_one(): - card = Card.one( - number='5448750078699849', exp_month=2, exp_year=2026, cvv2='353' - ) - assert card.id - - @pytest.mark.vcr def test_card_one_errors(): with pytest.raises(NoResultFound): diff --git a/tests/resources/test_commissions.py b/tests/resources/test_commissions.py index 3acd764a..0ee85938 100644 --- a/tests/resources/test_commissions.py +++ b/tests/resources/test_commissions.py @@ -17,7 +17,7 @@ def test_commission_retrieve_with_cash_deposit(): assert commission.id == id_commission related_transaction = commission.related_transaction assert related_transaction - assert type(related_transaction) == Deposit + assert isinstance(related_transaction, Deposit) assert related_transaction.network == 'cash' @@ -28,5 +28,5 @@ def test_commission_retrieve_with_cash_transfer(): assert commission.id == id_commission related_transaction = commission.related_transaction assert related_transaction - assert type(related_transaction) == Transfer + assert isinstance(related_transaction, Transfer) assert related_transaction.network == 'spei' diff --git a/tests/resources/test_endpoints.py b/tests/resources/test_endpoints.py index 4317b9ff..5509c56b 100644 --- a/tests/resources/test_endpoints.py +++ b/tests/resources/test_endpoints.py @@ -43,7 +43,7 @@ def test_endpoint_update(): ) assert endpoint.id == id_endpoint assert len(endpoint.events) == 2 - assert endpoint.url == 'https://url.io' + assert endpoint.url.unicode_string() == 'https://url.io/' assert not endpoint.is_enable assert endpoint.is_active diff --git a/tests/resources/test_login_tokens.py b/tests/resources/test_login_tokens.py index 91656e21..ef136f29 100644 --- a/tests/resources/test_login_tokens.py +++ b/tests/resources/test_login_tokens.py @@ -20,5 +20,5 @@ def test_login_token(session): UserLogin.create('222222', session=session) login_token = LoginToken.create(session=session) session.headers.pop('X-Cuenca-LoginId') - session.configure(login_token=login_token.id) + session.configure(login_token=login_token.id.get_secret_value()) Transfer.count(session=session) diff --git a/tests/resources/test_otps.py b/tests/resources/test_otps.py index 74152ae0..26b9e8fe 100644 --- a/tests/resources/test_otps.py +++ b/tests/resources/test_otps.py @@ -25,4 +25,4 @@ def test_otps(session): session.configure(login_token=login_token.id) otp = Otp.create() assert otp - assert type(otp.secret) == str + assert isinstance(otp.secret.get_secret_value(), str) diff --git a/tests/resources/test_sessions.py b/tests/resources/test_sessions.py index 30366772..de43bbdd 100644 --- a/tests/resources/test_sessions.py +++ b/tests/resources/test_sessions.py @@ -1,5 +1,3 @@ -from typing import Dict - import pytest from cuenca_validations.types import SessionType from pydantic import ValidationError @@ -9,7 +7,7 @@ @pytest.mark.vcr -def test_session_create(curp_validation_request: Dict, user_request: Dict): +def test_session_create(curp_validation_request: dict, user_request: dict): curp_valdation = CurpValidation.create(**curp_validation_request) user_request['curp'] = curp_valdation.validated_curp user = User.create(**user_request) @@ -34,10 +32,10 @@ def test_session_create(curp_validation_request: Dict, user_request: Dict): assert user_session.user_id == user.id assert user_session.type == SessionType.registration - assert user_session.success_url == success_url - assert user_session.failure_url == failure_url ephimeral_cuenca_session = cuenca.http.Session() - ephimeral_cuenca_session.configure(session_token=user_session.id) + ephimeral_cuenca_session.configure( + session_token=user_session.id.get_secret_value() + ) user = User.update(user.id, email_address='manu@example.com') assert user.email_address == 'manu@example.com' diff --git a/tests/resources/test_transfers.py b/tests/resources/test_transfers.py index 631d691e..327b645d 100644 --- a/tests/resources/test_transfers.py +++ b/tests/resources/test_transfers.py @@ -129,4 +129,4 @@ def test_transfers_count_vs_all(): def test_invalid_params(): with pytest.raises(ValidationError) as e: Transfer.one(invalid_param='invalid_param') - assert 'extra fields not permitted' in str(e) + assert 'Extra inputs are not permitted' in str(e)