Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -252,7 +252,7 @@ def test_email(self) -> None:
*,
new_field: str | None = None,
# ... other params
**_: Any,
**_: object,
) -> None:
```

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
++++++++++++++++++
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
36 changes: 18 additions & 18 deletions src/minfraud/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ class GeoIP2Location(geoip2.records.Location):
`RFC 3339 <https://datatracker.ietf.org/doc/html/rfc3339>`_. 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)
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -897,7 +897,7 @@ def __init__(
*,
code: str | None = None,
reason: str | None = None,
**_: Any,
**_: object,
) -> None:
"""Initialize a Reason instance."""
self.code = code
Expand All @@ -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
Expand Down Expand Up @@ -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 {}))
Expand Down Expand Up @@ -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 {}))
Expand Down Expand Up @@ -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 {}))
Expand Down
7 changes: 6 additions & 1 deletion tests/data/factors-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 6 additions & 1 deletion tests/data/insights-response.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
traits={
"is_anonymous": True,
Expand Down Expand Up @@ -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. "
Expand All @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions tests/test_webservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading