grip-client 0.1.1__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,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: grip-client
3
+ Version: 0.1.1
4
+ Summary: Python client for the GRIP API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: requests>=2.31
7
+ Provides-Extra: cachetools
8
+ Requires-Dist: cachetools>=5; extra == "cachetools"
9
+ Provides-Extra: redis
10
+ Requires-Dist: redis>=5; extra == "redis"
11
+ Provides-Extra: mmdb
12
+ Requires-Dist: filelock>=3; extra == "mmdb"
13
+ Requires-Dist: maxminddb>=2; extra == "mmdb"
14
+ Requires-Dist: pycountry>=24; extra == "mmdb"
15
+ Provides-Extra: cache
16
+ Requires-Dist: cachetools>=5; extra == "cache"
17
+ Requires-Dist: redis>=5; extra == "cache"
@@ -0,0 +1,74 @@
1
+ # grip-client
2
+
3
+ ## Python client for the GRIP API.
4
+
5
+ ```python
6
+ from grip_client.rest import GRIPClient
7
+
8
+ client = GRIPClient(token="token", timeout=5.0)
9
+ response = client.response_ip("8.8.8.8")
10
+
11
+ print(response.ip)
12
+ print(response.country_iso)
13
+ ```
14
+
15
+ #### Optional in-memory caching:
16
+
17
+ ```python
18
+ from grip_client.rest import GRIPClient, CachetoolsCache
19
+
20
+ client = GRIPClient(
21
+ token="token",
22
+ cache=CachetoolsCache(ttl=300, maxsize=1024),
23
+ )
24
+ response = client.response_ip("8.8.8.8")
25
+ ```
26
+
27
+ #### Optional Redis caching:
28
+
29
+ ```python
30
+ from grip_client.rest import GRIPClient, RedisCache
31
+
32
+ client = GRIPClient(
33
+ token="token",
34
+ cache=RedisCache.from_url("redis://localhost:6379/0", ttl=300),
35
+ )
36
+ response = client.response_ip("8.8.8.8")
37
+ ```
38
+
39
+ ## MMDB local lookup:
40
+
41
+ Features:
42
+
43
+ - MMDB downloads use the gzipped asset by default, and cache the decompressed `.mmdb` on disk.
44
+ - Files are updated asynchronously every hour.
45
+ - Reader is thread-safe
46
+
47
+ ```python
48
+ from grip_client.mmdb import GRIPMMDBClient
49
+
50
+ mmdb = GRIPMMDBClient(token="token")
51
+ res = mmdb.lookup("8.8.8.8")
52
+
53
+ print(
54
+ f"{res.asn.network_operator=}, "
55
+ f"{res.country.country_iso=}, "
56
+ f"{res.anonymous.is_anonymous=}"
57
+ )
58
+
59
+ ```
60
+
61
+ #### Individual MMDBs can be used directly:
62
+
63
+ ```python
64
+ from grip_client.mmdb import GRIPMMDB, GRIPMMDBKind
65
+
66
+ anonymous = GRIPMMDB(
67
+ kind=GRIPMMDBKind.GRIP_ANONYMOUS,
68
+ max_age_seconds=3600,
69
+ token="token"
70
+ )
71
+
72
+ if anonymous.is_anonymous("8.8.8.8"):
73
+ print("skip API lookup")
74
+ ```
@@ -0,0 +1,5 @@
1
+ from grip_client.mmdb import GRIPMMDBClient
2
+ from grip_client.rest import GRIPClient
3
+ from grip_client.enums import AccessType, OrgType
4
+
5
+ __all__ = ["GRIPClient", "GRIPMMDBClient", "AccessType", "OrgType"]
@@ -0,0 +1,33 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class AccessType(StrEnum):
5
+ """
6
+ How the network is used
7
+ """
8
+
9
+ # Consumer access networks, usually under OrgType.ISP
10
+ RESIDENTIAL = "residential" # cable/dsl/fiber to homes / CGNAT
11
+ MOBILE = "mobile" # cellular / CGNAT
12
+ SATELLITE = "satellite" # starlink, viasat, etc.
13
+
14
+ # Hosting infrastructure, usually under OrgType.HOSTING
15
+ DATACENTER = "datacenter" # cloud / hosting / colo
16
+
17
+ # Corporate, institutional, and internal networks. Typical OrgType BUSINESS, EDUCATION, GOVERNMENT
18
+ ENTERPRISE = "enterprise"
19
+
20
+ UNKNOWN = "unknown"
21
+
22
+
23
+ class OrgType(StrEnum):
24
+ """
25
+ What kind of organization owns the network/ASN/prefix
26
+ """
27
+
28
+ BUSINESS = "business"
29
+ EDUCATION = "education"
30
+ GOVERNMENT = "government"
31
+ HOSTING = "hosting"
32
+ ISP = "isp"
33
+ UNKNOWN = "unknown"
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class GRIPAPIError(Exception):
7
+ def __init__(
8
+ self,
9
+ *,
10
+ status_code: int | None,
11
+ message: str,
12
+ detail: Any = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.status_code = status_code
16
+ self.detail = detail
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("grip-client")
5
+ except PackageNotFoundError:
6
+ __version__ = "0+unknown"
7
+
8
+ USER_AGENT = f"grip-client/{__version__}"
@@ -0,0 +1,17 @@
1
+ from grip_client.mmdb.client import GRIPMMDB, GRIPMMDBClient, GRIPMMDBKind
2
+ from grip_client.mmdb.models import (
3
+ GRIPAnonymousRecord,
4
+ GRIPAsnRecord,
5
+ GRIPCountryRecord,
6
+ GRIPMMDBLookupResult,
7
+ )
8
+
9
+ __all__ = [
10
+ "GRIPAnonymousRecord",
11
+ "GRIPAsnRecord",
12
+ "GRIPCountryRecord",
13
+ "GRIPMMDB",
14
+ "GRIPMMDBClient",
15
+ "GRIPMMDBKind",
16
+ "GRIPMMDBLookupResult",
17
+ ]
@@ -0,0 +1,338 @@
1
+ from __future__ import annotations
2
+
3
+ import gzip
4
+ import logging
5
+ import os
6
+ import threading
7
+ import time
8
+ from concurrent.futures import Future, ThreadPoolExecutor
9
+ from email.utils import formatdate, parsedate_to_datetime
10
+ from enum import StrEnum
11
+ from pathlib import Path
12
+ from tempfile import NamedTemporaryFile
13
+ from typing import Any, Iterable
14
+ from ipaddress import IPv4Address, IPv6Address
15
+
16
+ import requests
17
+
18
+ from grip_client.metadata import USER_AGENT
19
+ from grip_client.exceptions import GRIPAPIError
20
+ from grip_client.mmdb.models import (
21
+ GRIPAnonymousRecord,
22
+ GRIPCountryRecord,
23
+ GRIPAsnRecord,
24
+ GRIPMMDBLookupResult,
25
+ )
26
+
27
+
28
+ class GRIPMMDBKind(StrEnum):
29
+ GRIP_ANONYMOUS = "grip_anonymous"
30
+ GRIP_COUNTRY = "grip_country"
31
+ GRIP_ASN = "grip_asn_accesstype"
32
+ GRIP_WHOIS = "grip_whois"
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class GRIPMMDBClient:
39
+ def __init__(
40
+ self,
41
+ kinds: Iterable[GRIPMMDBKind | str] = (
42
+ GRIPMMDBKind.GRIP_ANONYMOUS,
43
+ GRIPMMDBKind.GRIP_COUNTRY,
44
+ GRIPMMDBKind.GRIP_ASN,
45
+ ),
46
+ **mmdb_options: Any,
47
+ ) -> None:
48
+ self._dbs = {
49
+ (kind.value if isinstance(kind, GRIPMMDBKind) else kind): GRIPMMDB(
50
+ kind,
51
+ **mmdb_options,
52
+ )
53
+ for kind in kinds
54
+ }
55
+
56
+ def lookup(
57
+ self, ip_address: str | IPv4Address | IPv6Address
58
+ ) -> GRIPMMDBLookupResult:
59
+ raw = {kind: db.lookup(ip_address) for kind, db in self._dbs.items()}
60
+
61
+ return GRIPMMDBLookupResult(
62
+ anonymous=(
63
+ GRIPAnonymousRecord.from_dict(raw[GRIPMMDBKind.GRIP_ANONYMOUS])
64
+ if GRIPMMDBKind.GRIP_ANONYMOUS in raw
65
+ else None
66
+ ),
67
+ country=(
68
+ GRIPCountryRecord.from_dict(raw[GRIPMMDBKind.GRIP_COUNTRY])
69
+ if GRIPMMDBKind.GRIP_COUNTRY in raw
70
+ else None
71
+ ),
72
+ asn=(
73
+ GRIPAsnRecord.from_dict(raw[GRIPMMDBKind.GRIP_ASN])
74
+ if GRIPMMDBKind.GRIP_ASN in raw
75
+ else None
76
+ ),
77
+ raw=raw,
78
+ )
79
+
80
+ def refresh(self, *, block: bool = True):
81
+ futures = [db.refresh(block=False) for db in self._dbs.values()]
82
+ if not block:
83
+ return futures
84
+ return [future.result() for future in futures]
85
+
86
+ def close(self) -> None:
87
+ for db in self._dbs.values():
88
+ db.close()
89
+
90
+
91
+ class GRIPMMDB:
92
+ def __init__(
93
+ self,
94
+ kind: GRIPMMDBKind | str,
95
+ *,
96
+ base_url: str = "https://grip.net",
97
+ download_url: str | None = None,
98
+ cache_dir: str | Path | None = None,
99
+ max_age_seconds: int = 3600,
100
+ download_timeout: float | None = 60.0,
101
+ token: str | None = None,
102
+ download_on_init: bool = True,
103
+ ) -> None:
104
+ self.kind = kind.value if isinstance(kind, GRIPMMDBKind) else kind
105
+ self.filename = f"{self.kind}.mmdb"
106
+ self.download_filename = f"{self.filename}.gz"
107
+ self.base_url = base_url.rstrip("/")
108
+ self.download_url = (
109
+ download_url or f"{self.base_url}/files/{self.download_filename}"
110
+ )
111
+ self.cache_dir = (
112
+ Path(cache_dir) if cache_dir is not None else _default_cache_dir()
113
+ )
114
+ self.max_age_seconds = max_age_seconds
115
+ self.download_timeout = download_timeout
116
+ self.lock_timeout_seconds = download_timeout
117
+ self.token = token
118
+ self.path = self.cache_dir / self.filename
119
+ self.lock_path = self.cache_dir / f".{self.filename}.lock"
120
+
121
+ self._executor = ThreadPoolExecutor(max_workers=1)
122
+ self._future: Future[Path] | None = None
123
+ self._lock = threading.Lock()
124
+ self._reader: Any | None = None
125
+ self._reader_mtime_ns: int | None = None
126
+ self._reader_lock = threading.RLock()
127
+ self._remote_last_checked_at: float | None = None
128
+
129
+ if download_on_init and self.is_stale():
130
+ self.refresh(block=False)
131
+
132
+ def __enter__(self) -> "GRIPMMDB":
133
+ return self
134
+
135
+ def __exit__(self, *args: object) -> None:
136
+ self.close()
137
+
138
+ def lookup(
139
+ self, ip_address: str | IPv4Address | IPv6Address
140
+ ) -> dict[str, Any] | None:
141
+ self._ensure_available()
142
+ with self._reader_lock:
143
+ result = self._get_reader().get(ip_address)
144
+
145
+ if result is None:
146
+ return None
147
+ if not isinstance(result, dict):
148
+ raise ValueError(f"MMDB lookup returned a non-object value for {ip_address}")
149
+ return result
150
+
151
+ def is_anonymous(self, ip_address: str | IPv4Address | IPv6Address) -> bool:
152
+ return self.anonymous_lookup(ip_address).is_anonymous
153
+
154
+ def country_iso(self, ip_address: str | IPv4Address | IPv6Address) -> str | None:
155
+ return self.country_lookup(ip_address).country_iso
156
+
157
+ def anonymous_lookup(
158
+ self, ip_address: str | IPv4Address | IPv6Address
159
+ ) -> GRIPAnonymousRecord:
160
+ if self.kind != GRIPMMDBKind.GRIP_ANONYMOUS.value:
161
+ raise ValueError(
162
+ f"{GRIPMMDBKind.GRIP_ANONYMOUS.value} MMDB required, got {self.kind!r}."
163
+ )
164
+ result = self.lookup(ip_address)
165
+ return GRIPAnonymousRecord.from_dict(result)
166
+
167
+ def country_lookup(
168
+ self, ip_address: str | IPv4Address | IPv6Address
169
+ ) -> GRIPCountryRecord:
170
+ if self.kind != GRIPMMDBKind.GRIP_COUNTRY.value:
171
+ raise ValueError(
172
+ f"{GRIPMMDBKind.GRIP_COUNTRY.value} MMDB required, got {self.kind!r}."
173
+ )
174
+ result = self.lookup(ip_address)
175
+ return GRIPCountryRecord.from_dict(result)
176
+
177
+ def asn_lookup(self, ip_address: str | IPv4Address | IPv6Address) -> GRIPAsnRecord:
178
+ if self.kind != GRIPMMDBKind.GRIP_ASN.value:
179
+ raise ValueError(
180
+ f"{GRIPMMDBKind.GRIP_ASN.value} MMDB required, " f"got {self.kind!r}."
181
+ )
182
+ result = self.lookup(ip_address)
183
+ return GRIPAsnRecord.from_dict(result)
184
+
185
+ def is_stale(self) -> bool:
186
+ if not self.path.exists():
187
+ return True
188
+ age_seconds = time.time() - self.path.stat().st_mtime
189
+ return age_seconds > self.max_age_seconds
190
+
191
+ def refresh(self, *, block: bool = True) -> Path | Future[Path]:
192
+ future = self._ensure_download_started()
193
+ if block:
194
+ return future.result()
195
+ return future
196
+
197
+ def close(self) -> None:
198
+ self._close_reader()
199
+ self._executor.shutdown(wait=False, cancel_futures=False)
200
+
201
+ def _ensure_available(self) -> None:
202
+ if self.path.exists() and self.path.stat().st_size > 0:
203
+ if self.is_stale() and self._should_check_remote():
204
+ self.refresh(block=False)
205
+ return
206
+ logger.warning("Downloading mmdb file...")
207
+ self.refresh(block=True)
208
+
209
+ def _should_check_remote(self) -> bool:
210
+ # If file on disk is older than max_age_seconds, but the remote
211
+ # file isn't updated, check remote at most every 60 seconds.
212
+ now = time.time()
213
+ if (
214
+ self._remote_last_checked_at is not None
215
+ and now - self._remote_last_checked_at < 60
216
+ ):
217
+ return False
218
+
219
+ self._remote_last_checked_at = now
220
+ return True
221
+
222
+ def _ensure_download_started(self) -> Future[Path]:
223
+ with self._lock:
224
+ if self._future is None or self._future.done():
225
+ self._future = self._executor.submit(self._download)
226
+ return self._future
227
+
228
+ def _download(self) -> Path:
229
+ try:
230
+ from filelock import FileLock
231
+ except ImportError as error:
232
+ raise ImportError(
233
+ "Install grip-client[mmdb] to use MMDB download locking."
234
+ ) from error
235
+
236
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
237
+ tmp_path: Path | None = None
238
+ headers = {
239
+ "Accept": "application/octet-stream",
240
+ "User-Agent": USER_AGENT,
241
+ }
242
+ if self.token:
243
+ headers["Authorization"] = f"Bearer {self.token}"
244
+
245
+ try:
246
+ with FileLock(str(self.lock_path), timeout=self.lock_timeout_seconds):
247
+ if self.path.exists() and self.path.stat().st_size > 0:
248
+ headers["If-Modified-Since"] = formatdate(
249
+ self.path.stat().st_mtime,
250
+ usegmt=True,
251
+ )
252
+
253
+ with requests.get(
254
+ self.download_url,
255
+ headers=headers,
256
+ stream=True,
257
+ timeout=self.download_timeout,
258
+ ) as response:
259
+ if response.status_code == 304:
260
+ self._validate_file(self.path)
261
+ return self.path
262
+
263
+ response.raise_for_status()
264
+ last_modified = response.headers.get("Last-Modified")
265
+
266
+ with NamedTemporaryFile(
267
+ dir=self.cache_dir,
268
+ prefix=f".{self.filename}.",
269
+ delete=False,
270
+ ) as tmp:
271
+ tmp_path = Path(tmp.name)
272
+ with gzip.GzipFile(fileobj=response.raw, mode="rb") as gzipped:
273
+ while True:
274
+ chunk = gzipped.read(1024 * 1024)
275
+ if not chunk:
276
+ break
277
+ tmp.write(chunk)
278
+
279
+ self._validate_file(tmp_path)
280
+ os.replace(tmp_path, self.path)
281
+ if last_modified is not None:
282
+ try:
283
+ modified_at = parsedate_to_datetime(last_modified).timestamp()
284
+ os.utime(self.path, (modified_at, modified_at))
285
+ except (TypeError, ValueError, OSError, OverflowError):
286
+ pass
287
+ self._close_reader()
288
+ return self.path
289
+ except requests.RequestException as error:
290
+ response = getattr(error, "response", None)
291
+ raise GRIPAPIError(
292
+ status_code=response.status_code if response is not None else None,
293
+ message=(
294
+ f"GRIP MMDB download failed with HTTP {response.status_code}"
295
+ if response is not None
296
+ else "GRIP MMDB download failed"
297
+ ),
298
+ detail=response.text if response is not None else str(error),
299
+ ) from error
300
+ finally:
301
+ if tmp_path is not None and tmp_path.exists():
302
+ tmp_path.unlink()
303
+
304
+ def _get_reader(self) -> Any:
305
+ self._validate_file(self.path)
306
+ current_mtime_ns = self.path.stat().st_mtime_ns
307
+ if self._reader is not None and self._reader_mtime_ns == current_mtime_ns:
308
+ return self._reader
309
+
310
+ self._close_reader()
311
+
312
+ try:
313
+ import maxminddb
314
+ except ImportError as error:
315
+ raise ImportError(
316
+ "Install grip-client[mmdb] to use GRIPMMDB lookups."
317
+ ) from error
318
+
319
+ self._reader = maxminddb.open_database(self.path)
320
+ self._reader_mtime_ns = current_mtime_ns
321
+ return self._reader
322
+
323
+ def _close_reader(self) -> None:
324
+ with self._reader_lock:
325
+ if self._reader is not None:
326
+ self._reader.close()
327
+ self._reader = None
328
+ self._reader_mtime_ns = None
329
+
330
+ def _validate_file(self, path: Path) -> None:
331
+ if not path.exists():
332
+ raise FileNotFoundError(f"MMDB file does not exist: {path}")
333
+ if path.stat().st_size <= 0:
334
+ raise ValueError(f"MMDB file is empty: {path}")
335
+
336
+
337
+ def _default_cache_dir() -> Path:
338
+ return Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "grip-client"
@@ -0,0 +1,104 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+ from grip_client.enums import AccessType
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class GRIPAnonymousRecord:
9
+ is_cloud: bool = False
10
+ is_proxy: bool = False
11
+ is_vpn: bool = False
12
+ is_org_hosting: bool = False
13
+ is_asn_hosting: bool = False
14
+ is_access_type_hosting: bool = False
15
+ is_density_anon: bool = False
16
+ is_anonymous: bool = False
17
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
18
+
19
+ @classmethod
20
+ def from_dict(cls, data: dict[str, Any] | None) -> "GRIPAnonymousRecord":
21
+ if data is None:
22
+ return cls()
23
+
24
+ return cls(
25
+ is_cloud=bool(data.get("is_cloud", False)),
26
+ is_proxy=bool(data.get("is_proxy", False)),
27
+ is_vpn=bool(data.get("is_vpn", False)),
28
+ is_org_hosting=bool(data.get("is_org_hosting", False)),
29
+ is_asn_hosting=bool(data.get("is_asn_hosting", False)),
30
+ is_access_type_hosting=bool(data.get("is_access_type_hosting", False)),
31
+ is_density_anon=bool(data.get("is_density_anon", False)),
32
+ is_anonymous=bool(data.get("is_anonymous", False)),
33
+ raw=data,
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class GRIPCountryRecord:
39
+ geofeed_country_iso: str | None = None
40
+ registered_country_iso: str | None = None
41
+ announced_country_iso: str | None = None
42
+ country_iso: str | None = None
43
+ source: str | None = None
44
+ geofeed_conflict: bool = False
45
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
46
+
47
+ @classmethod
48
+ def from_dict(cls, data: dict[str, Any] | None) -> "GRIPCountryRecord":
49
+ if data is None:
50
+ return cls()
51
+
52
+ return cls(
53
+ geofeed_country_iso=data.get("geofeed_country_iso") or None,
54
+ registered_country_iso=data.get("registered_country_iso") or None,
55
+ announced_country_iso=data.get("announced_country_iso") or None,
56
+ country_iso=data.get("country_iso") or None,
57
+ source=data.get("source") or None,
58
+ geofeed_conflict=bool(data.get("geofeed_conflict", False)),
59
+ raw=data,
60
+ )
61
+
62
+ @property
63
+ def country(self) -> Any | None:
64
+ if self.country_iso is None:
65
+ return None
66
+
67
+ try:
68
+ import pycountry
69
+ except ImportError as error:
70
+ raise ImportError(
71
+ "Install grip-client[mmdb] to use GRIPCountryRecord.country."
72
+ ) from error
73
+
74
+ return pycountry.countries.get(alpha_2=self.country_iso)
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class GRIPAsnRecord:
79
+ asn: int | None = None
80
+ network_operator: str | None= None
81
+ access_type: AccessType | None= None
82
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
83
+
84
+ @classmethod
85
+ def from_dict(cls, data: dict[str, Any] | None) -> "GRIPAsnRecord":
86
+ if data is None:
87
+ return cls()
88
+ return cls(
89
+ asn=data.get("asn") or None,
90
+ network_operator=data.get("network_operator") or None,
91
+ access_type=(
92
+ AccessType(data["access_type"]) if data.get("access_type") else None
93
+ ),
94
+ raw=data,
95
+ )
96
+
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class GRIPMMDBLookupResult:
101
+ anonymous: GRIPAnonymousRecord | None = None
102
+ country: GRIPCountryRecord | None = None
103
+ asn: GRIPAsnRecord | None = None
104
+ raw: dict[str, dict[str, Any] | None] = field(default_factory=dict)
@@ -0,0 +1,13 @@
1
+ from grip_client.rest.cache import CacheBackend, CachetoolsCache, MappingCache, RedisCache
2
+ from grip_client.rest.client import GRIPClient
3
+ from grip_client.rest.models import GRIPIpResponse, LocationFlat
4
+
5
+ __all__ = [
6
+ "CacheBackend",
7
+ "CachetoolsCache",
8
+ "GRIPClient",
9
+ "GRIPIpResponse",
10
+ "LocationFlat",
11
+ "MappingCache",
12
+ "RedisCache",
13
+ ]
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import MutableMapping
5
+ from typing import Any, Protocol
6
+
7
+
8
+ JsonObject = dict[str, Any]
9
+
10
+
11
+ class CacheBackend(Protocol):
12
+ def get(self, key: str) -> JsonObject | None:
13
+ ...
14
+
15
+ def set(self, key: str, value: JsonObject) -> None:
16
+ ...
17
+
18
+
19
+ class MappingCache:
20
+ def __init__(self, cache: MutableMapping[str, JsonObject] | None = None) -> None:
21
+ self.cache: MutableMapping[str, JsonObject] = cache if cache is not None else {}
22
+
23
+ def get(self, key: str) -> JsonObject | None:
24
+ return self.cache.get(key)
25
+
26
+ def set(self, key: str, value: JsonObject) -> None:
27
+ self.cache[key] = value
28
+
29
+
30
+ class CachetoolsCache(MappingCache):
31
+ def __init__(self, *, ttl: int = 300, maxsize: int = 1024) -> None:
32
+ try:
33
+ from cachetools import TTLCache
34
+ except ImportError as error:
35
+ raise ImportError(
36
+ "Install grip-client[cachetools] to use CachetoolsCache."
37
+ ) from error
38
+
39
+ super().__init__(TTLCache(maxsize=maxsize, ttl=ttl))
40
+
41
+
42
+ class RedisCache:
43
+ def __init__(
44
+ self,
45
+ redis_client: Any,
46
+ *,
47
+ ttl: int = 300,
48
+ prefix: str = "grip-client",
49
+ ) -> None:
50
+ self.redis_client = redis_client
51
+ self.ttl = ttl
52
+ self.prefix = prefix.rstrip(":")
53
+
54
+ @classmethod
55
+ def from_url(
56
+ cls,
57
+ url: str,
58
+ *,
59
+ ttl: int = 300,
60
+ prefix: str = "grip-client",
61
+ **kwargs: Any,
62
+ ) -> "RedisCache":
63
+ try:
64
+ from redis import Redis
65
+ except ImportError as error:
66
+ raise ImportError("Install grip-client[redis] to use RedisCache.") from error
67
+
68
+ return cls(Redis.from_url(url, **kwargs), ttl=ttl, prefix=prefix)
69
+
70
+ def get(self, key: str) -> JsonObject | None:
71
+ value = self.redis_client.get(self._key(key))
72
+ if value is None:
73
+ return None
74
+ if isinstance(value, bytes):
75
+ value = value.decode("utf-8")
76
+
77
+ parsed = json.loads(value)
78
+ if not isinstance(parsed, dict):
79
+ return None
80
+ return parsed
81
+
82
+ def set(self, key: str, value: JsonObject) -> None:
83
+ payload = json.dumps(value, separators=(",", ":"))
84
+ self.redis_client.setex(self._key(key), self.ttl, payload)
85
+
86
+ def _key(self, key: str) -> str:
87
+ return f"{self.prefix}:{key}"
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from urllib.parse import quote
5
+
6
+ import requests
7
+
8
+ from grip_client.metadata import USER_AGENT
9
+ from grip_client.exceptions import GRIPAPIError
10
+ from grip_client.rest.cache import CacheBackend
11
+ from grip_client.rest.models import GRIPIpResponse
12
+
13
+
14
+ class GRIPClient:
15
+
16
+ def __init__(
17
+ self,
18
+ token: str | None = None,
19
+ *,
20
+ base_url: str = "https://grip.net",
21
+ timeout: float | None = 30.0,
22
+ cache: CacheBackend | None = None,
23
+ ) -> None:
24
+ self.base_url = base_url.rstrip("/")
25
+ self.token = token
26
+ self.timeout = timeout
27
+ self.cache = cache
28
+
29
+ def response_ip(self, ip_address: str) -> GRIPIpResponse:
30
+ data = self._request_json(
31
+ "GET",
32
+ f"/ip/{quote(ip_address, safe='')}",
33
+ cacheable=True,
34
+ )
35
+ return GRIPIpResponse.from_dict(data)
36
+
37
+ def get_ip(self, ip_address: str) -> GRIPIpResponse:
38
+ return self.response_ip(ip_address)
39
+
40
+ def _request_json(
41
+ self,
42
+ method: str,
43
+ path: str,
44
+ *,
45
+ cacheable: bool = False,
46
+ ) -> dict[str, Any]:
47
+ cache_key = self._cache_key(method, path)
48
+ if cacheable and self.cache is not None:
49
+ cached = self.cache.get(cache_key)
50
+ if cached is not None:
51
+ return cached
52
+ headers = {
53
+ "Accept": "application/json",
54
+ "User-Agent": USER_AGENT,
55
+ }
56
+ if self.token:
57
+ headers["Authorization"] = f"Bearer {self.token}"
58
+
59
+ try:
60
+ response = requests.request(
61
+ method=method,
62
+ url=f"{self.base_url}{path}",
63
+ headers=headers,
64
+ timeout=self.timeout,
65
+ )
66
+ except requests.RequestException as error:
67
+ raise GRIPAPIError(
68
+ status_code=None,
69
+ message="GRIP API request failed",
70
+ detail=str(error),
71
+ ) from error
72
+
73
+ if response.status_code != 200:
74
+ raise self._api_error(response)
75
+
76
+ if not response.content:
77
+ return {}
78
+
79
+ try:
80
+ parsed = response.json()
81
+ except ValueError as error:
82
+ raise GRIPAPIError(
83
+ status_code=response.status_code,
84
+ message="GRIP API returned invalid JSON",
85
+ ) from error
86
+
87
+ if not isinstance(parsed, dict):
88
+ raise GRIPAPIError(
89
+ status_code=None,
90
+ message="GRIP API returned a non-object JSON response",
91
+ )
92
+
93
+ if cacheable and self.cache is not None:
94
+ self.cache.set(cache_key, parsed)
95
+
96
+ return parsed
97
+
98
+ def _cache_key(self, method: str, path: str) -> str:
99
+ return f"{method.upper()} {path}"
100
+
101
+ def _api_error(self, response: requests.Response) -> GRIPAPIError:
102
+ try:
103
+ detail: Any = response.json()
104
+ except ValueError:
105
+ detail = response.text
106
+
107
+ return GRIPAPIError(
108
+ status_code=response.status_code,
109
+ message=f"GRIP API request failed with HTTP {response.status_code}",
110
+ detail=detail,
111
+ )
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from grip_client.enums import AccessType, OrgType
7
+
8
+ JsonObject = dict[str, Any]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class LocationFlat:
13
+ geoname_id: int | None
14
+
15
+ continent_iso: str | None = None
16
+ continent_name: str | None = None
17
+
18
+ country_iso: str | None = None
19
+ country_name: str | None = None
20
+
21
+ subdivision_1_iso: str | None = None
22
+ subdivision_1_name: str | None = None
23
+ subdivision_2_iso: str | None = None
24
+ subdivision_2_name: str | None = None
25
+
26
+ city_name: str | None = None
27
+ metro_name: str | None = None
28
+
29
+ timezone: str | None = None
30
+ is_in_european_union: bool | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class GRIPIpResponse:
35
+ ip: str
36
+ announced_prefix: JsonObject | None
37
+ whois_registration: JsonObject
38
+ location: JsonObject
39
+ ip_exploded: str
40
+ country_iso: str | None
41
+ is_bogon: bool = False
42
+ is_anycast: bool = False
43
+ is_crawler: bool = False
44
+ is_abuser: bool = False
45
+ is_cgnat: bool = False
46
+ is_anonymous: bool = False
47
+ access_type: AccessType | None = None
48
+ predictions: JsonObject = field(default_factory=dict)
49
+ privacy: JsonObject = field(default_factory=dict)
50
+ cloud: list[JsonObject] = field(default_factory=list)
51
+ relay: list[JsonObject] = field(default_factory=list)
52
+ vpn: list[JsonObject] = field(default_factory=list)
53
+ proxy: list[JsonObject] = field(default_factory=list)
54
+ tags: list[JsonObject] = field(default_factory=list)
55
+ dns: JsonObject | None = None
56
+ probe: JsonObject | None = None
57
+ raw: JsonObject = field(default_factory=dict, repr=False, compare=False)
58
+
59
+ @property
60
+ def isp(self) -> str | None:
61
+ return self.announced_prefix.get("owner_name", None)
62
+
63
+ @property
64
+ def company(self) -> str | None:
65
+ return self.whois_registration["network"].get("owner_name", None)
66
+
67
+ @property
68
+ def org_type(self) -> OrgType | None:
69
+ org_type = self.announced_prefix.get("organization", {}).get("org_type", None)
70
+ return OrgType(org_type) if org_type else None
71
+
72
+ @property
73
+ def is_anonymous_vpn(self) -> bool:
74
+ return self.is_anonymous and len(self.vpn) > 0
75
+
76
+ @property
77
+ def is_hosting(self) -> bool:
78
+ return self.org_type == OrgType.HOSTING or len(self.cloud) > 0
79
+
80
+ @property
81
+ def is_private_relay(self) -> bool:
82
+ return len(self.relay) > 0
83
+
84
+ @property
85
+ def is_residential_proxy(self) -> bool:
86
+ return self.is_anonymous and len(self.proxy) > 0
87
+
88
+ @property
89
+ def is_like_residential_proxy(self) -> bool:
90
+ return self.is_residential_proxy or self.privacy.get(
91
+ "residential_proxy_density_30d_base_network", 0
92
+ ) > (1 / 3)
93
+
94
+ @property
95
+ def location_flat(self) -> LocationFlat:
96
+ scope = self.location.get("scope")
97
+ if scope is None:
98
+ return LocationFlat(geoname_id=None)
99
+ loc_scope = self.location[scope]
100
+ loc = self.location
101
+ return LocationFlat(
102
+ geoname_id=loc_scope["geoname_id"],
103
+ continent_iso=loc["continent"].get("iso_code") if loc["continent"] else None,
104
+ continent_name=loc["continent"].get("name") if loc["continent"] else None,
105
+ country_iso=loc["country"].get("iso_code") if loc["country"] else None,
106
+ country_name=loc["country"].get("name") if loc["country"] else None,
107
+ subdivision_1_iso=(
108
+ loc["subdivision_1"].get("iso_code") if loc["subdivision_1"] else None
109
+ ),
110
+ subdivision_1_name=(
111
+ loc["subdivision_1"].get("name") if loc["subdivision_1"] else None
112
+ ),
113
+ subdivision_2_iso=(
114
+ loc["subdivision_2"].get("iso_code") if loc["subdivision_2"] else None
115
+ ),
116
+ subdivision_2_name=(
117
+ loc["subdivision_2"].get("name") if loc["subdivision_2"] else None
118
+ ),
119
+ city_name=loc["city"].get("name") if loc["city"] else None,
120
+ metro_name=loc["metro"].get("name") if loc["metro"] else None,
121
+ timezone=loc["timezone"],
122
+ # todo: ---v
123
+ is_in_european_union=loc.get("is_in_european_union", None),
124
+ )
125
+
126
+ @classmethod
127
+ def from_dict(cls, data: JsonObject) -> "GRIPIpResponse":
128
+ return cls(
129
+ ip=data["ip"],
130
+ announced_prefix=data.get("announced_prefix", None),
131
+ whois_registration=data["whois_registration"],
132
+ location=data["location"],
133
+ ip_exploded=data["ip_exploded"],
134
+ country_iso=data["country_iso"],
135
+ access_type=AccessType(data["access_type"]),
136
+ is_bogon=bool(data.get("is_bogon", False)),
137
+ is_anycast=bool(data.get("is_anycast", False)),
138
+ is_crawler=bool(data.get("is_crawler", False)),
139
+ is_abuser=bool(data.get("is_abuser", False)),
140
+ is_cgnat=bool(data.get("is_cgnat", False)),
141
+ is_anonymous=bool(data.get("is_anonymous", False)),
142
+ predictions=data.get("predictions", {}),
143
+ privacy=data.get("privacy", {}),
144
+ cloud=data.get("cloud", []),
145
+ relay=data.get("relay", []),
146
+ vpn=data.get("vpn", []),
147
+ proxy=data.get("proxy", []),
148
+ tags=data.get("tags", []),
149
+ dns=data.get("dns", None),
150
+ probe=data.get("probe", None),
151
+ raw=data,
152
+ )
@@ -0,0 +1,17 @@
1
+ Metadata-Version: 2.4
2
+ Name: grip-client
3
+ Version: 0.1.1
4
+ Summary: Python client for the GRIP API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: requests>=2.31
7
+ Provides-Extra: cachetools
8
+ Requires-Dist: cachetools>=5; extra == "cachetools"
9
+ Provides-Extra: redis
10
+ Requires-Dist: redis>=5; extra == "redis"
11
+ Provides-Extra: mmdb
12
+ Requires-Dist: filelock>=3; extra == "mmdb"
13
+ Requires-Dist: maxminddb>=2; extra == "mmdb"
14
+ Requires-Dist: pycountry>=24; extra == "mmdb"
15
+ Provides-Extra: cache
16
+ Requires-Dist: cachetools>=5; extra == "cache"
17
+ Requires-Dist: redis>=5; extra == "cache"
@@ -0,0 +1,18 @@
1
+ README.md
2
+ pyproject.toml
3
+ grip_client/__init__.py
4
+ grip_client/enums.py
5
+ grip_client/exceptions.py
6
+ grip_client/metadata.py
7
+ grip_client.egg-info/PKG-INFO
8
+ grip_client.egg-info/SOURCES.txt
9
+ grip_client.egg-info/dependency_links.txt
10
+ grip_client.egg-info/requires.txt
11
+ grip_client.egg-info/top_level.txt
12
+ grip_client/mmdb/__init__.py
13
+ grip_client/mmdb/client.py
14
+ grip_client/mmdb/models.py
15
+ grip_client/rest/__init__.py
16
+ grip_client/rest/cache.py
17
+ grip_client/rest/client.py
18
+ grip_client/rest/models.py
@@ -0,0 +1,16 @@
1
+ requests>=2.31
2
+
3
+ [cache]
4
+ cachetools>=5
5
+ redis>=5
6
+
7
+ [cachetools]
8
+ cachetools>=5
9
+
10
+ [mmdb]
11
+ filelock>=3
12
+ maxminddb>=2
13
+ pycountry>=24
14
+
15
+ [redis]
16
+ redis>=5
@@ -0,0 +1,2 @@
1
+ dist
2
+ grip_client
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "grip-client"
7
+ version = "0.1.1"
8
+ description = "Python client for the GRIP API"
9
+ requires-python = ">=3.10"
10
+ dependencies = ["requests>=2.31"]
11
+
12
+ [project.optional-dependencies]
13
+ cachetools = ["cachetools>=5"]
14
+ redis = ["redis>=5"]
15
+ mmdb = ["filelock>=3", "maxminddb>=2", "pycountry>=24"]
16
+ cache = ["cachetools>=5", "redis>=5"]
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+