diff --git a/CLAUDE.md b/CLAUDE.md index 6d04a7b..26d9927 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,13 +105,13 @@ def __init__( field_name: str | None = None, is_flag: bool | 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 (forward compatibility) +- Accept `**_: object` to ignore unknown keys from the API (forward compatibility) - Use `| None = None` for optional parameters - Boolean fields can be `None` if not provided by API @@ -252,7 +252,7 @@ def test_email(self) -> None: *, new_field: str | None = None, # ... other params - **_: Any, + **_: object, ) -> None: ``` @@ -281,7 +281,7 @@ When creating a new model class: 2. **Follow the constructor pattern** from existing models 3. **Use type hints** for all attributes 4. **Use keyword-only arguments** with `*` separator -5. **Accept `**_: Any`** to ignore unknown API keys +5. **Accept `**_: object`** to ignore unknown API keys 6. **Provide comprehensive docstrings** for all attributes 7. **Add corresponding tests** with full coverage @@ -376,7 +376,7 @@ Adding required parameters breaks existing code. - Always add new parameters as optional with defaults - Use keyword-only arguments (after `*`) - Never add required positional parameters to existing constructors -- Use `**_: Any` to silently accept unknown parameters from API +- Use `**_: object` to silently accept unknown parameters from API ## Code Style Requirements diff --git a/HISTORY.rst b/HISTORY.rst index 99fd48b..5c789fb 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -21,6 +21,12 @@ 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. +* The ``ip_address.anonymizer`` object may now contain a ``residential`` + attribute. This is a ``geoip2.records.AnonymizerFeed`` object containing + residential proxy data for the network. It has the following fields: + ``confidence``, ``network_last_seen``, and ``provider_name``. It may be + populated even when no other ``anonymizer`` attributes are set. This + requires ``geoip2`` 5.3.0 or greater. 3.2.0 (2025-11-20) ++++++++++++++++++ diff --git a/pyproject.toml b/pyproject.toml index bb2eb62..73b713a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,8 +92,7 @@ ignorelist = ["id"] [tool.ruff.lint.per-file-ignores] "docs/*" = ["ALL"] -"src/minfraud/models.py" = ["ANN401", "PLR0913"] -"src/minfraud/webservice.py" = ["ANN401"] +"src/minfraud/models.py" = ["PLR0913"] "tests/*" = ["ANN201", "D"] [tool.tox] diff --git a/src/minfraud/models.py b/src/minfraud/models.py index d86e2ba..8cfc45d 100644 --- a/src/minfraud/models.py +++ b/src/minfraud/models.py @@ -100,7 +100,7 @@ class GeoIP2Location(geoip2.records.Location): `RFC 3339 `_. For instance, the local time in Boston might be returned as 2015-04-27T19:17:24-04:00.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: + def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 """Initialize a GeoIP2Location instance.""" self.local_time = kwargs.get("local_time") super().__init__(*args, **kwargs) @@ -135,7 +135,7 @@ def __init__( location: dict[str, Any] | None = None, risk: float | None = None, risk_reasons: list[dict[str, Any]] | None = None, - **kwargs: Any, + **kwargs: Any, # noqa: ANN401 ) -> None: """Initialize an IPAddress instance.""" # For raw attribute @@ -161,7 +161,7 @@ class ScoreIPAddress(_Serializable): """This field contains the risk associated with the IP address. The value ranges from 0.01 to 99. A higher score indicates a higher risk.""" - def __init__(self, *, risk: float | None = None, **_: Any) -> None: + def __init__(self, *, risk: float | None = None, **_: object) -> None: """Initialize a ScoreIPAddress instance.""" self.risk = risk @@ -197,7 +197,7 @@ def __init__( matches_provided_name: bool | None = None, phone_number: str | None = None, matches_provided_phone_number: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an Issuer instance.""" self.name = name @@ -239,7 +239,7 @@ def __init__( id: str | None = None, last_seen: str | None = None, local_time: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Device instance.""" self.confidence = confidence @@ -277,7 +277,7 @@ def __init__( action: str | None = None, reason: str | None = None, rule_label: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Disposition instance.""" self.action = action @@ -310,7 +310,7 @@ def __init__( status: str | None = None, last_visited_on: str | None = None, has_redirect: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an EmailDomainVisit instance.""" self.status = status @@ -354,7 +354,7 @@ def __init__( risk: float | None = None, volume: float | None = None, visit: dict[str, Any] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an EmailDomain instance.""" self.first_seen = first_seen @@ -498,7 +498,7 @@ def __init__( longitude: float | None = None, distance_to_ip_location: int | None = None, is_in_ip_country: bool | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a BillingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -553,7 +553,7 @@ def __init__( is_in_ip_country: bool | None = None, is_high_risk: bool | None = None, distance_to_billing_address: int | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a ShippingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -604,7 +604,7 @@ def __init__( matches_postal: bool | None = None, network_operator: str | None = None, number_type: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Phone instance.""" self.country = country @@ -639,7 +639,7 @@ def __init__( code: str | None = None, warning: str | None = None, input_pointer: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a ServiceWarning instance.""" self.code = code @@ -783,7 +783,7 @@ def __init__( shipping_address: float | None = None, shipping_address_distance_to_ip_location: float | None = None, time_of_day: float | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Subscores instance.""" self.avs_result = avs_result @@ -897,7 +897,7 @@ def __init__( *, code: str | None = None, reason: str | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Reason instance.""" self.code = code @@ -922,7 +922,7 @@ def __init__( *, multiplier: float, reasons: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a RiskScoreReason instance.""" self.multiplier = multiplier @@ -1030,7 +1030,7 @@ def __init__( subscores: dict[str, Any] | None = None, warnings: list[dict[str, Any]] | None = None, risk_score_reasons: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Factors instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1136,7 +1136,7 @@ def __init__( shipping_address: dict[str, Any] | None = None, shipping_phone: dict[str, Any] | None = None, warnings: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize an Insights instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1201,7 +1201,7 @@ def __init__( queries_remaining: int, risk_score: float, warnings: list[dict[str, Any]] | None = None, - **_: Any, + **_: object, ) -> None: """Initialize a Score instance.""" self.disposition = Disposition(**(disposition or {})) diff --git a/tests/data/factors-response.json b/tests/data/factors-response.json index 1aee01f..9317084 100644 --- a/tests/data/factors-response.json +++ b/tests/data/factors-response.json @@ -28,7 +28,12 @@ "is_residential_proxy": true, "is_tor_exit_node": true, "network_last_seen": "2025-01-15", - "provider_name": "TestVPN" + "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift" + } }, "city": { "confidence": 42, diff --git a/tests/data/insights-response.json b/tests/data/insights-response.json index 14a4152..bcee439 100644 --- a/tests/data/insights-response.json +++ b/tests/data/insights-response.json @@ -28,7 +28,12 @@ "is_residential_proxy": true, "is_tor_exit_node": true, "network_last_seen": "2025-01-15", - "provider_name": "TestVPN" + "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift" + } }, "city": { "confidence": 42, diff --git a/tests/test_models.py b/tests/test_models.py index b7fa6c8..05cd2e9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -197,6 +197,11 @@ def test_ip_address(self) -> None: "is_tor_exit_node": True, "network_last_seen": "2025-01-15", "provider_name": "TestVPN", + "residential": { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, }, traits={ "is_anonymous": True, @@ -238,6 +243,25 @@ def test_ip_address(self) -> None: ) self.assertEqual("TestVPN", address.anonymizer.provider_name) + # Test anonymizer.residential attribute + self.assertEqual(82, address.anonymizer.residential.confidence) + self.assertEqual( + datetime.date(2026, 5, 11), + address.anonymizer.residential.network_last_seen, + ) + self.assertEqual("quickshift", address.anonymizer.residential.provider_name) + + # Test to_dict() serialization of anonymizer.residential. The date + # is serialized in its ISO 8601 format. + self.assertEqual( + { + "confidence": 82, + "network_last_seen": "2026-05-11", + "provider_name": "quickshift", + }, + address.to_dict()["anonymizer"]["residential"], + ) + self.assertEqual("ANONYMOUS_IP", address.risk_reasons[0].code) self.assertEqual( "The IP address belongs to an anonymous network. " @@ -252,6 +276,23 @@ def test_ip_address(self) -> None: address.risk_reasons[1].reason, ) + def test_ip_address_anonymizer_without_residential(self) -> None: + address = IPAddress( + ["en"], + anonymizer={ + "is_anonymous": True, + "provider_name": "TestVPN", + }, + ) + + self.assertIsNone(address.anonymizer.residential.confidence) + self.assertIsNone(address.anonymizer.residential.network_last_seen) + self.assertIsNone(address.anonymizer.residential.provider_name) + + # Empty nested records are skipped during serialization, so there + # should be no residential key. + self.assertNotIn("residential", address.to_dict()["anonymizer"]) + def test_empty_address(self) -> None: address = IPAddress([]) self.assertEqual([], address.risk_reasons) diff --git a/tests/test_webservice.py b/tests/test_webservice.py index 2dfff37..d949642 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -236,6 +236,14 @@ def test_200(self) -> None: self.assertEqual("310", model.ip_address.traits.mobile_country_code) self.assertEqual("004", model.ip_address.traits.mobile_network_code) self.assertEqual("ANONYMOUS_IP", model.ip_address.risk_reasons[0].code) + self.assertEqual( + 82, + model.ip_address.anonymizer.residential.confidence, + ) + self.assertEqual( + "quickshift", + model.ip_address.anonymizer.residential.provider_name, + ) def test_authorization_header(self) -> None: # Credentials must be sent via the Authorization header rather than the