From 00482e079b30de69daecc70fc21e77ebf572e404 Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Thu, 16 Jan 2025 13:02:55 -0800
Subject: [PATCH 1/7] Replace NamedTuple subclasses for model code

---
 HISTORY.rst              |   8 +-
 minfraud/models.py       | 688 ++++++++++++++++++++-------------------
 minfraud/webservice.py   |   4 +-
 tests/test_models.py     | 199 +++++------
 tests/test_webservice.py |  12 +-
 5 files changed, 457 insertions(+), 454 deletions(-)

diff --git a/HISTORY.rst b/HISTORY.rst
index 5e38cb8..2066db9 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,9 +3,15 @@
 History
 -------
 
-2.12.0
+3.0.0
 +++++++++++++++++++
 
+* BREAKING CHANGE: The ``minfraud.model.*`` classes have been refactored to
+  simplify them and make them more flexible. They are no longer subclass
+  NamedTuple and are now standard Python classes. This also means the
+  classes are no longer immutable. For most users, these differences should
+  not impact their integration.
+* BREAKING CHANGE: Model attributes that were formerly tuples are now lists.
 * The minFraud Factors subscores have been deprecated. They will be removed
   in March 2025. Please see `our release notes <https://dev.maxmind.com/minfraud/release-notes/2024/#deprecation-of-risk-factor-scoressubscores>`_
   for more information.
diff --git a/minfraud/models.py b/minfraud/models.py
index 094ea0a..55884cf 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -6,62 +6,15 @@
 
 """
 
-# pylint:disable=too-many-lines
-from collections import namedtuple
-from functools import update_wrapper
-from typing import Any, Dict, List, Optional, Tuple
+# pylint:disable=too-many-lines,too-many-instance-attributes,too-many-locals
+from typing import Dict, List, Optional
 
+from geoip2.mixins import SimpleEquality
 import geoip2.models
 import geoip2.records
 
 
-# Using a factory decorator rather than a metaclass as supporting
-# metaclasses on Python 2 and 3 is more painful (although we could use
-# six, I suppose). Using a closure rather than a class-based decorator as
-# class based decorators don't work right with `update_wrapper`,
-# causing help(class) to not work correctly.
-def _inflate_to_namedtuple(orig_cls):
-    keys = sorted(orig_cls._fields.keys())
-    fields = orig_cls._fields
-    name = orig_cls.__name__
-    orig_cls.__name__ += "Super"
-    ntup = namedtuple(name, keys)
-    ntup.__name__ = name + "NamedTuple"
-    ntup.__new__.__defaults__ = (None,) * len(keys)
-    new_cls = type(
-        name, (ntup, orig_cls), {"__slots__": (), "__doc__": orig_cls.__doc__}
-    )
-    update_wrapper(_inflate_to_namedtuple, new_cls)
-    orig_new = new_cls.__new__
-
-    # wipe out original namedtuple field docs as they aren't useful
-    # for attr in fields:
-    #     getattr(cls, attr).__func__.__doc__ = None
-
-    def new(cls, *args, **kwargs):
-        """Create new instance."""
-        if (args and kwargs) or len(args) > 1:
-            raise ValueError(
-                "Only provide a single (dict) positional argument"
-                " or use keyword arguments. Do not use both."
-            )
-        if args:
-            values = args[0] if args[0] else {}
-
-            for field, default in fields.items():
-                if callable(default):
-                    kwargs[field] = default(values.get(field))
-                else:
-                    kwargs[field] = values.get(field, default)
-
-        return orig_new(cls, **kwargs)
-
-    new_cls.__new__ = staticmethod(new)
-    return new_cls
-
-
-@_inflate_to_namedtuple
-class IPRiskReason:
+class IPRiskReason(SimpleEquality):
     """Reason for the IP risk.
 
     This class provides both a machine-readable code and a human-readable
@@ -102,19 +55,9 @@ class IPRiskReason:
     code: Optional[str]
     reason: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "code": None,
-        "reason": None,
-    }
-
-
-def _create_ip_risk_reasons(
-    reasons: Optional[List[Dict[str, str]]]
-) -> Tuple[IPRiskReason, ...]:
-    if not reasons:
-        return ()
-    return tuple(IPRiskReason(x) for x in reasons)  # type: ignore
+    def __init__(self, code: Optional[str] = None, reason: Optional[str] = None):
+        self.code = code
+        self.reason = reason
 
 
 class GeoIP2Location(geoip2.records.Location):
@@ -255,31 +198,27 @@ class IPAddress(geoip2.models.Insights):
     country: GeoIP2Country
     location: GeoIP2Location
     risk: Optional[float]
-    risk_reasons: Tuple[IPRiskReason, ...]
-
-    def __init__(self, ip_address: Dict[str, Any]) -> None:
-        if ip_address is None:
-            ip_address = {}
-        locales = ip_address.get("_locales")
-        if "_locales" in ip_address:
-            del ip_address["_locales"]
-        super().__init__(ip_address, locales=locales)
-        self.country = GeoIP2Country(locales, **ip_address.get("country", {}))
-        self.location = GeoIP2Location(**ip_address.get("location", {}))
-        self.risk = ip_address.get("risk", None)
-        self.risk_reasons = _create_ip_risk_reasons(ip_address.get("risk_reasons"))
-        self._finalized = True
-
-    # Unfortunately the GeoIP2 models are not immutable, only the records. This
-    # corrects that for minFraud
-    def __setattr__(self, name: str, value: Any) -> None:
-        if hasattr(self, "_finalized") and self._finalized:
-            raise AttributeError("can't set attribute")
-        super().__setattr__(name, value)
-
-
-@_inflate_to_namedtuple
-class ScoreIPAddress:
+    risk_reasons: List[IPRiskReason]
+
+    def __init__(
+        self,
+        *,
+        locales: Optional[List[str]] = None,
+        country: Optional[Dict] = None,
+        location: Optional[Dict] = None,
+        risk: Optional[float] = None,
+        risk_reasons: Optional[List[Dict]] = None,
+        **kwargs,
+    ) -> None:
+
+        super().__init__(kwargs, locales=locales)
+        self.country = GeoIP2Country(locales, **(country or {}))
+        self.location = GeoIP2Location(**(location or {}))
+        self.risk = risk
+        self.risk_reasons = [IPRiskReason(**x) for x in risk_reasons or []]
+
+
+class ScoreIPAddress(SimpleEquality):
     """Information about the IP address for minFraud Score.
 
     .. attribute:: risk
@@ -292,14 +231,11 @@ class ScoreIPAddress:
 
     risk: Optional[float]
 
-    __slots__ = ()
-    _fields = {
-        "risk": None,
-    }
+    def __init__(self, *, risk: Optional[float] = None, **_):
+        self.risk = risk
 
 
-@_inflate_to_namedtuple
-class Issuer:
+class Issuer(SimpleEquality):
     """Information about the credit card issuer.
 
     .. attribute:: name
@@ -342,17 +278,22 @@ class Issuer:
     phone_number: Optional[str]
     matches_provided_phone_number: Optional[bool]
 
-    __slots__ = ()
-    _fields = {
-        "name": None,
-        "matches_provided_name": None,
-        "phone_number": None,
-        "matches_provided_phone_number": None,
-    }
-
-
-@_inflate_to_namedtuple
-class Device:
+    def __init__(
+        self,
+        *,
+        name: Optional[str] = None,
+        matches_provided_name: Optional[bool] = None,
+        phone_number: Optional[str] = None,
+        matches_provided_phone_number: Optional[bool] = None,
+        **_,
+    ):
+        self.name = name
+        self.matches_provided_name = matches_provided_name
+        self.phone_number = phone_number
+        self.matches_provided_phone_number = matches_provided_phone_number
+
+
+class Device(SimpleEquality):
     """Information about the device associated with the IP address.
 
     In order to receive device output from minFraud Insights or minFraud
@@ -396,17 +337,23 @@ class Device:
     last_seen: Optional[str]
     local_time: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "confidence": None,
-        "id": None,
-        "last_seen": None,
-        "local_time": None,
-    }
-
-
-@_inflate_to_namedtuple
-class Disposition:
+    def __init__(
+        self,
+        *,
+        confidence: Optional[float] = None,
+        # pylint:disable=redefined-builtin
+        id: Optional[str] = None,
+        last_seen: Optional[str] = None,
+        local_time: Optional[str] = None,
+        **_,
+    ):
+        self.confidence = confidence
+        self.id = id
+        self.last_seen = last_seen
+        self.local_time = local_time
+
+
+class Disposition(SimpleEquality):
     """Information about disposition for the request as set by custom rules.
 
     In order to receive a disposition, you must be use the minFraud custom
@@ -442,16 +389,20 @@ class Disposition:
     reason: Optional[str]
     rule_label: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "action": None,
