ipdatainfo 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.
- ipdatainfo/__init__.py +36 -0
- ipdatainfo/client.py +161 -0
- ipdatainfo/errors.py +18 -0
- ipdatainfo/models.py +286 -0
- ipdatainfo/py.typed +0 -0
- ipdatainfo-0.1.0.dist-info/METADATA +111 -0
- ipdatainfo-0.1.0.dist-info/RECORD +9 -0
- ipdatainfo-0.1.0.dist-info/WHEEL +4 -0
- ipdatainfo-0.1.0.dist-info/licenses/LICENSE +21 -0
ipdatainfo/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Official Python client for the ipdata.info IP geolocation, ASN, and
|
|
2
|
+
threat-intelligence API. See https://ipdata.info.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .client import DEFAULT_BASE_URL, PRO_BASE_URL, Client, __version__
|
|
6
|
+
from .errors import IpDataError
|
|
7
|
+
from .models import (
|
|
8
|
+
ASNBrief,
|
|
9
|
+
Connection,
|
|
10
|
+
Currency,
|
|
11
|
+
Flag,
|
|
12
|
+
GeoInfo,
|
|
13
|
+
IPInfo,
|
|
14
|
+
Network,
|
|
15
|
+
Security,
|
|
16
|
+
ThreatMatch,
|
|
17
|
+
TimeZone,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Client",
|
|
22
|
+
"IpDataError",
|
|
23
|
+
"DEFAULT_BASE_URL",
|
|
24
|
+
"PRO_BASE_URL",
|
|
25
|
+
"__version__",
|
|
26
|
+
"IPInfo",
|
|
27
|
+
"GeoInfo",
|
|
28
|
+
"ASNBrief",
|
|
29
|
+
"ThreatMatch",
|
|
30
|
+
"Network",
|
|
31
|
+
"Flag",
|
|
32
|
+
"Connection",
|
|
33
|
+
"TimeZone",
|
|
34
|
+
"Currency",
|
|
35
|
+
"Security",
|
|
36
|
+
]
|
ipdatainfo/client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""HTTP client for the ipdata.info API.
|
|
2
|
+
|
|
3
|
+
Uses only the standard library (``urllib.request``/``json``) so the package
|
|
4
|
+
has zero required runtime dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .errors import IpDataError
|
|
16
|
+
from .models import ASNBrief, GeoInfo, IPInfo, ThreatMatch
|
|
17
|
+
|
|
18
|
+
#: Free tier host (50 req/min, no key).
|
|
19
|
+
DEFAULT_BASE_URL = "https://ipdata.info"
|
|
20
|
+
#: Paid tier host, used automatically once an API key is set (unless the
|
|
21
|
+
#: caller overrides ``base_url`` explicitly).
|
|
22
|
+
PRO_BASE_URL = "https://pro.ipdata.info"
|
|
23
|
+
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
_USER_AGENT = f"ipdatainfo-python/{__version__}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Client:
|
|
29
|
+
"""ipdata.info API client.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
api_key: Optional API key, sent as the ``X-Api-Key`` header. When
|
|
33
|
+
set and ``base_url`` is not overridden, the client targets the
|
|
34
|
+
pro tier host automatically.
|
|
35
|
+
base_url: API host, no trailing slash. Defaults to the free tier
|
|
36
|
+
(``https://ipdata.info``), or the pro tier when ``api_key`` is
|
|
37
|
+
set.
|
|
38
|
+
timeout: Request timeout in seconds. Defaults to 10.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
base_url: str | None = None,
|
|
45
|
+
timeout: float = 10.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.api_key = api_key
|
|
48
|
+
if base_url is not None:
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
elif api_key:
|
|
51
|
+
self.base_url = PRO_BASE_URL
|
|
52
|
+
else:
|
|
53
|
+
self.base_url = DEFAULT_BASE_URL
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
|
|
56
|
+
# -- lookup family -----------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def lookup(self, ip: str = "") -> IPInfo:
|
|
59
|
+
"""Return the full record for ``ip``. Empty string = caller's own IP."""
|
|
60
|
+
data = self._get(f"/json/{urllib.parse.quote(ip, safe='')}")
|
|
61
|
+
return IPInfo.from_dict(data)
|
|
62
|
+
|
|
63
|
+
def geo(self, ip: str) -> GeoInfo:
|
|
64
|
+
"""Return the compact geo subset for ``ip``."""
|
|
65
|
+
data = self._get(f"/api/v1/{urllib.parse.quote(ip, safe='')}/geo")
|
|
66
|
+
return GeoInfo.from_dict(data)
|
|
67
|
+
|
|
68
|
+
def asn(self, ip: str) -> ASNBrief:
|
|
69
|
+
"""Return the compact ASN subset for ``ip``."""
|
|
70
|
+
data = self._get(f"/api/v1/{urllib.parse.quote(ip, safe='')}/asn")
|
|
71
|
+
return ASNBrief.from_dict(data)
|
|
72
|
+
|
|
73
|
+
def batch(self, ips: list[str]) -> list[IPInfo]:
|
|
74
|
+
"""Look up many IPs at once. Requires an API key (paid tier)."""
|
|
75
|
+
data = self._post("/api/v1/batch", ips)
|
|
76
|
+
if not isinstance(data, list):
|
|
77
|
+
raise IpDataError(status=200, message="unexpected batch response shape")
|
|
78
|
+
return [IPInfo.from_dict(item) for item in data]
|
|
79
|
+
|
|
80
|
+
# -- ASN metadata --------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def asn_detail(self, number: int) -> dict[str, Any]:
|
|
83
|
+
"""Return the detailed ASN record (prefixes, peering) as raw JSON."""
|
|
84
|
+
return self._get(f"/api/v1/asn/{number}")
|
|
85
|
+
|
|
86
|
+
def asn_whois_history(self, number: int) -> dict[str, Any]:
|
|
87
|
+
"""Return the ASN whois history as raw JSON."""
|
|
88
|
+
return self._get(f"/api/v1/asn/{number}/whois-history")
|
|
89
|
+
|
|
90
|
+
def asn_changes(self) -> Any:
|
|
91
|
+
"""Return the ASN change feed as raw JSON."""
|
|
92
|
+
return self._get("/api/v1/asn-changes")
|
|
93
|
+
|
|
94
|
+
# -- threat intel --------------------------------------------------
|
|
95
|
+
|
|
96
|
+
def threat_domain(self, domain: str) -> ThreatMatch:
|
|
97
|
+
"""Look up a domain in the threat-intel store."""
|
|
98
|
+
data = self._get(f"/api/v1/threat/domain/{urllib.parse.quote(domain, safe='')}")
|
|
99
|
+
return ThreatMatch.from_dict(data)
|
|
100
|
+
|
|
101
|
+
def threat_hash(self, hash: str) -> ThreatMatch:
|
|
102
|
+
"""Look up a file hash (md5/sha1/sha256)."""
|
|
103
|
+
data = self._get(f"/api/v1/threat/hash/{urllib.parse.quote(hash, safe='')}")
|
|
104
|
+
return ThreatMatch.from_dict(data)
|
|
105
|
+
|
|
106
|
+
def threat_url(self, url: str) -> ThreatMatch:
|
|
107
|
+
"""Look up a URL (server falls back to its domain on a miss)."""
|
|
108
|
+
query = urllib.parse.urlencode({"u": url})
|
|
109
|
+
data = self._get(f"/api/v1/threat/url?{query}")
|
|
110
|
+
return ThreatMatch.from_dict(data)
|
|
111
|
+
|
|
112
|
+
# -- internals -----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _get(self, path: str) -> Any:
|
|
115
|
+
return self._request("GET", path, body=None)
|
|
116
|
+
|
|
117
|
+
def _post(self, path: str, payload: Any) -> Any:
|
|
118
|
+
body = json.dumps(payload).encode("utf-8")
|
|
119
|
+
return self._request("POST", path, body=body)
|
|
120
|
+
|
|
121
|
+
def _request(self, method: str, path: str, body: bytes | None) -> Any:
|
|
122
|
+
url = self.base_url + path
|
|
123
|
+
headers = {
|
|
124
|
+
"User-Agent": _USER_AGENT,
|
|
125
|
+
"Accept": "application/json",
|
|
126
|
+
}
|
|
127
|
+
if body is not None:
|
|
128
|
+
headers["Content-Type"] = "application/json"
|
|
129
|
+
if self.api_key:
|
|
130
|
+
headers["X-Api-Key"] = self.api_key
|
|
131
|
+
|
|
132
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
133
|
+
try:
|
|
134
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
135
|
+
raw = resp.read()
|
|
136
|
+
status = resp.status
|
|
137
|
+
except urllib.error.HTTPError as exc:
|
|
138
|
+
raw = exc.read()
|
|
139
|
+
raise IpDataError(status=exc.code, message=_error_message(raw)) from exc
|
|
140
|
+
except urllib.error.URLError as exc:
|
|
141
|
+
raise IpDataError(status=0, message=str(exc.reason)) from exc
|
|
142
|
+
|
|
143
|
+
if status < 200 or status >= 300:
|
|
144
|
+
raise IpDataError(status=status, message=_error_message(raw))
|
|
145
|
+
|
|
146
|
+
if not raw:
|
|
147
|
+
return {}
|
|
148
|
+
try:
|
|
149
|
+
return json.loads(raw)
|
|
150
|
+
except json.JSONDecodeError as exc:
|
|
151
|
+
raise IpDataError(status=status, message=f"decode response: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _error_message(raw: bytes) -> str:
|
|
155
|
+
try:
|
|
156
|
+
parsed = json.loads(raw)
|
|
157
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
158
|
+
return raw.decode("utf-8", errors="replace")
|
|
159
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
160
|
+
return str(parsed["error"])
|
|
161
|
+
return raw.decode("utf-8", errors="replace")
|
ipdatainfo/errors.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Error types for the ipdatainfo client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IpDataError(Exception):
|
|
7
|
+
"""Raised for any non-2xx response from the ipdata.info API.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
status: HTTP status code of the response.
|
|
11
|
+
message: Error message parsed from the ``{"error": "..."}`` body,
|
|
12
|
+
or the raw response body when that shape is absent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, message: str) -> None:
|
|
16
|
+
self.status = status
|
|
17
|
+
self.message = message
|
|
18
|
+
super().__init__(f"ipdatainfo: HTTP {status}: {message}")
|
ipdatainfo/models.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Response models for the ipdatainfo client.
|
|
2
|
+
|
|
3
|
+
All dataclasses tolerate missing and unknown JSON fields: the API omits
|
|
4
|
+
null/empty fields from responses, and future API additions should not break
|
|
5
|
+
older SDK versions. Each model exposes a ``from_dict`` classmethod that reads
|
|
6
|
+
known keys and ignores everything else.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get(data: dict[str, Any], key: str, default: Any = None) -> Any:
|
|
16
|
+
value = data.get(key, default)
|
|
17
|
+
return value if value is not None else default
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Network:
|
|
22
|
+
asn: int = 0
|
|
23
|
+
as_name: str = ""
|
|
24
|
+
route: str = ""
|
|
25
|
+
registry_country: str = ""
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "Network | None":
|
|
29
|
+
if not data:
|
|
30
|
+
return None
|
|
31
|
+
return cls(
|
|
32
|
+
asn=_get(data, "asn", 0),
|
|
33
|
+
as_name=_get(data, "as_name", ""),
|
|
34
|
+
route=_get(data, "route", ""),
|
|
35
|
+
registry_country=_get(data, "registry_country", ""),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Flag:
|
|
41
|
+
img: str = ""
|
|
42
|
+
emoji: str = ""
|
|
43
|
+
emoji_unicode: str = ""
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "Flag | None":
|
|
47
|
+
if not data:
|
|
48
|
+
return None
|
|
49
|
+
return cls(
|
|
50
|
+
img=_get(data, "img", ""),
|
|
51
|
+
emoji=_get(data, "emoji", ""),
|
|
52
|
+
emoji_unicode=_get(data, "emoji_unicode", ""),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Connection:
|
|
58
|
+
asn: int = 0
|
|
59
|
+
org: str = ""
|
|
60
|
+
isp: str = ""
|
|
61
|
+
domain: str = ""
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "Connection | None":
|
|
65
|
+
if not data:
|
|
66
|
+
return None
|
|
67
|
+
return cls(
|
|
68
|
+
asn=_get(data, "asn", 0),
|
|
69
|
+
org=_get(data, "org", ""),
|
|
70
|
+
isp=_get(data, "isp", ""),
|
|
71
|
+
domain=_get(data, "domain", ""),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class TimeZone:
|
|
77
|
+
id: str = ""
|
|
78
|
+
abbr: str = ""
|
|
79
|
+
is_dst: bool = False
|
|
80
|
+
offset: int = 0
|
|
81
|
+
utc: str = ""
|
|
82
|
+
current_time: str = ""
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "TimeZone | None":
|
|
86
|
+
if not data:
|
|
87
|
+
return None
|
|
88
|
+
return cls(
|
|
89
|
+
id=_get(data, "id", ""),
|
|
90
|
+
abbr=_get(data, "abbr", ""),
|
|
91
|
+
is_dst=_get(data, "is_dst", False),
|
|
92
|
+
offset=_get(data, "offset", 0),
|
|
93
|
+
utc=_get(data, "utc", ""),
|
|
94
|
+
current_time=_get(data, "current_time", ""),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class Currency:
|
|
100
|
+
name: str = ""
|
|
101
|
+
code: str = ""
|
|
102
|
+
symbol: str = ""
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "Currency | None":
|
|
106
|
+
if not data:
|
|
107
|
+
return None
|
|
108
|
+
return cls(
|
|
109
|
+
name=_get(data, "name", ""),
|
|
110
|
+
code=_get(data, "code", ""),
|
|
111
|
+
symbol=_get(data, "symbol", ""),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Security:
|
|
117
|
+
anonymous: bool = False
|
|
118
|
+
proxy: bool = False
|
|
119
|
+
vpn: bool = False
|
|
120
|
+
tor: bool = False
|
|
121
|
+
hosting: bool = False
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any] | None) -> "Security | None":
|
|
125
|
+
if not data:
|
|
126
|
+
return None
|
|
127
|
+
return cls(
|
|
128
|
+
anonymous=_get(data, "anonymous", False),
|
|
129
|
+
proxy=_get(data, "proxy", False),
|
|
130
|
+
vpn=_get(data, "vpn", False),
|
|
131
|
+
tor=_get(data, "tor", False),
|
|
132
|
+
hosting=_get(data, "hosting", False),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass
|
|
137
|
+
class IPInfo:
|
|
138
|
+
"""Full geolocation record returned by ``lookup``.
|
|
139
|
+
|
|
140
|
+
The API omits null/empty fields, so most fields default to a falsy value
|
|
141
|
+
(empty string, 0, False) when absent.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
ip: str = ""
|
|
145
|
+
success: bool = False
|
|
146
|
+
type: str = ""
|
|
147
|
+
continent: str = ""
|
|
148
|
+
continent_code: str = ""
|
|
149
|
+
country: str = ""
|
|
150
|
+
country_code: str = ""
|
|
151
|
+
region: str = ""
|
|
152
|
+
city: str = ""
|
|
153
|
+
latitude: float = 0.0
|
|
154
|
+
longitude: float = 0.0
|
|
155
|
+
is_eu: bool = False
|
|
156
|
+
timezone: str = ""
|
|
157
|
+
zip: str = ""
|
|
158
|
+
postal: str = ""
|
|
159
|
+
calling_code: str = ""
|
|
160
|
+
capital: str = ""
|
|
161
|
+
borders: str = ""
|
|
162
|
+
asn: int = 0
|
|
163
|
+
asn_org: str = ""
|
|
164
|
+
isp: str = ""
|
|
165
|
+
registry: str = ""
|
|
166
|
+
is_proxy: bool = False
|
|
167
|
+
is_hosting: bool = False
|
|
168
|
+
network: Network | None = None
|
|
169
|
+
flag: Flag | None = None
|
|
170
|
+
connection: Connection | None = None
|
|
171
|
+
time_zone: TimeZone | None = None
|
|
172
|
+
currency: Currency | None = None
|
|
173
|
+
security: Security | None = None
|
|
174
|
+
|
|
175
|
+
@classmethod
|
|
176
|
+
def from_dict(cls, data: dict[str, Any]) -> "IPInfo":
|
|
177
|
+
return cls(
|
|
178
|
+
ip=_get(data, "ip", ""),
|
|
179
|
+
success=_get(data, "success", False),
|
|
180
|
+
type=_get(data, "type", ""),
|
|
181
|
+
continent=_get(data, "continent", ""),
|
|
182
|
+
continent_code=_get(data, "continent_code", ""),
|
|
183
|
+
country=_get(data, "country", ""),
|
|
184
|
+
country_code=_get(data, "country_code", ""),
|
|
185
|
+
region=_get(data, "region", ""),
|
|
186
|
+
city=_get(data, "city", ""),
|
|
187
|
+
latitude=_get(data, "latitude", 0.0),
|
|
188
|
+
longitude=_get(data, "longitude", 0.0),
|
|
189
|
+
is_eu=_get(data, "is_eu", False),
|
|
190
|
+
timezone=_get(data, "timezone", ""),
|
|
191
|
+
zip=_get(data, "zip", ""),
|
|
192
|
+
postal=_get(data, "postal", ""),
|
|
193
|
+
calling_code=_get(data, "calling_code", ""),
|
|
194
|
+
capital=_get(data, "capital", ""),
|
|
195
|
+
borders=_get(data, "borders", ""),
|
|
196
|
+
asn=_get(data, "asn", 0),
|
|
197
|
+
asn_org=_get(data, "asn_org", ""),
|
|
198
|
+
isp=_get(data, "isp", ""),
|
|
199
|
+
registry=_get(data, "registry", ""),
|
|
200
|
+
is_proxy=_get(data, "is_proxy", False),
|
|
201
|
+
is_hosting=_get(data, "is_hosting", False),
|
|
202
|
+
network=Network.from_dict(data.get("network")),
|
|
203
|
+
flag=Flag.from_dict(data.get("flag")),
|
|
204
|
+
connection=Connection.from_dict(data.get("connection")),
|
|
205
|
+
time_zone=TimeZone.from_dict(data.get("time_zone")),
|
|
206
|
+
currency=Currency.from_dict(data.get("currency")),
|
|
207
|
+
security=Security.from_dict(data.get("security")),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@dataclass
|
|
212
|
+
class GeoInfo:
|
|
213
|
+
"""Compact geo subset returned by ``geo``."""
|
|
214
|
+
|
|
215
|
+
ip: str = ""
|
|
216
|
+
city: str = ""
|
|
217
|
+
region: str = ""
|
|
218
|
+
country: str = ""
|
|
219
|
+
country_code: str = ""
|
|
220
|
+
latitude: float = 0.0
|
|
221
|
+
longitude: float = 0.0
|
|
222
|
+
timezone: str = ""
|
|
223
|
+
zip: str = ""
|
|
224
|
+
|
|
225
|
+
@classmethod
|
|
226
|
+
def from_dict(cls, data: dict[str, Any]) -> "GeoInfo":
|
|
227
|
+
return cls(
|
|
228
|
+
ip=_get(data, "ip", ""),
|
|
229
|
+
city=_get(data, "city", ""),
|
|
230
|
+
region=_get(data, "region", ""),
|
|
231
|
+
country=_get(data, "country", ""),
|
|
232
|
+
country_code=_get(data, "country_code", ""),
|
|
233
|
+
latitude=_get(data, "latitude", 0.0),
|
|
234
|
+
longitude=_get(data, "longitude", 0.0),
|
|
235
|
+
timezone=_get(data, "timezone", ""),
|
|
236
|
+
zip=_get(data, "zip", ""),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class ASNBrief:
|
|
242
|
+
"""Compact ASN subset returned by ``asn``."""
|
|
243
|
+
|
|
244
|
+
ip: str = ""
|
|
245
|
+
asn: int = 0
|
|
246
|
+
asn_org: str = ""
|
|
247
|
+
isp: str = ""
|
|
248
|
+
registry: str = ""
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_dict(cls, data: dict[str, Any]) -> "ASNBrief":
|
|
252
|
+
return cls(
|
|
253
|
+
ip=_get(data, "ip", ""),
|
|
254
|
+
asn=_get(data, "asn", 0),
|
|
255
|
+
asn_org=_get(data, "asn_org", ""),
|
|
256
|
+
isp=_get(data, "isp", ""),
|
|
257
|
+
registry=_get(data, "registry", ""),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass
|
|
262
|
+
class ThreatMatch:
|
|
263
|
+
"""Result of a threat-intel lookup. When ``listed`` is False the
|
|
264
|
+
remaining fields default to empty."""
|
|
265
|
+
|
|
266
|
+
value: str = ""
|
|
267
|
+
ioc_type: str = ""
|
|
268
|
+
listed: bool = False
|
|
269
|
+
threat_type: str = ""
|
|
270
|
+
sources: list[str] = field(default_factory=list)
|
|
271
|
+
confidence: int = 0
|
|
272
|
+
first_seen: str = ""
|
|
273
|
+
last_seen: str = ""
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def from_dict(cls, data: dict[str, Any]) -> "ThreatMatch":
|
|
277
|
+
return cls(
|
|
278
|
+
value=_get(data, "value", ""),
|
|
279
|
+
ioc_type=_get(data, "ioc_type", ""),
|
|
280
|
+
listed=_get(data, "listed", False),
|
|
281
|
+
threat_type=_get(data, "threat_type", ""),
|
|
282
|
+
sources=_get(data, "sources", []) or [],
|
|
283
|
+
confidence=_get(data, "confidence", 0),
|
|
284
|
+
first_seen=_get(data, "first_seen", ""),
|
|
285
|
+
last_seen=_get(data, "last_seen", ""),
|
|
286
|
+
)
|
ipdatainfo/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ipdatainfo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the ipdata.info IP geolocation, ASN, and threat-intelligence API
|
|
5
|
+
Project-URL: Homepage, https://ipdata.info
|
|
6
|
+
Project-URL: Repository, https://github.com/IPDataInfo/ipdata-python
|
|
7
|
+
Project-URL: Documentation, https://ipdata.info/docs/sdks
|
|
8
|
+
Project-URL: Changelog, https://github.com/IPDataInfo/ipdata-python/blob/main/CHANGELOG.md
|
|
9
|
+
Author: ipdata.info
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: geoip,ip-api,ip-geolocation,ip-lookup,ipdata,threat-intelligence
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Internet
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.8
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# IPData.info Python SDK — Free IP Geolocation & Threat Intelligence API
|
|
32
|
+
|
|
33
|
+
[](https://pypi.org/project/ipdatainfo/) [](../../actions/workflows/ci.yml) [](./LICENSE)
|
|
34
|
+
|
|
35
|
+
Official Python client for [**ipdata.info**](https://ipdata.info) — a free,
|
|
36
|
+
fast IP geolocation, ASN, and threat-intelligence API. Look up country, city,
|
|
37
|
+
ASN, timezone, currency, and security flags (proxy/VPN/Tor/hosting) for any IPv4
|
|
38
|
+
or IPv6 address. Powered by [ipdata.info](https://ipdata.info).
|
|
39
|
+
|
|
40
|
+
## Get a free API key
|
|
41
|
+
|
|
42
|
+
The public endpoint is **free — 50 requests/min, no signup, no key**. For higher
|
|
43
|
+
rate limits and batch lookups, [**create a free API key at
|
|
44
|
+
ipdata.info/register**](https://ipdata.info/register) and manage it in your
|
|
45
|
+
[dashboard](https://ipdata.info/dashboard).
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install ipdatainfo
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quickstart
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from ipdatainfo import Client
|
|
57
|
+
|
|
58
|
+
# Free tier — no API key needed. For higher limits + batch, get a free key
|
|
59
|
+
# at https://ipdata.info/register and use Client(api_key="...").
|
|
60
|
+
client = Client()
|
|
61
|
+
|
|
62
|
+
info = client.lookup("8.8.8.8")
|
|
63
|
+
print(info.city, info.country, info.asn_org) # Mountain View United States Google LLC
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Methods
|
|
67
|
+
|
|
68
|
+
| Method | What it returns |
|
|
69
|
+
|--------|-----------------|
|
|
70
|
+
| `lookup(ip="")` | Full geolocation record (own IP if omitted) |
|
|
71
|
+
| `geo(ip)` | Geo subset (city/region/country/lat/lon/tz) |
|
|
72
|
+
| `asn(ip)` | ASN + ISP/registry for an IP |
|
|
73
|
+
| `batch(ips)` | Many IPs at once (**requires an API key**) |
|
|
74
|
+
| `asn_detail(n)` | ASN detail incl. prefixes |
|
|
75
|
+
| `asn_whois_history(n)` | ASN whois history |
|
|
76
|
+
| `asn_changes()` | ASN change feed |
|
|
77
|
+
| `threat_domain/threat_hash/threat_url(x)` | Threat-intel lookup (domain / file hash / URL) |
|
|
78
|
+
|
|
79
|
+
Full response schema: [ipdata.info API docs](https://ipdata.info/docs) ·
|
|
80
|
+
[SDK contract](https://ipdata.info/docs/sdks).
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from ipdatainfo import Client
|
|
86
|
+
|
|
87
|
+
client = Client(
|
|
88
|
+
api_key="KEY", # sent as X-Api-Key; switches to pro.ipdata.info
|
|
89
|
+
base_url="https://ipdata.info", # override the host
|
|
90
|
+
timeout=10.0, # seconds
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Errors from non-2xx responses are raised as `ipdatainfo.IpDataError` with
|
|
95
|
+
`.status` and `.message`.
|
|
96
|
+
|
|
97
|
+
## Rate limits
|
|
98
|
+
|
|
99
|
+
- Free (`ipdata.info`): 50 req/min, no key.
|
|
100
|
+
- Paid (`pro.ipdata.info`): higher limits + `batch`, with an
|
|
101
|
+
[API key](https://ipdata.info/register).
|
|
102
|
+
|
|
103
|
+
## Other SDKs
|
|
104
|
+
|
|
105
|
+
12 official SDKs — see the full list at
|
|
106
|
+
[**ipdata.info/docs/sdks**](https://ipdata.info/docs/sdks): Python, Node.js, Go,
|
|
107
|
+
PHP, Java, Rust, .NET, Kotlin, Swift, Dart, Bash, Objective-C.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT © [ipdata.info](https://ipdata.info)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
ipdatainfo/__init__.py,sha256=TKYTl_GJl3193DfOumE84wOuBfAqZysbrQhWB9hW8GE,659
|
|
2
|
+
ipdatainfo/client.py,sha256=c-PyHM5L-dGZjtHBbiWKBEitoDnZ0R4MZOfbbfZi348,6011
|
|
3
|
+
ipdatainfo/errors.py,sha256=tnooj169bJUXFyGjTum8Cnxt9gLF2Hyma9tmhbIWVy8,577
|
|
4
|
+
ipdatainfo/models.py,sha256=omGzeQDPeweBwrowkODjumPVAytpl5Sf-RV6taCeBMU,8180
|
|
5
|
+
ipdatainfo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
ipdatainfo-0.1.0.dist-info/METADATA,sha256=TNCXnozgcQUYgrjXAW9i4U_PGmj944oxlaKnqMF1QBE,4072
|
|
7
|
+
ipdatainfo-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
8
|
+
ipdatainfo-0.1.0.dist-info/licenses/LICENSE,sha256=gcy7wMqpZTLOLq72UJJIFZlChntabKWKYAE21GrjM1Y,1068
|
|
9
|
+
ipdatainfo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ipdata.info
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|