cuenca-validations 2.1.27__tar.gz → 2.1.29.dev0__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 (37) hide show
  1. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/PKG-INFO +1 -1
  2. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/general.py +8 -1
  3. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/queries.py +5 -0
  4. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/requests.py +21 -7
  5. cuenca_validations-2.1.29.dev0/cuenca_validations/validators.py +86 -0
  6. cuenca_validations-2.1.29.dev0/cuenca_validations/version.py +1 -0
  7. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations.egg-info/PKG-INFO +1 -1
  8. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations.egg-info/SOURCES.txt +2 -1
  9. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_requests.py +29 -0
  10. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_types.py +46 -0
  11. cuenca_validations-2.1.29.dev0/tests/test_validators.py +52 -0
  12. cuenca_validations-2.1.27/cuenca_validations/validators.py +0 -42
  13. cuenca_validations-2.1.27/cuenca_validations/version.py +0 -1
  14. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/LICENSE +0 -0
  15. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/README.md +0 -0
  16. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/__init__.py +0 -0
  17. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/card_bins.py +0 -0
  18. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/errors.py +0 -0
  19. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/py.typed +0 -0
  20. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/__init__.py +0 -0
  21. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/card.py +0 -0
  22. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/enums.py +0 -0
  23. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/files.py +0 -0
  24. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/helpers.py +0 -0
  25. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/identities.py +0 -0
  26. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/types/morals.py +0 -0
  27. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations/typing.py +0 -0
  28. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations.egg-info/dependency_links.txt +0 -0
  29. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations.egg-info/requires.txt +0 -0
  30. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/cuenca_validations.egg-info/top_level.txt +0 -0
  31. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/setup.cfg +0 -0
  32. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/setup.py +0 -0
  33. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/__init__.py +0 -0
  34. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_card.py +0 -0
  35. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_errors.py +0 -0
  36. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_helpers.py +0 -0
  37. {cuenca_validations-2.1.27 → cuenca_validations-2.1.29.dev0}/tests/test_statement.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuenca_validations
3
- Version: 2.1.27
3
+ Version: 2.1.29.dev0
4
4
  Summary: Cuenca common validations
5
5
  Home-page: https://github.com/cuenca-mx/cuenca-validations
6
6
  Author: Cuenca
@@ -3,6 +3,7 @@ from dataclasses import dataclass
3
3
  from typing import Annotated, Any, Optional
4
4
 