-        "reason": None,
-        "rule_label": None,
-    }
+    def __init__(
+        self,
+        *,
+        action: Optional[str] = None,
+        reason: Optional[str] = None,
+        rule_label: Optional[str] = None,
+        **_,
+    ):
+        self.action = action
+        self.reason = reason
+        self.rule_label = rule_label
 
 
-@_inflate_to_namedtuple
-class EmailDomain:
+class EmailDomain(SimpleEquality):
     """Information about the email domain passed in the request.
 
     .. attribute:: first_seen
@@ -466,14 +417,11 @@ class EmailDomain:
 
     first_seen: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "first_seen": None,
-    }
+    def __init__(self, *, first_seen: Optional[str] = None, **_):
+        self.first_seen = first_seen
 
 
-@_inflate_to_namedtuple
-class Email:
+class Email(SimpleEquality):
     """Information about the email address passed in the request.
 
     .. attribute:: domain
@@ -521,18 +469,22 @@ class Email:
     is_free: Optional[bool]
     is_high_risk: Optional[bool]
 
-    __slots__ = ()
-    _fields = {
-        "domain": EmailDomain,
-        "first_seen": None,
-        "is_disposable": None,
-        "is_free": None,
-        "is_high_risk": None,
-    }
-
-
-@_inflate_to_namedtuple
-class CreditCard:
+    def __init__(
+        self,
+        domain: Optional[Dict] = None,
+        first_seen: Optional[str] = None,
+        is_disposable: Optional[bool] = None,
+        is_free: Optional[bool] = None,
+        is_high_risk: Optional[bool] = None,
+    ):
+        self.domain = EmailDomain(**(domain or {}))
+        self.first_seen = first_seen
+        self.is_disposable = is_disposable
+        self.is_free = is_free
+        self.is_high_risk = is_high_risk
+
+
+class CreditCard(SimpleEquality):
     """Information about the credit card based on the issuer ID number.
 
     .. attribute:: country
@@ -604,21 +556,29 @@ class CreditCard:
     is_virtual: Optional[bool]
     type: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "issuer": Issuer,
-        "country": None,
-        "brand": None,
-        "is_business": None,
-        "is_issued_in_billing_address_country": None,
-        "is_prepaid": None,
-        "is_virtual": None,
-        "type": None,
-    }
-
-
-@_inflate_to_namedtuple
-class BillingAddress:
+    def __init__(
+        self,
+        issuer: Optional[Dict] = None,
+        country: Optional[str] = None,
+        brand: Optional[str] = None,
+        is_business: Optional[bool] = None,
+        is_issued_in_billing_address_country: Optional[bool] = None,
+        is_prepaid: Optional[bool] = None,
+        is_virtual: Optional[bool] = None,
+        # pylint:disable=redefined-builtin
+        type: Optional[str] = None,
+    ):
+        self.issuer = Issuer(**(issuer or {}))
+        self.country = country
+        self.brand = brand
+        self.is_business = is_business
+        self.is_issued_in_billing_address_country = is_issued_in_billing_address_country
+        self.is_prepaid = is_prepaid
+        self.is_virtual = is_virtual
+        self.type = type
+
+
+class BillingAddress(SimpleEquality):
     """Information about the billing address.
 
     .. attribute:: distance_to_ip_location
@@ -667,18 +627,24 @@ class BillingAddress:
     distance_to_ip_location: Optional[int]
     is_in_ip_country: Optional[bool]
 
-    __slots__ = ()
-    _fields = {
-        "is_postal_in_city": None,
-        "latitude": None,
-        "longitude": None,
-        "distance_to_ip_location": None,
-        "is_in_ip_country": None,
-    }
-
-
-@_inflate_to_namedtuple
-class ShippingAddress:
+    def __init__(
+        self,
+        *,
+        is_postal_in_city: Optional[bool] = None,
+        latitude: Optional[float] = None,
+        longitude: Optional[float] = None,
+        distance_to_ip_location: Optional[int] = None,
+        is_in_ip_country: Optional[bool] = None,
+        **_,
+    ):
+        self.is_postal_in_city = is_postal_in_city
+        self.latitude = latitude
+        self.longitude = longitude
+        self.distance_to_ip_location = distance_to_ip_location
+        self.is_in_ip_country = is_in_ip_country
+
+
+class ShippingAddress(SimpleEquality):
     """Information about the shipping address.
 
     .. attribute:: distance_to_ip_location
@@ -746,20 +712,28 @@ class ShippingAddress:
     is_high_risk: Optional[bool]
     distance_to_billing_address: Optional[int]
 
-    __slots__ = ()
-    _fields = {
-        "is_postal_in_city": None,
-        "latitude": None,
-        "longitude": None,
-        "distance_to_ip_location": None,
-        "is_in_ip_country": None,
-        "is_high_risk": None,
-        "distance_to_billing_address": None,
-    }
-
-
-@_inflate_to_namedtuple
-class Phone:
+    def __init__(
+        self,
+        *,
+        is_postal_in_city: Optional[bool] = None,
+        latitude: Optional[float] = None,
+        longitude: Optional[float] = None,
+        distance_to_ip_location: Optional[int] = None,
+        is_in_ip_country: Optional[bool] = None,
+        is_high_risk: Optional[bool] = None,
+        distance_to_billing_address: Optional[int] = None,
+        **_,
+    ):
+        self.is_postal_in_city = is_postal_in_city
+        self.latitude = latitude
+        self.longitude = longitude
+        self.distance_to_ip_location = distance_to_ip_location
+        self.is_in_ip_country = is_in_ip_country
+        self.is_high_risk = is_high_risk
+        self.distance_to_billing_address = distance_to_billing_address
+
+
+class Phone(SimpleEquality):
     """Information about the billing or shipping phone number.
 
     .. attribute:: country
@@ -801,17 +775,22 @@ class Phone:
     network_operator: Optional[str]
     number_type: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "country": None,
-        "is_voip": None,
-        "network_operator": None,
-        "number_type": None,
-    }
-
-
-@_inflate_to_namedtuple
-class ServiceWarning:
+    def __init__(
+        self,
+        *,
+        country: Optional[str] = None,
+        is_voip: Optional[bool] = None,
+        network_operator: Optional[str] = None,
+        number_type: Optional[str] = None,
+        **_,
+    ):
+        self.country = country
+        self.is_voip = is_voip
+        self.network_operator = network_operator
+        self.number_type = number_type
+
+
+class ServiceWarning(SimpleEquality):
     """Warning from the web service.
 
     .. attribute:: code
@@ -845,22 +824,20 @@ class ServiceWarning:
     warning: Optional[str]
     input_pointer: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "code": None,
-        "warning": None,
-        "input_pointer": None,
-    }
-
+    def __init__(
+        self,
+        *,
+        code: Optional[str] = None,
+        warning: Optional[str] = None,
+        input_pointer: Optional[str] = None,
+        **_,
+    ):
+        self.code = code
+        self.warning = warning
+        self.input_pointer = input_pointer
 
-def _create_warnings(warnings: List[Dict[str, str]]) -> Tuple[ServiceWarning, ...]:
-    if not warnings:
-        return ()
-    return tuple(ServiceWarning(x) for x in warnings)  # type: ignore
 
-
-@_inflate_to_namedtuple
-class Subscores:
+class Subscores(SimpleEquality):
     """Risk factor scores used in calculating the overall risk score.
 
     .. deprecated:: 2.12.0
@@ -1053,33 +1030,58 @@ class Subscores:
     shipping_address_distance_to_ip_location: Optional[float]
     time_of_day: Optional[float]
 
-    __slots__ = ()
-    _fields = {
-        "avs_result": None,
-        "billing_address": None,
-        "billing_address_distance_to_ip_location": None,
-        "browser": None,
-        "chargeback": None,
-        "country": None,
-        "country_mismatch": None,
-        "cvv_result": None,
-        "device": None,
-        "email_address": None,
-        "email_domain": None,
-        "email_local_part": None,
-        "email_tenure": None,
-        "ip_tenure": None,
-        "issuer_id_number": None,
-        "order_amount": None,
-        "phone_number": None,
-        "shipping_address": None,
-        "shipping_address_distance_to_ip_location": None,
-        "time_of_day": None,
-    }
-
-
-@_inflate_to_namedtuple
-class Reason:
+    def __init__(
+        self,
+        *,
+        avs_result: Optional[float] = None,
+        billing_address: Optional[float] = None,
+        billing_address_distance_to_ip_location: Optional[float] = None,
+        browser: Optional[float] = None,
+        chargeback: Optional[float] = None,
+        country: Optional[float] = None,
+        country_mismatch: Optional[float] = None,
+        cvv_result: Optional[float] = None,
+        device: Optional[float] = None,
+        email_address: Optional[float] = None,
+        email_domain: Optional[float] = None,
+        email_local_part: Optional[float] = None,
+        email_tenure: Optional[float] = None,
+        ip_tenure: Optional[float] = None,
+        issuer_id_number: Optional[float] = None,
+        order_amount: Optional[float] = None,
+        phone_number: Optional[float] = None,
+        shipping_address: Optional[float] = None,
+        shipping_address_distance_to_ip_location: Optional[float] = None,
+        time_of_day: Optional[float] = None,
+        **_,
+    ):
+        self.avs_result = avs_result
+        self.billing_address = billing_address
+        self.billing_address_distance_to_ip_location = (
+            billing_address_distance_to_ip_location
+        )
+        self.browser = browser
+        self.chargeback = chargeback
+        self.country = country
+        self.country_mismatch = country_mismatch
+        self.cvv_result = cvv_result
+        self.device = device
+        self.email_address = email_address
+        self.email_domain = email_domain
+        self.email_local_part = email_local_part
+        self.email_tenure = email_tenure
+        self.ip_tenure = ip_tenure
+        self.issuer_id_number = issuer_id_number
+        self.order_amount = order_amount
+        self.phone_number = phone_number
+        self.shipping_address = shipping_address
+        self.shipping_address_distance_to_ip_location = (
+            shipping_address_distance_to_ip_location
+        )
+        self.time_of_day = time_of_day
+
+
+class Reason(SimpleEquality):
     """The risk score reason for the multiplier.
 
     This class provides both a machine-readable code and a human-readable
@@ -1165,21 +1167,14 @@ class Reason:
     code: Optional[str]
     reason: Optional[str]
 
-    __slots__ = ()
-    _fields = {
-        "code": None,
-        "reason": None,
-    }
-
-
-def _create_reasons(reasons: Optional[List[Dict[str, str]]]) -> Tuple[Reason, ...]:
-    if not reasons:
-        return ()
-    return tuple(Reason(x) for x in reasons)  # type: ignore
+    def __init__(
+        self, *, code: Optional[str] = None, reason: Optional[str] = None, **_
+    ):
+        self.code = code
+        self.reason = reason
 
 
-@_inflate_to_namedtuple
-class RiskScoreReason:
+class RiskScoreReason(SimpleEquality):
     """The risk score multiplier and the reasons for that multiplier.
 
     .. attribute:: multiplier
