sep-python-sdk 0.1.0__py3-none-any.whl

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.
sep_sdk/validation.py ADDED
@@ -0,0 +1,99 @@
1
+ """Small, strict validators used by the request models.
2
+
3
+ Everything raises :class:`SepValidationError` with a message naming the field, so
4
+ a mistake is reported at construction time rather than as a vague gateway error.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from urllib.parse import urlsplit
11
+
12
+ from sep_sdk.errors import SepValidationError
13
+
14
+ _DIGIT_TRANSLATION = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789")
15
+ _CELL_PATTERN = re.compile(r"(?:\+98|0098|98|0)?(9\d{9})")
16
+ _IBAN_PATTERN = re.compile(r"IR\d{24}")
17
+ _HEX32_PATTERN = re.compile(r"[0-9A-Fa-f]{32}")
18
+ _CONTROL_CHARACTERS = re.compile(r"[\x00-\x1f\x7f]")
19
+
20
+
21
+ def to_ascii_digits(value: str) -> str:
22
+ """Replace Persian and Arabic-Indic digits with ASCII digits."""
23
+ return value.translate(_DIGIT_TRANSLATION)
24
+
25
+
26
+ def require_int(
27
+ name: str, value: object, *, minimum: int | None = None, maximum: int | None = None
28
+ ) -> int:
29
+ """An ``int`` (``bool`` is refused) inside optional bounds."""
30
+ if isinstance(value, bool) or not isinstance(value, int):
31
+ raise SepValidationError(f"{name} must be an integer, got {type(value).__name__}.")
32
+ if minimum is not None and value < minimum:
33
+ raise SepValidationError(f"{name} must be at least {minimum}, got {value}.")
34
+ if maximum is not None and value > maximum:
35
+ raise SepValidationError(f"{name} must be at most {maximum}, got {value}.")
36
+ return value
37
+
38
+
39
+ def require_text(name: str, value: object, *, max_length: int | None = None) -> str:
40
+ """A non-empty string without control characters."""
41
+ if not isinstance(value, str):
42
+ raise SepValidationError(f"{name} must be a string, got {type(value).__name__}.")
43
+ if not value.strip():
44
+ raise SepValidationError(f"{name} must not be empty.")
45
+ if _CONTROL_CHARACTERS.search(value):
46
+ raise SepValidationError(f"{name} must not contain control characters.")
47
+ if max_length is not None and len(value) > max_length:
48
+ raise SepValidationError(f"{name} must be at most {max_length} characters.")
49
+ return value
50
+
51
+
52
+ def require_http_url(name: str, value: object, *, max_length: int) -> str:
53
+ """An ``http(s)`` URL with a host, no whitespace and a bounded length."""
54
+ url = require_text(name, value, max_length=max_length)
55
+ parts = urlsplit(url)
56
+ if parts.scheme not in {"http", "https"} or not parts.hostname or any(c.isspace() for c in url):
57
+ raise SepValidationError(f"{name} must be an absolute http(s) URL.")
58
+ return url
59
+
60
+
61
+ def normalize_cell_number(value: object) -> str:
62
+ """An Iranian mobile number in the ``9xxxxxxxxx`` form the guide's examples use.
63
+
64
+ Accepts ``09123456789``, ``+989123456789``, ``9123456789`` and Persian digits.
65
+ """
66
+ if isinstance(value, bool) or not isinstance(value, str | int):
67
+ raise SepValidationError("cell_number must be a string or an integer.")
68
+ cleaned = to_ascii_digits(re.sub(r"[\s-]", "", str(value)))
69
+ match = _CELL_PATTERN.fullmatch(cleaned)
70
+ if match is None:
71
+ raise SepValidationError("cell_number must be an Iranian mobile number.")
72
+ return match.group(1)
73
+
74
+
75
+ def normalize_iban(value: object) -> str:
76
+ """An Iranian IBAN (``IR`` + 24 digits), upper-cased and without spaces."""
77
+ text = require_text("iban", value).replace(" ", "").upper()
78
+ if _IBAN_PATTERN.fullmatch(text) is None:
79
+ raise SepValidationError("iban must look like IR followed by 24 digits.")
80
+ return text
81
+
82
+
83
+ def iban_checksum_ok(iban: str) -> bool:
84
+ """ISO 13616 mod-97 check. Exposed for callers who want it; not enforced."""
85
+ text = normalize_iban(iban)
86
+ rearranged = text[4:] + text[:4]
87
+ numeric = "".join(str(int(char, 36)) for char in rearranged)
88
+ return int(numeric) % 97 == 1
89
+
90
+
91
+ def normalize_card_hash(value: object) -> str:
92
+ """A 32-character MD5 hex digest, upper-cased."""
93
+ text = require_text("allowed_cards item", value)
94
+ if _HEX32_PATTERN.fullmatch(text) is None:
95
+ raise SepValidationError(
96
+ "allowed_cards items must be MD5 hashes (32 hex characters); "
97
+ "use sep_sdk.hash_card_number() to create them."
98
+ )
99
+ return text.upper()