From d9852f9ad542c6158c61e4db48b09316a59f145a Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 10 Jul 2026 20:52:33 +0000 Subject: [PATCH] Add residential sub-object to anonymizer for Insights The residential proxy feed is a superset of what is in the Anonymous Plus database, so the web service now nests a `residential` object inside `anonymizer` sourced from the full-feed GeoIP Residential Proxy database. Add a reusable geoip2.records.AnonymizerFeed record ( confidence, network_last_seen, provider_name) so future sibling feeds (vpn, mobile, datacenter) can reuse the same shape, and expose it as Anonymizer.residential. Since the residential data can arrive without any of the existing Anonymous Plus fields, anonymizer may now contain only the residential attribute. Co-Authored-By: Claude Fable 5 --- HISTORY.rst | 7 ++++++ src/geoip2/records.py | 56 +++++++++++++++++++++++++++++++++++++++++++ tests/models_test.py | 52 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/HISTORY.rst b/HISTORY.rst index bc99679..ccd5aaa 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -14,6 +14,13 @@ History ``aiohttp.encode_basic_auth()`` instead of the ``aiohttp.BasicAuth`` / ``auth=`` parameter, which are deprecated as of aiohttp 3.14.0. As a result, the minimum required ``aiohttp`` version is now 3.14.0. +* A new ``residential`` attribute has been added to + ``geoip2.records.Anonymizer``. This is a ``geoip2.records.AnonymizerFeed`` + object sourced from the GeoIP Residential Proxy database and contains the + following fields: ``confidence``, ``network_last_seen``, and + ``provider_name``. Because the residential proxy feed is a superset of what + is in the Anonymous Plus database, the ``anonymizer`` object may now contain + only this attribute. 5.2.0 (2025-11-20) ++++++++++++++++++ diff --git a/src/geoip2/records.py b/src/geoip2/records.py index a517500..a051c32 100644 --- a/src/geoip2/records.py +++ b/src/geoip2/records.py @@ -262,11 +262,59 @@ def __init__(self, *, queries_remaining: int | None = None, **_: Any) -> None: self.queries_remaining = queries_remaining +class AnonymizerFeed(Record): + """Contains data for a network from one of the full-feed anonymizer databases. + + This class contains data for a network classification sourced from one + of the full-feed anonymizer databases, currently only the GeoIP + Residential Proxy database. + + This record is returned by ``insights`` as the ``residential`` attribute + of :py:class:`Anonymizer`. + """ + + confidence: int | None + """A score ranging from 1 to 99 that represents our percent confidence + that the network is currently part of an actively used residential + proxy. This attribute is only available from the Insights end point. + """ + + network_last_seen: datetime.date | None + """The last day that the network was sighted in this anonymizer feed. + This attribute is only available from the Insights end point. + """ + + provider_name: str | None + """The name of the provider associated with the network. This attribute + is only available from the Insights end point. + """ + + def __init__( + self, + *, + confidence: int | None = None, + network_last_seen: str | None = None, + provider_name: str | None = None, + **_: Any, + ) -> None: + self.confidence = confidence + self.network_last_seen = ( + datetime.date.fromisoformat(network_last_seen) + if network_last_seen + else None + ) + self.provider_name = provider_name + + class Anonymizer(Record): """Contains data for the anonymizer record associated with an IP address. This class contains the anonymizer data associated with an IP address. + Note that because the residential proxy feed is a superset of what is + contained in the Anonymous Plus database, this object may contain only + the ``residential`` attribute. + This record is returned by ``insights``. """ @@ -288,6 +336,12 @@ class Anonymizer(Record): point. """ + residential: AnonymizerFeed + """Residential proxy data for the network associated with the IP address, + sourced from the GeoIP Residential Proxy database. This attribute is only + available from the Insights end point. + """ + is_anonymous: bool """This is true if the IP address belongs to any sort of anonymous network. This attribute is only available from the Insights end point. @@ -337,6 +391,7 @@ def __init__( is_tor_exit_node: bool = False, network_last_seen: str | None = None, provider_name: str | None = None, + residential: dict[str, Any] | None = None, **_: Any, ) -> None: self.confidence = confidence @@ -352,6 +407,7 @@ def __init__( else None ) self.provider_name = provider_name + self.residential = AnonymizerFeed(**(residential or {})) class Postal(Record): diff --git a/tests/models_test.py b/tests/models_test.py index 772450e..42c9f15 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -24,6 +24,11 @@ def test_insights_full(self) -> None: # noqa: PLR0915 "is_tor_exit_node": True, "network_last_seen": "2025-04-14", "provider_name": "FooBar VPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, }, "city": { "confidence": 76, @@ -266,6 +271,19 @@ def test_insights_full(self) -> None: # noqa: PLR0915 ) self.assertEqual(model.anonymizer.provider_name, "FooBar VPN") + # Test anonymizer.residential object + self.assertEqual( + type(model.anonymizer.residential), + geoip2.records.AnonymizerFeed, + "geoip2.records.AnonymizerFeed object", + ) + self.assertEqual(model.anonymizer.residential.confidence, 82) + self.assertEqual( + model.anonymizer.residential.network_last_seen, + __import__("datetime").date(2026, 5, 11), + ) + self.assertEqual(model.anonymizer.residential.provider_name, "quickshift") + def test_insights_min(self) -> None: model = geoip2.models.Insights(["en"], traits={"ip_address": "5.6.7.8"}) self.assertEqual( @@ -324,6 +342,40 @@ def test_insights_min(self) -> None: self.assertIsNone(model.anonymizer.provider_name) self.assertFalse(model.anonymizer.is_anonymous) self.assertFalse(model.anonymizer.is_anonymous_vpn) + # Test that anonymizer.residential defaults correctly + self.assertEqual( + type(model.anonymizer.residential), + geoip2.records.AnonymizerFeed, + "geoip2.records.AnonymizerFeed object", + ) + self.assertIsNone(model.anonymizer.residential.confidence) + self.assertIsNone(model.anonymizer.residential.network_last_seen) + self.assertIsNone(model.anonymizer.residential.provider_name) + + def test_insights_anonymizer_residential_only(self) -> None: + # The residential proxy feed is a superset of what is in the + # Anonymous Plus database, so the anonymizer object may contain + # only the residential attribute. + model = geoip2.models.Insights( + ["en"], + anonymizer={ + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, + }, + traits={"ip_address": "5.6.7.8"}, + ) + self.assertIsNone(model.anonymizer.confidence) + self.assertIsNone(model.anonymizer.provider_name) + self.assertFalse(model.anonymizer.is_anonymous) + self.assertEqual(model.anonymizer.residential.confidence, 82) + self.assertEqual( + model.anonymizer.residential.network_last_seen, + __import__("datetime").date(2026, 5, 11), + ) + self.assertEqual(model.anonymizer.residential.provider_name, "quickshift") def test_city_full(self) -> None: raw = {