ipdatainfo 0.1.0__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,20 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13']
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: ${{ matrix.python-version }}
17
+ - run: python -m pip install -e .
18
+ - run: python -m unittest discover -s tests -t . -v
19
+ - name: Live smoke test
20
+ run: IPDATA_LIVE=1 python -m unittest discover -s tests -t . -v -k test_live_lookup
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .env
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-07-08
4
+ ### Added
5
+ - Initial release: `lookup`, `geo`, `asn`, `batch`, `asn_detail`,
6
+ `asn_whois_history`, `asn_changes`, `threat_domain`, `threat_hash`,
7
+ `threat_url`.
8
+ - `IpDataError` typed error; `Client(api_key, base_url, timeout)` constructor.
9
+ - Zero required runtime dependencies (stdlib `urllib`/`json` only).
@@ -0,0 +1,30 @@
1
+ # Contributing
2
+
3
+ Thanks for considering a contribution to `ipdatainfo`.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ python3 -m venv .venv
9
+ source .venv/bin/activate
10
+ pip install -e ".[dev]"
11
+ ```
12
+
13
+ ## Tests
14
+
15
+ ```
16
+ python -m unittest discover -s tests -t . -v
17
+ ```
18
+
19
+ To include the live smoke test against `https://ipdata.info` (requires network):
20
+
21
+ ```
22
+ IPDATA_LIVE=1 python -m unittest discover -s tests -t . -v
23
+ ```
24
+
25
+ ## Pull requests
26
+
27
+ - Keep changes scoped and add/update tests for behavior changes.
28
+ - Match the method surface in [`docs/sdk-api-contract.md`](https://github.com/IPDataInfo/ipdata-python) —
29
+ this SDK must stay behaviorally identical to the other official ipdata.info SDKs.
30
+ - Run the test suite before opening a PR.
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/ipdatainfo.svg)](https://pypi.org/project/ipdatainfo/) [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./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,81 @@
1
+ # IPData.info Python SDK — Free IP Geolocation & Threat Intelligence API
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/ipdatainfo.svg)](https://pypi.org/project/ipdatainfo/) [![CI](../../actions/workflows/ci.yml/badge.svg)](../../actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
4
+
5
+ Official Python client for [**ipdata.info**](https://ipdata.info) — a free,
6
+ fast IP geolocation, ASN, and threat-intelligence API. Look up country, city,
7
+ ASN, timezone, currency, and security flags (proxy/VPN/Tor/hosting) for any IPv4
8
+ or IPv6 address. Powered by [ipdata.info](https://ipdata.info).
9
+
10
+ ## Get a free API key
11
+
12
+ The public endpoint is **free — 50 requests/min, no signup, no key**. For higher
13
+ rate limits and batch lookups, [**create a free API key at
14
+ ipdata.info/register**](https://ipdata.info/register) and manage it in your
15
+ [dashboard](https://ipdata.info/dashboard).
16
+
17
+ ## Install
18
+
19
+ ```
20
+ pip install ipdatainfo
21
+ ```
22
+
23
+ ## Quickstart
24
+
25
+ ```python
26
+ from ipdatainfo import Client
27
+
28
+ # Free tier — no API key needed. For higher limits + batch, get a free key
29
+ # at https://ipdata.info/register and use Client(api_key="...").
30
+ client = Client()
31
+
32
+ info = client.lookup("8.8.8.8")
33
+ print(info.city, info.country, info.asn_org) # Mountain View United States Google LLC
34
+ ```
35
+
36
+ ## Methods
37
+
38
+ | Method | What it returns |
39
+ |--------|-----------------|
40
+ | `lookup(ip="")` | Full geolocation record (own IP if omitted) |
41
+ | `geo(ip)` | Geo subset (city/region/country/lat/lon/tz) |
42
+ | `asn(ip)` | ASN + ISP/registry for an IP |
43
+ | `batch(ips)` | Many IPs at once (**requires an API key**) |
44
+ | `asn_detail(n)` | ASN detail incl. prefixes |
45
+ | `asn_whois_history(n)` | ASN whois history |
46
+ | `asn_changes()` | ASN change feed |
47
+ | `threat_domain/threat_hash/threat_url(x)` | Threat-intel lookup (domain / file hash / URL) |
48
+
49
+ Full response schema: [ipdata.info API docs](https://ipdata.info/docs) ·
50
+ [SDK contract](https://ipdata.info/docs/sdks).
51
+
52
+ ## Configuration
53
+
54
+ ```python
55
+ from ipdatainfo import Client
56
+
57
+ client = Client(
58
+ api_key="KEY", # sent as X-Api-Key; switches to pro.ipdata.info
59
+ base_url="https://ipdata.info", # override the host
60
+ timeout=10.0, # seconds
61
+ )
62
+ ```
63
+
64
+ Errors from non-2xx responses are raised as `ipdatainfo.IpDataError` with
65
+ `.status` and `.message`.
66
+
67
+ ## Rate limits
68
+
69
+ - Free (`ipdata.info`): 50 req/min, no key.
70
+ - Paid (`pro.ipdata.info`): higher limits + `batch`, with an
71
+ [API key](https://ipdata.info/register).
72
+
73
+ ## Other SDKs
74
+
75
+ 12 official SDKs — see the full list at
76
+ [**ipdata.info/docs/sdks**](https://ipdata.info/docs/sdks): Python, Node.js, Go,
77
+ PHP, Java, Rust, .NET, Kotlin, Swift, Dart, Bash, Objective-C.
78
+
79
+ ## License
80
+
81
+ MIT © [ipdata.info](https://ipdata.info)
@@ -0,0 +1,17 @@
1
+ """ASN lookup example. Run: python examples/asn_lookup.py"""
2
+
3
+ from ipdatainfo import Client
4
+
5
+
6
+ def main() -> None:
7
+ client = Client()
8
+
9
+ brief = client.asn("8.8.8.8")
10
+ print(f"ASN {brief.asn} ({brief.asn_org}) via {brief.isp}, registry {brief.registry}")
11
+
12
+ detail = client.asn_detail(brief.asn)
13
+ print(f"AS name: {detail.get('as_name')}, IPv4 prefixes: {detail.get('ipv4_prefix_count')}")
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
@@ -0,0 +1,26 @@
1
+ """Batch lookup example (requires a paid API key). See https://ipdata.info/register.
2
+
3
+ Run: IPDATA_API_KEY=your_key python examples/batch_lookup.py
4
+ """
5
+
6
+ import os
7
+
8
+ from ipdatainfo import Client, IpDataError
9
+
10
+
11
+ def main() -> None:
12
+ api_key = os.environ.get("IPDATA_API_KEY")
13
+ client = Client(api_key=api_key)
14
+
15
+ try:
16
+ results = client.batch(["8.8.8.8", "1.1.1.1"])
17
+ except IpDataError as exc:
18
+ print(f"batch failed ({exc.status}): {exc.message}")
19
+ return
20
+
21
+ for info in results:
22
+ print(f"{info.ip}: {info.city}, {info.country}")
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
@@ -0,0 +1,22 @@
1
+ """Quickstart example for the ipdata.info Python SDK.
2
+
3
+ Run: python examples/quickstart.py
4
+ """
5
+
6
+ from ipdatainfo import Client
7
+
8
+
9
+ def main() -> None:
10
+ # Free tier -- no API key needed. For higher limits + batch, get a free
11
+ # key at https://ipdata.info/register and use Client(api_key="...").
12
+ client = Client()
13
+
14
+ info = client.lookup("8.8.8.8")
15
+ print(f"{info.ip} is in {info.city}, {info.country} (ASN {info.asn} — {info.asn_org})")
16
+
17
+ threat = client.threat_domain("example.com")
18
+ print(f"example.com listed as a threat: {threat.listed}")
19
+
20
+
21
+ if __name__ == "__main__":
22
+ main()
@@ -0,0 +1,20 @@
1
+ """Threat-intel lookup example. Run: python examples/threat_lookup.py"""
2
+
3
+ from ipdatainfo import Client
4
+
5
+
6
+ def main() -> None:
7
+ client = Client()
8
+
9
+ domain = client.threat_domain("example.com")
10
+ print(f"domain example.com listed: {domain.listed}")
11
+
12
+ url = client.threat_url("https://example.com/path")
13
+ print(f"url listed: {url.listed}")
14
+
15
+ file_hash = client.threat_hash("d41d8cd98f00b204e9800998ecf8427e")
16
+ print(f"hash listed: {file_hash.listed}")
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ipdatainfo"
7
+ version = "0.1.0"
8
+ description = "Official Python client for the ipdata.info IP geolocation, ASN, and threat-intelligence API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.8"
13
+ authors = [
14
+ { name = "ipdata.info" },
15
+ ]
16
+ keywords = [
17
+ "ip-geolocation",
18
+ "ip-api",
19
+ "geoip",
20
+ "ip-lookup",
21
+ "threat-intelligence",
22
+ "ipdata",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 4 - Beta",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3",
29
+ "Programming Language :: Python :: 3.8",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Internet",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Typing :: Typed",
38
+ ]
39
+ dependencies = []
40
+
41
+ [project.optional-dependencies]
42
+ dev = ["pytest>=7.0"]
43
+
44
+ [project.urls]
45
+ Homepage = "https://ipdata.info"
46
+ Repository = "https://github.com/IPDataInfo/ipdata-python"
47
+ Documentation = "https://ipdata.info/docs/sdks"
48
+ Changelog = "https://github.com/IPDataInfo/ipdata-python/blob/main/CHANGELOG.md"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/ipdatainfo"]
@@ -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
+ ]
@@ -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")
@@ -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}")
@@ -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
+ )
File without changes
File without changes
@@ -0,0 +1,57 @@
1
+ """Minimal local mock HTTP server used by the unit tests, mirroring the
2
+ Go SDK's use of ``net/http/httptest``.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from typing import Callable
10
+
11
+
12
+ class MockServer:
13
+ """Runs a ``BaseHTTPRequestHandler``-backed server on a background thread.
14
+
15
+ ``handler_fn`` receives the request path and must return
16
+ ``(status_code, body_bytes)``.
17
+ """
18
+
19
+ def __init__(self, handler_fn: Callable[[str], tuple[int, bytes]]) -> None:
20
+ outer = self
21
+
22
+ class Handler(BaseHTTPRequestHandler):
23
+ def _handle(self) -> None:
24
+ length = int(self.headers.get("Content-Length", 0))
25
+ if length:
26
+ self.rfile.read(length)
27
+ status, body = outer.handler_fn(self.path)
28
+ self.send_response(status)
29
+ self.send_header("Content-Type", "application/json")
30
+ self.end_headers()
31
+ self.wfile.write(body)
32
+
33
+ def do_GET(self) -> None: # noqa: N802 (stdlib API name)
34
+ self._handle()
35
+
36
+ def do_POST(self) -> None: # noqa: N802 (stdlib API name)
37
+ self._handle()
38
+
39
+ def log_message(self, format: str, *args: object) -> None: # silence
40
+ pass
41
+
42
+ self.handler_fn = handler_fn
43
+ self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
44
+ self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
45
+
46
+ @property
47
+ def url(self) -> str:
48
+ host, port = self.httpd.server_address[:2]
49
+ return f"http://{host}:{port}"
50
+
51
+ def start(self) -> "MockServer":
52
+ self.thread.start()
53
+ return self
54
+
55
+ def stop(self) -> None:
56
+ self.httpd.shutdown()
57
+ self.httpd.server_close()
@@ -0,0 +1,73 @@
1
+ """Unit tests for the ipdatainfo client, using a local mock HTTP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import unittest
6
+
7
+ from ipdatainfo import Client, IpDataError
8
+ from ipdatainfo.client import PRO_BASE_URL
9
+
10
+ from .mock_server import MockServer
11
+
12
+
13
+ class TestLookupParsesResponse(unittest.TestCase):
14
+ def test_lookup_parses_response(self) -> None:
15
+ body = (
16
+ b'{"ip":"8.8.8.8","success":true,"type":"IPv4",'
17
+ b'"country":"United States","country_code":"US","asn":15169,'
18
+ b'"security":{"tor":false,"proxy":false}}'
19
+ )
20
+ seen_paths: list[str] = []
21
+
22
+ def handler(path: str) -> tuple[int, bytes]:
23
+ seen_paths.append(path)
24
+ return 200, body
25
+
26
+ server = MockServer(handler).start()
27
+ try:
28
+ client = Client(base_url=server.url)
29
+ info = client.lookup("8.8.8.8")
30
+ finally:
31
+ server.stop()
32
+
33
+ self.assertEqual(seen_paths, ["/json/8.8.8.8"])
34
+ self.assertEqual(info.country_code, "US")
35
+ self.assertEqual(info.asn, 15169)
36
+ self.assertIsNotNone(info.security)
37
+ assert info.security is not None
38
+ self.assertFalse(info.security.tor)
39
+
40
+
41
+ class TestErrorResponse(unittest.TestCase):
42
+ def test_error_response_raises_ipdata_error(self) -> None:
43
+ def handler(path: str) -> tuple[int, bytes]:
44
+ return 403, b'{"error":"batch lookup requires a paid tier API key"}'
45
+
46
+ server = MockServer(handler).start()
47
+ try:
48
+ client = Client(base_url=server.url)
49
+ with self.assertRaises(IpDataError) as ctx:
50
+ client.batch(["8.8.8.8"])
51
+ finally:
52
+ server.stop()
53
+
54
+ self.assertEqual(ctx.exception.status, 403)
55
+ self.assertTrue(ctx.exception.message)
56
+
57
+
58
+ class TestKeySwitchesToProHost(unittest.TestCase):
59
+ def test_key_switches_to_pro_host(self) -> None:
60
+ client = Client(api_key="secret")
61
+ self.assertEqual(client.base_url, PRO_BASE_URL)
62
+
63
+ def test_no_key_uses_free_host(self) -> None:
64
+ client = Client()
65
+ self.assertEqual(client.base_url, "https://ipdata.info")
66
+
67
+ def test_explicit_base_url_overrides_key_default(self) -> None:
68
+ client = Client(api_key="secret", base_url="https://example.test")
69
+ self.assertEqual(client.base_url, "https://example.test")
70
+
71
+
72
+ if __name__ == "__main__":
73
+ unittest.main()
@@ -0,0 +1,24 @@
1
+ """Live smoke test against the real free-tier endpoint.
2
+
3
+ Skipped unless IPDATA_LIVE=1 (requires network access).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import unittest
10
+
11
+ from ipdatainfo import Client
12
+
13
+
14
+ @unittest.skipUnless(os.environ.get("IPDATA_LIVE"), "set IPDATA_LIVE=1 to run the live smoke test")
15
+ class TestLiveSmoke(unittest.TestCase):
16
+ def test_live_lookup(self) -> None:
17
+ client = Client()
18
+ info = client.lookup("8.8.8.8")
19
+ self.assertTrue(info.success)
20
+ self.assertEqual(info.ip, "8.8.8.8")
21
+
22
+
23
+ if __name__ == "__main__":
24
+ unittest.main()