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..9d488a3 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -21,6 +21,11 @@ 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. 3.2.0 (2025-11-20) ++++++++++++++++++ diff --git a/lychee.toml b/lychee.toml index efa1bfe..5d70281 100644 --- a/lychee.toml +++ b/lychee.toml @@ -5,7 +5,7 @@ # lychee './**/*.md' './**/*.rst' './src/minfraud/**/*.py' './pyproject.toml' # Include URL fragments in checks -include_fragments = true +include_fragments = "full" # Don't allow any redirects, so links that have moved are surfaced and can be # updated to their canonical destination. diff --git a/mise.lock b/mise.lock index 156e912..09ffa7e 100644 --- a/mise.lock +++ b/mise.lock @@ -1,29 +1,35 @@ # @generated - this file is auto-generated by `mise lock` https://mise.jdx.dev/dev-tools/mise-lock.html [[tools.lychee]] -version = "0.23.0" +version = "0.24.2" backend = "aqua:lycheeverse/lychee" [tools.lychee."platforms.linux-arm64"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:5d0b0e3aeab240f41920c633a6eaf97599be6eedda034b36e858ede7dba5e535" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409958602" [tools.lychee."platforms.linux-arm64-musl"] -checksum = "sha256:97eb93b02a7d78a752fc33e5b0983439ccaadbf3db952b68a0a4401acd92e6e0" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-aarch64-unknown-linux-gnu.tar.gz" +checksum = "sha256:5d0b0e3aeab240f41920c633a6eaf97599be6eedda034b36e858ede7dba5e535" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409958602" [tools.lychee."platforms.linux-x64"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959271" [tools.lychee."platforms.linux-x64-musl"] -checksum = "sha256:5538440d2c69a45a0a09983271e5dee0c2fe7137d8035d25b2632e10a66a090a" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-unknown-linux-musl.tar.gz" +checksum = "sha256:73657a111819a30c47c08352896796f23d64e4eb2b3ed39b6d32149241566fc5" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959271" [tools.lychee."platforms.macos-arm64"] -checksum = "sha256:4c8034900e11083b68ac6f6582c377ff1f704e268991999e09d717973e493e7f" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-arm64-macos.dmg" +checksum = "sha256:c9d3740ea2d891854d37116c9fba840f37b6e7c89d330e7db84ac333631c4977" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409957951" [tools.lychee."platforms.windows-x64"] -checksum = "sha256:0fda7ff0a60c0250939fc25361c2d4e6e7853c31c996733fdd5a1dd760bcb824" -url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.23.0/lychee-x86_64-windows.exe" +checksum = "sha256:32975d1493ee1a975d6bb41e4fb56fe419cb442ded628bb772ba2e614acfacad" +url = "https://github.com/lycheeverse/lychee/releases/download/lychee-v0.24.2/lychee-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/lycheeverse/lychee/releases/assets/409959491" diff --git a/pyproject.toml b/pyproject.toml index bb2eb62..195d0c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ dependencies = [ "aiohttp>=3.14.0,<4.0.0", "email_validator>=2.0.0,<3.0.0", - "geoip2>=5.2.0,<6.0.0", + "geoip2>=5.3.0,<6.0.0", "requests>=2.24.0,<3.0.0", "typing-extensions>=4.13.2", "voluptuous", @@ -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/src/minfraud/webservice.py b/src/minfraud/webservice.py index 85abd02..0a8f02a 100644 --- a/src/minfraud/webservice.py +++ b/src/minfraud/webservice.py @@ -256,7 +256,7 @@ def __init__( # noqa: PLR0913 :type timeout: float :param proxy: The URL of an HTTP proxy to use. It may optionally include a basic auth username and password, e.g., - ``http://username:password@host:port``. + ``http://:@:``. :return: Client object :rtype: Client """ @@ -510,7 +510,7 @@ def __init__( # noqa: PLR0913 60. :param proxy: The URL of an HTTP proxy to use. It may optionally include a basic auth username and password, e.g., - ``http://username:password@host:port``. + ``http://:@:``. :type timeout: float :return: Client object :rtype: Client 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 diff --git a/uv.lock b/uv.lock index c86f266..a5a35e8 100644 --- a/uv.lock +++ b/uv.lock @@ -345,7 +345,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -457,16 +457,16 @@ wheels = [ [[package]] name = "geoip2" -version = "5.2.0" +version = "5.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "maxminddb" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/70/3d9e87289f79713aaf0fea9df4aa8e68776640fe59beb6299bb214610cfd/geoip2-5.2.0.tar.gz", hash = "sha256:6c9ded1953f8eb16043ed0a8ea20e6e9524ea7b65eb745724e12490aca44ef00", size = 176498, upload-time = "2025-11-20T18:21:08.874Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/8d/a62c6e56545049605777ad47de2274a72ed60e3991433ca3f1bcdd761ef5/geoip2-5.3.0.tar.gz", hash = "sha256:66df653e16c535f9bb45e3c7ce992fbe008911d9c7c349540574ea2fe8931db5", size = 177948, upload-time = "2026-07-16T22:34:01.497Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/d2/d55df737199a52b9d06e742ed2a608c525f0677e40375951372e65714fbd/geoip2-5.2.0-py3-none-any.whl", hash = "sha256:3d1546fd4eb7cad20445d027d2d9e81d3a71c074e019383f30db5d45e2c23320", size = 28991, upload-time = "2025-11-20T18:21:07.178Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/407ea894512ad989f6fbf9f8e50a02a69ce888bf6a2b799e53a99e4d26a7/geoip2-5.3.0-py3-none-any.whl", hash = "sha256:7f82c587f3f3691f30adc8634d0b93de9853d96d514ed48ff3ba38f188a5df31", size = 29186, upload-time = "2026-07-16T22:34:00.126Z" }, ] [[package]] @@ -744,7 +744,7 @@ lint = [ requires-dist = [ { name = "aiohttp", specifier = ">=3.14.0,<4.0.0" }, { name = "email-validator", specifier = ">=2.0.0,<3.0.0" }, - { name = "geoip2", specifier = ">=5.2.0,<6.0.0" }, + { name = "geoip2", specifier = ">=5.3.0,<6.0.0" }, { name = "requests", specifier = ">=2.24.0,<3.0.0" }, { name = "typing-extensions", specifier = ">=4.13.2" }, { name = "voluptuous" },