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/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/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 a517500..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,15 +258,63 @@ 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 +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, + **_: object, + ) -> 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,7 +391,8 @@ def __init__( is_tor_exit_node: bool = False, network_last_seen: str | None = None, provider_name: str | None = None, - **_: Any, + residential: dict[str, Any] | None = None, + **_: object, ) -> None: self.confidence = confidence self.is_anonymous = is_anonymous @@ -352,6 +407,7 @@ def __init__( else None ) self.provider_name = provider_name + self.residential = AnonymizerFeed(**(residential or {})) class Postal(Record): @@ -378,7 +434,7 @@ def __init__( *, code: str | None = None, confidence: int | None = None, - **_: Any, + **_: object, ) -> None: self.code = code self.confidence = confidence @@ -412,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 @@ -708,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 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 = {