gst-validator 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.
- gst_validator/__init__.py +46 -0
- gst_validator/__main__.py +3 -0
- gst_validator/cache.py +94 -0
- gst_validator/cli.py +157 -0
- gst_validator/client.py +413 -0
- gst_validator/exceptions.py +37 -0
- gst_validator/models.py +596 -0
- gst_validator/py.typed +0 -0
- gst_validator-0.1.0.dist-info/METADATA +512 -0
- gst_validator-0.1.0.dist-info/RECORD +13 -0
- gst_validator-0.1.0.dist-info/WHEEL +4 -0
- gst_validator-0.1.0.dist-info/entry_points.txt +3 -0
- gst_validator-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Validate GSTINs and fetch taxpayer details from the Indian GST portal."""
|
|
2
|
+
|
|
3
|
+
from .cache import DEFAULT_CACHE, NullCache, TaxpayerCache, TTLCache
|
|
4
|
+
from .cli import main
|
|
5
|
+
from .client import AsyncGSTClient, GSTClient
|
|
6
|
+
from .exceptions import (
|
|
7
|
+
CaptchaError,
|
|
8
|
+
GSTValidatorError,
|
|
9
|
+
InvalidGSTINError,
|
|
10
|
+
TaxpayerLookupError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
GSTIN,
|
|
14
|
+
Address,
|
|
15
|
+
Captcha,
|
|
16
|
+
FilingPreference,
|
|
17
|
+
FinancialYear,
|
|
18
|
+
GoodsOrService,
|
|
19
|
+
Jurisdiction,
|
|
20
|
+
TaxpayerDetails,
|
|
21
|
+
TaxpayerProfile,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"DEFAULT_CACHE",
|
|
26
|
+
"GSTIN",
|
|
27
|
+
"Address",
|
|
28
|
+
"AsyncGSTClient",
|
|
29
|
+
"Captcha",
|
|
30
|
+
"CaptchaError",
|
|
31
|
+
"FilingPreference",
|
|
32
|
+
"FinancialYear",
|
|
33
|
+
"GSTClient",
|
|
34
|
+
"GSTValidatorError",
|
|
35
|
+
"GoodsOrService",
|
|
36
|
+
"InvalidGSTINError",
|
|
37
|
+
"Jurisdiction",
|
|
38
|
+
"NullCache",
|
|
39
|
+
"TTLCache",
|
|
40
|
+
"TaxpayerCache",
|
|
41
|
+
"TaxpayerDetails",
|
|
42
|
+
"TaxpayerLookupError",
|
|
43
|
+
"TaxpayerProfile",
|
|
44
|
+
"main",
|
|
45
|
+
]
|
|
46
|
+
__version__ = "0.1.0"
|
gst_validator/cache.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Lookup cache.
|
|
2
|
+
|
|
3
|
+
Every portal lookup costs one human-solved captcha, so a cache hit is worth far
|
|
4
|
+
more here than in an ordinary HTTP client. The cache is process-wide and shared
|
|
5
|
+
between clients on purpose: the *client* must stay per-session (it owns the
|
|
6
|
+
cookies a captcha is bound to), while cached results are session-independent.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections import OrderedDict
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Final, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
from .models import TaxpayerDetails
|
|
16
|
+
|
|
17
|
+
__all__ = ["DEFAULT_CACHE", "NullCache", "TTLCache", "TaxpayerCache"]
|
|
18
|
+
|
|
19
|
+
_DEFAULT_TTL: Final = 24 * 60 * 60.0
|
|
20
|
+
_DEFAULT_MAXSIZE: Final = 512
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@runtime_checkable
|
|
24
|
+
class TaxpayerCache(Protocol):
|
|
25
|
+
"""Minimal cache contract - swap in Redis, Django cache, whatever."""
|
|
26
|
+
|
|
27
|
+
def get(self, gstin: str) -> TaxpayerDetails | None: ...
|
|
28
|
+
|
|
29
|
+
def set(self, gstin: str, details: TaxpayerDetails) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TTLCache:
|
|
33
|
+
"""Thread-safe, size-bounded, time-to-live cache with LRU eviction."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
ttl: float = _DEFAULT_TTL,
|
|
39
|
+
maxsize: int = _DEFAULT_MAXSIZE,
|
|
40
|
+
clock: Callable[[], float] | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
if ttl <= 0:
|
|
43
|
+
raise ValueError("ttl must be positive")
|
|
44
|
+
if maxsize <= 0:
|
|
45
|
+
raise ValueError("maxsize must be positive")
|
|
46
|
+
self.ttl: float = ttl
|
|
47
|
+
self.maxsize: int = maxsize
|
|
48
|
+
self._clock = clock or time.monotonic
|
|
49
|
+
self._entries: OrderedDict[str, tuple[float, TaxpayerDetails]] = OrderedDict()
|
|
50
|
+
self._lock = threading.Lock()
|
|
51
|
+
|
|
52
|
+
def get(self, gstin: str) -> TaxpayerDetails | None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
entry = self._entries.get(gstin)
|
|
55
|
+
if entry is None:
|
|
56
|
+
return None
|
|
57
|
+
stored_at, details = entry
|
|
58
|
+
if self._clock() - stored_at >= self.ttl:
|
|
59
|
+
del self._entries[gstin]
|
|
60
|
+
return None
|
|
61
|
+
self._entries.move_to_end(gstin)
|
|
62
|
+
return details
|
|
63
|
+
|
|
64
|
+
def set(self, gstin: str, details: TaxpayerDetails) -> None:
|
|
65
|
+
with self._lock:
|
|
66
|
+
self._entries[gstin] = (self._clock(), details)
|
|
67
|
+
self._entries.move_to_end(gstin)
|
|
68
|
+
while len(self._entries) > self.maxsize:
|
|
69
|
+
self._entries.popitem(last=False)
|
|
70
|
+
|
|
71
|
+
def clear(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._entries.clear()
|
|
74
|
+
|
|
75
|
+
def __len__(self) -> int:
|
|
76
|
+
with self._lock:
|
|
77
|
+
return len(self._entries)
|
|
78
|
+
|
|
79
|
+
def __contains__(self, gstin: object) -> bool:
|
|
80
|
+
return isinstance(gstin, str) and self.get(gstin) is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NullCache:
|
|
84
|
+
"""Cache that never stores anything - pass it to disable caching."""
|
|
85
|
+
|
|
86
|
+
def get(self, gstin: str) -> TaxpayerDetails | None:
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def set(self, gstin: str, details: TaxpayerDetails) -> None:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
DEFAULT_CACHE: Final[TTLCache] = TTLCache()
|
|
94
|
+
"""Process-wide cache used by clients that are not given one explicitly."""
|
gst_validator/cli.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Command-line entry point: ``gst-validator <GSTIN>``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from .client import GSTClient
|
|
12
|
+
from .exceptions import GSTValidatorError
|
|
13
|
+
from .models import GSTIN, TaxpayerProfile
|
|
14
|
+
|
|
15
|
+
__all__ = ["main"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="gst-validator",
|
|
21
|
+
description="Validate a GSTIN and fetch taxpayer details from the GST portal.",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("gstin", help="the 15-character GSTIN to look up")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--offline",
|
|
26
|
+
action="store_true",
|
|
27
|
+
help="only validate the GSTIN format and checksum, no network call",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--json",
|
|
31
|
+
action="store_true",
|
|
32
|
+
dest="as_json",
|
|
33
|
+
help="print the modelled fields as JSON instead of a table",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--details-only",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="skip the captcha-free extras (HSN/SAC codes, years, filing preferences)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--raw",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="print the portal's response body verbatim, nothing dropped",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--captcha-path",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help="where to write the captcha image (default: a temporary file)",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--captcha-base64",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="print the captcha as a base64 data URI instead of writing a file",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--refresh",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="ignore any cached result for this GSTIN",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--keep-captcha",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="keep the captcha image on disk after it has been solved",
|
|
65
|
+
)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format(value: object) -> str:
|
|
70
|
+
if isinstance(value, list):
|
|
71
|
+
items: list[Any] = cast(list[Any], value) # type: ignore[redundant-cast]
|
|
72
|
+
return ", ".join(str(item) for item in items)
|
|
73
|
+
return str(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render(profile: TaxpayerProfile) -> str:
|
|
77
|
+
rows: list[tuple[str, str]] = [
|
|
78
|
+
(key.replace("_", " "), _format(value))
|
|
79
|
+
for key, value in profile.as_dict().items()
|
|
80
|
+
if value not in (None, [], "")
|
|
81
|
+
]
|
|
82
|
+
width = max((len(name) for name, _ in rows), default=0)
|
|
83
|
+
return "\n".join(f"{name:<{width}} {value}" for name, value in rows)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _lookup(client: GSTClient, gstin: GSTIN, args: argparse.Namespace) -> TaxpayerProfile:
|
|
87
|
+
"""Fetch a captcha, obtain its text, then look the GSTIN up."""
|
|
88
|
+
captcha = client.fetch_captcha()
|
|
89
|
+
written: Path | None = None
|
|
90
|
+
try:
|
|
91
|
+
if args.captcha_base64:
|
|
92
|
+
print(captcha.data_uri)
|
|
93
|
+
else:
|
|
94
|
+
written = Path(
|
|
95
|
+
args.captcha_path or Path(tempfile.gettempdir()) / f"{gstin}-captcha.png"
|
|
96
|
+
)
|
|
97
|
+
captcha.save(str(written))
|
|
98
|
+
print(f"captcha image written to {written}", file=sys.stderr)
|
|
99
|
+
solved = input("captcha text: ")
|
|
100
|
+
if args.details_only:
|
|
101
|
+
return TaxpayerProfile(details=client.fetch_details(gstin, solved, refresh=True))
|
|
102
|
+
return client.fetch_profile(gstin, solved, refresh=True)
|
|
103
|
+
finally:
|
|
104
|
+
# The image is single-use: discard it once the text has been read.
|
|
105
|
+
if written is not None and not args.keep_captcha:
|
|
106
|
+
written.unlink(missing_ok=True)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
110
|
+
args = _parser().parse_args(argv)
|
|
111
|
+
try:
|
|
112
|
+
gstin = GSTIN.parse(args.gstin)
|
|
113
|
+
except GSTValidatorError as error:
|
|
114
|
+
print(error, file=sys.stderr)
|
|
115
|
+
return 2
|
|
116
|
+
|
|
117
|
+
if args.offline:
|
|
118
|
+
if args.as_json:
|
|
119
|
+
print(
|
|
120
|
+
json.dumps({"gstin": gstin.value, "state_code": gstin.state_code, "pan": gstin.pan})
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
print(f"{gstin} is valid (state {gstin.state_code}, PAN {gstin.pan})")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with GSTClient() as client:
|
|
128
|
+
cached = None if args.refresh else client.cached(gstin)
|
|
129
|
+
if cached is None:
|
|
130
|
+
profile = _lookup(client, gstin, args)
|
|
131
|
+
elif args.details_only:
|
|
132
|
+
profile = TaxpayerProfile(details=cached)
|
|
133
|
+
else:
|
|
134
|
+
# The details were cached; the extras cost no captcha.
|
|
135
|
+
profile = TaxpayerProfile(
|
|
136
|
+
details=cached,
|
|
137
|
+
goods_and_services=client.fetch_goods_and_services(gstin),
|
|
138
|
+
financial_years=client.fetch_financial_years(gstin),
|
|
139
|
+
filing_preferences=client.fetch_filing_preferences(gstin),
|
|
140
|
+
)
|
|
141
|
+
if args.raw:
|
|
142
|
+
print(json.dumps(profile.details.raw, indent=2, ensure_ascii=False))
|
|
143
|
+
elif args.as_json:
|
|
144
|
+
print(json.dumps(profile.as_dict(), indent=2, ensure_ascii=False))
|
|
145
|
+
else:
|
|
146
|
+
print(_render(profile))
|
|
147
|
+
except GSTValidatorError as error:
|
|
148
|
+
print(error, file=sys.stderr)
|
|
149
|
+
return 1
|
|
150
|
+
except (EOFError, KeyboardInterrupt):
|
|
151
|
+
print("aborted", file=sys.stderr)
|
|
152
|
+
return 130
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
raise SystemExit(main())
|