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 = {