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.
@@ -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"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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())