feat(ip-info): return timezone and add a required-field filter

get_ip_info(require=...) skips a geolocation API whose result lacks a required field, so ipinfo Lite falls through to ipinfo.io or ip-api.in.
This commit is contained in:
imSp4rky
2026-10-15 10:30:21 -06:00
parent 8a1fbf2de1
commit c231114cda
2 changed files with 26 additions and 3 deletions
+15
View File
@@ -80,3 +80,18 @@ def test_get_ip_info_collects_connection_errors_only(monkeypatch):
errors: list[Exception] = []
assert ip_info.get_ip_info(SimpleNamespace(proxies={"all": "http://127.0.0.1:9"}), errors=errors) is None
assert [str(e) for e in errors] == ["refused"]
def test_require_skips_provider_missing_field(monkeypatch):
lite = {"country": "ca", "timezone": ""}
full = {"country": "ca", "timezone": "America/Edmonton"}
monkeypatch.setattr(
ip_info, "build_providers", lambda: [("lite", lambda s: dict(lite)), ("full", lambda s: dict(full))]
)
monkeypatch.setattr(
ip_info,
"Cacher",
lambda *a: SimpleNamespace(get=lambda k: SimpleNamespace(data=None, expired=True, set=lambda *a, **k: None)),
)
assert ip_info.get_ip_info(object())["_provider"] == "lite"
assert ip_info.get_ip_info(object(), require=("timezone",))["timezone"] == "America/Edmonton"
+11 -3
View File
@@ -37,6 +37,7 @@ def normalize(
asn: str = "",
as_name: str = "",
continent_code: str = "",
timezone: str = "",
) -> Optional[dict]:
"""Assemble the canonical IP-info dict, or None if no country code is present."""
code = country_code.strip()
@@ -52,6 +53,7 @@ def normalize(
"asn": asn,
"as_name": as_name,
"continent_code": continent_code.upper(),
"timezone": timezone,
}
@@ -75,6 +77,7 @@ def parse_ipinfo(data: dict) -> Optional[dict]:
region=data.get("region") or "",
city=data.get("city") or "",
org=data.get("org") or "",
timezone=data.get("timezone") or "",
)
@@ -90,6 +93,7 @@ def parse_ip_api_in(data: dict) -> Optional[dict]:
asn=asn,
as_name=org_name,
continent_code=data.get("continent_code") or "",
timezone=data.get("timezone") or "",
)
@@ -187,14 +191,16 @@ def get_ip_info(
*,
cached: bool = False,
errors: Optional[list[Exception]] = None,
require: tuple[str, ...] = (),
) -> Optional[dict]:
"""
Look up IP/geolocation info through ipinfo.io (Lite when `ipinfo_api_key` configured)
with fallback to ip-api.in.
Live lookups return a dict with `ip`, `country` (lowercase ISO2), `country_code`
(uppercase ISO2), `region`, `city`, `org`, `asn`, `as_name`, `continent_code` and
`_provider`. Cached lookups return only `ip`/`country`/`country_code` (see GEO_CACHE_KEYS).
(uppercase ISO2), `region`, `city`, `org`, `asn`, `as_name`, `continent_code`,
`timezone` (IANA name, empty from ipinfo Lite) and `_provider`. Cached lookups return
only `ip`/`country`/`country_code` (see GEO_CACHE_KEYS).
Returns None if every geolocation API fails.
Args:
@@ -206,6 +212,8 @@ def get_ip_info(
local IP lookups, never with a proxied HTTP session.
errors: When given, each geolocation API that raised an exception adds it to
this list. A 429 or an unusable response adds nothing.
require: Fields a live result must have. A geolocation API whose result has
an empty required field is skipped, and the next API is tried.
"""
cache = None
if cached:
@@ -242,7 +250,7 @@ def get_ip_info(
errors.append(e)
continue
if not normalized:
if not normalized or not all(normalized.get(k) for k in require):
log.debug(f"Provider {name} returned no usable data")
continue