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.
- grip_client-0.1.1/PKG-INFO +17 -0
- grip_client-0.1.1/README.md +74 -0
- grip_client-0.1.1/grip_client/__init__.py +5 -0
- grip_client-0.1.1/grip_client/enums.py +33 -0
- grip_client-0.1.1/grip_client/exceptions.py +16 -0
- grip_client-0.1.1/grip_client/metadata.py +8 -0
- grip_client-0.1.1/grip_client/mmdb/__init__.py +17 -0
- grip_client-0.1.1/grip_client/mmdb/client.py +338 -0
- grip_client-0.1.1/grip_client/mmdb/models.py +104 -0
- grip_client-0.1.1/grip_client/rest/__init__.py +13 -0
- grip_client-0.1.1/grip_client/rest/cache.py +87 -0
- grip_client-0.1.1/grip_client/rest/client.py +111 -0
- grip_client-0.1.1/grip_client/rest/models.py +152 -0
- grip_client-0.1.1/grip_client.egg-info/PKG-INFO +17 -0
- grip_client-0.1.1/grip_client.egg-info/SOURCES.txt +18 -0
- grip_client-0.1.1/grip_client.egg-info/dependency_links.txt +1 -0
- grip_client-0.1.1/grip_client.egg-info/requires.txt +16 -0
- grip_client-0.1.1/grip_client.egg-info/top_level.txt +2 -0
- grip_client-0.1.1/pyproject.toml +19 -0
- grip_client-0.1.1/setup.cfg +4 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -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 = ["."]
|