Source code for nadzoring.network_base.geolocation_ip
"""
Geographic IP lookup via ip-api.com.
Typical usage::
from nadzoring.network_base.geolocation_ip import geo_ip
result = geo_ip("8.8.8.8")
if not result:
print("Geolocation failed")
else:
print(result["country"]) # "United States"
print(result["city"]) # "Mountain View"
print(result["lat"]) # "37.386"
print(result["lon"]) # "-122.0838"
"""
from logging import Logger
from requests import RequestException, Response, get
from nadzoring.logger import get_logger
logger: Logger = get_logger(__name__)
_GEO_API_URL = "http://ip-api.com/json/{ip}"
_GEO_FIELDS = "lat,lon,country,city,status,message"
_REQUEST_TIMEOUT = 10
GeoResult = dict[str, str]
"""Dict with keys ``lat``, ``lon``, ``country``, ``city`` (all strings)."""
[docs]
def _fetch_geo_data(ip: str) -> dict | None:
"""
Fetch raw JSON from ip-api.com for *ip*.
Args:
ip: IPv4 or IPv6 address string.
Returns:
Parsed JSON dict on success, ``None`` on network or parse failure.
"""
try:
response: Response = get(
url=_GEO_API_URL.format(ip=ip),
params={"fields": _GEO_FIELDS},
timeout=_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()
except (RequestException, ValueError):
logger.exception("Failed to fetch geolocation data for IP %s", ip)
return None
[docs]
def _parse_geo_response(data: dict, ip: str) -> GeoResult | None:
"""
Validate and extract fields from ip-api response dict.
Args:
data: Raw JSON dict from ip-api.com.
ip: Original IP address (used for logging only).
Returns:
:data:`GeoResult` on success, ``None`` when the API signals
failure.
"""
if data.get("status") == "fail":
logger.warning(
"ip-api.com rejected query for %s: %s",
ip,
data.get("message"),
)
return None
return {
"lat": str(data.get("lat", "")),
"lon": str(data.get("lon", "")),
"country": str(data.get("country", "")),
"city": str(data.get("city", "")),
}
[docs]
def geo_ip(ip: str) -> GeoResult:
"""
Retrieve geographic information for a given public IP address.
Queries the `ip-api.com <http://ip-api.com>`_ JSON API and returns a
flat dictionary with location data. Returns an empty dict on any
network or parse error so that callers can use a simple truthiness
check::
result = geo_ip("8.8.8.8")
if not result:
# handle failure
...
.. note::
ip-api.com rate-limits free callers to 45 requests per minute.
Private / reserved IP addresses (e.g. ``192.168.x.x``) will
return an empty dict because the API rejects them.
Args:
ip: IPv4 or IPv6 address to geolocate.
Returns:
:data:`GeoResult` dict with string keys ``lat``, ``lon``,
``country``, and ``city`` when the lookup succeeds, or an empty
dict ``{}`` on failure.
Examples:
Successful lookup::
result = geo_ip("8.8.8.8")
assert "lat" in result and "lon" in result
print(f"{result['city']}, {result['country']}")
Handling failure (private IP, network error, rate-limit)::
result = geo_ip("192.168.1.1")
if not result:
print("Geolocation not available")
Using results for logging / display::
for ip in ["8.8.8.8", "1.1.1.1", "9.9.9.9"]:
geo = geo_ip(ip)
location = f"{geo['city']}, {geo['country']}" if geo else "unknown"
print(f"{ip} → {location}")
"""
raw: dict[str, str | int | float] | None = _fetch_geo_data(ip)
if raw is None:
return {}
parsed: dict[str, str] | None = _parse_geo_response(raw, ip)
return parsed or {}