gst-validator 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rahul Gurujala
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.
@@ -0,0 +1,512 @@
1
+ Metadata-Version: 2.4
2
+ Name: gst-validator
3
+ Version: 0.1.0
4
+ Summary: Validate GSTINs and fetch taxpayer details from the Indian GST portal
5
+ Keywords: gst,gstin,india,tax,validator,taxpayer
6
+ Author: rahulgurujala
7
+ Author-email: rahulgurujala <isaacnewtonrahul@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Natural Language :: English
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Typing :: Typed
20
+ Requires-Dist: httpx>=0.28.1
21
+ Requires-Python: >=3.13
22
+ Project-URL: Homepage, https://github.com/rahulgurujala/gst-validator
23
+ Project-URL: Repository, https://github.com/rahulgurujala/gst-validator
24
+ Project-URL: Issues, https://github.com/rahulgurujala/gst-validator/issues
25
+ Description-Content-Type: text/markdown
26
+
27
+ # gst-validator
28
+
29
+ Validate Indian GSTINs offline and pull taxpayer details from the public GST
30
+ portal. Ships as a typed library (`import gst_validator`) and a CLI
31
+ (`gst-validator`). The CLI is a thin wrapper over the same public API, so
32
+ anything it does, your app can do.
33
+
34
+ - Offline GSTIN validation: format **and** mod-36 checksum, no network
35
+ - Structured objects, not raw dicts: dates parsed, `"NA"`/`""` normalised to `None`
36
+ - Captcha as bytes / base64 / data URI, so a browser or a human can solve it
37
+ - Sync and async clients, strict-typed, `py.typed`
38
+ - Built-in TTL cache, because each lookup costs one human-solved captcha
39
+ - Three extra portal endpoints that need **no captcha** at all
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ uv add gst-validator # into your project
45
+ uv sync # working on this repo
46
+ ```
47
+
48
+ Requires Python 3.13+. Only runtime dependency: `httpx`.
49
+
50
+ ---
51
+
52
+ # CLI
53
+
54
+ ```
55
+ gst-validator [-h] [--offline] [--json] [--details-only] [--raw]
56
+ [--captcha-path PATH] [--captcha-base64] [--refresh]
57
+ [--keep-captcha] GSTIN
58
+ ```
59
+
60
+ ### Validate without touching the network
61
+
62
+ ```bash
63
+ $ gst-validator 27AAACR5055K1Z7 --offline
64
+ 27AAACR5055K1Z7 is valid (state 27, PAN AAACR5055K)
65
+
66
+ $ gst-validator 27AAACR5055K1Z7 --offline --json
67
+ {"gstin": "27AAACR5055K1Z7", "state_code": "27", "pan": "AAACR5055K"}
68
+ ```
69
+
70
+ Use this in CI, in a pre-commit check, or to screen input before spending a
71
+ captcha. Exit code `2` means the GSTIN is malformed.
72
+
73
+ ### Full lookup (interactive)
74
+
75
+ ```bash
76
+ $ gst-validator 27AAACR5055K1Z7
77
+ captcha image written to /tmp/27AAACR5055K1Z7-captcha.png # stderr
78
+ captcha text: 784077
79
+ gstin 27AAACR5055K1Z7
80
+ legal_name <registered name as the portal returns it>
81
+ status Active
82
+ principal_address <registered place of business, one line>
83
+ goods_and_services 39269080 - POLYPROPYLENE ARTICLES, NOT ELSEWHERE SPECIFIED...
84
+ financial_years 2017-2018, 2018-2019, ...
85
+ ...
86
+ ```
87
+
88
+ Open the image, type the text. The file is deleted once you have entered it.
89
+
90
+ ### Machine-readable output
91
+
92
+ ```bash
93
+ gst-validator 27AAACR5055K1Z7 --json # modelled fields, dates as ISO strings
94
+ gst-validator 27AAACR5055K1Z7 --raw # the portal's body verbatim, nothing dropped
95
+ ```
96
+
97
+ `--json` is the one to parse: stable key names, `null` instead of `"NA"`,
98
+ dates as `2025-09-15`. `--raw` is for debugging what the portal actually sent.
99
+ Both go to stdout; progress messages go to stderr, so piping is safe:
100
+
101
+ ```bash
102
+ gst-validator 27AAACR5055K1Z7 --json | jq -r '.legal_name, .principal_address'
103
+ ```
104
+
105
+ ### Solving the captcha somewhere else
106
+
107
+ ```bash
108
+ $ gst-validator 27AAACR5055K1Z7 --captcha-base64
109
+ data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALY...
110
+ captcha text: 784077
111
+ ```
112
+
113
+ The data URI goes to stdout. Paste it into a browser address bar, drop it in
114
+ an `<img src=...>`, or hand it to a solving service. The process keeps the
115
+ portal session open while it waits on stdin, which is what makes this work.
116
+
117
+ ### Other flags
118
+
119
+ ```bash
120
+ gst-validator 27AAACR5055K1Z7 --details-only # skip the captcha-free extras
121
+ gst-validator 27AAACR5055K1Z7 --refresh # ignore the cache, force a fresh lookup
122
+ gst-validator 27AAACR5055K1Z7 --keep-captcha # keep the image file for inspection
123
+ gst-validator 27AAACR5055K1Z7 --captcha-path ./c.png # write it where you want
124
+ ```
125
+
126
+ Also runnable as a module: `python -m gst_validator 27AAACR5055K1Z7`.
127
+
128
+ ### Exit codes
129
+
130
+ | Code | Meaning |
131
+ |---|---|
132
+ | `0` | success |
133
+ | `1` | lookup failed (wrong captcha, portal error, network) |
134
+ | `2` | GSTIN failed format or checksum validation |
135
+ | `130` | aborted (Ctrl-C / EOF) |
136
+
137
+ ```bash
138
+ if gst-validator "$GSTIN" --offline >/dev/null 2>&1; then
139
+ echo "well-formed"
140
+ fi
141
+ ```
142
+
143
+ ---
144
+
145
+ # Using it in your app
146
+
147
+ Everything is importable from the package root:
148
+
149
+ ```python
150
+ from gst_validator import (
151
+ GSTIN,
152
+ Captcha,
153
+ GSTClient,
154
+ AsyncGSTClient,
155
+ TaxpayerDetails,
156
+ TaxpayerProfile,
157
+ Address,
158
+ Jurisdiction,
159
+ GoodsOrService,
160
+ FinancialYear,
161
+ FilingPreference,
162
+ TTLCache,
163
+ NullCache,
164
+ DEFAULT_CACHE,
165
+ TaxpayerCache,
166
+ GSTValidatorError,
167
+ InvalidGSTINError,
168
+ CaptchaError,
169
+ TaxpayerLookupError,
170
+ )
171
+ ```
172
+
173
+ ## 1. Validate a GSTIN (no network, no captcha)
174
+
175
+ ```python
176
+ from gst_validator import GSTIN, InvalidGSTINError
177
+
178
+ GSTIN.is_valid("27AAACR5055K1Z7") # True - never raises
179
+ GSTIN.is_valid("27AAACR5055K1ZA") # False - checksum digit is wrong
180
+
181
+ gstin = GSTIN.parse(" 27aatcm7522p1zj ") # strips, upper-cases, validates
182
+ gstin.value # '27AAACR5055K1Z7'
183
+ gstin.state_code # '27'
184
+ gstin.state_name # 'Maharashtra'
185
+ gstin.pan # 'AAACR5055K'
186
+ gstin.entity_type # 'Company' (4th PAN character)
187
+ gstin.registration_sequence # '1' (Nth registration of this PAN in this state)
188
+
189
+ try:
190
+ GSTIN.parse(user_input)
191
+ except InvalidGSTINError as error:
192
+ print(error.value, error.reason) # the input, and why it was rejected
193
+ ```
194
+
195
+ `GSTIN` is a frozen dataclass: hashable, comparable, usable as a dict key.
196
+ Take one as a function parameter and malformed input cannot reach your code.
197
+
198
+ ## 2. The data you get without a captcha
199
+
200
+ Three portal endpoints return data with no captcha at all, verified against
201
+ the live portal with no cookies and no prior captcha solve. The client still
202
+ opens a session first, since the portal could tighten this at any time:
203
+
204
+ ```python
205
+ from gst_validator import GSTClient
206
+
207
+ with GSTClient() as client:
208
+ client.fetch_goods_and_services("27AAACR5055K1Z7")
209
+ # (GoodsOrService(code='55151190', description='OTHER', is_service=False),
210
+ # GoodsOrService(code='39269080', description='POLYPROPYLENE ARTICLES, NOT
211
+ # ELSEWHERE SPECIFIED OR INCLUDED', is_service=False), ...)
212
+ # Service providers come back as SAC codes instead, with is_service=True:
213
+ # GoodsOrService(code='998314', description='Information technology
214
+ # design and development services', is_service=True)
215
+
216
+ client.fetch_financial_years("27AAACR5055K1Z7")
217
+ # (FinancialYear(label='2025-2026', value='2025'), FinancialYear('2026-2027', '2026'))
218
+
219
+ client.fetch_filing_preferences("27AAACR5055K1Z7")
220
+ # (FilingPreference(quarter='Q1', preference='Q'), ...) -> .is_quarterly / .is_monthly
221
+ ```
222
+
223
+ ## 3. The full lookup (one captcha)
224
+
225
+ The captcha is bound to the client's cookies, so fetch and submit must happen
226
+ on the **same instance**:
227
+
228
+ ```python
229
+ with GSTClient() as client:
230
+ captcha = client.fetch_captcha()
231
+ solved = input(f"solve this: {captcha.data_uri}\n> ")
232
+ profile = client.fetch_profile("27AAACR5055K1Z7", solved)
233
+
234
+ profile.name # registered trade name, else the legal name
235
+ profile.is_active # True
236
+ profile.details # TaxpayerDetails
237
+ profile.as_dict() # everything, JSON-ready
238
+ ```
239
+
240
+ `fetch_details()` instead of `fetch_profile()` if you only want the
241
+ captcha-gated part.
242
+
243
+ ## 4. Web app: captcha to the browser, text back
244
+
245
+ The pattern the original Flask app was reaching for: keep one client per
246
+ pending lookup, keyed by a session id:
247
+
248
+ ```python
249
+ import uuid
250
+ from fastapi import FastAPI, HTTPException
251
+ from gst_validator import GSTClient, GSTValidatorError, InvalidGSTINError
252
+
253
+ app = FastAPI()
254
+ pending: dict[str, GSTClient] = {} # swap for Redis + a TTL in production
255
+
256
+
257
+ @app.post("/captcha")
258
+ def start() -> dict[str, str]:
259
+ client = GSTClient()
260
+ captcha = client.fetch_captcha()
261
+ session_id = str(uuid.uuid4())
262
+ pending[session_id] = client
263
+ return {"session_id": session_id, "image": captcha.data_uri}
264
+
265
+
266
+ @app.post("/lookup")
267
+ def lookup(session_id: str, gstin: str, captcha: str) -> dict[str, object]:
268
+ client = pending.pop(session_id, None)
269
+ if client is None:
270
+ raise HTTPException(400, "unknown or expired session")
271
+ try:
272
+ return client.fetch_profile(gstin, captcha).as_dict()
273
+ except InvalidGSTINError as error:
274
+ raise HTTPException(422, str(error)) from error
275
+ except GSTValidatorError as error:
276
+ raise HTTPException(502, str(error)) from error
277
+ finally:
278
+ client.close()
279
+ ```
280
+
281
+ The front end renders `image` straight into `<img src="{{ image }}">`, since it is
282
+ already a `data:` URI. Give `pending` an expiry; portal sessions do not live
283
+ forever, and an abandoned entry leaks a connection pool.
284
+
285
+ ## 5. Async
286
+
287
+ Same API, `await` and `async with`:
288
+
289
+ ```python
290
+ import asyncio
291
+ from gst_validator import AsyncGSTClient
292
+
293
+
294
+ async def codes(gstin: str) -> tuple[str, ...]:
295
+ async with AsyncGSTClient() as client:
296
+ items = await client.fetch_goods_and_services(gstin)
297
+ return tuple(item.code or "" for item in items)
298
+
299
+
300
+ asyncio.run(codes("27AAACR5055K1Z7"))
301
+ ```
302
+
303
+ ## 6. Caching
304
+
305
+ Each live lookup costs a human-solved captcha, so successful results are
306
+ cached in a process-wide `TTLCache` (24 h, 512 entries, LRU, thread-safe).
307
+
308
+ ```python
309
+ from gst_validator import DEFAULT_CACHE, GSTClient, NullCache, TTLCache
310
+
311
+ GSTClient() # shares DEFAULT_CACHE
312
+ GSTClient(cache=TTLCache(ttl=300)) # private, 5-minute cache
313
+ GSTClient(cache=NullCache()) # caching off
314
+
315
+ with GSTClient() as client:
316
+ if (hit := client.cached(gstin)) is not None:
317
+ details = hit # no captcha spent
318
+ else:
319
+ details = client.fetch_details(gstin, solved)
320
+
321
+ client.fetch_details(gstin, solved, refresh=True) # bypass and overwrite
322
+ ```
323
+
324
+ Back it with anything that satisfies the `TaxpayerCache` protocol:
325
+
326
+ ```python
327
+ import json
328
+ from gst_validator import TaxpayerDetails
329
+
330
+
331
+ class RedisCache:
332
+ def __init__(self, redis, ttl: int = 86_400) -> None:
333
+ self._redis, self._ttl = redis, ttl
334
+
335
+ def get(self, gstin: str) -> TaxpayerDetails | None:
336
+ blob = self._redis.get(f"gst:{gstin}")
337
+ return TaxpayerDetails.from_payload(json.loads(blob)) if blob else None
338
+
339
+ def set(self, gstin: str, details: TaxpayerDetails) -> None:
340
+ self._redis.setex(f"gst:{gstin}", self._ttl, json.dumps(details.raw))
341
+
342
+
343
+ client = GSTClient(cache=RedisCache(redis_connection))
344
+ ```
345
+
346
+ **The client is deliberately not a singleton.** It owns the cookies a captcha
347
+ is bound to, so one shared instance would cross captcha sessions between
348
+ concurrent lookups. The *cache* is the shared piece; clients stay cheap and
349
+ short-lived. The cache stores `.raw`, so a cached entry survives a model
350
+ upgrade.
351
+
352
+ ## 7. Error handling
353
+
354
+ ```
355
+ GSTValidatorError
356
+ ├── InvalidGSTINError (also a ValueError) .value, .reason
357
+ ├── CaptchaError captcha could not be fetched
358
+ └── TaxpayerLookupError .code = the portal's errorCode
359
+ ```
360
+
361
+ ```python
362
+ from gst_validator import CaptchaError, GSTValidatorError, InvalidGSTINError, TaxpayerLookupError
363
+
364
+ try:
365
+ profile = client.fetch_profile(gstin, solved)
366
+ except InvalidGSTINError:
367
+ ... # bad input, never hit the network
368
+ except CaptchaError:
369
+ ... # portal did not hand out an image
370
+ except TaxpayerLookupError as error:
371
+ if error.code == "SWEB_9000":
372
+ ... # wrong or expired captcha - fetch a new one
373
+ except GSTValidatorError:
374
+ ... # catch-all for this package
375
+ ```
376
+
377
+ The portal answers rejections with HTTP 200 and a body carrying an
378
+ `errorCode`, so the *absence* of `gstin` in the body, not the status code,
379
+ is what marks a failed lookup. One `except GSTValidatorError` catches
380
+ everything this package raises; `httpx` errors are wrapped, never leaked.
381
+
382
+ ---
383
+
384
+ # What you get back
385
+
386
+ ### `TaxpayerProfile`
387
+
388
+ | Attribute | Type | Source |
389
+ |---|---|---|
390
+ | `details` | `TaxpayerDetails` | `taxpayerDetails` (captcha) |
391
+ | `goods_and_services` | `tuple[GoodsOrService, ...]` | `goodservice` |
392
+ | `financial_years` | `tuple[FinancialYear, ...]` | `dropdownfinyear` |
393
+ | `filing_preferences` | `tuple[FilingPreference, ...]` | `taxpayerProfileDetails` |
394
+
395
+ Shortcuts: `gstin`, `name`, `is_active`, `as_dict()`.
396
+
397
+ ### `TaxpayerDetails`
398
+
399
+ | Attribute | Portal key | Type |
400
+ |---|---|---|
401
+ | `gstin` / `number` | `gstin` | `str` / `GSTIN \| None` |
402
+ | `legal_name` | `lgnm` | `str \| None` |
403
+ | `trade_name` | `tradeNam` | `str \| None` |
404
+ | `name` | (derived) | trade name, else legal name |
405
+ | `status` | `sts` | `str \| None` |
406
+ | `constitution` | `ctb` | `str \| None` |
407
+ | `taxpayer_type` | `dty` | `str \| None` |
408
+ | `registration_date` | `rgdt` | `datetime.date \| None` |
409
+ | `cancellation_date` | `cxdt` | `datetime.date \| None` |
410
+ | `last_updated` | `lstupdt` | `datetime.date \| None` |
411
+ | `nature_of_business` | `nba` | `tuple[str, ...]` |
412
+ | `principal_address` | `pradr` | `Address \| None` |
413
+ | `additional_addresses` | `adadr` | `tuple[Address, ...]` |
414
+ | `central_jurisdiction` | `ctj`, `ctjCd` | `Jurisdiction` |
415
+ | `state_jurisdiction` | `stj`, `stjCd` | `Jurisdiction` |
416
+ | `einvoice_enabled` | `einvoiceStatus` | `bool \| None` |
417
+ | `is_field_visit_conducted` | `isFieldVisitConducted` | `bool \| None` |
418
+ | `core_business_activity` | `ntcrbs` (code expanded) | `str \| None` |
419
+ | `aadhaar_verified` | `adhrVFlag` | `bool \| None` |
420
+ | `aadhaar_verified_on` | `adhrVdt` | `datetime.date \| None` |
421
+ | `ekyc_status` | `ekycVFlag` | `str \| None` |
422
+ | `composition_rate` | `cmpRt` | `str \| None` |
423
+ | `raw` | everything | `dict[str, Any]` |
424
+
425
+ Helpers: `is_active`, `is_cancelled`, `addresses` (principal first),
426
+ `as_dict()`, and `unmapped`, which lists portal keys this class does not model, so a new
427
+ portal field is never silently dropped.
428
+
429
+ `Address` carries split fields (`building_name`, `street`, `pincode`, …) *and*
430
+ `full`: the portal usually sends the principal address as one `adr` string, so
431
+ `as_line()` returns whichever form arrived.
432
+
433
+ ---
434
+
435
+ # Endpoints and what each costs
436
+
437
+ | Method | Endpoint | Captcha? |
438
+ |---|---|---|
439
+ | `fetch_captcha()` | `/services/captcha` | opens the session |
440
+ | `fetch_details()` | `/api/search/taxpayerDetails` | **yes**, one per lookup |
441
+ | `fetch_goods_and_services()` | `/api/search/goodservice` | no |
442
+ | `fetch_financial_years()` | `/api/dropdownfinyear` | no |
443
+ | `fetch_filing_preferences()` | `/api/search/taxpayerProfileDetails` | no |
444
+ | `fetch_profile()` | all of the above | one |
445
+
446
+ `goodservice` returns SAC codes for service providers (`bzsdtls`) and HSN
447
+ codes for goods (`bzgddtls`); both are parsed into `GoodsOrService`, with
448
+ `is_service` telling them apart.
449
+
450
+ The portal fingerprints clients, so the package sends a browser `User-Agent`
451
+ and the `Referer`/`Origin` headers the site expects; without them the captcha
452
+ request is reset. For unattended or high-volume use, the official
453
+ [GST API](https://developer.gst.gov.in/) through a licensed GSP is the
454
+ supported route; this package drives the public, captcha-gated search.
455
+
456
+ ---
457
+
458
+ # Development
459
+
460
+ ```bash
461
+ uv sync # install, including dev dependencies
462
+ uv run pytest # 46 tests, fully offline via httpx.MockTransport
463
+ uv run mypy # strict
464
+ uv run pyright # strict
465
+ uv run ruff check .
466
+ ```
467
+
468
+ Tests parse payloads with the exact shape the live portal returns
469
+ (`tests/fixtures/`, one service taxpayer and one goods taxpayer, with the
470
+ identifying values replaced by fictional ones) and assert `unmapped == {}`,
471
+ so a portal schema change fails the suite instead of quietly losing data.
472
+
473
+ All GSTINs in this README and in the tests are fictional placeholders built
474
+ on the dummy PAN `AAACR5055K`; they are checksum-valid but belong to nobody.
475
+
476
+ ## Releasing
477
+
478
+ CI runs lint, both type checkers, the tests and a build on every push and PR.
479
+
480
+ To publish a release:
481
+
482
+ ```bash
483
+ uv version --bump patch # or minor / major
484
+ git commit -am "Release v$(uv version --short)"
485
+ git tag "v$(uv version --short)"
486
+ git push origin main --tags
487
+ ```
488
+
489
+ The tag triggers `.github/workflows/release.yml`, which re-runs the checks,
490
+ builds the sdist and wheel, publishes to PyPI and creates a GitHub release
491
+ with generated notes. The workflow refuses to publish if the tag does not
492
+ match the version in `pyproject.toml`.
493
+
494
+ Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
495
+ (OIDC, no stored secret). One-time setup on PyPI, under
496
+ *Your projects -> Publishing* (or *Pending publishers* for a name that does
497
+ not exist yet):
498
+
499
+ | Field | Value |
500
+ |---|---|
501
+ | PyPI project name | `gst-validator` |
502
+ | Owner | `rahulgurujala` |
503
+ | Repository name | `gst-validator` |
504
+ | Workflow name | `release.yml` |
505
+ | Environment name | `pypi` |
506
+
507
+ If a `PYPI_API_TOKEN` repository secret is set instead, the workflow uses that
508
+ and skips OIDC.
509
+
510
+ ## License
511
+
512
+ MIT. See [LICENSE](LICENSE).