5
5
  from pydantic import (
6
+ AfterValidator,
6
7
  AnyUrl,
7
8
  Field,
8
9
  HttpUrl,
@@ -11,7 +12,7 @@ from pydantic import (
11
12
  StringConstraints,
12
13
  )
13
14
 
14
- from ..validators import sanitize_dict, sanitize_item
15
+ from ..validators import normalize_name, sanitize_dict, sanitize_item
15
16
  from .enums import (
16
17
  AccountUseType,
17
18
  IncomeType,
@@ -33,6 +34,12 @@ NonEmptyStr = Annotated[
33
34
  str, StringConstraints(strip_whitespace=True, min_length=1)
34
35
  ]
35
36
 
37
+ NormalizedName = Annotated[
38
+ str,
39
+ StringConstraints(strip_whitespace=True, min_length=2),
40
+ AfterValidator(normalize_name),
41
+ ]
42
+
36
43
 
37
44
  class SantizedDict(dict):
38
45
  def __init__(self, *args, **kwargs):
@@ -1,6 +1,7 @@
1
1
  import datetime as dt
2
2
  from typing import Annotated, Optional
3
3
 
4
+ from clabe import Clabe
4
5
  from pydantic import (
5
6
  BaseModel,
6
7
  ConfigDict,
@@ -26,6 +27,7 @@ from .enums import (
26
27
  TransferNetwork,
27
28
  UserStatus,
28
29
  )
30
+ from .general import NormalizedName
29
31
  from .identities import Curp
30
32
 
31
33
  MAX_PAGE_SIZE = 100
@@ -157,6 +159,9 @@ class UserQuery(QueryParams):
157
159
  status: Optional[UserStatus] = None
158
160
  identity_uri: Optional[str] = None
159
161
  has_curp_document: Optional[bool] = None
162
+ clabe: Optional[Clabe] = None
163
+ curp: Optional[Curp] = None
164
+ name: Optional[NormalizedName] = None
160
165
 
161
166
 
162
167
  class IdentityQuery(QueryParams):
@@ -56,6 +56,7 @@ from ..types.enums import (
56
56
  WebhookObject,
57
57
  )
58
58
  from ..typing import DictStrAny
59
+ from ..validators import normalize_email, normalize_phone_number
59
60
  from .card import (
60
61
  Cvv,
61
62
  ExpMonth,
@@ -543,6 +544,16 @@ class UserUpdateRequest(BaseRequest):
543
544
  raise ValueError('At least one parameter must be provided')
544
545
  return values
545
546
 
547
+ @field_validator('email_address', mode='before')
548
+ @classmethod
549
+ def validate_email_address(cls, v: Optional[str]) -> Optional[str]:
550
+ return normalize_email(v) if v else v
551
+
552
+ @field_validator('phone_number', mode='before')
553
+ @classmethod
554
+ def validate_phone_number(cls, v: Optional[str]) -> Optional[str]:
555
+ return normalize_phone_number(v) if v else v
556
+
546
557
  @field_validator('beneficiaries')
547
558
  @classmethod
548
559
  def beneficiary_percentage(
@@ -641,13 +652,16 @@ class VerificationRequest(BaseRequest):
641
652
  },
642
653
  )
643
654
 
644
- @field_validator('recipient')
645
- def validate_sender(cls, recipient: str, values):
646
- return (
647
- EmailStr(recipient)
648
- if type == VerificationType.email
649
- else PhoneNumber(recipient)
650
- )
655
+ @model_validator(mode='before')
656
+ @classmethod
657
+ def normalize_recipient(cls, data):
658
+ _type = data.get('type')
659
+ recipient = data.get('recipient')
660
+ if _type == VerificationType.email:
661
+ data['recipient'] = normalize_email(recipient)
662
+ else:
663
+ data['recipient'] = normalize_phone_number(recipient)
664
+ return data
651
665
 
652
666
 
653
667
  class VerificationAttemptRequest(BaseRequest):
@@ -0,0 +1,86 @@
1
+ import base64
2
+ import datetime as dt
3
+ import re
4
+ import unicodedata
5
+ from enum import Enum
6
+ from typing import Any, Callable, Optional, Union
7
+
8
+ SANITIZE_PHONE_NUMBER = re.compile(r'[+.()\-\s]')
9
+ STRIP_MX_MOBILE_PREFIX = re.compile(r'(52)(?:(?:044)|1)?(\d{10})$')
10
+ STRIP_US_DUPLICATE_PREFIX = re.compile(r'^11(\d{10})$')
11
+
12
+
13
+ def normalize_email(email: str) -> str:
14
+ """Lowercase email and strip plus labels from the local part.
15
+
16
+ mateohhr@Yahoo.com -> mateohhr@yahoo.com
17
+ guerradzul+cuenca@gmail.com -> guerradzul@gmail.com
18
+ """
19
+ local, _, domain = email.partition('@')
20
+ return f'{local.split("+")[0]}@{domain}'.lower()
21
+
22
+
23
+ def normalize_phone_number(phone_number: str) -> str:
24
+ """Sanitize and normalize phone numbers to E.164 format.
25
+
26
+ Handles:
27
+ - Special characters: +52 (55) 1234-5678 -> +525512345678
28
+ - MX mobile prefix: +5215512345678 -> +525512345678
29
+ - MX 044 prefix: +520445512345678 -> +525512345678
30
+ - US duplicate prefix: +116504401222 -> +16504401222
31
+ """
32
+ pn = SANITIZE_PHONE_NUMBER.sub('', phone_number)
33
+ pn = STRIP_MX_MOBILE_PREFIX.sub(r'\1\2', pn)
34
+ pn = STRIP_US_DUPLICATE_PREFIX.sub(r'1\1', pn)
35
+ return f'+{pn}'
36
+
37
+
38
+ def normalize_name(name: str) -> str:
39
+ """Normalize names for search/index matching.
40
+
41
+ Strips accents, lowercases, and collapses internal whitespace.
42
+
43
+ Raúl Andrés -> raul andres
44
+ MARÍA JOSÉ -> maria jose
45
+ """
46
+ collapsed = ' '.join(name.split())
47
+ nfkd = unicodedata.normalize('NFKD', collapsed)
48
+ return ''.join(c for c in nfkd if not unicodedata.combining(c)).lower()
49
+
50
+
51
+ def sanitize_dict(d: dict) -> dict:
52
+ for k, v in d.items():
53
+ d[k] = sanitize_item(v)
54
+ return d
55
+
56
+
57
+ def sanitize_item(
58
+ item: Any, default: Optional[Callable[..., Any]] = None
59
+ ) -> Any:
60
+ """
61
+ :param item: item to be sanitized
62
+ :param default: Optional function to be used when there is no case
63
+ for this type of item, default `None` it returns the item as is.
64
+ """
65
+ rv: Union[str, list[Any]]
66
+ if isinstance(item, dt.date):
67
+ if isinstance(item, dt.datetime) and not item.tzinfo:
68
+ rv = item.astimezone(dt.timezone.utc).isoformat()
69
+ else:
70
+ rv = item.isoformat()
71
+ elif isinstance(item, list):
72
+ rv = [
73
+ sanitize_dict(e) if isinstance(e, dict) else sanitize_item(e)
74
+ for e in item
75
+ ]
76
+ elif isinstance(item, bytes):
77
+ rv = base64.b64encode(item).decode('utf-8')
78
+ elif isinstance(item, Enum):
79
+ rv = item.value
80
+ elif hasattr(item, 'to_dict'):
81
+ rv = item.to_dict()
82
+ elif default:
83
+ rv = default(item)
84
+ else:
85
+ rv = item
86
+ return rv
@@ -0,0 +1 @@
1
+ __version__ = '2.1.29.dev0'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuenca_validations
3
- Version: 2.1.27
3
+ Version: 2.1.29.dev0
4
4
  Summary: Cuenca common validations
5
5
  Home-page: https://github.com/cuenca-mx/cuenca-validations
6
6
  Author: Cuenca
@@ -30,4 +30,5 @@ tests/test_errors.py
30
30
  tests/test_helpers.py
31
31
  tests/test_requests.py
32
32
  tests/test_statement.py
33
- tests/test_types.py
33
+ tests/test_types.py
34
+ tests/test_validators.py
@@ -1,9 +1,12 @@
1
1
  import pytest
2
2
  from pydantic import ValidationError
3
+ from pydantic_extra_types.phone_numbers import PhoneNumber
3
4
 
5
+ from cuenca_validations.types.enums import VerificationType
4
6
  from cuenca_validations.types.requests import (
5
7
  UserTOSAgreementRequest,
6
8
  UserUpdateRequest,
9
+ VerificationRequest,
7
10
  )
8
11
  from cuenca_validations.typing import DictStrAny
9
12
 
@@ -53,3 +56,29 @@ def test_update_user_update_govt() -> None:
53
56
  with pytest.raises(ValueError) as ex:
54
57
  UserUpdateRequest(**govt_id)
55
58
  assert 'uri_back must be provided for type ine' in str(ex.value)
59
+
60
+
61
+ def test_verification_request_normalizes_email() -> None:
62
+ req = VerificationRequest(
63
+ recipient='user+cuenca@Gmail.com',
64
+ type=VerificationType.email,
65
+ )
66
+ assert req.recipient == 'user@gmail.com'
67
+
68
+
69
+ def test_verification_request_normalizes_phone() -> None:
70
+ req = VerificationRequest(
71
+ recipient='+116504401222',
72
+ type=VerificationType.phone,
73
+ )
74
+ assert req.recipient == '+16504401222'
75
+
76
+
77
+ def test_user_update_request_normalizes_email() -> None:
78
+ req = UserUpdateRequest(email_address='user+tag@Gmail.com')
79
+ assert req.email_address == 'user@gmail.com'
80
+
81
+
82
+ def test_user_update_request_normalizes_phone() -> None:
83
+ req = UserUpdateRequest(phone_number=PhoneNumber('+116504401222'))
84
+ assert req.phone_number == '+16504401222'
@@ -5,6 +5,7 @@ from enum import Enum
5
5
  from typing import Annotated
6
6
 
7
7
  import pytest
8
+ from clabe import Clabe
8
9
  from freezegun import freeze_time
9
10
  from pydantic import AfterValidator, BaseModel, SecretStr, ValidationError
10
11
  from pydantic.fields import FieldInfo
@@ -16,6 +17,7 @@ from cuenca_validations.types import (
16
17
  SantizedDict,
17
18
  SessionRequest,
18
19
  TransactionStatus,
20
+ UserQuery,
19
21
  digits,
20
22
  get_account_use_type_name,
21
23
  get_income_type_name,
@@ -201,6 +203,50 @@ def test_card_query_exp_cvv_if_number_not_set(input_value):
201
203
  CardQuery(**input_value)
202
204
 
203
205
 
206
+ def test_user_query_accepts_new_fields():
207
+ query = UserQuery(
208
+ clabe='646180157098510917',
209
+ curp='ABCD920604HDFSRN03',
210
+ name='Pedro Páramo',
211
+ )
212
+ assert isinstance(query.clabe, Clabe)
213
+ assert query.clabe == '646180157098510917'
214
+ assert query.curp == 'ABCD920604HDFSRN03'
215
+ assert query.name == 'pedro paramo'
216
+
217
+
218
+ @pytest.mark.parametrize(
219
+ 'raw, normalized',
220
+ [
221
+ ('ab', 'ab'),
222
+ (' ab ', 'ab'),
223
+ ('Raúl Andrés', 'raul andres'),
224
+ ('raul Andres', 'raul andres'),
225
+ (' RAÚL ANDRÉS ', 'raul andres'),
226
+ ('María José', 'maria jose'),
227
+ ('ÑANDÚ', 'nandu'),
228
+ ],
229
+ )
230
+ def test_user_query_name_normalizes(raw, normalized):
231
+ assert UserQuery(name=raw).name == normalized
232
+
233
+
234
+ @pytest.mark.parametrize(
235
+ 'field, value',
236
+ [
237
+ ('clabe', 'not-a-clabe'),
238
+ ('curp', 'not-a-curp'),
239
+ ('name', ''),
240
+ ('name', ' '),
241
+ ('name', 'a'),
242
+ ('name', ' a '),
243
+ ],
244
+ )
245
+ def test_user_query_rejects_invalid(field, value):
246
+ with pytest.raises(ValidationError):
247
+ UserQuery(**{field: value})
248
+
249
+
204
250
  def test_exclude_none_in_dict():
205
251
  request = ApiKeyUpdateRequest(user_id='US123')
206
252
  assert request.model_dump() == dict(user_id='US123')
@@ -0,0 +1,52 @@
1
+ import pytest
2
+
3
+ from cuenca_validations.validators import (
4
+ normalize_email,
5
+ normalize_name,
6
+ normalize_phone_number,
7
+ )
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ 'raw, normalized',
12
+ [
13
+ ('user@Yahoo.com', 'user@yahoo.com'), # uppercase domain
14
+ ('user@iCloud.com', 'user@icloud.com'), # uppercase domain
15
+ ('user@Gmail.com', 'user@gmail.com'), # uppercase domain
16
+ ('user+cuenca@gmail.com', 'user@gmail.com'), # plus label
17
+ ('user@gmail.com', 'user@gmail.com'), # already normalized
18
+ ],
19
+ )
20
+ def test_normalize_email(raw: str, normalized: str) -> None:
21
+ assert normalize_email(raw) == normalized
22
+
23
+
24
+ @pytest.mark.parametrize(
25
+ 'raw, normalized',
26
+ [
27
+ ('+116503456789', '+16503456789'), # US duplicate country code
28
+ ('+5215512345678', '+525512345678'), # MX mobile prefix
29
+ ('+520445512345678', '+525512345678'), # MX 044 prefix
30
+ ('+52 (55) 1234-5678', '+525512345678'), # special characters
31
+ ('+525512345678', '+525512345678'), # already correct MX
32
+ ('+16503456789', '+16503456789'), # already correct US
33
+ ],
34
+ )
35
+ def test_normalize_phone_number(raw: str, normalized: str) -> None:
36
+ assert normalize_phone_number(raw) == normalized
37
+
38
+
39
+ @pytest.mark.parametrize(
40
+ 'raw, normalized',
41
+ [
42
+ ('Raúl Andrés', 'raul andres'), # accents + mixed case
43
+ ('raul andres', 'raul andres'), # already normalized
44
+ ('RAÚL ANDRÉS', 'raul andres'), # uppercase with accents
45
+ ('ÑANDÚ', 'nandu'), # ñ and accent
46
+ ('María José', 'maria jose'), # collapse internal whitespace
47
+ (' Raúl ', 'raul'), # trim + lowercase
48
+ ('Nuño Garçía', 'nuno garcia'), # tilde and cedilla
49
+ ],
50
+ )
51
+ def test_normalize_name(raw: str, normalized: str) -> None:
52
+ assert normalize_name(raw) == normalized
@@ -1,42 +0,0 @@
1
- import base64
2
- import datetime as dt
3
- from enum import Enum
4
- from typing import Any, Callable, Optional, Union
5
-
6
-
7
- def sanitize_dict(d: dict) -> dict:
8
- for k, v in d.items():
9
- d[k] = sanitize_item(v)
10
- return d
11
-
12
-
13
- def sanitize_item(
14
- item: Any, default: Optional[Callable[..., Any]] = None
15
- ) -> Any:
16
- """
17
- :param item: item to be sanitized
18
- :param default: Optional function to be used when there is no case
19
- for this type of item, default `None` it returns the item as is.
20
- """
21
- rv: Union[str, list[Any]]
22
- if isinstance(item, dt.date):
23
- if isinstance(item, dt.datetime) and not item.tzinfo:
24
- rv = item.astimezone(dt.timezone.utc).isoformat()
25
- else:
26
- rv = item.isoformat()
27
- elif isinstance(item, list):
28
- rv = [
29
- sanitize_dict(e) if isinstance(e, dict) else sanitize_item(e)
30
- for e in item
31
- ]
32
- elif isinstance(item, bytes):
33
- rv = base64.b64encode(item).decode('utf-8')
34
- elif isinstance(item, Enum):
35
- rv = item.value
36
- elif hasattr(item, 'to_dict'):
37
- rv = item.to_dict()
38
- elif default:
39
- rv = default(item)
40
- else:
41
- rv = item
42
- return rv
@@ -1 +0,0 @@
1
- __version__ = '2.1.27'