kramank 0.1.0__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.
@@ -0,0 +1,18 @@
1
+ node_modules/
2
+ dist/
3
+ .wrangler/
4
+ .dev.vars
5
+ .env
6
+ *.log
7
+ .DS_Store
8
+ ._*
9
+
10
+ # Python (packages/validators-py)
11
+ .venv/
12
+ __pycache__/
13
+ *.egg-info/
14
+ .pytest_cache/
15
+
16
+ # Manually-downloaded source datasets for local-only imports (e.g. the
17
+ # pincode directory CSV) — never committed, never distributed.
18
+ data/
kramank-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Karndev Jhala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
kramank-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.5
2
+ Name: kramank
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency validators for Indian GSTIN, PAN, IFSC, and pincode formats — checksum-verified, type-hinted.
5
+ Project-URL: Homepage, https://kramank.pages.dev
6
+ Project-URL: Repository, https://github.com/planbcreator365/IndianAPI
7
+ Project-URL: Issues, https://github.com/planbcreator365/IndianAPI/issues
8
+ Author: Karndev Jhala
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: checksum,gst,gstin,ifsc,india,pan,pincode,validation
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # kramank
25
+
26
+ Zero-dependency validators for Indian identifiers: **GSTIN**, **PAN**, **IFSC**, and **pincode**.
27
+ Checksum-verified where a checksum exists (GSTIN), fully type-hinted, pure Python (no runtime dependencies).
28
+
29
+ This is a line-for-line Python port of the [TypeScript `kramank` package](https://www.npmjs.com/package/kramank) — same error codes, same decoded fields, tested against the exact same fixture files.
30
+
31
+ > **Format validation only.** These functions check that a string is a well-formed GSTIN/PAN/IFSC/pincode. They do **not** confirm the value is real, active, or currently registered with GSTN, the Income Tax Department, or a bank — that requires a live lookup against those systems, which this package does not do.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install kramank
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from kramank import validate_gstin, validate_pan, validate_ifsc, validate_pincode
43
+
44
+ validate_gstin("27AAPFU0939F1ZV")
45
+ # {
46
+ # "valid": True,
47
+ # "input": "27AAPFU0939F1ZV",
48
+ # "errors": [],
49
+ # "warnings": [],
50
+ # "stateCode": "27",
51
+ # "state": "Maharashtra",
52
+ # "category": "REGULAR",
53
+ # "pan": "AAPFU0939F",
54
+ # "panEntityType": "Firm",
55
+ # "note": "Format check only — does not confirm this value is real, active, or currently registered.",
56
+ # ...
57
+ # }
58
+
59
+ validate_pan("ABCPD1234E")
60
+ # {"valid": True, "entityType": "Individual", ...}
61
+
62
+ validate_ifsc("HDFC0001234")
63
+ # {"valid": True, "bankCode": "HDFC", ...}
64
+
65
+ validate_pincode("110001")
66
+ # {"valid": True, "zoneDigit": "1", "zone": "Northern Region — ...", ...}
67
+ ```
68
+
69
+ Every validator returns the same shape — never a bare boolean — so a failed validation always tells you *why* (`errors[0]["code"]`), not just that it failed:
70
+
71
+ ```python
72
+ {
73
+ "valid": bool,
74
+ "input": str, # trimmed, normalized
75
+ "errors": [{"code": str, "message": str}],
76
+ "warnings": [{"code": str, "message": str}],
77
+ # ...plus decoded fields specific to each identifier
78
+ }
79
+ ```
80
+
81
+ Result keys are `camelCase` (matching the JSON wire format of the hosted API and the TypeScript package) rather than `snake_case`, so a result dict can be serialized straight to JSON and match the API's response shape.
82
+
83
+ ## What this package does *not* do
84
+
85
+ - No network calls, no live registry lookups, no "is this GSTIN actually registered" check.
86
+ - No maintained reference data for IFSC branches or pincode post offices — just the structural/zone rules encoded in the formats themselves.
87
+
88
+ ## The hosted Kramank API
89
+
90
+ [**Kramank**](https://kramank.pages.dev) (क्रमांक, "number") is a hosted API that builds on these same validators and adds what a pure format check can't give you:
91
+
92
+ - **IFSC lookup** — real bank, branch, and address data for a given IFSC code, kept up to date.
93
+ - **Pincode lookup** — real post office, district, and state data for a given pincode.
94
+ - The same GSTIN/PAN/IFSC/pincode format validation as this package, as a hosted endpoint — useful if you'd rather not vendor the logic at all (or aren't on Python/JS).
95
+
96
+ See the [API reference](https://kramank.pages.dev) for endpoints and free-tier limits, or get a key directly on [RapidAPI](https://rapidapi.com/planbcreator365/api/kramank-india-bank-pincode-gst-data1) — **BASIC** (free, 500 requests/month) and **PRO** ($9/month, 50,000 requests/month) are both live.
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1,77 @@
1
+ # kramank
2
+
3
+ Zero-dependency validators for Indian identifiers: **GSTIN**, **PAN**, **IFSC**, and **pincode**.
4
+ Checksum-verified where a checksum exists (GSTIN), fully type-hinted, pure Python (no runtime dependencies).
5
+
6
+ This is a line-for-line Python port of the [TypeScript `kramank` package](https://www.npmjs.com/package/kramank) — same error codes, same decoded fields, tested against the exact same fixture files.
7
+
8
+ > **Format validation only.** These functions check that a string is a well-formed GSTIN/PAN/IFSC/pincode. They do **not** confirm the value is real, active, or currently registered with GSTN, the Income Tax Department, or a bank — that requires a live lookup against those systems, which this package does not do.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install kramank
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from kramank import validate_gstin, validate_pan, validate_ifsc, validate_pincode
20
+
21
+ validate_gstin("27AAPFU0939F1ZV")
22
+ # {
23
+ # "valid": True,
24
+ # "input": "27AAPFU0939F1ZV",
25
+ # "errors": [],
26
+ # "warnings": [],
27
+ # "stateCode": "27",
28
+ # "state": "Maharashtra",
29
+ # "category": "REGULAR",
30
+ # "pan": "AAPFU0939F",
31
+ # "panEntityType": "Firm",
32
+ # "note": "Format check only — does not confirm this value is real, active, or currently registered.",
33
+ # ...
34
+ # }
35
+
36
+ validate_pan("ABCPD1234E")
37
+ # {"valid": True, "entityType": "Individual", ...}
38
+
39
+ validate_ifsc("HDFC0001234")
40
+ # {"valid": True, "bankCode": "HDFC", ...}
41
+
42
+ validate_pincode("110001")
43
+ # {"valid": True, "zoneDigit": "1", "zone": "Northern Region — ...", ...}
44
+ ```
45
+
46
+ Every validator returns the same shape — never a bare boolean — so a failed validation always tells you *why* (`errors[0]["code"]`), not just that it failed:
47
+
48
+ ```python
49
+ {
50
+ "valid": bool,
51
+ "input": str, # trimmed, normalized
52
+ "errors": [{"code": str, "message": str}],
53
+ "warnings": [{"code": str, "message": str}],
54
+ # ...plus decoded fields specific to each identifier
55
+ }
56
+ ```
57
+
58
+ Result keys are `camelCase` (matching the JSON wire format of the hosted API and the TypeScript package) rather than `snake_case`, so a result dict can be serialized straight to JSON and match the API's response shape.
59
+
60
+ ## What this package does *not* do
61
+
62
+ - No network calls, no live registry lookups, no "is this GSTIN actually registered" check.
63
+ - No maintained reference data for IFSC branches or pincode post offices — just the structural/zone rules encoded in the formats themselves.
64
+
65
+ ## The hosted Kramank API
66
+
67
+ [**Kramank**](https://kramank.pages.dev) (क्रमांक, "number") is a hosted API that builds on these same validators and adds what a pure format check can't give you:
68
+
69
+ - **IFSC lookup** — real bank, branch, and address data for a given IFSC code, kept up to date.
70
+ - **Pincode lookup** — real post office, district, and state data for a given pincode.
71
+ - The same GSTIN/PAN/IFSC/pincode format validation as this package, as a hosted endpoint — useful if you'd rather not vendor the logic at all (or aren't on Python/JS).
72
+
73
+ See the [API reference](https://kramank.pages.dev) for endpoints and free-tier limits, or get a key directly on [RapidAPI](https://rapidapi.com/planbcreator365/api/kramank-india-bank-pincode-gst-data1) — **BASIC** (free, 500 requests/month) and **PRO** ($9/month, 50,000 requests/month) are both live.
74
+
75
+ ## License
76
+
77
+ MIT
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kramank"
7
+ version = "0.1.0"
8
+ description = "Zero-dependency validators for Indian GSTIN, PAN, IFSC, and pincode formats — checksum-verified, type-hinted."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ authors = [{ name = "Karndev Jhala" }]
12
+ requires-python = ">=3.9"
13
+ keywords = ["gstin", "gst", "pan", "ifsc", "pincode", "india", "validation", "checksum"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://kramank.pages.dev"
28
+ Repository = "https://github.com/planbcreator365/IndianAPI"
29
+ Issues = "https://github.com/planbcreator365/IndianAPI/issues"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/kramank"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+
37
+ [dependency-groups]
38
+ dev = ["pytest>=8"]
@@ -0,0 +1,20 @@
1
+ """kramank: zero-dependency validators for Indian GSTIN, PAN, IFSC, and pincode formats."""
2
+
3
+ from .checksum import compute_gstin_check_digit
4
+ from .gstin import GSTIN_STATE_CODES, validate_gstin
5
+ from .ifsc import validate_ifsc
6
+ from .pan import PAN_ENTITY_TYPES, validate_pan
7
+ from .pincode import PINCODE_ZONES, validate_pincode
8
+ from .types import FORMAT_ONLY_NOTICE
9
+
10
+ __all__ = [
11
+ "compute_gstin_check_digit",
12
+ "GSTIN_STATE_CODES",
13
+ "validate_gstin",
14
+ "validate_ifsc",
15
+ "PAN_ENTITY_TYPES",
16
+ "validate_pan",
17
+ "PINCODE_ZONES",
18
+ "validate_pincode",
19
+ "FORMAT_ONLY_NOTICE",
20
+ ]
@@ -0,0 +1,19 @@
1
+ """GSTIN check-digit algorithm. Mirrors packages/validators/src/checksum.ts."""
2
+
3
+ _BASE36_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
4
+
5
+
6
+ def compute_gstin_check_digit(first_14_chars: str) -> str:
7
+ """Base-36 weighted-modulus checksum over the first 14 characters of a
8
+ GSTIN, processed right to left with a factor alternating 2/1. Verified
9
+ against the widely-published real example GSTIN 27AAPFU0939F1ZV."""
10
+ factor = 2
11
+ total = 0
12
+ for char in reversed(first_14_chars):
13
+ code_point = _BASE36_CHARS.index(char)
14
+ digit = factor * code_point
15
+ factor = 1 if factor == 2 else 2
16
+ digit = digit // 36 + digit % 36
17
+ total += digit
18
+ check_code_point = (36 - (total % 36)) % 36
19
+ return _BASE36_CHARS[check_code_point]
@@ -0,0 +1,225 @@
1
+ """GSTIN format validation. Mirrors packages/validators/src/gstin.ts."""
2
+
3
+ import re
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .checksum import compute_gstin_check_digit
7
+ from .pan import PAN_ENTITY_TYPES
8
+ from .types import FORMAT_ONLY_NOTICE, ValidationError, ValidationWarning
9
+
10
+ # GST state/UT code table.
11
+ #
12
+ # Codes 25 and 28 are "legacy": both were genuinely issued (Daman and Diu
13
+ # before merging into 26 in Jan 2020; undivided Andhra Pradesh before the
14
+ # 2014 Telangana split) and both still appear on real historical invoices.
15
+ # Rejecting them outright would be wrong, so they're kept as valid with
16
+ # status "legacy" — validate_gstin surfaces this as a LEGACY_STATE_CODE
17
+ # *warning*, never an error.
18
+ #
19
+ # Codes 97 ("Other Territory") and 99 ("Centre Jurisdiction") are special
20
+ # jurisdiction codes, not states, but state code alone isn't a reliable
21
+ # signal for UIN/non-resident-OIDAR category detection — see the
22
+ # category-detection gap noted on validate_gstin below.
23
+ GSTIN_STATE_CODES: Dict[str, Dict[str, str]] = {
24
+ "01": {"name": "Jammu and Kashmir", "status": "active"},
25
+ "02": {"name": "Himachal Pradesh", "status": "active"},
26
+ "03": {"name": "Punjab", "status": "active"},
27
+ "04": {"name": "Chandigarh", "status": "active"},
28
+ "05": {"name": "Uttarakhand", "status": "active"},
29
+ "06": {"name": "Haryana", "status": "active"},
30
+ "07": {"name": "Delhi", "status": "active"},
31
+ "08": {"name": "Rajasthan", "status": "active"},
32
+ "09": {"name": "Uttar Pradesh", "status": "active"},
33
+ "10": {"name": "Bihar", "status": "active"},
34
+ "11": {"name": "Sikkim", "status": "active"},
35
+ "12": {"name": "Arunachal Pradesh", "status": "active"},
36
+ "13": {"name": "Nagaland", "status": "active"},
37
+ "14": {"name": "Manipur", "status": "active"},
38
+ "15": {"name": "Mizoram", "status": "active"},
39
+ "16": {"name": "Tripura", "status": "active"},
40
+ "17": {"name": "Meghalaya", "status": "active"},
41
+ "18": {"name": "Assam", "status": "active"},
42
+ "19": {"name": "West Bengal", "status": "active"},
43
+ "20": {"name": "Jharkhand", "status": "active"},
44
+ "21": {"name": "Odisha", "status": "active"},
45
+ "22": {"name": "Chhattisgarh", "status": "active"},
46
+ "23": {"name": "Madhya Pradesh", "status": "active"},
47
+ "24": {"name": "Gujarat", "status": "active"},
48
+ "25": {"name": "Daman and Diu", "status": "legacy"},
49
+ "26": {"name": "Dadra and Nagar Haveli and Daman and Diu", "status": "active"},
50
+ "27": {"name": "Maharashtra", "status": "active"},
51
+ "28": {"name": "Andhra Pradesh (Before Division)", "status": "legacy"},
52
+ "29": {"name": "Karnataka", "status": "active"},
53
+ "30": {"name": "Goa", "status": "active"},
54
+ "31": {"name": "Lakshadweep", "status": "active"},
55
+ "32": {"name": "Kerala", "status": "active"},
56
+ "33": {"name": "Tamil Nadu", "status": "active"},
57
+ "34": {"name": "Puducherry", "status": "active"},
58
+ "35": {"name": "Andaman and Nicobar Islands", "status": "active"},
59
+ "36": {"name": "Telangana", "status": "active"},
60
+ "37": {"name": "Andhra Pradesh", "status": "active"},
61
+ "38": {"name": "Ladakh", "status": "active"},
62
+ "97": {"name": "Other Territory", "status": "active"},
63
+ "99": {"name": "Centre Jurisdiction", "status": "active"},
64
+ }
65
+
66
+ _PAN_SLOT_REGEX = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
67
+ _TAN_SLOT_REGEX = re.compile(r"^[A-Z]{4}[0-9]{5}[A-Z]$")
68
+ _GSTIN_CHARSET_REGEX = re.compile(r"^[0-9A-Z]{15}$")
69
+
70
+
71
+ def validate_gstin(raw_input: str) -> Dict[str, Any]:
72
+ """FORMAT validation only. Does not confirm the GSTIN is real, active, or
73
+ actually registered with GSTN.
74
+
75
+ A GSTIN whose middle segment (characters 3-12) matches neither a PAN nor
76
+ a TAN pattern is NOT treated as malformed on that basis alone:
77
+ `category` becomes `'UNKNOWN'` with an `UNRECOGNIZED_STRUCTURE` warning,
78
+ and `valid` stays `True` as long as the length, character set, state
79
+ code, and checksum are all otherwise correct. GSTINs in categories this
80
+ validator can't structurally identify (UIN, OIDAR, and any other
81
+ special-registration category) fall into exactly this shape, and
82
+ treating "we don't recognize this structure" the same as "this is wrong"
83
+ would reject real GSTINs.
84
+
85
+ Known gaps:
86
+ - UIN (state code 97) and non-resident/OIDAR (state code 99) registrant
87
+ categories cannot be structurally detected; `category` is decided
88
+ purely by whether characters 3-12 match a PAN or TAN pattern.
89
+ - TCS (e-commerce operators collecting tax at source) is not
90
+ structurally distinguishable from a REGULAR PAN-based GSTIN either.
91
+ """
92
+ input_ = raw_input.strip().upper()
93
+
94
+ if len(input_) == 0:
95
+ return {
96
+ "valid": False,
97
+ "input": input_,
98
+ "errors": [{"code": "GSTIN_EMPTY", "message": "GSTIN is empty."}],
99
+ "warnings": [],
100
+ "note": FORMAT_ONLY_NOTICE,
101
+ }
102
+ if len(input_) != 15:
103
+ return {
104
+ "valid": False,
105
+ "input": input_,
106
+ "errors": [
107
+ {
108
+ "code": "GSTIN_INVALID_LENGTH",
109
+ "message": f"GSTIN must be exactly 15 characters (found {len(input_)}).",
110
+ }
111
+ ],
112
+ "warnings": [],
113
+ "note": FORMAT_ONLY_NOTICE,
114
+ }
115
+ if not _GSTIN_CHARSET_REGEX.match(input_):
116
+ return {
117
+ "valid": False,
118
+ "input": input_,
119
+ "errors": [
120
+ {
121
+ "code": "GSTIN_INVALID_CHARACTERS",
122
+ "message": "GSTIN must contain only digits and uppercase letters.",
123
+ }
124
+ ],
125
+ "warnings": [],
126
+ "note": FORMAT_ONLY_NOTICE,
127
+ }
128
+
129
+ errors: List[ValidationError] = []
130
+ warnings: List[ValidationWarning] = []
131
+
132
+ state_code = input_[0:2]
133
+ pan_or_tan_slot = input_[2:12]
134
+ entity_code = input_[12]
135
+ default_character = input_[13]
136
+ check_digit = input_[14]
137
+
138
+ state_info = GSTIN_STATE_CODES.get(state_code)
139
+ if state_info is None:
140
+ errors.append(
141
+ {
142
+ "code": "GSTIN_INVALID_STATE_CODE",
143
+ "message": f'"{state_code}" is not a recognized GST state/UT code.',
144
+ }
145
+ )
146
+ elif state_info["status"] == "legacy":
147
+ warnings.append(
148
+ {
149
+ "code": "LEGACY_STATE_CODE",
150
+ "message": (
151
+ f'State code "{state_code}" ({state_info["name"]}) is no longer issued '
152
+ "for new registrations, but GSTINs issued under it before the change "
153
+ "remain valid."
154
+ ),
155
+ }
156
+ )
157
+
158
+ category: str
159
+ pan: Optional[str] = None
160
+ pan_entity_type_code: Optional[str] = None
161
+ pan_entity_type: Optional[str] = None
162
+ tan: Optional[str] = None
163
+
164
+ if _PAN_SLOT_REGEX.match(pan_or_tan_slot):
165
+ category = "REGULAR"
166
+ pan = pan_or_tan_slot
167
+ pan_entity_type_code = pan[3]
168
+ pan_entity_type = PAN_ENTITY_TYPES.get(pan_entity_type_code)
169
+ if pan_entity_type is None:
170
+ errors.append(
171
+ {
172
+ "code": "GSTIN_UNKNOWN_PAN_ENTITY_TYPE",
173
+ "message": f'"{pan_entity_type_code}" is not a recognized PAN entity-type character.',
174
+ }
175
+ )
176
+ elif _TAN_SLOT_REGEX.match(pan_or_tan_slot):
177
+ category = "TDS"
178
+ tan = pan_or_tan_slot
179
+ else:
180
+ category = "UNKNOWN"
181
+ warnings.append(
182
+ {
183
+ "code": "UNRECOGNIZED_STRUCTURE",
184
+ "message": (
185
+ "Characters 3-12 do not match a known PAN (AAAAA9999A) or TAN "
186
+ "(AAAA99999A) pattern. This does not make the GSTIN invalid — it may "
187
+ "be a UIN, OIDAR, or other special-category registration this "
188
+ "validator cannot yet structurally identify."
189
+ ),
190
+ }
191
+ )
192
+
193
+ if category in ("REGULAR", "TDS") and default_character != "Z":
194
+ errors.append(
195
+ {
196
+ "code": "GSTIN_INVALID_DEFAULT_CHARACTER",
197
+ "message": f'Character 14 must be "Z" (found "{default_character}").',
198
+ }
199
+ )
200
+
201
+ expected_check_digit = compute_gstin_check_digit(input_[0:14])
202
+ if check_digit != expected_check_digit:
203
+ errors.append(
204
+ {
205
+ "code": "GSTIN_CHECKSUM_MISMATCH",
206
+ "message": f'Checksum character does not match (expected "{expected_check_digit}", found "{check_digit}").',
207
+ }
208
+ )
209
+
210
+ return {
211
+ "valid": len(errors) == 0,
212
+ "input": input_,
213
+ "errors": errors,
214
+ "warnings": warnings,
215
+ "stateCode": state_code,
216
+ "state": state_info["name"] if state_info else None,
217
+ "stateCodeStatus": state_info["status"] if state_info else None,
218
+ "category": category,
219
+ "pan": pan,
220
+ "panEntityTypeCode": pan_entity_type_code,
221
+ "panEntityType": pan_entity_type,
222
+ "tan": tan,
223
+ "entityCode": entity_code,
224
+ "note": FORMAT_ONLY_NOTICE,
225
+ }
@@ -0,0 +1,50 @@
1
+ """IFSC format validation. Mirrors packages/validators/src/ifsc.ts."""
2
+
3
+ import re
4
+ from typing import Any, Dict
5
+
6
+ _IFSC_REGEX = re.compile(r"^[A-Z]{4}0[A-Z0-9]{6}$")
7
+
8
+
9
+ def validate_ifsc(raw_input: str) -> Dict[str, Any]:
10
+ input_ = raw_input.strip().upper()
11
+
12
+ if len(input_) == 0:
13
+ return {
14
+ "valid": False,
15
+ "input": input_,
16
+ "errors": [{"code": "IFSC_EMPTY", "message": "IFSC is empty."}],
17
+ "warnings": [],
18
+ }
19
+ if len(input_) != 11:
20
+ return {
21
+ "valid": False,
22
+ "input": input_,
23
+ "errors": [
24
+ {
25
+ "code": "IFSC_INVALID_LENGTH",
26
+ "message": f"IFSC must be exactly 11 characters (found {len(input_)}).",
27
+ }
28
+ ],
29
+ "warnings": [],
30
+ }
31
+ if not _IFSC_REGEX.match(input_):
32
+ return {
33
+ "valid": False,
34
+ "input": input_,
35
+ "errors": [
36
+ {
37
+ "code": "IFSC_INVALID_FORMAT",
38
+ "message": 'IFSC must match AAAA0XXXXXX (4 letters, then "0", then 6 alphanumerics).',
39
+ }
40
+ ],
41
+ "warnings": [],
42
+ }
43
+
44
+ return {
45
+ "valid": True,
46
+ "input": input_,
47
+ "errors": [],
48
+ "warnings": [],
49
+ "bankCode": input_[:4],
50
+ }
@@ -0,0 +1,87 @@
1
+ """PAN format validation. Mirrors packages/validators/src/pan.ts."""
2
+
3
+ import re
4
+ from typing import Any, Dict, List
5
+
6
+ from .types import FORMAT_ONLY_NOTICE, ValidationError
7
+
8
+ # The 4th character of a PAN identifies the holder's entity type. This is
9
+ # the complete, official 10-code list. There is no separate code for LLP:
10
+ # PAN allotment has no distinct LLP category, so an LLP's PAN carries the
11
+ # same 'F' (Firm) code as a partnership firm.
12
+ PAN_ENTITY_TYPES: Dict[str, str] = {
13
+ "A": "Association of Persons (AOP)",
14
+ "B": "Body of Individuals (BOI)",
15
+ "C": "Company",
16
+ "F": "Firm",
17
+ "G": "Government",
18
+ "H": "Hindu Undivided Family (HUF)",
19
+ "J": "Artificial Juridical Person",
20
+ "L": "Local Authority",
21
+ "P": "Individual",
22
+ "T": "Trust",
23
+ }
24
+
25
+ _PAN_REGEX = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
26
+
27
+
28
+ def validate_pan(raw_input: str) -> Dict[str, Any]:
29
+ """FORMAT validation only. Does not confirm the PAN is real, active, or
30
+ actually allotted by the Income Tax Department."""
31
+ input_ = raw_input.strip().upper()
32
+
33
+ if len(input_) == 0:
34
+ return {
35
+ "valid": False,
36
+ "input": input_,
37
+ "errors": [{"code": "PAN_EMPTY", "message": "PAN is empty."}],
38
+ "warnings": [],
39
+ "note": FORMAT_ONLY_NOTICE,
40
+ }
41
+ if len(input_) != 10:
42
+ return {
43
+ "valid": False,
44
+ "input": input_,
45
+ "errors": [
46
+ {
47
+ "code": "PAN_INVALID_LENGTH",
48
+ "message": f"PAN must be exactly 10 characters (found {len(input_)}).",
49
+ }
50
+ ],
51
+ "warnings": [],
52
+ "note": FORMAT_ONLY_NOTICE,
53
+ }
54
+ if not _PAN_REGEX.match(input_):
55
+ return {
56
+ "valid": False,
57
+ "input": input_,
58
+ "errors": [
59
+ {
60
+ "code": "PAN_INVALID_FORMAT",
61
+ "message": "PAN must match AAAAA9999A (5 letters, 4 digits, 1 letter).",
62
+ }
63
+ ],
64
+ "warnings": [],
65
+ "note": FORMAT_ONLY_NOTICE,
66
+ }
67
+
68
+ errors: List[ValidationError] = []
69
+ entity_type_code = input_[3]
70
+ entity_type = PAN_ENTITY_TYPES.get(entity_type_code)
71
+ if entity_type is None:
72
+ errors.append(
73
+ {
74
+ "code": "PAN_UNKNOWN_ENTITY_TYPE",
75
+ "message": f'"{entity_type_code}" is not a recognized PAN entity-type character.',
76
+ }
77
+ )
78
+
79
+ return {
80
+ "valid": len(errors) == 0,
81
+ "input": input_,
82
+ "errors": errors,
83
+ "warnings": [],
84
+ "entityTypeCode": entity_type_code,
85
+ "entityType": entity_type,
86
+ "note": FORMAT_ONLY_NOTICE,
87
+ }
@@ -0,0 +1,74 @@
1
+ """Pincode format validation. Mirrors packages/validators/src/pincode.ts."""
2
+
3
+ import re
4
+ from typing import Any, Dict
5
+
6
+ # First-digit postal zone table.
7
+ PINCODE_ZONES: Dict[str, str] = {
8
+ "1": "Northern Region — Delhi, Haryana, Himachal Pradesh, Punjab, Chandigarh, Jammu & Kashmir, Ladakh",
9
+ "2": "Northern Region — Uttarakhand, Uttar Pradesh",
10
+ "3": "Western Region — Gujarat, Rajasthan, Dadra and Nagar Haveli and Daman and Diu",
11
+ "4": "Western Region — Chhattisgarh, Goa, Madhya Pradesh, Maharashtra",
12
+ "5": "Southern Region — Andhra Pradesh, Karnataka, Telangana",
13
+ "6": "Southern Region — Kerala, Tamil Nadu, Puducherry, Lakshadweep",
14
+ "7": "Eastern Region — Arunachal Pradesh, Assam, Manipur, Meghalaya, Mizoram, Nagaland, Odisha, Sikkim, Tripura, West Bengal, Andaman and Nicobar Islands",
15
+ "8": "Eastern Region — Bihar, Jharkhand",
16
+ "9": "Army Postal Service (APS) / Field Post Office (FPO)",
17
+ }
18
+
19
+ _PINCODE_REGEX = re.compile(r"^[0-9]{6}$")
20
+
21
+
22
+ def validate_pincode(raw_input: str) -> Dict[str, Any]:
23
+ input_ = raw_input.strip()
24
+
25
+ if len(input_) == 0:
26
+ return {
27
+ "valid": False,
28
+ "input": input_,
29
+ "errors": [{"code": "PINCODE_EMPTY", "message": "Pincode is empty."}],
30
+ "warnings": [],
31
+ }
32
+ if len(input_) != 6:
33
+ return {
34
+ "valid": False,
35
+ "input": input_,
36
+ "errors": [
37
+ {
38
+ "code": "PINCODE_INVALID_LENGTH",
39
+ "message": f"Pincode must be exactly 6 digits (found {len(input_)}).",
40
+ }
41
+ ],
42
+ "warnings": [],
43
+ }
44
+ if not _PINCODE_REGEX.match(input_):
45
+ return {
46
+ "valid": False,
47
+ "input": input_,
48
+ "errors": [{"code": "PINCODE_INVALID_CHARACTERS", "message": "Pincode must contain only digits."}],
49
+ "warnings": [],
50
+ }
51
+
52
+ zone_digit = input_[0]
53
+ if zone_digit == "0":
54
+ return {
55
+ "valid": False,
56
+ "input": input_,
57
+ "errors": [
58
+ {
59
+ "code": "PINCODE_INVALID_FIRST_DIGIT",
60
+ "message": "The first digit of a pincode must be 1-9, not 0.",
61
+ }
62
+ ],
63
+ "warnings": [],
64
+ "zoneDigit": zone_digit,
65
+ }
66
+
67
+ return {
68
+ "valid": True,
69
+ "input": input_,
70
+ "errors": [],
71
+ "warnings": [],
72
+ "zoneDigit": zone_digit,
73
+ "zone": PINCODE_ZONES.get(zone_digit),
74
+ }
File without changes
@@ -0,0 +1,30 @@
1
+ """Shared result shapes. Mirrors packages/validators/src/types.ts."""
2
+
3
+ from typing import List, TypedDict
4
+
5
+
6
+ class ValidationError(TypedDict):
7
+ code: str
8
+ message: str
9
+
10
+
11
+ # Same shape as an error, but never affects `valid` — used for facts worth
12
+ # surfacing (e.g. a legacy-but-still-real state code) that aren't defects.
13
+ ValidationWarning = ValidationError
14
+
15
+
16
+ class ValidationResult(TypedDict):
17
+ valid: bool
18
+ input: str
19
+ errors: List[ValidationError]
20
+ warnings: List[ValidationWarning]
21
+
22
+
23
+ # Attached to GSTIN and PAN results only — these are tax identifiers where
24
+ # "format is well-formed" is easily misread as "this is a real, registered
25
+ # taxpayer." IFSC/pincode don't carry that same real-world-registration
26
+ # ambiguity, so they don't need this notice.
27
+ FORMAT_ONLY_NOTICE = (
28
+ "Format check only — does not confirm this value is real, active, or "
29
+ "currently registered."
30
+ )
@@ -0,0 +1,10 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ FIXTURES_DIR = Path(__file__).parent.parent.parent / "validators" / "fixtures"
5
+
6
+
7
+ def load_fixtures(name: str) -> dict:
8
+ """Loads the SAME fixtures the TypeScript package tests against —
9
+ single source of truth in packages/validators/fixtures/."""
10
+ return json.loads((FIXTURES_DIR / f"{name}.json").read_text())
@@ -0,0 +1,32 @@
1
+ import re
2
+
3
+ import pytest
4
+ from conftest import load_fixtures
5
+
6
+ from kramank import validate_gstin
7
+
8
+ FIXTURES = load_fixtures("gstin")
9
+
10
+
11
+ @pytest.mark.parametrize("fixture", FIXTURES["valid"], ids=lambda f: f["description"])
12
+ def test_valid(fixture):
13
+ result = validate_gstin(fixture["input"])
14
+ assert result["valid"] is True
15
+ assert result["errors"] == []
16
+ assert result["input"] == fixture["input"].strip().upper()
17
+ for key, value in fixture["expected"].items():
18
+ assert result[key] == value
19
+ expected_warning_codes = fixture.get("expectedWarningCodes", [])
20
+ assert sorted(w["code"] for w in result["warnings"]) == sorted(expected_warning_codes)
21
+
22
+
23
+ @pytest.mark.parametrize("fixture", FIXTURES["invalid"], ids=lambda f: f["description"])
24
+ def test_invalid(fixture):
25
+ result = validate_gstin(fixture["input"])
26
+ assert result["valid"] is False
27
+ assert sorted(e["code"] for e in result["errors"]) == sorted(fixture["expectedErrorCodes"])
28
+
29
+
30
+ def test_format_only_notice():
31
+ assert re.search(r"does not confirm", validate_gstin("27AAPFU0939F1ZV")["note"], re.IGNORECASE)
32
+ assert re.search(r"does not confirm", validate_gstin("")["note"], re.IGNORECASE)
@@ -0,0 +1,23 @@
1
+ import pytest
2
+ from conftest import load_fixtures
3
+
4
+ from kramank import validate_ifsc
5
+
6
+ FIXTURES = load_fixtures("ifsc")
7
+
8
+
9
+ @pytest.mark.parametrize("fixture", FIXTURES["valid"], ids=lambda f: f["description"])
10
+ def test_valid(fixture):
11
+ result = validate_ifsc(fixture["input"])
12
+ assert result["valid"] is True
13
+ assert result["errors"] == []
14
+ assert result["input"] == fixture["input"].strip().upper()
15
+ for key, value in fixture["expected"].items():
16
+ assert result[key] == value
17
+
18
+
19
+ @pytest.mark.parametrize("fixture", FIXTURES["invalid"], ids=lambda f: f["description"])
20
+ def test_invalid(fixture):
21
+ result = validate_ifsc(fixture["input"])
22
+ assert result["valid"] is False
23
+ assert sorted(e["code"] for e in result["errors"]) == sorted(fixture["expectedErrorCodes"])
@@ -0,0 +1,30 @@
1
+ import re
2
+
3
+ import pytest
4
+ from conftest import load_fixtures
5
+
6
+ from kramank import validate_pan
7
+
8
+ FIXTURES = load_fixtures("pan")
9
+
10
+
11
+ @pytest.mark.parametrize("fixture", FIXTURES["valid"], ids=lambda f: f["description"])
12
+ def test_valid(fixture):
13
+ result = validate_pan(fixture["input"])
14
+ assert result["valid"] is True
15
+ assert result["errors"] == []
16
+ assert result["input"] == fixture["input"].strip().upper()
17
+ for key, value in fixture["expected"].items():
18
+ assert result[key] == value
19
+
20
+
21
+ @pytest.mark.parametrize("fixture", FIXTURES["invalid"], ids=lambda f: f["description"])
22
+ def test_invalid(fixture):
23
+ result = validate_pan(fixture["input"])
24
+ assert result["valid"] is False
25
+ assert sorted(e["code"] for e in result["errors"]) == sorted(fixture["expectedErrorCodes"])
26
+
27
+
28
+ def test_format_only_notice():
29
+ assert re.search(r"does not confirm", validate_pan("ABCPD1234E")["note"], re.IGNORECASE)
30
+ assert re.search(r"does not confirm", validate_pan("")["note"], re.IGNORECASE)
@@ -0,0 +1,23 @@
1
+ import pytest
2
+ from conftest import load_fixtures
3
+
4
+ from kramank import validate_pincode
5
+
6
+ FIXTURES = load_fixtures("pincode")
7
+
8
+
9
+ @pytest.mark.parametrize("fixture", FIXTURES["valid"], ids=lambda f: f["description"])
10
+ def test_valid(fixture):
11
+ result = validate_pincode(fixture["input"])
12
+ assert result["valid"] is True
13
+ assert result["errors"] == []
14
+ assert result["input"] == fixture["input"].strip()
15
+ for key, value in fixture["expected"].items():
16
+ assert result[key] == value
17
+
18
+
19
+ @pytest.mark.parametrize("fixture", FIXTURES["invalid"], ids=lambda f: f["description"])
20
+ def test_invalid(fixture):
21
+ result = validate_pincode(fixture["input"])
22
+ assert result["valid"] is False
23
+ assert sorted(e["code"] for e in result["errors"]) == sorted(fixture["expectedErrorCodes"])
kramank-0.1.0/uv.lock ADDED
@@ -0,0 +1,200 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.10'",
6
+ "python_full_version < '3.10'",
7
+ ]
8
+
9
+ [[package]]
10
+ name = "colorama"
11
+ version = "0.4.6"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "exceptiongroup"
20
+ version = "1.3.1"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ dependencies = [
23
+ { name = "typing-extensions" },
24
+ ]
25
+ sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
26
+ wheels = [
27
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
28
+ ]
29
+
30
+ [[package]]
31
+ name = "iniconfig"
32
+ version = "2.1.0"
33
+ source = { registry = "https://pypi.org/simple" }
34
+ resolution-markers = [
35
+ "python_full_version < '3.10'",
36
+ ]
37
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
38
+ wheels = [
39
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
40
+ ]
41
+
42
+ [[package]]
43
+ name = "iniconfig"
44
+ version = "2.3.0"
45
+ source = { registry = "https://pypi.org/simple" }
46
+ resolution-markers = [
47
+ "python_full_version >= '3.10'",
48
+ ]
49
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
50
+ wheels = [
51
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
52
+ ]
53
+
54
+ [[package]]
55
+ name = "kramank"
56
+ version = "0.1.0"
57
+ source = { editable = "." }
58
+
59
+ [package.dev-dependencies]
60
+ dev = [
61
+ { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
62
+ { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
63
+ ]
64
+
65
+ [package.metadata]
66
+
67
+ [package.metadata.requires-dev]
68
+ dev = [{ name = "pytest", specifier = ">=8" }]
69
+
70
+ [[package]]
71
+ name = "packaging"
72
+ version = "26.3"
73
+ source = { registry = "https://pypi.org/simple" }
74
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
75
+ wheels = [
76
+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
77
+ ]
78
+
79
+ [[package]]
80
+ name = "pluggy"
81
+ version = "1.6.0"
82
+ source = { registry = "https://pypi.org/simple" }
83
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
84
+ wheels = [
85
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
86
+ ]
87
+
88
+ [[package]]
89
+ name = "pygments"
90
+ version = "2.21.0"
91
+ source = { registry = "https://pypi.org/simple" }
92
+ sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
93
+ wheels = [
94
+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
95
+ ]
96
+
97
+ [[package]]
98
+ name = "pytest"
99
+ version = "8.4.2"
100
+ source = { registry = "https://pypi.org/simple" }
101
+ resolution-markers = [
102
+ "python_full_version < '3.10'",
103
+ ]
104
+ dependencies = [
105
+ { name = "colorama", marker = "sys_platform == 'win32'" },
106
+ { name = "exceptiongroup" },
107
+ { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
108
+ { name = "packaging" },
109
+ { name = "pluggy" },
110
+ { name = "pygments" },
111
+ { name = "tomli" },
112
+ ]
113
+ sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
114
+ wheels = [
115
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
116
+ ]
117
+
118
+ [[package]]
119
+ name = "pytest"
120
+ version = "9.1.1"
121
+ source = { registry = "https://pypi.org/simple" }
122
+ resolution-markers = [
123
+ "python_full_version >= '3.10'",
124
+ ]
125
+ dependencies = [
126
+ { name = "colorama", marker = "sys_platform == 'win32'" },
127
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
128
+ { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } },
129
+ { name = "packaging" },
130
+ { name = "pluggy" },
131
+ { name = "pygments" },
132
+ { name = "tomli", marker = "python_full_version < '3.11'" },
133
+ ]
134
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
135
+ wheels = [
136
+ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
137
+ ]
138
+
139
+ [[package]]
140
+ name = "tomli"
141
+ version = "2.4.1"
142
+ source = { registry = "https://pypi.org/simple" }
143
+ sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
144
+ wheels = [
145
+ { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
146
+ { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
147
+ { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
148
+ { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
149
+ { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
150
+ { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
151
+ { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
152
+ { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
153
+ { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
154
+ { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
155
+ { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
156
+ { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
157
+ { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
158
+ { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
159
+ { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
160
+ { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
161
+ { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
162
+ { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
163
+ { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
164
+ { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
165
+ { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
166
+ { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
167
+ { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
168
+ { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
169
+ { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
170
+ { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
171
+ { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
172
+ { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
173
+ { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
174
+ { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
175
+ { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
176
+ { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
177
+ { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
178
+ { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
179
+ { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
180
+ { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
181
+ { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
182
+ { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
183
+ { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
184
+ { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
185
+ { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
186
+ { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
187
+ { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
188
+ { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
189
+ { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
190
+ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
191
+ ]
192
+
193
+ [[package]]
194
+ name = "typing-extensions"
195
+ version = "4.16.0"
196
+ source = { registry = "https://pypi.org/simple" }
197
+ sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
198
+ wheels = [
199
+ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
200
+ ]