cuenca-validations 2.0.0.dev8__tar.gz → 2.0.0.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/PKG-INFO +11 -8
  2. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/__init__.py +6 -3
  3. cuenca_validations-2.0.0.dev10/cuenca_validations/types/card.py +38 -0
  4. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/files.py +3 -2
  5. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/general.py +21 -8
  6. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/identities.py +19 -36
  7. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/morals.py +3 -3
  8. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/queries.py +4 -5
  9. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/requests.py +44 -60
  10. cuenca_validations-2.0.0.dev10/cuenca_validations/typing.py +5 -0
  11. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/validators.py +2 -7
  12. cuenca_validations-2.0.0.dev10/cuenca_validations/version.py +1 -0
  13. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations.egg-info/PKG-INFO +11 -8
  14. cuenca_validations-2.0.0.dev10/cuenca_validations.egg-info/requires.txt +4 -0
  15. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/setup.py +10 -7
  16. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/tests/test_card.py +12 -8
  17. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/tests/test_errors.py +10 -7
  18. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/tests/test_types.py +54 -63
  19. cuenca_validations-2.0.0.dev8/cuenca_validations/types/card.py +0 -42
  20. cuenca_validations-2.0.0.dev8/cuenca_validations/typing.py +0 -5
  21. cuenca_validations-2.0.0.dev8/cuenca_validations/version.py +0 -1
  22. cuenca_validations-2.0.0.dev8/cuenca_validations.egg-info/requires.txt +0 -7
  23. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/LICENSE +0 -0
  24. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/README.md +0 -0
  25. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/__init__.py +0 -0
  26. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/card_bins.py +0 -0
  27. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/errors.py +0 -0
  28. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/py.typed +0 -0
  29. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations/types/enums.py +0 -0
  30. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations.egg-info/SOURCES.txt +0 -0
  31. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations.egg-info/dependency_links.txt +0 -0
  32. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/cuenca_validations.egg-info/top_level.txt +0 -0
  33. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/setup.cfg +0 -0
  34. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/tests/__init__.py +0 -0
  35. {cuenca_validations-2.0.0.dev8 → cuenca_validations-2.0.0.dev10}/tests/test_statement.py +0 -0
@@ -1,22 +1,25 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cuenca_validations
3
- Version: 2.0.0.dev8
3
+ Version: 2.0.0.dev10
4
4
  Summary: Cuenca common validations
5
5
  Home-page: https://github.com/cuenca-mx/cuenca-validations
6
6
  Author: Cuenca
7
7
  Author-email: dev@cuenca.com
8
8
  Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.8
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
10
14
  Classifier: License :: OSI Approved :: MIT License
11
15
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.8
16
+ Requires-Python: >=3.9
13
17
  Description-Content-Type: text/markdown
14
18
  License-File: LICENSE
15
- Requires-Dist: clabe>=2.0.0.dev0
16
- Requires-Dist: pydantic[email]>=2.0
17
- Requires-Dist: pydantic-extra-types==2.10.1
18
- Requires-Dist: dataclasses>=0.6; python_version < "3.7"
19
- Requires-Dist: python-dateutil>=2.7.0
19
+ Requires-Dist: clabe>=2.0.0
20
+ Requires-Dist: pydantic[email]>=2.10.0
21
+ Requires-Dist: pydantic-extra-types>=2.10.0
22
+ Requires-Dist: python-dateutil>=2.9.0
20
23
 
21
24
  # Cuenca - validations
22
25
 