@@ -1201,25 +1196,20 @@ class RiskScoreReason:
     """
 
     multiplier: float
-    reasons: Tuple[Reason, ...]
-
-    __slots__ = ()
-    _fields = {
-        "multiplier": None,
-        "reasons": _create_reasons,
-    }
-
+    reasons: List[Reason]
 
-def _create_risk_score_reasons(
-    risk_score_reasons: Optional[List[Dict[str, str]]]
-) -> Tuple[RiskScoreReason, ...]:
-    if not risk_score_reasons:
-        return ()
-    return tuple(RiskScoreReason(x) for x in risk_score_reasons)  # type: ignore
+    def __init__(
+        self,
+        *,
+        multiplier: float,
+        reasons: Optional[List] = None,
+        **_,
+    ):
+        self.multiplier = multiplier
+        self.reasons = [Reason(**x) for x in reasons or []]
 
 
-@_inflate_to_namedtuple
-class Factors:
+class Factors(SimpleEquality):
     """Model for Factors response.
 
     .. attribute:: id
@@ -1361,32 +1351,52 @@ class Factors:
     shipping_address: ShippingAddress
     shipping_phone: Phone
     subscores: Subscores
-    warnings: Tuple[ServiceWarning, ...]
-    risk_score_reasons: Tuple[RiskScoreReason, ...]
-
-    __slots__ = ()
-    _fields = {
-        "billing_address": BillingAddress,
-        "billing_phone": Phone,
-        "credit_card": CreditCard,
-        "disposition": Disposition,
-        "funds_remaining": None,
-        "device": Device,
-        "email": Email,
-        "id": None,
-        "ip_address": IPAddress,
-        "queries_remaining": None,
-        "risk_score": None,
-        "shipping_address": ShippingAddress,
-        "shipping_phone": Phone,
-        "subscores": Subscores,
-        "warnings": _create_warnings,
-        "risk_score_reasons": _create_risk_score_reasons,
-    }
-
-
-@_inflate_to_namedtuple
-class Insights:
+    warnings: List[ServiceWarning]
+    risk_score_reasons: List[RiskScoreReason]
+
+    def __init__(
+        self,
+        *,
+        billing_address: Optional[Dict] = None,
+        billing_phone: Optional[Dict] = None,
+        credit_card: Optional[Dict] = None,
+        disposition: Optional[Dict] = None,
+        funds_remaining: float,
+        device: Optional[Dict] = None,
+        email: Optional[Dict] = None,
+        # pylint:disable=redefined-builtin
+        id: str,
+        ip_address: Optional[Dict] = None,
+        queries_remaining: int,
+        risk_score: float,
+        shipping_address: Optional[Dict] = None,
+        shipping_phone: Optional[Dict] = None,
+        subscores: Optional[Dict] = None,
+        warnings: Optional[List[Dict]] = None,
+        risk_score_reasons: Optional[List[Dict]] = None,
+        **_,
+    ):
+        self.billing_address = BillingAddress(**(billing_address or {}))
+        self.billing_phone = Phone(**(billing_phone or {}))
+        self.credit_card = CreditCard(**(credit_card or {}))
+        self.disposition = Disposition(**(disposition or {}))
+        self.funds_remaining = funds_remaining
+        self.device = Device(**(device or {}))
+        self.email = Email(**(email or {}))
+        self.id = id
+        self.ip_address = IPAddress(**(ip_address or {}))
+        self.queries_remaining = queries_remaining
+        self.risk_score = risk_score
+        self.shipping_address = ShippingAddress(**(shipping_address or {}))
+        self.shipping_phone = Phone(**(shipping_phone or {}))
+        self.subscores = Subscores(**(subscores or {}))
+        self.warnings = [ServiceWarning(**x) for x in warnings or []]
+        self.risk_score_reasons = [
+            RiskScoreReason(**x) for x in risk_score_reasons or []
+        ]
+
+
+class Insights(SimpleEquality):
     """Model for Insights response.
 
     .. attribute:: id
@@ -1507,29 +1517,45 @@ class Insights:
     risk_score: float
     shipping_address: ShippingAddress
     shipping_phone: Phone
-    warnings: Tuple[ServiceWarning, ...]
-
-    __slots__ = ()
-    _fields = {
-        "billing_address": BillingAddress,
-        "billing_phone": Phone,
-        "credit_card": CreditCard,
-        "device": Device,
-        "disposition": Disposition,
-        "email": Email,
-        "funds_remaining": None,
-        "id": None,
-        "ip_address": IPAddress,
-        "queries_remaining": None,
-        "risk_score": None,
-        "shipping_address": ShippingAddress,
-        "shipping_phone": Phone,
-        "warnings": _create_warnings,
-    }
-
-
-@_inflate_to_namedtuple
-class Score:
+    warnings: List[ServiceWarning]
+
+    def __init__(
+        self,
+        *,
+        billing_address: Optional[Dict] = None,
+        billing_phone: Optional[Dict] = None,
+        credit_card: Optional[Dict] = None,
+        device: Optional[Dict] = None,
+        disposition: Optional[Dict] = None,
+        email: Optional[Dict] = None,
+        funds_remaining: float,
+        # pylint:disable=redefined-builtin
+        id: str,
+        ip_address: Optional[Dict] = None,
+        queries_remaining: int,
+        risk_score: float,
+        shipping_address: Optional[Dict] = None,
+        shipping_phone: Optional[Dict] = None,
+        warnings: Optional[List[Dict]] = None,
+        **_,
+    ):
+        self.billing_address = BillingAddress(**(billing_address or {}))
+        self.billing_phone = Phone(**(billing_phone or {}))
+        self.credit_card = CreditCard(**(credit_card or {}))
+        self.device = Device(**(device or {}))
+        self.disposition = Disposition(**(disposition or {}))
+        self.email = Email(**(email or {}))
+        self.funds_remaining = funds_remaining
+        self.id = id
+        self.ip_address = IPAddress(**(ip_address or {}))
+        self.queries_remaining = queries_remaining
+        self.risk_score = risk_score
+        self.shipping_address = ShippingAddress(**(shipping_address or {}))
+        self.shipping_phone = Phone(**(shipping_phone or {}))
+        self.warnings = [ServiceWarning(**x) for x in warnings or []]
+
+
+class Score(SimpleEquality):
     """Model for Score response.
 
     .. attribute:: id
@@ -1593,15 +1619,25 @@ class Score:
     ip_address: ScoreIPAddress
     queries_remaining: int
     risk_score: float
-    warnings: Tuple[ServiceWarning, ...]
-
-    __slots__ = ()
-    _fields = {
-        "disposition": Disposition,
-        "funds_remaining": None,
-        "id": None,
-        "ip_address": ScoreIPAddress,
-        "queries_remaining": None,
-        "risk_score": None,
-        "warnings": _create_warnings,
-    }
+    warnings: List[ServiceWarning]
+
+    def __init__(
+        self,
+        *,
+        disposition: Optional[Dict] = None,
+        funds_remaining: float,
+        # pylint:disable=redefined-builtin
+        id: str,
+        ip_address: Optional[Dict] = None,
+        queries_remaining: int,
+        risk_score: float,
+        warnings: Optional[List[Dict]] = None,
+        **_,
+    ):
+        self.disposition = Disposition(**(disposition or {}))
+        self.funds_remaining = funds_remaining
+        self.id = id
+        self.ip_address = ScoreIPAddress(**(ip_address or {}))
+        self.queries_remaining = queries_remaining
+        self.risk_score = risk_score
+        self.warnings = [ServiceWarning(**x) for x in warnings or []]
diff --git a/minfraud/webservice.py b/minfraud/webservice.py
index 69d7be7..78e46df 100644
--- a/minfraud/webservice.py
+++ b/minfraud/webservice.py
@@ -82,8 +82,8 @@ def _handle_success(
                 uri,
             ) from ex
         if "ip_address" in decoded_body:
