From c231114cdac5375f62469269d50945963adb85ef Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 15 Oct 2026 10:30:21 -0600 Subject: [PATCH] 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. --- tests/core/test_verify_proxy_exit.py | 15 +++++++++++++++ unshackle/core/utils/ip_info.py | 14 +++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/core/test_verify_proxy_exit.py b/tests/core/test_verify_proxy_exit.py index 5862efbb..e29fc7c8 100644 --- a/tests/core/test_verify_proxy_exit.py +++ b/tests/core/test_verify_proxy_exit.py @@ -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" diff --git a/unshackle/core/utils/ip_info.py b/unshackle/core/utils/ip_info.py index 5bd6b81d..5082370b 100644 --- a/unshackle/core/utils/ip_info.py +++ b/unshackle/core/utils/ip_info.py @@ -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