From 635bcb75d4654564f7902cb98a804a3b7dd7de22 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Fri, 10 Jul 2026 20:52:33 +0000 Subject: [PATCH 1/2] Add residential sub-object to anonymizer for Insights The GeoIP Insights web service now nests a residential object inside the anonymizer object. Add a reusable geoip2.records.AnonymizerFeed record (confidence, network_last_seen, provider_name) so additional anonymizer feeds can share the same shape, and expose it as Anonymizer.residential. The residential attribute may be populated even when no other anonymizer attributes are set, so the anonymizer object 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 | 83 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 145 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index bc99679..504d71a 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 providing residential proxy data for the network and contains the + following fields: ``confidence``, ``network_last_seen``, and + ``provider_name``. This attribute may be populated even when no other + anonymizer attributes are set, so 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..74105a3 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 one type of anonymizer detection. + + This class contains data for one type of anonymizer detection, + currently residential proxies. Additional feeds may be added in the + future. + + 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 this anonymizer feed. 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 our analysis of 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 in this + anonymizer feed. 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 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. + This may be populated even when no other anonymizer attributes are set. + 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..1142684 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -1,3 +1,4 @@ +import datetime import ipaddress import sys import unittest @@ -24,6 +25,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, @@ -262,10 +268,32 @@ def test_insights_full(self) -> None: # noqa: PLR0915 self.assertIs(model.anonymizer.is_tor_exit_node, True) self.assertEqual( model.anonymizer.network_last_seen, - __import__("datetime").date(2025, 4, 14), + datetime.date(2025, 4, 14), ) 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, + datetime.date(2026, 5, 11), + ) + self.assertEqual(model.anonymizer.residential.provider_name, "quickshift") + self.assertEqual( + model.anonymizer.to_dict()["residential"], + { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, + "residential is serialized by to_dict", + ) + def test_insights_min(self) -> None: model = geoip2.models.Insights(["en"], traits={"ip_address": "5.6.7.8"}) self.assertEqual( @@ -324,6 +352,59 @@ 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) + self.assertEqual( + model.to_dict(), + {"traits": {"ip_address": "5.6.7.8"}}, + "empty anonymizer is not serialized by to_dict", + ) + + def test_insights_anonymizer_residential_only(self) -> None: + # The residential attribute may be populated even when no other + # anonymizer attributes are set, 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, + datetime.date(2026, 5, 11), + ) + self.assertEqual(model.anonymizer.residential.provider_name, "quickshift") + self.assertEqual( + model.to_dict(), + { + "anonymizer": { + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, + }, + "traits": {"ip_address": "5.6.7.8"}, + }, + "anonymizer contains only residential in to_dict output", + ) def test_city_full(self) -> None: raw = { From a1e227ae2c65e20433a9e59af40a746c2d39ae44 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Tue, 14 Jul 2026 16:06:31 +0000 Subject: [PATCH 2/2] Replace Any with object for unused kwargs Use object rather than Any for the discarded **_ keyword-argument parameters. object is the correct type for values that are accepted but never used, and it satisfies Ruff's ANN401 rule, so the per-file ANN401 ignore for models.py and records.py can be removed. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 +++--- pyproject.toml | 2 +- src/geoip2/models.py | 18 +++++++++--------- src/geoip2/records.py | 22 +++++++++++----------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c7dd2c..fd8c97b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,13 +65,13 @@ def __init__( continent: dict[str, Any] | None = None, country: dict[str, Any] | None = None, # ... other keyword-only parameters - **_: Any, # ignore unknown keys + **_: object, # ignore unknown keys ) -> None: ``` Key points: - Use `*` to enforce keyword-only arguments -- Accept `**_: Any` to ignore unknown keys from the API +- Accept `**_: object` to ignore unknown keys from the API - Use `| None = None` for optional parameters - Boolean fields default to `False` if not present @@ -251,7 +251,7 @@ When creating a new model class: 3. **Extend the appropriate base class** (e.g., `Country`, `City`, `SimpleModel`) 4. **Use type hints** for all attributes 5. **Use keyword-only arguments** with `*` separator -6. **Accept `**_: Any`** to ignore unknown API keys +6. **Accept `**_: object`** to ignore unknown API keys 7. **Provide comprehensive docstrings** for all attributes 8. **Add corresponding tests** with full coverage diff --git a/pyproject.toml b/pyproject.toml index 699688c..c676f91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "docs/*" = ["ALL"] -"src/geoip2/{models,records}.py" = [ "ANN401", "D107", "PLR0913" ] +"src/geoip2/{models,records}.py" = [ "D107", "PLR0913" ] # FBT003: We use assertIs with boolean literals to verify values are actual # booleans (True/False), not just truthy/falsy values "tests/*" = ["ANN201", "D", "FBT003"] diff --git a/src/geoip2/models.py b/src/geoip2/models.py index 0a72f6c..655e693 100644 --- a/src/geoip2/models.py +++ b/src/geoip2/models.py @@ -63,7 +63,7 @@ def __init__( registered_country: dict[str, Any] | None = None, represented_country: dict[str, Any] | None = None, traits: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: self._locales = locales self.continent = geoip2.records.Continent(locales, **(continent or {})) @@ -127,7 +127,7 @@ def __init__( represented_country: dict[str, Any] | None = None, subdivisions: list[dict[str, Any]] | None = None, traits: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: super().__init__( locales, @@ -171,7 +171,7 @@ def __init__( represented_country: dict[str, Any] | None = None, subdivisions: list[dict[str, Any]] | None = None, traits: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: super().__init__( locales, @@ -301,7 +301,7 @@ def __init__( is_tor_exit_node: bool = False, network: str | None = None, prefix_len: int | None = None, - **_: Any, + **_: object, ) -> None: super().__init__(ip_address, network, prefix_len) self.is_anonymous = is_anonymous @@ -345,7 +345,7 @@ def __init__( network_last_seen: str | None = None, prefix_len: int | None = None, provider_name: str | None = None, - **_: Any, + **_: object, ) -> None: super().__init__( is_anonymous=is_anonymous, @@ -383,7 +383,7 @@ def __init__( autonomous_system_organization: str | None = None, network: str | None = None, prefix_len: int | None = None, - **_: Any, + **_: object, ) -> None: super().__init__(ip_address, network, prefix_len) self.autonomous_system_number = autonomous_system_number @@ -412,7 +412,7 @@ def __init__( connection_type: str | None = None, network: str | None = None, prefix_len: int | None = None, - **_: Any, + **_: object, ) -> None: super().__init__(ip_address, network, prefix_len) self.connection_type = connection_type @@ -431,7 +431,7 @@ def __init__( domain: str | None = None, network: str | None = None, prefix_len: int | None = None, - **_: Any, + **_: object, ) -> None: super().__init__(ip_address, network, prefix_len) self.domain = domain @@ -470,7 +470,7 @@ def __init__( organization: str | None = None, network: str | None = None, prefix_len: int | None = None, - **_: Any, + **_: object, ) -> None: super().__init__( autonomous_system_number=autonomous_system_number, diff --git a/src/geoip2/records.py b/src/geoip2/records.py index 74105a3..b8ad88e 100644 --- a/src/geoip2/records.py +++ b/src/geoip2/records.py @@ -74,7 +74,7 @@ def __init__( confidence: int | None = None, geoname_id: int | None = None, names: dict[str, str] | None = None, - **_: Any, + **_: object, ) -> None: self.confidence = confidence self.geoname_id = geoname_id @@ -102,7 +102,7 @@ def __init__( code: str | None = None, geoname_id: int | None = None, names: dict[str, str] | None = None, - **_: Any, + **_: object, ) -> None: self.code = code self.geoname_id = geoname_id @@ -139,7 +139,7 @@ def __init__( is_in_european_union: bool = False, iso_code: str | None = None, names: dict[str, str] | None = None, - **_: Any, + **_: object, ) -> None: self.confidence = confidence self.geoname_id = geoname_id @@ -172,7 +172,7 @@ def __init__( iso_code: str | None = None, names: dict[str, str] | None = None, type: str | None = None, # noqa: A002 - **_: Any, + **_: object, ) -> None: self.type = type super().__init__( @@ -239,7 +239,7 @@ def __init__( metro_code: int | None = None, population_density: int | None = None, time_zone: str | None = None, - **_: Any, + **_: object, ) -> None: self.average_income = average_income self.accuracy_radius = accuracy_radius @@ -258,7 +258,7 @@ class MaxMind(Record): calling. """ - def __init__(self, *, queries_remaining: int | None = None, **_: Any) -> None: + def __init__(self, *, queries_remaining: int | None = None, **_: object) -> None: self.queries_remaining = queries_remaining @@ -297,7 +297,7 @@ def __init__( confidence: int | None = None, network_last_seen: str | None = None, provider_name: str | None = None, - **_: Any, + **_: object, ) -> None: self.confidence = confidence self.network_last_seen = ( @@ -392,7 +392,7 @@ def __init__( network_last_seen: str | None = None, provider_name: str | None = None, residential: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: self.confidence = confidence self.is_anonymous = is_anonymous @@ -434,7 +434,7 @@ def __init__( *, code: str | None = None, confidence: int | None = None, - **_: Any, + **_: object, ) -> None: self.code = code self.confidence = confidence @@ -468,7 +468,7 @@ def __init__( geoname_id: int | None = None, iso_code: str | None = None, names: dict[str, str] | None = None, - **_: Any, + **_: object, ) -> None: self.confidence = confidence self.geoname_id = geoname_id @@ -764,7 +764,7 @@ def __init__( mobile_country_code: str | None = None, mobile_network_code: str | None = None, is_anycast: bool = False, - **_: Any, + **_: object, ) -> None: self.autonomous_system_number = autonomous_system_number self.autonomous_system_organization = autonomous_system_organization