-            decoded_body["ip_address"]["_locales"] = self._locales
-        return model_class(decoded_body)  # type: ignore
+            decoded_body["ip_address"]["locales"] = self._locales
+        return model_class(**decoded_body)  # type: ignore
 
     def _exception_for_error(
         self, status: int, content_type: Optional[str], raw_body: str, uri: str
diff --git a/tests/test_models.py b/tests/test_models.py
index b42bacb..34943c4 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,33 +1,11 @@
-from minfraud.models import *
-
 import unittest
 
+from minfraud.models import *
 
-class TestModels(unittest.TestCase):
-    def test_model_immutability(self):
-        """This tests some level of _shallow_ immutability for these classes"""
-        T = namedtuple("T", ["obj", "attr"])
-        models = [
-            T(IPRiskReason(), "code"),
-            T(Issuer(), "name"),
-            T(CreditCard(), "country"),
-            T(Device(), "id"),
-            T(Email(), "is_free"),
-            T(EmailDomain(), "first_seen"),
-            T(BillingAddress(), "latitude"),
-            T(ShippingAddress(), "latitude"),
-            T(ServiceWarning(), "code"),
-            T(Insights(), "id"),
-            T(Score(), "id"),
-            T(IPAddress({}), "city"),
-        ]
-        for model in models:
-            for attr in (model.attr, "does_not_exist"):
-                with self.assertRaises(AttributeError, msg=f"{model.obj} - {attr}"):
-                    setattr(model.obj, attr, 5)  # type: ignore
 
+class TestModels(unittest.TestCase):
     def test_billing_address(self):
-        address = BillingAddress(self.address_dict)
+        address = BillingAddress(**self.address_dict)
         self.check_address(address)
 
     def test_shipping_address(self):
@@ -35,7 +13,7 @@ def test_shipping_address(self):
         address_dict["is_high_risk"] = False
         address_dict["distance_to_billing_address"] = 200
 
-        address = ShippingAddress(address_dict)
+        address = ShippingAddress(**address_dict)
         self.check_address(address)
         self.assertEqual(False, address.is_high_risk)
         self.assertEqual(200, address.distance_to_billing_address)
@@ -59,16 +37,14 @@ def check_address(self, address):
 
     def test_credit_card(self):
         cc = CreditCard(
-            {
-                "issuer": {"name": "Bank"},
-                "brand": "Visa",
-                "country": "US",
-                "is_issued_in_billing_address_country": True,
-                "is_business": True,
-                "is_prepaid": True,
-                "is_virtual": True,
-                "type": "credit",
-            }
+            issuer={"name": "Bank"},
+            brand="Visa",
+            country="US",
+            is_issued_in_billing_address_country=True,
+            is_business=True,
+            is_prepaid=True,
+            is_virtual=True,
+            type="credit",
         )
 
         self.assertEqual("Bank", cc.issuer.name)
@@ -85,12 +61,10 @@ def test_device(self):
         last_seen = "2016-06-08T14:16:38Z"
         local_time = "2016-06-10T14:19:10-08:00"
         device = Device(
-            {
-                "confidence": 99,
-                "id": id,
-                "last_seen": last_seen,
-                "local_time": local_time,
-            }
+            confidence=99,
+            id=id,
+            last_seen=last_seen,
+            local_time=local_time,
         )
 
         self.assertEqual(99, device.confidence)
@@ -100,7 +74,9 @@ def test_device(self):
 
     def test_disposition(self):
         disposition = Disposition(
-            {"action": "accept", "reason": "default", "rule_label": "custom rule label"}
+            action="accept",
+            reason="default",
+            rule_label="custom rule label",
         )
 
         self.assertEqual("accept", disposition.action)
@@ -110,12 +86,10 @@ def test_disposition(self):
     def test_email(self):
         first_seen = "2016-01-01"
         email = Email(
-            {
-                "first_seen": first_seen,
-                "is_disposable": True,
-                "is_free": True,
-                "is_high_risk": False,
-            }
+            first_seen=first_seen,
+            is_disposable=True,
+            is_free=True,
+            is_high_risk=False,
         )
 
         self.assertEqual(first_seen, email.first_seen)
@@ -126,9 +100,7 @@ def test_email(self):
     def test_email_domain(self):
         first_seen = "2016-01-01"
         domain = EmailDomain(
-            {
-                "first_seen": first_seen,
-            }
+            first_seen=first_seen,
         )
 
         self.assertEqual(first_seen, domain.first_seen)
@@ -147,38 +119,36 @@ def test_geoip2_location(self):
     def test_ip_address(self):
         time = "2015-04-19T12:59:23-01:00"
         address = IPAddress(
-            {
-                "country": {
-                    "is_high_risk": True,
-                    "is_in_european_union": True,
-                },
-                "location": {
-                    "local_time": time,
+            country={
+                "is_high_risk": True,
+                "is_in_european_union": True,
+            },
+            location={
+                "local_time": time,
+            },
+            risk=99,
+            risk_reasons=[
+                {
+                    "code": "ANONYMOUS_IP",
+                    "reason": "The IP address belongs to an anonymous network. See /ip_address/traits for more details.",
                 },
-                "risk": 99,
-                "risk_reasons": [
-                    {
-                        "code": "ANONYMOUS_IP",
-                        "reason": "The IP address belongs to an anonymous network. See /ip_address/traits for more details.",
-                    },
-                    {
-                        "code": "MINFRAUD_NETWORK_ACTIVITY",
-                        "reason": "Suspicious activity has been seen on this IP address across minFraud customers.",
-                    },
-                ],
-                "traits": {
-                    "is_anonymous": True,
-                    "is_anonymous_proxy": True,
-                    "is_anonymous_vpn": True,
-                    "is_hosting_provider": True,
-                    "is_public_proxy": True,
-                    "is_residential_proxy": True,
-                    "is_satellite_provider": True,
-                    "is_tor_exit_node": True,
-                    "mobile_country_code": "310",
-                    "mobile_network_code": "004",
+                {
+                    "code": "MINFRAUD_NETWORK_ACTIVITY",
+                    "reason": "Suspicious activity has been seen on this IP address across minFraud customers.",
                 },
-            }
+            ],
+            traits={
+                "is_anonymous": True,
+                "is_anonymous_proxy": True,
+                "is_anonymous_vpn": True,
+                "is_hosting_provider": True,
+                "is_public_proxy": True,
+                "is_residential_proxy": True,
+                "is_satellite_provider": True,
+                "is_tor_exit_node": True,
+                "mobile_country_code": "310",
+                "mobile_network_code": "004",
+            },
         )
 
         self.assertEqual(time, address.location.local_time)
@@ -210,20 +180,18 @@ def test_ip_address(self):
         )
 
     def test_empty_address(self):
-        address = IPAddress({})
-        self.assertEqual((), address.risk_reasons)
+        address = IPAddress()
+        self.assertEqual([], address.risk_reasons)
 
     def test_score_ip_address(self):
-        address = ScoreIPAddress({"risk": 99})
+        address = ScoreIPAddress(risk=99)
         self.assertEqual(99, address.risk)
 
     def test_ip_address_locales(self):
         loc = IPAddress(
-            {
-                "_locales": ["fr"],
-                "country": {"names": {"fr": "Country"}},
-                "city": {"names": {"fr": "City"}},
-            }
+            locales=["fr"],
+            country={"names": {"fr": "Country"}},
+            city={"names": {"fr": "City"}},
         )
 
         self.assertEqual("City", loc.city.name)
@@ -233,12 +201,10 @@ def test_issuer(self):
         phone = "132-342-2131"
 
         issuer = Issuer(
-            {
-                "name": "Bank",
-                "matches_provided_name": True,
-                "phone_number": phone,
-                "matches_provided_phone_number": True,
-            }
+            name="Bank",
+            matches_provided_name=True,
+            phone_number=phone,
+            matches_provided_phone_number=True,
         )
 
         self.assertEqual("Bank", issuer.name)
@@ -248,12 +214,10 @@ def test_issuer(self):
 
     def test_phone(self):
         phone = Phone(
-            {
-                "country": "US",
-                "is_voip": True,
-                "network_operator": "Verizon/1",
-                "number_type": "fixed",
-            }
+            country="US",
+            is_voip=True,
+            network_operator="Verizon/1",
+            number_type="fixed",
         )
 
         self.assertEqual("US", phone.country)
@@ -265,9 +229,7 @@ def test_warning(self):
         code = "INVALID_INPUT"
         msg = "Input invalid"
 
-        warning = ServiceWarning(
-            {"code": code, "warning": msg, "input_pointer": "/first/second"}
-        )
+        warning = ServiceWarning(code=code, warning=msg, input_pointer="/first/second")
 
         self.assertEqual(code, warning.code)
         self.assertEqual(msg, warning.warning)
@@ -277,7 +239,7 @@ def test_reason(self):
         code = "EMAIL_ADDRESS_NEW"
         msg = "Riskiness of newly-sighted email address"
 
-        reason = Reason({"code": code, "reason": msg})
+        reason = Reason(code=code, reason=msg)
 
         self.assertEqual(code, reason.code)
         self.assertEqual(msg, reason.reason)
@@ -288,7 +250,8 @@ def test_risk_score_reason(self):
         msg = "Riskiness of newly-sighted email address"
 
         reason = RiskScoreReason(
-            {"multiplier": 0.34, "reasons": [{"code": code, "reason": msg}]}
+            multiplier=0.34,
+            reasons=[{"code": code, "reason": msg}],
         )
 
         self.assertEqual(multiplier, reason.multiplier)
@@ -298,14 +261,12 @@ def test_risk_score_reason(self):
     def test_score(self):
         id = "b643d445-18b2-4b9d-bad4-c9c4366e402a"
         score = Score(
-            {
-                "id": id,
-                "funds_remaining": 10.01,
-                "queries_remaining": 123,
-                "risk_score": 0.01,
-                "ip_address": {"risk": 99},
-                "warnings": [{"code": "INVALID_INPUT"}],
-            }
+            id=id,
+            funds_remaining=10.01,
+            queries_remaining=123,
+            risk_score=0.01,
+            ip_address={"risk": 99},
+            warnings=[{"code": "INVALID_INPUT"}],
         )
 
         self.assertEqual(id, score.id)
@@ -318,12 +279,12 @@ def test_score(self):
     def test_insights(self):
         response = self.factors_response()
         del response["subscores"]
-        insights = Insights(response)
+        insights = Insights(**response)
         self.check_insights_data(insights, response["id"])
 
     def test_factors(self):
         response = self.factors_response()
-        factors = Factors(response)
+        factors = Factors(**response)
         self.check_insights_data(factors, response["id"])
         self.check_risk_score_reasons_data(factors.risk_score_reasons)
         self.assertEqual(0.01, factors.subscores.avs_result)
@@ -427,9 +388,7 @@ def check_insights_data(self, insights, uuid):
         self.assertEqual(123, insights.queries_remaining)
         self.assertEqual(0.01, insights.risk_score)
         self.assertEqual("INVALID_INPUT", insights.warnings[0].code)
-        self.assertIsInstance(
-            insights.warnings, tuple, "warnings is a tuple, not a dict"
-        )
+        self.assertIsInstance(insights.warnings, list, "warnings is a list")
 
     def check_risk_score_reasons_data(self, reasons):
         self.assertEqual(1, len(reasons))
diff --git a/tests/test_webservice.py b/tests/test_webservice.py
index 7f5a9c9..62b55d7 100644
--- a/tests/test_webservice.py
+++ b/tests/test_webservice.py
@@ -182,8 +182,8 @@ def test_200(self):
         model = self.create_success()
         response = json.loads(self.response)
         if self.has_ip_location():
-            response["ip_address"]["_locales"] = ("en",)
-        self.assertEqual(self.cls(response), model)
+            response["ip_address"]["locales"] = ("en",)
+        self.assertEqual(self.cls(**response), model)
         if self.has_ip_location():
             self.assertEqual("United Kingdom", model.ip_address.country.name)
             self.assertEqual(True, model.ip_address.traits.is_residential_proxy)
@@ -241,8 +241,8 @@ def test_200_with_locales(self):
         model = self.create_success(client=client)
         response = json.loads(self.response)
         if self.has_ip_location():
-            response["ip_address"]["_locales"] = locales
-        self.assertEqual(self.cls(response), model)
+            response["ip_address"]["locales"] = locales
+        self.assertEqual(self.cls(**response), model)
         if self.has_ip_location():
             self.assertEqual("Royaume-Uni", model.ip_address.country.name)
             self.assertEqual("Londres", model.ip_address.city.name)
@@ -251,6 +251,8 @@ def test_200_with_reserved_ip_warning(self):
         model = self.create_success(
             """
                 {
+                    "funds_remaining": 10.00,
+                    "queries_remaining": 1000,
                     "risk_score": 12,
                     "id": "0e52f5ac-7690-4780-a939-173cb13ecd75",
                     "warnings": [
@@ -274,7 +276,7 @@ def test_200_with_no_risk_score_reasons(self):
         response = json.loads(self.response)
         del response["risk_score_reasons"]
         model = self.create_success(text=json.dumps(response))
-        self.assertEqual(tuple(), model.risk_score_reasons)
+        self.assertEqual([], model.risk_score_reasons)
 
     def test_200_with_no_body(self):
         with self.assertRaisesRegex(

From 165d40a36f0f841ea8df66ac9897f73d29aeecca Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Thu, 16 Jan 2025 13:56:51 -0800
Subject: [PATCH 2/7] Pass in locales as a normal parameter

---
 minfraud/models.py       | 12 +++++++-----
 minfraud/webservice.py   | 27 +++++++++++++--------------
 tests/test_models.py     |  9 +++++----
 tests/test_webservice.py | 11 +++++++----
 4 files changed, 32 insertions(+), 27 deletions(-)

diff --git a/minfraud/models.py b/minfraud/models.py
index 55884cf..ce6c2b9 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -7,7 +7,7 @@
 """
 
 # pylint:disable=too-many-lines,too-many-instance-attributes,too-many-locals
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Sequence
 
 from geoip2.mixins import SimpleEquality
 import geoip2.models
@@ -202,8 +202,8 @@ class IPAddress(geoip2.models.Insights):
 
     def __init__(
         self,
+        locales: Sequence[str],
         *,
-        locales: Optional[List[str]] = None,
         country: Optional[Dict] = None,
         location: Optional[Dict] = None,
         risk: Optional[float] = None,
@@ -211,7 +211,7 @@ def __init__(
         **kwargs,
     ) -> None:
 
-        super().__init__(kwargs, locales=locales)
+        super().__init__(kwargs, locales=list(locales))
         self.country = GeoIP2Country(locales, **(country or {}))
         self.location = GeoIP2Location(**(location or {}))
         self.risk = risk
@@ -1356,6 +1356,7 @@ class Factors(SimpleEquality):
 
     def __init__(
         self,
+        locales: Sequence[str],
         *,
         billing_address: Optional[Dict] = None,
         billing_phone: Optional[Dict] = None,
@@ -1384,7 +1385,7 @@ def __init__(
         self.device = Device(**(device or {}))
         self.email = Email(**(email or {}))
         self.id = id
-        self.ip_address = IPAddress(**(ip_address or {}))
+        self.ip_address = IPAddress(locales, **(ip_address or {}))
         self.queries_remaining = queries_remaining
         self.risk_score = risk_score
         self.shipping_address = ShippingAddress(**(shipping_address or {}))
@@ -1521,6 +1522,7 @@ class Insights(SimpleEquality):
 
     def __init__(
         self,
+        locales: Sequence[str],
         *,
         billing_address: Optional[Dict] = None,
         billing_phone: Optional[Dict] = None,
@@ -1547,7 +1549,7 @@ def __init__(
         self.email = Email(**(email or {}))
         self.funds_remaining = funds_remaining
         self.id = id
-        self.ip_address = IPAddress(**(ip_address or {}))
+        self.ip_address = IPAddress(locales, **(ip_address or {}))
         self.queries_remaining = queries_remaining
         self.risk_score = risk_score
         self.shipping_address = ShippingAddress(**(shipping_address or {}))
diff --git a/minfraud/webservice.py b/minfraud/webservice.py
index 78e46df..7e6bcb0 100644
--- a/minfraud/webservice.py
+++ b/minfraud/webservice.py
@@ -7,7 +7,8 @@
 """
 
 import json
-from typing import Any, cast, Dict, Optional, Tuple, Type, Union
+from functools import partial
+from typing import Any, Callable, cast, Dict, Optional, Sequence, Union
 
 import aiohttp
 import aiohttp.http
@@ -39,7 +40,7 @@
 class BaseClient:
     _account_id: str
     _license_key: str
-    _locales: Tuple[str, ...]
+    _locales: Sequence[str]
     _timeout: float
 
     _score_uri: str
@@ -52,7 +53,7 @@ def __init__(
         account_id: int,
         license_key: str,
         host: str = "minfraud.maxmind.com",
-        locales: Tuple[str, ...] = ("en",),
+        locales: Sequence[str] = ("en",),
         timeout: float = 60,
     ) -> None:
         self._locales = locales
@@ -69,7 +70,7 @@ def _handle_success(
         self,
         raw_body: str,
         uri: str,
-        model_class: Union[Type[Factors], Type[Score], Type[Insights]],
+        model_class: Callable,
     ) -> Union[Score, Factors, Insights]:
         """Handle successful response."""
         try:
@@ -81,8 +82,6 @@ def _handle_success(
                 200,
                 uri,
             ) from ex
-        if "ip_address" in decoded_body:
-            decoded_body["ip_address"]["locales"] = self._locales
         return model_class(**decoded_body)  # type: ignore
 
     def _exception_for_error(
@@ -210,7 +209,7 @@ def __init__(
         account_id: int,
         license_key: str,
         host: str = "minfraud.maxmind.com",
-        locales: Tuple[str, ...] = ("en",),
+        locales: Sequence[str] = ("en",),
         timeout: float = 60,
         proxy: Optional[str] = None,
     ) -> None:
@@ -269,7 +268,7 @@ async def factors(
             Factors,
             await self._response_for(
                 self._factors_uri,
-                Factors,
+                partial(Factors, self._locales),
                 transaction,
                 validate,
                 hash_email,
@@ -308,7 +307,7 @@ async def insights(
             Insights,
             await self._response_for(
                 self._insights_uri,
-                Insights,
+                partial(Insights, self._locales),
                 transaction,
                 validate,
                 hash_email,
@@ -387,7 +386,7 @@ async def report(
     async def _response_for(
         self,
         uri: str,
-        model_class: Union[Type[Factors], Type[Score], Type[Insights]],
+        model_class: Callable,
         request: Dict[str, Any],
         validate: bool,
         hash_email: bool,
@@ -445,7 +444,7 @@ def __init__(
         account_id: int,
         license_key: str,
         host: str = "minfraud.maxmind.com",
-        locales: Tuple[str, ...] = ("en",),
+        locales: Sequence[str] = ("en",),
         timeout: float = 60,
         proxy: Optional[str] = None,
     ) -> None:
@@ -518,7 +517,7 @@ def factors(
             Factors,
             self._response_for(
                 self._factors_uri,
-                Factors,
+                partial(Factors, self._locales),
                 transaction,
                 validate,
                 hash_email,
@@ -557,7 +556,7 @@ def insights(
             Insights,
             self._response_for(
                 self._insights_uri,
-                Insights,
+                partial(Insights, self._locales),
                 transaction,
                 validate,
                 hash_email,
@@ -634,7 +633,7 @@ def report(self, report: Dict[str, Optional[str]], validate: bool = True) -> Non
     def _response_for(
         self,
         uri: str,
-        model_class: Union[Type[Factors], Type[Score], Type[Insights]],
+        model_class: Callable,
         request: Dict[str, Any],
         validate: bool,
         hash_email: bool,
diff --git a/tests/test_models.py b/tests/test_models.py
index 34943c4..83e0b10 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -119,6 +119,7 @@ def test_geoip2_location(self):
     def test_ip_address(self):
         time = "2015-04-19T12:59:23-01:00"
         address = IPAddress(
+            ["en"],
             country={
                 "is_high_risk": True,
                 "is_in_european_union": True,
@@ -180,7 +181,7 @@ def test_ip_address(self):
         )
 
     def test_empty_address(self):
-        address = IPAddress()
+        address = IPAddress([])
         self.assertEqual([], address.risk_reasons)
 
     def test_score_ip_address(self):
@@ -189,7 +190,7 @@ def test_score_ip_address(self):
 
     def test_ip_address_locales(self):
         loc = IPAddress(
-            locales=["fr"],
+            ["fr"],
             country={"names": {"fr": "Country"}},
             city={"names": {"fr": "City"}},
         )
@@ -279,12 +280,12 @@ def test_score(self):
     def test_insights(self):
         response = self.factors_response()
         del response["subscores"]
-        insights = Insights(**response)
+        insights = Insights(None, **response)
         self.check_insights_data(insights, response["id"])
 
     def test_factors(self):
         response = self.factors_response()
-        factors = Factors(**response)
+        factors = Factors(None, **response)
         self.check_insights_data(factors, response["id"])
         self.check_risk_score_reasons_data(factors.risk_score_reasons)
         self.assertEqual(0.01, factors.subscores.avs_result)
diff --git a/tests/test_webservice.py b/tests/test_webservice.py
index 62b55d7..47fab04 100644
--- a/tests/test_webservice.py
+++ b/tests/test_webservice.py
@@ -1,6 +1,7 @@
 import asyncio
 import json
 import os
+from functools import partial
 from io import open
 from typing import Type, Union
 from pytest_httpserver import HTTPServer
@@ -181,9 +182,10 @@ def has_ip_location(self):
     def test_200(self):
         model = self.create_success()
         response = json.loads(self.response)
+        cls = self.cls
         if self.has_ip_location():
-            response["ip_address"]["locales"] = ("en",)
-        self.assertEqual(self.cls(**response), model)
+            cls = partial(cls, ("en",))
+        self.assertEqual(cls(**response), model)
         if self.has_ip_location():
             self.assertEqual("United Kingdom", model.ip_address.country.name)
             self.assertEqual(True, model.ip_address.traits.is_residential_proxy)
@@ -240,9 +242,10 @@ def test_200_with_locales(self):
         )
         model = self.create_success(client=client)
         response = json.loads(self.response)
+        cls = self.cls
         if self.has_ip_location():
-            response["ip_address"]["locales"] = locales
-        self.assertEqual(self.cls(**response), model)
+            cls = partial(cls, locales)
+        self.assertEqual(cls(**response), model)
         if self.has_ip_location():
             self.assertEqual("Royaume-Uni", model.ip_address.country.name)
             self.assertEqual("Londres", model.ip_address.city.name)

From 518757dfa6c873ff2793403cd937663e962bcc49 Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Thu, 16 Jan 2025 15:22:04 -0800
Subject: [PATCH 3/7] Add to_dict method to models for serialization

Closes #26
---
 HISTORY.rst          |  3 ++
 minfraud/models.py   | 75 +++++++++++++++++++++++++++++++-------------
 tests/test_models.py | 54 ++++++++++++++++++++++++++-----
 3 files changed, 103 insertions(+), 29 deletions(-)

diff --git a/HISTORY.rst b/HISTORY.rst
index 2066db9..8c521af 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -12,6 +12,9 @@ History
   classes are no longer immutable. For most users, these differences should
   not impact their integration.
 * BREAKING CHANGE: Model attributes that were formerly tuples are now lists.
+* Added ``to_dict`` methods to the model classes. These return a dict version
+  of the object that is suitable for serialization. It recursively calls
+  ``to_dict`` or the equivalent on all objects contained within the object.
 * The minFraud Factors subscores have been deprecated. They will be removed
   in March 2025. Please see `our release notes <https://dev.maxmind.com/minfraud/release-notes/2024/#deprecation-of-risk-factor-scoressubscores>`_
   for more information.
diff --git a/minfraud/models.py b/minfraud/models.py
index ce6c2b9..3d6efe8 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -14,7 +14,31 @@
 import geoip2.records
 
 
-class IPRiskReason(SimpleEquality):
+class _Serializable(SimpleEquality):
+    def to_dict(self):
+        """Returns a dict of the object suitable for serialization"""
+        result = {}
+        for key, value in self.__dict__.items():
+            if hasattr(value, "to_dict") and callable(value.to_dict):
+                result[key] = value.to_dict()
+            elif hasattr(value, "raw"):
+                # geoip2 uses "raw" for historical reasons
+                result[key] = value.raw
+            elif isinstance(value, list):
+                result[key] = [
+                    (
+                        item.to_dict()
+                        if hasattr(item, "to_dict") and callable(item.to_dict)
+                        else item
+                    )
+                    for item in value
+                ]
+            else:
+                result[key] = value
+        return result
+
+
+class IPRiskReason(_Serializable):
     """Reason for the IP risk.
 
     This class provides both a machine-readable code and a human-readable
@@ -202,7 +226,7 @@ class IPAddress(geoip2.models.Insights):
 
     def __init__(
         self,
-        locales: Sequence[str],
+        locales: Optional[Sequence[str]],
         *,
         country: Optional[Dict] = None,
         location: Optional[Dict] = None,
@@ -210,15 +234,24 @@ def __init__(
         risk_reasons: Optional[List[Dict]] = None,
         **kwargs,
     ) -> None:
-
-        super().__init__(kwargs, locales=list(locales))
+        # For raw attribute
+        if country is not None:
+            kwargs["country"] = country
+        if location is not None:
+            kwargs["location"] = location
+        if risk is not None:
+            kwargs["risk"] = risk
+        if risk_reasons is not None:
+            kwargs["risk_reasons"] = risk_reasons
+
+        super().__init__(kwargs, locales=list(locales or []))
         self.country = GeoIP2Country(locales, **(country or {}))
         self.location = GeoIP2Location(**(location or {}))
         self.risk = risk
         self.risk_reasons = [IPRiskReason(**x) for x in risk_reasons or []]
 
 
-class ScoreIPAddress(SimpleEquality):
+class ScoreIPAddress(_Serializable):
     """Information about the IP address for minFraud Score.
 
     .. attribute:: risk
@@ -235,7 +268,7 @@ def __init__(self, *, risk: Optional[float] = None, **_):
         self.risk = risk
 
 
-class Issuer(SimpleEquality):
+class Issuer(_Serializable):
     """Information about the credit card issuer.
 
     .. attribute:: name
@@ -293,7 +326,7 @@ def __init__(
         self.matches_provided_phone_number = matches_provided_phone_number
 
 
-class Device(SimpleEquality):
+class Device(_Serializable):
     """Information about the device associated with the IP address.
 
     In order to receive device output from minFraud Insights or minFraud
@@ -353,7 +386,7 @@ def __init__(
         self.local_time = local_time
 
 
-class Disposition(SimpleEquality):
+class Disposition(_Serializable):
     """Information about disposition for the request as set by custom rules.
 
     In order to receive a disposition, you must be use the minFraud custom
@@ -402,7 +435,7 @@ def __init__(
         self.rule_label = rule_label
 
 
-class EmailDomain(SimpleEquality):
+class EmailDomain(_Serializable):
     """Information about the email domain passed in the request.
 
     .. attribute:: first_seen
@@ -421,7 +454,7 @@ def __init__(self, *, first_seen: Optional[str] = None, **_):
         self.first_seen = first_seen
 
 
-class Email(SimpleEquality):
+class Email(_Serializable):
     """Information about the email address passed in the request.
 
     .. attribute:: domain
@@ -484,7 +517,7 @@ def __init__(
         self.is_high_risk = is_high_risk
 
 
-class CreditCard(SimpleEquality):
+class CreditCard(_Serializable):
     """Information about the credit card based on the issuer ID number.
 
     .. attribute:: country
@@ -578,7 +611,7 @@ def __init__(
         self.type = type
 
 
-class BillingAddress(SimpleEquality):
+class BillingAddress(_Serializable):
     """Information about the billing address.
 
     .. attribute:: distance_to_ip_location
@@ -644,7 +677,7 @@ def __init__(
         self.is_in_ip_country = is_in_ip_country
 
 
-class ShippingAddress(SimpleEquality):
+class ShippingAddress(_Serializable):
     """Information about the shipping address.
 
     .. attribute:: distance_to_ip_location
@@ -733,7 +766,7 @@ def __init__(
         self.distance_to_billing_address = distance_to_billing_address
 
 
-class Phone(SimpleEquality):
+class Phone(_Serializable):
     """Information about the billing or shipping phone number.
 
     .. attribute:: country
@@ -790,7 +823,7 @@ def __init__(
         self.number_type = number_type
 
 
-class ServiceWarning(SimpleEquality):
+class ServiceWarning(_Serializable):
     """Warning from the web service.
 
     .. attribute:: code
@@ -837,7 +870,7 @@ def __init__(
         self.input_pointer = input_pointer
 
 
-class Subscores(SimpleEquality):
+class Subscores(_Serializable):
     """Risk factor scores used in calculating the overall risk score.
 
     .. deprecated:: 2.12.0
@@ -1081,7 +1114,7 @@ def __init__(
         self.time_of_day = time_of_day
 
 
-class Reason(SimpleEquality):
+class Reason(_Serializable):
     """The risk score reason for the multiplier.
 
     This class provides both a machine-readable code and a human-readable
@@ -1174,7 +1207,7 @@ def __init__(
         self.reason = reason
 
 
-class RiskScoreReason(SimpleEquality):
+class RiskScoreReason(_Serializable):
     """The risk score multiplier and the reasons for that multiplier.
 
     .. attribute:: multiplier
@@ -1209,7 +1242,7 @@ def __init__(
         self.reasons = [Reason(**x) for x in reasons or []]
 
 
-class Factors(SimpleEquality):
+class Factors(_Serializable):
     """Model for Factors response.
 
     .. attribute:: id
@@ -1397,7 +1430,7 @@ def __init__(
         ]
 
 
-class Insights(SimpleEquality):
+class Insights(_Serializable):
     """Model for Insights response.
 
     .. attribute:: id
@@ -1557,7 +1590,7 @@ def __init__(
         self.warnings = [ServiceWarning(**x) for x in warnings or []]
 
 
-class Score(SimpleEquality):
+class Score(_Serializable):
     """Model for Score response.
 
     .. attribute:: id
diff --git a/tests/test_models.py b/tests/test_models.py
index 83e0b10..b2792e2 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -4,6 +4,9 @@
 
 
 class TestModels(unittest.TestCase):
+    def setUp(self):
+        self.maxDiff = 20_000
+
     def test_billing_address(self):
         address = BillingAddress(**self.address_dict)
         self.check_address(address)
@@ -261,14 +264,15 @@ def test_risk_score_reason(self):
 
     def test_score(self):
         id = "b643d445-18b2-4b9d-bad4-c9c4366e402a"
-        score = Score(
-            id=id,
-            funds_remaining=10.01,
-            queries_remaining=123,
-            risk_score=0.01,
-            ip_address={"risk": 99},
-            warnings=[{"code": "INVALID_INPUT"}],
-        )
+        response = {
+            "id": id,
+            "funds_remaining": 10.01,
+            "queries_remaining": 123,
+            "risk_score": 0.01,
+            "ip_address": {"risk": 99},
+            "warnings": [{"code": "INVALID_INPUT"}],
+        }
+        score = Score(**response)
 
         self.assertEqual(id, score.id)
         self.assertEqual(10.01, score.funds_remaining)
@@ -277,11 +281,15 @@ def test_score(self):
         self.assertEqual("INVALID_INPUT", score.warnings[0].code)
         self.assertEqual(99, score.ip_address.risk)
 
+        self.assertEqual(response, self._remove_empty_values(score.to_dict()))
+
     def test_insights(self):
         response = self.factors_response()
+        del response["risk_score_reasons"]
         del response["subscores"]
         insights = Insights(None, **response)
         self.check_insights_data(insights, response["id"])
+        self.assertEqual(response, self._remove_empty_values(insights.to_dict()))
 
     def test_factors(self):
         response = self.factors_response()
@@ -313,6 +321,8 @@ def test_factors(self):
         )
         self.assertEqual(0.17, factors.subscores.time_of_day)
 
+        self.assertEqual(response, self._remove_empty_values(factors.to_dict()))
+
     def factors_response(self):
         return {
             "id": "b643d445-18b2-4b9d-bad4-c9c4366e402a",
@@ -399,3 +409,31 @@ def check_risk_score_reasons_data(self, reasons):
         self.assertEqual(
             "Risk due to IP being an Anonymous IP", reasons[0].reasons[0].reason
         )
+
+    def _remove_empty_values(self, data):
+        if isinstance(data, dict):
+            m = {}
+            for k, v in data.items():
+                v = self._remove_empty_values(v)
+                if self._is_not_empty(v):
+                    m[k] = v
+            return m
+
+        if isinstance(data, list):
+            ls = []
+            for e in data:
+                e = self._remove_empty_values(e)
+                if self._is_not_empty(e):
+                    ls.append(e)
+            return ls
+
+        return data
+
+    def _is_not_empty(self, v):
+        if v is None:
+            return False
+        if isinstance(v, dict) and not v:
+            return False
+        if isinstance(v, list) and not v:
+            return False
+        return True

From 04048a2d681c4b1076810995d8fe86f4b8e85f6f Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Thu, 16 Jan 2025 15:43:52 -0800
Subject: [PATCH 4/7] Drop 3.8 support

---
 .github/workflows/test.yml | 2 +-
 HISTORY.rst                | 2 ++
 README.rst                 | 2 +-
 pyproject.toml             | 3 +--
 setup.cfg                  | 3 +--
 5 files changed, 6 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b50fc00..1e00a48 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -12,7 +12,7 @@ jobs:
     strategy:
       matrix:
         platform: [ubuntu-latest, macos-latest, windows-latest]
-        python-version: [3.8, 3.9, "3.10", 3.11, 3.12, 3.13]
+        python-version: [3.9, "3.10", 3.11, 3.12, 3.13]
 
     name: Python ${{ matrix.python-version }} on ${{ matrix.platform }}
     runs-on: ${{ matrix.platform }}
diff --git a/HISTORY.rst b/HISTORY.rst
index 8c521af..17a3e8b 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -12,6 +12,8 @@ History
   classes are no longer immutable. For most users, these differences should
   not impact their integration.
 * BREAKING CHANGE: Model attributes that were formerly tuples are now lists.
+* IMPORTANT: Python 3.9 or greater is required. If you are using an older
+  version, please use an earlier release.
 * Added ``to_dict`` methods to the model classes. These return a dict version
   of the object that is suitable for serialization. It recursively calls
   ``to_dict`` or the equivalent on all objects contained within the object.
diff --git a/README.rst b/README.rst
index 144761d..22512e5 100644
--- a/README.rst
+++ b/README.rst
@@ -307,7 +307,7 @@ For asynchronous reporting:
 Requirements
 ------------
 
-Python 3.8 or greater is required. Older versions are not supported.
+Python 3.9 or greater is required. Older versions are not supported.
 
 Versioning
 ----------
diff --git a/pyproject.toml b/pyproject.toml
index ae68385..1a70a36 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,7 +12,7 @@ dependencies = [
     "requests>=2.24.0,<3.0.0",
     "voluptuous",
 ]
-requires-python = ">=3.8"
+requires-python = ">=3.9"
 readme = "README.rst"
 license = {text = "Apache License 2.0"}
 classifiers = [
@@ -22,7 +22,6 @@ classifiers = [
     "License :: OSI Approved :: Apache Software License",
     "Programming Language :: Python",
     "Programming Language :: Python :: 3",
-    "Programming Language :: Python :: 3.8",
     "Programming Language :: Python :: 3.9",
     "Programming Language :: Python :: 3.10",
     "Programming Language :: Python :: 3.11",
diff --git a/setup.cfg b/setup.cfg
index bddf03c..76e975f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -3,11 +3,10 @@
 max-line-length = 88
 
 [tox:tox]
-envlist = {py38,py39,py310,py311,py313}-test,py313-{black,lint,flake8,mypy}
+envlist = {py39,py310,py311,py313}-test,py313-{black,lint,flake8,mypy}
 
 [gh-actions]
 python =
-    3.8: py38
     3.9: py39
     3.10: py310
     3.11: py311

From bab9464d5d4be781b4e96e4800eed4a2e1b913a4 Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Thu, 16 Jan 2025 15:44:29 -0800
Subject: [PATCH 5/7] Update copyright years

---
 README.rst     | 2 +-
 docs/conf.py   | 2 +-
 docs/index.rst | 2 +-
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/README.rst b/README.rst
index 22512e5..b2251da 100644
--- a/README.rst
+++ b/README.rst
@@ -327,6 +327,6 @@ for assistance.
 Copyright and License
 ---------------------
 
-This software is Copyright © 2015-2024 by MaxMind, Inc.
+This software is Copyright © 2015-2025 by MaxMind, Inc.
 
 This is free software, licensed under the Apache License, Version 2.0.
diff --git a/docs/conf.py b/docs/conf.py
index d093582..3e9793e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -46,7 +46,7 @@
 
 # General information about the project.
 project = "minfraud"
-copyright = "2015-2024, MaxMind, Inc"
+copyright = "2015-2025, MaxMind, Inc"
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
diff --git a/docs/index.rst b/docs/index.rst
index 1f73485..a17b799 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -31,6 +31,6 @@ Indices and tables
 * :ref:`modindex`
 * :ref:`search`
 
-:copyright: © 2015-2024 by MaxMind, Inc.
+:copyright: © 2015-2025 by MaxMind, Inc.
 :license: Apache License, Version 2.0
 

From 83ca8f81901ae939b97d0e8eb6179d1fac9b98b4 Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Fri, 17 Jan 2025 07:10:57 -0800
Subject: [PATCH 6/7] Move removal of empty values to to_dict

This will make the dict more similar to the original response.
---
 minfraud/models.py   | 25 ++++++++++++++-----------
 tests/test_models.py | 34 +++-------------------------------
 2 files changed, 17 insertions(+), 42 deletions(-)

diff --git a/minfraud/models.py b/minfraud/models.py
index 3d6efe8..b948b2d 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -20,20 +20,23 @@ def to_dict(self):
         result = {}
         for key, value in self.__dict__.items():
             if hasattr(value, "to_dict") and callable(value.to_dict):
-                result[key] = value.to_dict()
+                if d := value.to_dict():
+                    result[key] = d
             elif hasattr(value, "raw"):
                 # geoip2 uses "raw" for historical reasons
-                result[key] = value.raw
+                if d := value.raw:
+                    result[key] = d
             elif isinstance(value, list):
-                result[key] = [
-                    (
-                        item.to_dict()
-                        if hasattr(item, "to_dict") and callable(item.to_dict)
-                        else item
-                    )
-                    for item in value
-                ]
-            else:
+                ls = []
+                for e in value:
+                    if hasattr(e, "to_dict") and callable(e.to_dict):
+                        if e := e.to_dict():
+                            ls.append(e)
+                    elif e is not None:
+                        ls.append(e)
+                if ls:
+                    result[key] = ls
+            elif value is not None:
                 result[key] = value
         return result
 
diff --git a/tests/test_models.py b/tests/test_models.py
index b2792e2..042438a 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -281,7 +281,7 @@ def test_score(self):
         self.assertEqual("INVALID_INPUT", score.warnings[0].code)
         self.assertEqual(99, score.ip_address.risk)
 
-        self.assertEqual(response, self._remove_empty_values(score.to_dict()))
+        self.assertEqual(response, score.to_dict())
 
     def test_insights(self):
         response = self.factors_response()
@@ -289,7 +289,7 @@ def test_insights(self):
         del response["subscores"]
         insights = Insights(None, **response)
         self.check_insights_data(insights, response["id"])
-        self.assertEqual(response, self._remove_empty_values(insights.to_dict()))
+        self.assertEqual(response, insights.to_dict())
 
     def test_factors(self):
         response = self.factors_response()
@@ -321,7 +321,7 @@ def test_factors(self):
         )
         self.assertEqual(0.17, factors.subscores.time_of_day)
 
-        self.assertEqual(response, self._remove_empty_values(factors.to_dict()))
+        self.assertEqual(response, factors.to_dict())
 
     def factors_response(self):
         return {
@@ -409,31 +409,3 @@ def check_risk_score_reasons_data(self, reasons):
         self.assertEqual(
             "Risk due to IP being an Anonymous IP", reasons[0].reasons[0].reason
         )
-
-    def _remove_empty_values(self, data):
-        if isinstance(data, dict):
-            m = {}
-            for k, v in data.items():
-                v = self._remove_empty_values(v)
-                if self._is_not_empty(v):
-                    m[k] = v
-            return m
-
-        if isinstance(data, list):
-            ls = []
-            for e in data:
-                e = self._remove_empty_values(e)
-                if self._is_not_empty(e):
-                    ls.append(e)
-            return ls
-
-        return data
-
-    def _is_not_empty(self, v):
-        if v is None:
-            return False
-        if isinstance(v, dict) and not v:
-            return False
-        if isinstance(v, list) and not v:
-            return False
-        return True

From 431c61625a6861370a38dbf2777a3e88045410f9 Mon Sep 17 00:00:00 2001
From: Gregory Oschwald <goschwald@maxmind.com>
Date: Fri, 17 Jan 2025 07:17:43 -0800
Subject: [PATCH 7/7] Remove deprecate attribute

---
 HISTORY.rst          |  2 ++
 minfraud/models.py   | 30 ------------------------------
 tests/test_models.py |  8 --------
 3 files changed, 2 insertions(+), 38 deletions(-)

diff --git a/HISTORY.rst b/HISTORY.rst
index 17a3e8b..1ba7797 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -12,6 +12,8 @@ History
   classes are no longer immutable. For most users, these differences should
   not impact their integration.
 * BREAKING CHANGE: Model attributes that were formerly tuples are now lists.
+* BREAKING CHANGE: The deprecated `is_high_risk` attribute on
+  `resp.ip_address.country` has been removed.
 * IMPORTANT: Python 3.9 or greater is required. If you are using an older
   version, please use an earlier release.
 * Added ``to_dict`` methods to the model classes. These return a dict version
diff --git a/minfraud/models.py b/minfraud/models.py
index b948b2d..e4082a6 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -116,34 +116,6 @@ def __init__(self, *args, **kwargs) -> None:
         super().__init__(*args, **kwargs)
 
 
-class GeoIP2Country(geoip2.records.Country):
-    """Country information for the IP address.
-
-    In addition to the attributes provided by ``geoip2.records.Country``,
-    this class provides:
-
-    .. attribute:: is_high_risk
-
-      This is true if the IP country is high risk.
-
-      :type: bool | None
-
-      .. deprecated:: 1.8.0
-        Deprecated effective August 29, 2019.
-
-    Parent:
-
-    """
-
-    __doc__ += geoip2.records.Country.__doc__  # type: ignore
-
-    is_high_risk: bool
-
-    def __init__(self, *args, **kwargs) -> None:
-        self.is_high_risk = kwargs.get("is_high_risk", False)
-        super().__init__(*args, **kwargs)
-
-
 class IPAddress(geoip2.models.Insights):
     """Model for minFraud and GeoIP2 data about the IP address.
 
@@ -222,7 +194,6 @@ class IPAddress(geoip2.models.Insights):
 
     """
 
-    country: GeoIP2Country
     location: GeoIP2Location
     risk: Optional[float]
     risk_reasons: List[IPRiskReason]
@@ -248,7 +219,6 @@ def __init__(
             kwargs["risk_reasons"] = risk_reasons
 
         super().__init__(kwargs, locales=list(locales or []))
-        self.country = GeoIP2Country(locales, **(country or {}))
         self.location = GeoIP2Location(**(location or {}))
         self.risk = risk
         self.risk_reasons = [IPRiskReason(**x) for x in risk_reasons or []]
diff --git a/tests/test_models.py b/tests/test_models.py
index 042438a..2cb97ed 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -108,11 +108,6 @@ def test_email_domain(self):
 
         self.assertEqual(first_seen, domain.first_seen)
 
-    def test_geoip2_country(self):
-        country = GeoIP2Country(is_high_risk=True, iso_code="US")
-        self.assertEqual(True, country.is_high_risk)
-        self.assertEqual("US", country.iso_code)
-
     def test_geoip2_location(self):
         time = "2015-04-19T12:59:23-01:00"
         location = GeoIP2Location(local_time=time, latitude=5)
@@ -124,7 +119,6 @@ def test_ip_address(self):
         address = IPAddress(
             ["en"],
             country={
-                "is_high_risk": True,
                 "is_in_european_union": True,
             },
             location={
@@ -156,7 +150,6 @@ def test_ip_address(self):
         )
 
         self.assertEqual(time, address.location.local_time)
-        self.assertEqual(True, address.country.is_high_risk)
         self.assertEqual(True, address.country.is_in_european_union)
         self.assertEqual(99, address.risk)
         self.assertEqual(True, address.traits.is_anonymous)
@@ -169,7 +162,6 @@ def test_ip_address(self):
         self.assertEqual(True, address.traits.is_tor_exit_node)
         self.assertEqual("310", address.traits.mobile_country_code)
         self.assertEqual("004", address.traits.mobile_network_code)
-        self.assertEqual(True, address.country.is_high_risk)
 
         self.assertEqual("ANONYMOUS_IP", address.risk_reasons[0].code)
         self.assertEqual(