@@ -49,7 +49,6 @@ __all__ = [
49
49
  'KYCVerificationUpdateRequest',
50
50
  'Language',
51
51
  'LimitedWalletRequest',
52
- 'PageSize',
53
52
  'PartnerRequest',
54
53
  'PartnerUpdateRequest',
55
54
  'PhoneNumber',
@@ -103,8 +102,10 @@ __all__ = [
103
102
  'WalletQuery',
104
103
  'WalletTransactionQuery',
105
104
  'WebhookEvent',
106
- 'digits',
105
+ 'Digits',
107
106
  'get_state_name',
107
+ 'HttpUrlString',
108
+ 'AnyUrlString',
108
109
  ]
109
110
 
110
111
  from .card import StrictPaymentCardNumber
@@ -152,11 +153,13 @@ from .enums import (
152
153
  )
153
154
  from .files import BatchFileMetadata
154
155
  from .general import (
156
+ AnyUrlString,
157
+ Digits,
158
+ HttpUrlString,
155
159
  JSONEncoder,
156
160
  SantizedDict,
157
161
  StrictPositiveFloat,
158
162
  StrictPositiveInt,
159
- digits,
160
163
  get_state_name,
161
164
  )
162
165
  from .identities import (
@@ -0,0 +1,38 @@
1
+ from pydantic_core import PydanticCustomError, core_schema
2
+ from pydantic_extra_types.payment import PaymentCardNumber
3
+
4
+ from ..card_bins import CARD_BINS
5
+
6
+
7
+ class StrictPaymentCardNumber(PaymentCardNumber):
8
+ """
9
+ Refactored `StrictPaymentCardNumber` to leverage Pydantic v2's
10
+ `PaymentCardNumber`, which now natively includes attributes such as
11
+ brand, bin, last4, and masked.
12
+
13
+ Previously, these attributes were manually computed in a custom
14
+ `PaymentCardNumber` class.
15
+
16
+ The `StrictPaymentCardNumber` class now wraps `PaymentCardNumber`
17
+ as a field, with additional validation to ensure the BIN is associated
18
+ with a known Mexican bank.
19
+ """
20
+
21
+ @classmethod
22
+ def validate(
23
+ cls, __input_value: str, _: core_schema.ValidationInfo
24
+ ) -> 'StrictPaymentCardNumber':
25
+ card = super().validate(__input_value, _)
26
+ if card.bin not in CARD_BINS:
27
+ raise PydanticCustomError(
28
+ 'payment_card_number.bin',
29
+ 'The card number contains a BIN (first six digits) '
30
+ 'that does not have a known association with a Mexican bank.'
31
+ 'To add the association, please file an issue:'
32
+ 'https://github.com/cuenca-mx/cuenca-validations/issues',
33
+ )
34
+ return cls(card)
35
+
36
+ @property
37
+ def bank_code(self) -> str:
38
+ return CARD_BINS[self.bin]
@@ -1,12 +1,13 @@
1
1
  from typing import Optional
2
2
 
3
- from pydantic import BaseModel, HttpUrl
3
+ from pydantic import BaseModel
4
4
 
5
5
  from .enums import KYCFileType
6
+ from .general import HttpUrlString
6
7
 
7
8
 
8
9
  class BatchFileMetadata(BaseModel):
9
10
  id: Optional[str] = None
10
11
  is_back: bool
11
12
  type: KYCFileType
12
- url: HttpUrl
13
+ url: HttpUrlString
@@ -1,12 +1,20 @@
1
1
  import json
2
- from typing import Any, Optional
2
+ from typing import Annotated, Any, Optional
3
3
 
4
- from pydantic import BeforeValidator, Field
5
- from typing_extensions import Annotated
4
+ from pydantic import AfterValidator, AnyUrl, BeforeValidator, Field, HttpUrl
6
5
 
7
6
  from ..validators import sanitize_dict, sanitize_item
8
7
  from .enums import State
9
8
 
9
+ # In Pydantic v2, URL fields like `HttpUrl` are stored as internal objects
10
+ # instead of `str`, which can break compatibility with code expecting str.
11
+ # Using `HttpUrlString` ensures the field is validated as a URL but stored as
12
+ # a `str` for compatibility.
13
+ # https://github.com/pydantic/pydantic/discussions/6395
14
+
15
+ HttpUrlString = Annotated[HttpUrl, AfterValidator(str)]
16
+ AnyUrlString = Annotated[AnyUrl, AfterValidator(str)]
17
+
10
18
 
11
19
  class SantizedDict(dict):
12
20
  def __init__(self, *args, **kwargs):
@@ -19,19 +27,24 @@ class JSONEncoder(json.JSONEncoder):
19
27
  return sanitize_item(o, default=super().default)
20
28
 
21
29
 
22
- StrictPositiveInt = Annotated[int, Field(strict=True, gt=0, le=21_474_836_47)]
30
+ MAX_VALUE_IN_DB = 21_474_836_47
31
+
32
+ StrictPositiveInt = Annotated[
33
+ int, Field(strict=True, gt=0, le=MAX_VALUE_IN_DB)
34
+ ]
23
35
 
24
36
 
25
37
  StrictPositiveFloat = Annotated[float, Field(strict=True, gt=0)]
26
38
 
27
39
 
28
- def validate_only_digits(value: str) -> str:
29
- if not value.isdigit():
40
+ def validate_only_digits(value: Any) -> str:
41
+ v_str = str(value)
42
+ if not v_str.isdigit():
30
43
  raise ValueError("Value must contain only digits")
31
- return value
44
+ return v_str
32
45
 
33
46
 
34
- def digits(
47
+ def Digits(
35
48
  min_length: Optional[int] = None, max_length: Optional[int] = None
36
49
  ) -> Any:
37
50
  return Annotated[
@@ -1,51 +1,34 @@
1
1
  import datetime as dt
2
- import re
3
- from typing import Any, Dict, List, Optional
2
+ from typing import Annotated, Any, Optional
4
3
 
5
4
  from pydantic import (
6
5
  BaseModel,
7
6
  ConfigDict,
8
7
  Field,
9
8
  IPvAnyAddress,
9
+ StringConstraints,
10
10
  model_validator,
11
11
  )
12
12
  from pydantic.types import StrictStr
13
13
 
14
14
  from .enums import Country, KYCFileType, State, VerificationStatus
15
15
 
16
+ PhoneNumber = Annotated[
17
+ str,
18
+ StringConstraints(
19
+ min_length=10, max_length=15, pattern=r'^\+?[0-9]{10,14}$'
20
+ ),
21
+ ]
16
22
 
17
- class PhoneNumber(StrictStr):
18
- min_length = 10
19
- max_length = 15
20
- regex = re.compile(r'^\+?[0-9]{10,14}$')
21
23
 
22
- @classmethod
23
- def __get_pydantic_core_schema__(
24
- cls, source_type: Any, handler: Any
25
- ) -> Dict[str, Any]:
26
- return {
27
- 'type': 'str',
28
- 'pattern': cls.regex.pattern,
29
- 'min_length': cls.min_length,
30
- 'max_length': cls.max_length,
31
- }
32
-
33
-
34
- class CurpField(StrictStr):
35
- min_length = 18
36
- max_length = 18
37
- regex = re.compile(r'^[A-Z]{4}[0-9]{6}[A-Z]{6}[A-Z|0-9][0-9]$')
38
-
39
- @classmethod
40
- def __get_pydantic_core_schema__(
41
- cls, source_type: Any, handler: Any
42
- ) -> Dict[str, Any]:
43
- return {
44
- 'type': 'str',
45
- 'pattern': cls.regex.pattern,
46
- 'min_length': cls.min_length,
47
- 'max_length': cls.max_length,
48
- }
24
+ CurpField = Annotated[
25
+ str,
26
+ StringConstraints(
27
+ min_length=18,
28
+ max_length=18,
29
+ pattern=r'^[A-Z]{4}[0-9]{6}[A-Z]{6}[A-Z|0-9][0-9]$',
30
+ ),
31
+ ]
49
32
 
50
33
 
51
34
  class Rfc(StrictStr):
@@ -61,7 +44,7 @@ class Rfc(StrictStr):
61
44
  @classmethod
62
45
  def __get_pydantic_core_schema__(
63
46
  cls, source_type: Any, handler: Any
64
- ) -> Dict[str, Any]:
47
+ ) -> dict[str, Any]:
65
48
  return {
66
49
  'type': 'str',
67
50
  'min_length': cls.min_length,
@@ -96,7 +79,7 @@ class Address(BaseModel):
96
79
 
97
80
  @model_validator(mode='before')
98
81
  @classmethod
99
- def full_name_complete(cls, values: Dict[str, Any]) -> Dict[str, Any]:
82
+ def full_name_complete(cls, values: dict[str, Any]) -> dict[str, Any]:
100
83
  if values.get('full_name'):
101
84
  return values
102
85
  if not values.get('street'):
@@ -162,7 +145,7 @@ class KYCFile(BaseModel):
162
145
  status: Optional[VerificationStatus] = Field(
163
146
  None, description='The status of the file depends on KYCValidation'
164
147
  )
165
- errors: Optional[List[VerificationErrors]] = Field(
148
+ errors: Optional[list[VerificationErrors]] = Field(
166
149
  None, description='List of document errors found during kyc validation'
167
150
  )
168
151
  verification_id: Optional[str] = Field(
@@ -1,5 +1,5 @@
1
1
  import datetime as dt
2
- from typing import List, Optional
2
+ from typing import Optional
3
3
 
4
4
  from pydantic import BaseModel, EmailStr
5
5
 
@@ -70,5 +70,5 @@ class ShareholderPhysical(PhysicalPerson):
70
70
  class Shareholder(BaseModel):
71
71
  name: str
72
72
  percentage: int
73
- shareholders: List[ShareholderPhysical]
74
- legal_representatives: List[LegalRepresentative]
73
+ shareholders: list[ShareholderPhysical]
74
+ legal_representatives: list[LegalRepresentative]
@@ -1,5 +1,5 @@
1
1
  import datetime as dt
2
- from typing import Optional
2
+ from typing import Annotated, Optional
3
3
 
4
4
  from pydantic import (
5
5
  BaseModel,
@@ -9,7 +9,6 @@ from pydantic import (
9
9
  PositiveInt,
10
10
  field_validator,
11
11
  )
12
- from typing_extensions import Annotated
13
12
 
14
13
  from ..typing import DictStrAny
15
14
  from ..validators import sanitize_dict
@@ -33,7 +32,7 @@ MAX_PAGE_SIZE = 100
33
32
  class QueryParams(BaseModel):
34
33
  count: bool = False
35
34
  page_size: Annotated[
36
- int, Field(strict=True, gt=0, le=MAX_PAGE_SIZE, default=MAX_PAGE_SIZE)
35
+ int, Field(gt=0, le=MAX_PAGE_SIZE, default=MAX_PAGE_SIZE)
37
36
  ]
38
37
  limit: Optional[PositiveInt] = None
39
38
  user_id: Optional[str] = None
@@ -61,10 +60,10 @@ class QueryParams(BaseModel):
61
60
  },
62
61
  )
63
62
 
64
- def dict(self, *args, **kwargs) -> DictStrAny:
63
+ def model_dump(self, *args, **kwargs) -> DictStrAny:
65
64
  kwargs.setdefault('exclude_none', True)
66
65
  kwargs.setdefault('exclude_unset', True)
67
- d = super().dict(*args, **kwargs)
66
+ d = super().model_dump(*args, **kwargs)
68
67
  if self.count:
69
68
  d['count'] = 1
70
69
  sanitize_dict(d)
@@ -1,21 +1,18 @@
1
1
  import datetime as dt
2
- from typing import List, Optional, Union
2
+ from typing import Annotated, Optional, Union
3
3
 
4
4
  from clabe import Clabe
5
5
  from pydantic import (
6
- AnyUrl,
7
6
  BaseModel,
8
7
  ConfigDict,
9
8
  EmailStr,
10
9
  Field,
11
- HttpUrl,
12
10
  StrictStr,
13
11
  StringConstraints,
14
12
  field_validator,
15
13
  model_validator,
16
14
  )
17
15
  from pydantic.networks import IPvAnyAddress
18
- from typing_extensions import Annotated
19
16
 
20
17
  from ..types.enums import (
21
18
  AuthorizerTransaction,
@@ -51,7 +48,7 @@ from ..types.enums import (
51
48
  from ..typing import DictStrAny
52
49
  from ..validators import validate_age_requirement
53
50
  from .card import PaymentCardNumber, StrictPaymentCardNumber
54
- from .general import StrictPositiveInt
51
+ from .general import AnyUrlString, HttpUrlString, StrictPositiveInt
55
52
  from .identities import (
56
53
  Address,
57
54
  Beneficiary,
@@ -75,17 +72,14 @@ from .morals import (
75
72
  class BaseRequest(BaseModel):
76
73
  model_config = ConfigDict(extra="forbid")
77
74
 
78
- def dict(self, *args, **kwargs) -> DictStrAny:
75
+ def model_dump(self, *args, **kwargs) -> DictStrAny:
79
76
  kwargs.setdefault('exclude_none', True)
80
77
  kwargs.setdefault('exclude_unset', True)
81
- return super().dict(*args, **kwargs)
78
+ return super().model_dump(*args, **kwargs)
82
79
 
83
80
 
84
- class TransferRequest(BaseRequest):
81
+ class BaseTransferRequest(BaseRequest):
85
82
  recipient_name: StrictStr
86
- account_number: Union[Clabe, PaymentCardNumber] = Field(
87
- ..., description='Destination Clabe or Card number'
88
- )
89
83
  amount: StrictPositiveInt = Field(
90
84
  ..., description='Always in cents, not in MXN pesos'
91
85
  )
@@ -112,8 +106,16 @@ class TransferRequest(BaseRequest):
112
106
  )
113
107
 
114
108
 
115
- class StrictTransferRequest(TransferRequest):
116
- account_number: Union[Clabe, StrictPaymentCardNumber] # type: ignore
109
+ class TransferRequest(BaseTransferRequest):
110
+ account_number: Union[Clabe, PaymentCardNumber] = Field(
111
+ ..., description='Destination Clabe or Card number'
112
+ )
113
+
114
+
115
+ class StrictTransferRequest(BaseTransferRequest):
116
+ account_number: Union[Clabe, StrictPaymentCardNumber] = Field(
117
+ ..., description='Destination Clabe or Card number'
118
+ )
117
119
 
118
120
 
119
121
  class CardUpdateRequest(BaseRequest):
@@ -131,15 +133,7 @@ class CardRequest(BaseRequest):
131
133
 
132
134
 
133
135
  class CardActivationRequest(BaseModel):
134
- number: Annotated[
135
- str,
136
- StringConstraints(
137
- strip_whitespace=True,
138
- min_length=16,
139
- max_length=16,
140
- pattern=r'\d{16}',
141
- ),
142
- ]
136
+ number: PaymentCardNumber
143
137
  exp_month: Annotated[int, Field(strict=True, ge=1, le=12)]
144
138
  exp_year: Annotated[int, Field(strict=True, ge=18, le=99)]
145
139
  cvv2: Annotated[
@@ -170,9 +164,9 @@ class UserCredentialUpdateRequest(BaseRequest):
170
164
  ),
171
165
  )
172
166
 
173
- def dict(self, *args, **kwargs) -> DictStrAny:
167
+ def model_dump(self, *args, **kwargs) -> DictStrAny:
174
168
  # Password can be None but BaseRequest excludes None
175
- return BaseModel.dict(self, *args, **kwargs)
169
+ return BaseModel.model_dump(self, *args, **kwargs)
176
170
 
177
171
  @model_validator(mode="before")
178
172
  @classmethod
@@ -250,9 +244,7 @@ class ARPCRequest(BaseModel):
250
244
  arqc: StrictStr
251
245
  arpc_method: Annotated[
252
246
  str,
253
- StringConstraints( # type: ignore
254
- strict=True, min_length=1, max_length=1
255
- ),
247
+ StringConstraints(strict=True, min_length=1, max_length=1),
256
248
  ]
257
249
  transaction_data: StrictStr
258
250
  response_code: StrictStr
@@ -285,8 +277,7 @@ class CardTransactionRequest(BaseModel):
285
277
  authorizer_number: Optional[str] = None
286
278
 
287
279
 
288
- class ReverseRequest(CardTransactionRequest):
289
- ...
280
+ class ReverseRequest(CardTransactionRequest): ... # noqa: E701
290
281
 
291
282
 
292
283
  class CardNotificationRequest(CardTransactionRequest):
@@ -300,9 +291,9 @@ class ChargeRequest(CardNotificationRequest):
300
291
  get_balance: Optional[bool] = False
301
292
  atm_fee: Optional[StrictPositiveInt] = None
302
293
  issuer: IssuerNetwork
303
- cardholder_verification_method: Optional[
304
- CardholderVerificationMethod
305
- ] = None
294
+ cardholder_verification_method: Optional[CardholderVerificationMethod] = (
295
+ None
296
+ )
306
297
  ecommerce_indicator: Optional[EcommerceIndicator] = None
307
298
  fraud_validation_id: Optional[str] = None
308
299
 
@@ -353,14 +344,14 @@ class FraudValidationRequest(BaseModel):
353
344
  logical_network: Optional[str] = None
354
345
  is_cvv: Optional[bool] = False
355
346
  issuer: IssuerNetwork
356
- cardholder_verification_method: Optional[
357
- CardholderVerificationMethod
358
- ] = None
347
+ cardholder_verification_method: Optional[CardholderVerificationMethod] = (
348
+ None
349
+ )
359
350
  ecommerce_indicator: Optional[EcommerceIndicator] = None
360
- card_id: Optional[str] = None # type: ignore
361
- user_id: Optional[str] = None # type: ignore
362
- card_type: Optional[CardType] = None # type: ignore
363
- card_status: Optional[CardStatus] = None # type: ignore
351
+ card_id: Optional[str] = None
352
+ user_id: Optional[str] = None
353
+ card_type: Optional[CardType] = None
354
+ card_status: Optional[CardStatus] = None
364
355
 
365
356
 
366
357
  class TransactionTokenValidationUpdateRequest(BaseRequest):
@@ -533,19 +524,19 @@ class UserUpdateRequest(BaseModel):
533
524
  email_verification_id: Optional[str] = None
534
525
  phone_verification_id: Optional[str] = None
535
526
  address: Optional[Address] = None
536
- beneficiaries: Optional[List[Beneficiary]] = None
527
+ beneficiaries: Optional[list[Beneficiary]] = None
537
528
  govt_id: Optional[KYCFile] = None
538
529
  proof_of_address: Optional[KYCFile] = None
539
530
  proof_of_life: Optional[KYCFile] = None
540
531
  status: Optional[UserStatus] = None
541
532
  terms_of_service: Optional[TOSRequest] = None
542
533
  platform_terms_of_service: Optional[TOSAgreement] = None
543
- curp_document_uri: Optional[HttpUrl] = None
534
+ curp_document_uri: Optional[HttpUrlString] = None
544
535
 
545
536
  @field_validator('beneficiaries')
546
537
  @classmethod
547
538
  def beneficiary_percentage(
548
- cls, beneficiaries: Optional[List[Beneficiary]] = None
539
+ cls, beneficiaries: Optional[list[Beneficiary]] = None
549
540
  ):
550
541
  if beneficiaries and sum(b.percentage for b in beneficiaries) > 100:
551
542
  raise ValueError('The total percentage is more than 100.')
@@ -570,8 +561,8 @@ class UserLoginRequest(BaseRequest):
570
561
  class SessionRequest(BaseRequest):
571
562
  user_id: str
572
563
  type: SessionType
573
- success_url: Optional[AnyUrl] = None
574
- failure_url: Optional[AnyUrl] = None
564
+ success_url: Optional[AnyUrlString] = None
565
+ failure_url: Optional[AnyUrlString] = None
575
566
  model_config = ConfigDict(
576
567
  json_schema_extra={
577
568
  'example': {
@@ -585,15 +576,15 @@ class SessionRequest(BaseRequest):
585
576
 
586
577
 
587
578
  class EndpointRequest(BaseRequest):
588
- url: HttpUrl
589
- events: Optional[List[WebhookEvent]] = None
579
+ url: HttpUrlString
580
+ events: Optional[list[WebhookEvent]] = None
590
581
  user_id: Optional[str] = None
591
582
 
592
583
 
593
584
  class EndpointUpdateRequest(BaseRequest):
594
- url: Optional[HttpUrl] = None
585
+ url: Optional[HttpUrlString] = None
595
586
  is_enable: Optional[bool] = None
596
- events: Optional[List[WebhookEvent]] = None
587
+ events: Optional[list[WebhookEvent]] = None
597
588
 
598
589
 
599
590
  class FileUploadRequest(BaseRequest):
@@ -606,12 +597,12 @@ class FileUploadRequest(BaseRequest):
606
597
 
607
598
  class FileRequest(BaseModel):
608
599
  is_back: Optional[bool] = False
609
- url: HttpUrl
600
+ url: HttpUrlString
610
601
  type: KYCFileType
611
602
 
612
603
 
613
604
  class FileBatchUploadRequest(BaseModel):
614
- files: List[FileRequest]
605
+ files: list[FileRequest]
615
606
  user_id: str
616
607
 
617
608
 
@@ -653,8 +644,6 @@ class VerificationAttemptRequest(BaseModel):
653
644
 
654
645
 
655
646
  class LimitedWalletRequest(BaseRequest):
656
- model_config = dict(arbitrary_types_allowed=True)
657
-
658
647
  allowed_curp: CurpField
659
648
  allowed_rfc: Optional[Rfc] = None
660
649
 
@@ -664,7 +653,6 @@ class KYCVerificationUpdateRequest(BaseRequest):
664
653
 
665
654
 
666
655
  class PlatformRequest(BaseModel):
667
- model_config = dict(arbitrary_types_allowed=True)
668
656
  name: str
669
657
  rfc: Optional[str] = None
670
658
  establishment_date: Optional[dt.date] = None
@@ -686,7 +674,7 @@ class WebhookRequest(BaseModel):
686
674
  class KYCValidationRequest(BaseRequest):
687
675
  user_id: str
688
676
  force: bool = False
689
- documents: List[KYCFile] = []
677
+ documents: list[KYCFile] = []
690
678
 
691
679
 
692
680
  class BankAccountValidationRequest(BaseModel):
@@ -740,8 +728,6 @@ class QuestionnairesRequest(BaseModel):
740
728
 
741
729
 
742
730
  class PartnerRequest(BaseRequest):
743
- model_config = dict(arbitrary_types_allowed=True)
744
-
745
731
  legal_name: str
746
732
  business_name: str
747
733
  nationality: Country
@@ -756,8 +742,6 @@ class PartnerRequest(BaseRequest):
756
742
 
757
743
 
758
744
  class PartnerUpdateRequest(BaseRequest):
759
- model_config = dict(arbitrary_types_allowed=True)
760
-
761
745
  legal_name: Optional[str] = None
762
746
  business_name: Optional[str] = None
763
747
  nationality: Optional[Country] = None
@@ -775,5 +759,5 @@ class PartnerUpdateRequest(BaseRequest):
775
759
  license: Optional[LicenseDetails] = None
776
760
  audit: Optional[AuditDetails] = None
777
761
  vulnerable_activity: Optional[VulnerableActivityDetails] = None
778
- legal_representatives: Optional[List[LegalRepresentative]] = None
779
- shareholders: Optional[List[Shareholder]] = None
762
+ legal_representatives: Optional[list[LegalRepresentative]] = None
763
+ shareholders: Optional[list[Shareholder]] = None
@@ -0,0 +1,5 @@
1
+ from typing import Any, MutableMapping, Optional, Union
2
+
3
+ ClientRequestParams = Union[None, bytes, MutableMapping[str, str]]
4
+ DictStrAny = dict[str, Any]
5
+ OptionalDict = Optional[dict[str, Union[int, str]]]
@@ -1,10 +1,9 @@
1
1
  import base64
2
2
  import datetime as dt
3
3
  from enum import Enum
4
- from typing import Any, Callable, List, Optional, Union
4
+ from typing import Any, Callable, Optional, Union
5
5
 
6
6
  from dateutil.relativedelta import relativedelta
7
- from pydantic import AnyUrl, HttpUrl
8
7
 
9
8
 
10
9
  def sanitize_dict(d: dict) -> dict:
@@ -21,16 +20,12 @@ def sanitize_item(
21
20
  :param default: Optional function to be used when there is no case
22
21
  for this type of item, default `None` it returns the item as is.
23
22
  """
24
- rv: Union[str, List[Any]]
23
+ rv: Union[str, list[Any]]
25
24
  if isinstance(item, dt.date):
26
25
  if isinstance(item, dt.datetime) and not item.tzinfo:
27
26
  rv = item.astimezone(dt.timezone.utc).isoformat()
28
27
  else:
29
28
  rv = item.isoformat()
30
- elif isinstance(item, HttpUrl):
31
- rv = str(item)
32
- elif isinstance(item, AnyUrl):
33
- rv = str(item)
34
29
  elif isinstance(item, list):
35
30
  rv = [
36
31
  sanitize_dict(e) if isinstance(e, dict) else sanitize_item(e)
@@ -0,0 +1 @@
1
+ __version__ = '2.0.0.dev10'
@@ -1,22 +1,25 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: cuenca_validations
3
- Version: 2.0.0.dev8
3
+ Version: 2.0.0.dev10
4
4
  Summary: Cuenca common validations
5
5
  Home-page: https://github.com/cuenca-mx/cuenca-validations
6
6
  Author: Cuenca
7
7
  Author-email: dev@cuenca.com
8
8
  Classifier: Programming Language :: Python :: 3
9
- Classifier: Programming Language :: Python :: 3.8
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
10
14
  Classifier: License :: OSI Approved :: MIT License
11
15
  Classifier: Operating System :: OS Independent
12
- Requires-Python: >=3.8
16
+ Requires-Python: >=3.9
13
17
  Description-Content-Type: text/markdown
14
18
  License-File: LICENSE
15
- Requires-Dist: clabe>=2.0.0.dev0
16
- Requires-Dist: pydantic[email]>=2.0
17
- Requires-Dist: pydantic-extra-types==2.10.1
18
- Requires-Dist: dataclasses>=0.6; python_version < "3.7"
19
- Requires-Dist: python-dateutil>=2.7.0
19
+ Requires-Dist: clabe>=2.0.0
20
+ Requires-Dist: pydantic[email]>=2.10.0
21
+ Requires-Dist: pydantic-extra-types>=2.10.0
22
+ Requires-Dist: python-dateutil>=2.9.0
20
23
 
21
24
  # Cuenca - validations
22
25
 
@@ -0,0 +1,4 @@
1
+ clabe>=2.0.0
2
+ pydantic[email]>=2.10.0
3
+ pydantic-extra-types>=2.10.0
4
+ python-dateutil>=2.9.0
@@ -23,17 +23,20 @@ setup(
23
23
  packages=find_packages(),
24
24
  include_package_data=True,
25
25
  package_data=dict(cuenca_validations=['py.typed']),
26
- python_requires='>=3.8',
26
+ python_requires='>=3.9',
27
27
  install_requires=[
28
- 'clabe>=2.0.0.dev0',
29
- 'pydantic[email]>=2.0',
30
- 'pydantic-extra-types==2.10.1',
31
- 'dataclasses>=0.6;python_version<"3.7"',
32
- 'python-dateutil>=2.7.0',
28
+ 'clabe>=2.0.0',
29
+ 'pydantic[email]>=2.10.0',
30
+ 'pydantic-extra-types>=2.10.0',
31
+ 'python-dateutil>=2.9.0',
33
32
  ],
34
33
  classifiers=[
35
34
  'Programming Language :: Python :: 3',
36
- 'Programming Language :: Python :: 3.8',
35
+ 'Programming Language :: Python :: 3.9',
36
+ 'Programming Language :: Python :: 3.10',
37
+ 'Programming Language :: Python :: 3.11',
38
+ 'Programming Language :: Python :: 3.12',
39
+ 'Programming Language :: Python :: 3.13',
37
40
  'License :: OSI Approved :: MIT License',
38
41
  'Operating System :: OS Independent',
39
42
  ],
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from pydantic import ValidationError
2
+ from pydantic import BaseModel, ValidationError
3
3
  from pydantic_extra_types.payment import PaymentCardBrand
4
4
 
5
5
  from cuenca_validations.types import StrictPaymentCardNumber
@@ -8,9 +8,13 @@ VALID_BBVA = '4772130000000003'
8
8
  INVALID_BIN = '4050000000000001'
9
9
 
10
10
 
11
+ class CardModel(BaseModel):
12
+ card_number: StrictPaymentCardNumber
13
+
14
+
11
15
  def test_invalid_bin_strict_payment():
12
16
  with pytest.raises(ValidationError) as exc_info:
13
- StrictPaymentCardNumber(card_number=INVALID_BIN)
17
+ CardModel(card_number=INVALID_BIN)
14
18
  print(exc_info.value)
15
19
  assert 'payment_card_number.bin' in str(exc_info.value)
16
20
  assert 'The card number contains a BIN (first six digits) ' in str(
@@ -19,9 +23,9 @@ def test_invalid_bin_strict_payment():
19
23
 
20
24
 
21
25
  def test_valid_bin_strict_payment():
22
- card = StrictPaymentCardNumber(card_number=VALID_BBVA)
23
- assert card.brand == PaymentCardBrand.visa
24
- assert card.bin == '477213'
25
- assert card.last4 == '0003'
26
- assert card.masked == '477213******0003'
27
- assert card.bank_code == '40012'
26
+ card = CardModel(card_number=VALID_BBVA)
27
+ assert card.card_number.brand == PaymentCardBrand.visa
28
+ assert card.card_number.bin == '477213'
29
+ assert card.card_number.last4 == '0003'
30
+ assert card.card_number.masked == '477213******0003'
31
+ assert card.card_number.bank_code == '40012'
@@ -1,3 +1,5 @@
1
+ import pytest
2
+
1
3
  from cuenca_validations.errors import (
2
4
  ApiError,
3
5
  AuthMethodNotAllowedError,
@@ -16,8 +18,9 @@ def test_cuenca_error_base():
16
18
  assert issubclass(CuencaError, Exception)
17
19
 
18
20
 
19
- def test_error_codes_and_status():
20
- test_cases = [
21
+ @pytest.mark.parametrize(
22
+ "error_class, expected_code, expected_status",
23
+ [
21
24
  (WrongCredsError, 101, 401),
22
25
  (MissingAuthorizationHeaderError, 102, 401),
23
26
  (UserNotLoggedInError, 103, 401),
@@ -27,8 +30,8 @@ def test_error_codes_and_status():
27
30
  (UserLocationError, 108, 401),
28
31
  (InvalidOTPCodeError, 109, 401),
29
32
  (ApiError, 500, 500),
30
- ]
31
-
32
- for error_class, expected_code, expected_status in test_cases:
33
- assert error_class.code == expected_code
34
- assert error_class.status_code == expected_status
33
+ ],
34
+ )
35
+ def test_error_codes_and_status(error_class, expected_code, expected_status):
36
+ assert error_class.code == expected_code
37
+ assert error_class.status_code == expected_status
@@ -5,18 +5,18 @@ from enum import Enum
5
5
 
6
6
  import pytest
7
7
  from freezegun import freeze_time
8
- from pydantic import AnyUrl, BaseModel, HttpUrl, ValidationError
8
+ from pydantic import BaseModel, ValidationError
9
9
 
10
10
  from cuenca_validations.types import (
11
11
  Address,
12
12
  CardQuery,
13
+ Digits,
13
14
  JSONEncoder,
14
15
  QueryParams,
15
16
  Rfc,
16
17
  SantizedDict,
17
18
  SessionRequest,
18
19
  TransactionStatus,
19
- digits,
20
20
  get_state_name,
21
21
  )
22
22
  from cuenca_validations.types.enums import (
@@ -67,19 +67,21 @@ class TestClass:
67
67
 
68
68
  def test_dict():
69
69
  model = QueryParams(count=1, created_before=now)
70
- assert model.dict() == dict(count=1, created_before=utcnow.isoformat())
70
+ assert model.model_dump() == dict(
71
+ count=1, created_before=utcnow.isoformat()
72
+ )
71
73
 
72
74
 
73
75
  def test_dict_with_exclude():
74
76
  model = QueryParams(count=1, created_before=now, user_id='USXXXX')
75
- assert model.dict(exclude={'user_id'}) == dict(
77
+ assert model.model_dump(exclude={'user_id'}) == dict(
76
78
  count=1, created_before=utcnow.isoformat()
77
79
  )
78
80
 
79
81
 
80
82
  def test_dict_with_exclude_unset():
81
83
  model = QueryParams(count=1, created_before=now)
82
- assert model.dict(exclude_unset=False) == dict(
84
+ assert model.model_dump(exclude_unset=False) == dict(
83
85
  count=1, created_before=utcnow.isoformat(), page_size=100
84
86
  )
85
87
 
@@ -132,17 +134,6 @@ def test_json_encoder(value, result):
132
134
  assert decoded['value'] == result
133
135
 
134
136
 
135
- def test_sanitized_dict_with_urls():
136
- data = SantizedDict(
137
- api_url=HttpUrl('https://api.cuenca.com/v1'),
138
- ftp_url=AnyUrl('ftp://files.example.com/'),
139
- )
140
- assert data == {
141
- 'api_url': 'https://api.cuenca.com/v1',
142
- 'ftp_url': 'ftp://files.example.com/',
143
- }
144
-
145
-
146
137
  def test_invalid_class():
147
138
  """
148
139
  For a class that doesn't have a `to_dict` method and it is not a type of
@@ -150,8 +141,7 @@ def test_invalid_class():
150
141
  raises a `TypeError`.
151
142
  """
152
143
 
153
- class ClassWithoutToDict:
154
- ...
144
+ class ClassWithoutToDict: ... # noqa: E701
155
145
 
156
146
  invalid_class = ClassWithoutToDict()
157
147
  with pytest.raises(TypeError):
@@ -159,12 +149,20 @@ def test_invalid_class():
159
149
 
160
150
 
161
151
  class Accounts(BaseModel):
162
- number: digits(5, 8) # type: ignore
152
+ number: Digits(5, 8) # type: ignore
163
153
 
164
154
 
165
- def test_only_digits():
166
- acc = Accounts(number='123456')
167
- assert acc.number == '123456'
155
+ @pytest.mark.parametrize(
156
+ "input_number, expected",
157
+ [
158
+ ('123456', '123456'),
159
+ (123456, '123456'),
160
+ ('0012312', '0012312'),
161
+ ],
162
+ )
163
+ def test_only_digits(input_number, expected):
164
+ acc = Accounts(number=input_number)
165
+ assert acc.number == expected
168
166
 
169
167
 
170
168
  @pytest.mark.parametrize(
@@ -205,7 +203,7 @@ def test_card_query_exp_cvv_if_number_not_set(input_value):
205
203
 
206
204
  def test_exclude_none_in_dict():
207
205
  request = ApiKeyUpdateRequest(user_id='US123')
208
- assert request.dict() == dict(user_id='US123')
206
+ assert request.model_dump() == dict(user_id='US123')
209
207
 
210
208
 
211
209
  def test_update_one_property_at_a_time_request():
@@ -229,7 +227,7 @@ def test_update_one_property_at_a_time_request():
229
227
  )
230
228
  def test_update_credential_update_request_dict(data, expected_dict):
231
229
  req = UserCredentialUpdateRequest(**data)
232
- assert req.dict() == expected_dict
230
+ assert req.model_dump() == expected_dict
233
231
 
234
232
 
235
233
  def test_card_transaction_requests():
@@ -356,7 +354,7 @@ def test_user_request():
356
354
  required_level=3,
357
355
  terms_of_service=None,
358
356
  )
359
- assert UserRequest(**request).dict() == request
357
+ assert UserRequest(**request).model_dump() == request
360
358
 
361
359
  # changing curp so user is underage
362
360
  request['curp'] = 'ABCD060604HDFSRN03'
@@ -393,7 +391,7 @@ def test_curp_validation_request():
393
391
  assert all(field in error_msg for field in required_fields)
394
392
 
395
393
  req_curp = CurpValidationRequest(**request)
396
- assert req_curp.dict() == request
394
+ assert req_curp.model_dump() == request
397
395
 
398
396
  request['date_of_birth'] = dt.date(2006, 5, 17)
399
397
 
@@ -431,9 +429,9 @@ def test_user_update_request():
431
429
  curp_document_uri='https://sandbox.cuenca.com/files/EF123',
432
430
  )
433
431
  update_req = UserUpdateRequest(**request)
434
- beneficiaries = [b.dict() for b in update_req.beneficiaries]
432
+ beneficiaries = [b.model_dump() for b in update_req.beneficiaries]
435
433
  assert beneficiaries == request['beneficiaries']
436
- assert str(update_req.curp_document_uri) == request['curp_document_uri']
434
+ assert update_req.curp_document_uri == request['curp_document_uri']
437
435
 
438
436
  request['beneficiaries'] = [
439
437
  dict(
@@ -586,49 +584,42 @@ class TestFloatModel(BaseModel):
586
584
  value: StrictPositiveFloat
587
585
 
588
586
 
589
- def test_strict_positive_float_valid():
590
- model = TestFloatModel(value=10.5)
591
- assert model.value == 10.5
592
- model = TestFloatModel(value=0.000001)
593
- assert model.value == 0.000001
587
+ @pytest.mark.parametrize("value", [10.5, 0.000001])
588
+ def test_strict_positive_float_valid(value):
589
+ model = TestFloatModel(value=value)
590
+ assert model.value == value
594
591
 
595
592
 
596
- def test_strict_positive_float_invalid():
593
+ @pytest.mark.parametrize("value", [0.0, -1.5])
594
+ def test_strict_positive_float_invalid(value):
597
595
  with pytest.raises(ValueError, match="Input should be greater than 0"):
598
- TestFloatModel(value=0.0)
599
- with pytest.raises(ValueError, match="Input should be greater than 0"):
600
- TestFloatModel(value=-1.5)
596
+ TestFloatModel(value=value)
601
597
 
602
598
 
603
599
  class TestIntModel(BaseModel):
604
600
  value: StrictPositiveInt
605
601
 
606
602
 
607
- def test_strict_positive_int_valid():
608
- model = TestIntModel(value=100)
609
- assert model.value == 100
610
-
611
- model = TestIntModel(value=1)
612
- assert model.value == 1
613
-
614
- model = TestIntModel(value=21_474_836_47)
615
- assert model.value == 21_474_836_47
603
+ @pytest.mark.parametrize("value", [100, 1, 21_474_836_47])
604
+ def test_strict_positive_int_valid(value):
605
+ model = TestIntModel(value=value)
606
+ assert model.value == value
616
607
 
617
608
 
618
- def test_strict_positive_int_invalid():
619
- with pytest.raises(ValueError, match="Input should be greater than 0"):
620
- TestIntModel(value=0)
621
-
622
- with pytest.raises(ValueError, match="Input should be greater than 0"):
623
- TestIntModel(value=-5)
624
-
625
- with pytest.raises(
626
- ValueError, match="Input should be less than or equal to 2147483647"
627
- ):
628
- TestIntModel(value=21_474_836_48)
629
-
630
- with pytest.raises(ValueError, match="Input should be a valid integer"):
631
- TestIntModel(value=5.5)
632
-
633
- with pytest.raises(ValueError, match="Input should be a valid integer"):
634
- TestIntModel(value="10")
609
+ @pytest.mark.parametrize(
610
+ "value, expected_error, expected_message",
611
+ [
612
+ (0, ValueError, "Input should be greater than 0"),
613
+ (-5, ValueError, "Input should be greater than 0"),
614
+ (
615
+ 21_474_836_48,
616
+ ValueError,
617
+ "Input should be less than or equal to 2147483647",
618
+ ),
619
+ (5.5, ValueError, "Input should be a valid integer"),
620
+ ("10", ValueError, "Input should be a valid integer"),
621
+ ],
622
+ )
623
+ def test_strict_positive_int_invalid(value, expected_error, expected_message):
624
+ with pytest.raises(expected_error, match=expected_message):
625
+ TestIntModel(value=value)
@@ -1,42 +0,0 @@
1
- from pydantic import BaseModel, field_validator
2
- from pydantic_core import PydanticCustomError
3
- from pydantic_extra_types.payment import PaymentCardBrand, PaymentCardNumber
4
-
5
- from ..card_bins import CARD_BINS
6
-
7
-
8
- class StrictPaymentCardNumber(BaseModel):
9
-
10
- card_number: PaymentCardNumber
11
-
12
- @field_validator('card_number')
13
- def validate_bin(cls, card_number: PaymentCardNumber) -> PaymentCardNumber:
14
- if card_number.bin not in CARD_BINS:
15
- raise PydanticCustomError(
16
- 'payment_card_number.bin',
17
- 'The card number contains a BIN (first six digits) '
18
- 'that does not have a known association with a Mexican bank.'
19
- 'To add the association, please file an issue:'
20
- 'https://github.com/cuenca-mx/cuenca-validations/issues',
21
- )
22
- return card_number
23
-
24
- @property
25
- def brand(self) -> PaymentCardBrand:
26
- return self.card_number.brand
27
-
28
- @property
29
- def last4(self) -> str:
30
- return self.card_number.last4
31
-
32
- @property
33
- def masked(self) -> str:
34
- return self.card_number.masked
35
-
36
- @property
37
- def bin(self) -> str:
38
- return self.card_number.bin
39
-
40
- @property
41
- def bank_code(self) -> str:
42
- return CARD_BINS[self.card_number.bin]
@@ -1,5 +0,0 @@
1
- from typing import Any, Dict, MutableMapping, Optional, Union
2
-
3
- ClientRequestParams = Union[None, bytes, MutableMapping[str, str]]
4
- DictStrAny = Dict[str, Any]
5
- OptionalDict = Optional[Dict[str, Union[int, str]]]
@@ -1 +0,0 @@
1
- __version__ = '2.0.0.dev8'
@@ -1,7 +0,0 @@
1
- clabe>=2.0.0.dev0
2
- pydantic[email]>=2.0
3
- pydantic-extra-types==2.10.1
4
- python-dateutil>=2.7.0
5
-
6
- [:python_version < "3.7"]
7
- dataclasses>=0.6