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.
- ipdatainfo-0.1.0/.github/workflows/ci.yml +20 -0
- ipdatainfo-0.1.0/.gitignore +12 -0
- ipdatainfo-0.1.0/CHANGELOG.md +9 -0
- ipdatainfo-0.1.0/CONTRIBUTING.md +30 -0
- ipdatainfo-0.1.0/LICENSE +21 -0
- ipdatainfo-0.1.0/PKG-INFO +111 -0
- ipdatainfo-0.1.0/README.md +81 -0
- ipdatainfo-0.1.0/examples/asn_lookup.py +17 -0
- ipdatainfo-0.1.0/examples/batch_lookup.py +26 -0
- ipdatainfo-0.1.0/examples/quickstart.py +22 -0
- ipdatainfo-0.1.0/examples/threat_lookup.py +20 -0
- ipdatainfo-0.1.0/pyproject.toml +51 -0
- ipdatainfo-0.1.0/src/ipdatainfo/__init__.py +36 -0
- ipdatainfo-0.1.0/src/ipdatainfo/client.py +161 -0
- ipdatainfo-0.1.0/src/ipdatainfo/errors.py +18 -0
- ipdatainfo-0.1.0/src/ipdatainfo/models.py +286 -0
- ipdatainfo-0.1.0/src/ipdatainfo/py.typed +0 -0
- ipdatainfo-0.1.0/tests/__init__.py +0 -0
- ipdatainfo-0.1.0/tests/mock_server.py +57 -0
- ipdatainfo-0.1.0/tests/test_client.py +73 -0
- ipdatainfo-0.1.0/tests/test_live_smoke.py +24 -0
|
@@ -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,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.
|
ipdatainfo-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](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,81 @@
|
|
|
1
|
+
# IPData.info Python SDK — Free IP Geolocation & Threat Intelligence API
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/ipdatainfo/) [](../../actions/workflows/ci.yml) [](./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()
|