diff --git a/.travis.yml b/.travis.yml
index 9a1c98c..6dbcc8e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,32 +3,25 @@ sudo: false
language: python
matrix:
include:
- - python: 2.7
- dist: trusty
- - python: 3.5
- dist: trusty
- python: 3.6
- dist: trusty
- python: 3.7
- dist: xenial
- python: 3.8
- dist: xenial
env:
- RUN_LINTER=1
- RUN_SNYK=1
- python: nightly
dist: xenial
- - python: pypy
- dist: trusty
allow_failures:
- python: nightly
before_install:
- "if [[ $RUN_SNYK && $SNYK_TOKEN ]]; then sudo apt-get install -y nodejs; npm install -g snyk; fi"
install:
- - "if [[ $RUN_LINTER ]]; then pip install --upgrade pylint black; fi"
+ - pip install -e git+https://github.com/maxmind/GeoIP2-python#egg=geoip2
+ - "if [[ $RUN_LINTER ]]; then pip install --upgrade pylint black mypy voluptuous-stubs; fi"
script:
- python setup.py test
+ - "if [[ $RUN_LINTER ]]; then mypy minfraud tests; fi"
- "if [[ $RUN_LINTER ]]; then ./.travis-pylint.sh; fi"
- "if [[ $RUN_LINTER ]]; then ./.travis-black.sh; fi"
- "if [[ $RUN_SNYK && $SNYK_TOKEN ]]; then snyk test --org=maxmind --file=requirements.txt; fi"
diff --git a/HISTORY.rst b/HISTORY.rst
index 64a731b..3b55958 100644
--- a/HISTORY.rst
+++ b/HISTORY.rst
@@ -3,6 +3,16 @@
History
-------
+2.0.0
+
+* IMPORTANT: Python 2.7 and 3.5 support has been dropped. Python 3.6 or greater
+ is required.
+* Type hints have been added.
+* Email validation is now done with ``email_validator`` rather than
+ ``validate_email``.
+* URL validation is now done with ``urllib.parse`` rather than ``rfc3987``.
+* RFC 3339 timestamp validation is now done via a regular expression.
+
1.13.0 (2020-07-14)
+++++++++++++++++++
diff --git a/README.rst b/README.rst
index 45ae664..f0153c9 100644
--- a/README.rst
+++ b/README.rst
@@ -5,7 +5,7 @@ minFraud Score, Insights, Factors and Report Transaction Python API
Description
-----------
-This package provides an API for the `MaxMind minFraud Score, Insights, and
+This package provides an API for the `MaxMind minFraud Score, Insights, and
Factors web services `_ as well as the
`Report Transaction web service
`_.
@@ -252,8 +252,7 @@ Report Transactions Example
Requirements
------------
-This code requires Python 2.7+ or 3.5+. Older versions are not supported.
-This library has been tested with CPython and PyPy.
+Python 3.6 or greater is required. Older versions are not supported.
Versioning
----------
diff --git a/dev-bin/release.sh b/dev-bin/release.sh
index 53157cc..e5e5293 100755
--- a/dev-bin/release.sh
+++ b/dev-bin/release.sh
@@ -35,7 +35,7 @@ if [ -n "$(git status --porcelain)" ]; then
fi
# Make sure release deps are installed with the current python
-pip install -U sphinx wheel voluptuous strict_rfc3339 validate_email rfc3987
+pip install -U sphinx wheel voluptuous email_validator twine
perl -pi -e "s/(?<=__version__ = \").+?(?=\")/$version/g" minfraud/version.py
diff --git a/minfraud/errors.py b/minfraud/errors.py
index 799cb46..43ce283 100644
--- a/minfraud/errors.py
+++ b/minfraud/errors.py
@@ -6,6 +6,8 @@
"""
+from typing import Optional
+
class MinFraudError(RuntimeError):
"""There was a non-specific error in minFraud.
@@ -39,7 +41,9 @@ class HTTPError(MinFraudError):
"""
- def __init__(self, message, http_status=None, uri=None):
+ def __init__(
+ self, message: str, http_status: Optional[int] = None, uri: Optional[str] = None
+ ) -> None:
super(HTTPError, self).__init__(message)
self.http_status = http_status
self.uri = uri
diff --git a/minfraud/models.py b/minfraud/models.py
index 0cd129f..9f91997 100644
--- a/minfraud/models.py
+++ b/minfraud/models.py
@@ -8,6 +8,7 @@
# pylint:disable=too-many-lines
from collections import namedtuple
from functools import update_wrapper
+from typing import Any, Dict, List, Optional, Tuple
import geoip2.models
import geoip2.records
@@ -58,12 +59,6 @@ def new(cls, *args, **kwargs):
return new_cls
-def _create_warnings(warnings):
- if not warnings:
- return ()
- return tuple([ServiceWarning(x) for x in warnings])
-
-
class GeoIP2Location(geoip2.records.Location):
"""Location information for the IP address.
@@ -84,9 +79,11 @@ class GeoIP2Location(geoip2.records.Location):
"""
- __doc__ += geoip2.records.Location.__doc__
+ __doc__ += geoip2.records.Location.__doc__ # type: ignore
+
+ local_time: Optional[str]
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
self.local_time = kwargs.get("local_time", None)
super(GeoIP2Location, self).__init__(*args, **kwargs)
@@ -110,9 +107,11 @@ class GeoIP2Country(geoip2.records.Country):
"""
- __doc__ += geoip2.records.Country.__doc__
+ __doc__ += geoip2.records.Country.__doc__ # type: ignore
- def __init__(self, *args, **kwargs):
+ is_high_risk: bool
+
+ def __init__(self, *args, **kwargs) -> None:
self.is_high_risk = kwargs.get("is_high_risk", False)
super(GeoIP2Country, self).__init__(*args, **kwargs)
@@ -187,7 +186,11 @@ class IPAddress(geoip2.models.Insights):
"""
- def __init__(self, ip_address):
+ country: GeoIP2Country
+ location: GeoIP2Location
+ risk: Optional[float]
+
+ def __init__(self, ip_address: Dict[str, Any]) -> None:
if ip_address is None:
ip_address = {}
locales = ip_address.get("_locales")
@@ -201,14 +204,14 @@ def __init__(self, ip_address):
# Unfortunately the GeoIP2 models are not immutable, only the records. This
# corrects that for minFraud
- def __setattr__(self, name, value):
+ def __setattr__(self, name: str, value: Any) -> None:
if hasattr(self, "_finalized") and self._finalized:
raise AttributeError("can't set attribute")
super(IPAddress, self).__setattr__(name, value)
@_inflate_to_namedtuple
-class ScoreIPAddress(object):
+class ScoreIPAddress:
"""Information about the IP address for minFraud Score.
.. attribute:: risk
@@ -219,6 +222,8 @@ class ScoreIPAddress(object):
:type: float | None
"""
+ risk: Optional[float]
+
__slots__ = ()
_fields = {
"risk": None,
@@ -226,7 +231,7 @@ class ScoreIPAddress(object):
@_inflate_to_namedtuple
-class Issuer(object):
+class Issuer:
"""Information about the credit card issuer.
.. attribute:: name
@@ -243,14 +248,14 @@ class Issuer(object):
no issuer ID number (IIN) was provided in the request or if MaxMind does
not have a name associated with the IIN.
- :type: bool
+ :type: bool | None
.. attribute:: phone_number
The phone number of the bank which issued the credit
card. In some cases the phone number we return may be out of date.
- :type: str
+ :type: str | None
.. attribute:: matches_provided_phone_number
@@ -260,10 +265,15 @@ class Issuer(object):
phone number or no issuer ID number (IIN) was provided in the request or
if MaxMind does not have a phone number associated with the IIN.
- :type: bool
+ :type: bool | None
"""
+ name: Optional[str]
+ matches_provided_name: Optional[bool]
+ phone_number: Optional[str]
+ matches_provided_phone_number: Optional[bool]
+
__slots__ = ()
_fields = {
"name": None,
@@ -274,7 +284,7 @@ class Issuer(object):
@_inflate_to_namedtuple
-class Device(object):
+class Device:
"""Information about the device associated with the IP address.
In order to receive device output from minFraud Insights or minFraud
@@ -317,6 +327,11 @@ class Device(object):
"""
+ confidence: Optional[float]
+ id: Optional[str]
+ last_seen: Optional[str]
+ local_time: Optional[str]
+
__slots__ = ()
_fields = {
"confidence": None,
@@ -327,7 +342,7 @@ class Device(object):
@_inflate_to_namedtuple
-class Disposition(object):
+class Disposition:
"""Information about disposition for the request as set by custom rules.
In order to receive a disposition, you must be use the minFraud custom
@@ -350,6 +365,9 @@ class Disposition(object):
:type: str | None
"""
+ action: Optional[str]
+ reason: Optional[str]
+
__slots__ = ()
_fields = {
"action": None,
@@ -358,7 +376,7 @@ class Disposition(object):
@_inflate_to_namedtuple
-class EmailDomain(object):
+class EmailDomain:
"""Information about the email domain passed in the request.
.. attribute:: first_seen
@@ -367,10 +385,12 @@ class EmailDomain(object):
was first seen by MaxMind. This is expressed using the ISO 8601 date
format.
- :type: str
+ :type: str | None
"""
+ first_seen: Optional[str]
+
__slots__ = ()
_fields = {
"first_seen": None,
@@ -378,7 +398,7 @@ class EmailDomain(object):
@_inflate_to_namedtuple
-class Email(object):
+class Email:
"""Information about the email address passed in the request.
.. attribute:: domain
@@ -393,7 +413,7 @@ class Email(object):
was first seen by MaxMind. This is expressed using the ISO 8601 date
format.
- :type: str
+ :type: str | None
.. attribute:: is_disposable
@@ -420,6 +440,12 @@ class Email(object):
"""
+ domain: EmailDomain
+ first_seen: Optional[str]
+ is_disposable: Optional[bool]
+ is_free: Optional[bool]
+ is_high_risk: Optional[bool]
+
__slots__ = ()
_fields = {
"domain": EmailDomain,
@@ -431,7 +457,7 @@ class Email(object):
@_inflate_to_namedtuple
-class CreditCard(object):
+class CreditCard:
"""Information about the credit card based on the issuer ID number.
.. attribute:: country
@@ -494,6 +520,15 @@ class CreditCard(object):
"""
+ issuer: Issuer
+ country: Optional[str]
+ brand: Optional[str]
+ is_business: Optional[bool]
+ is_issued_in_billing_address_country: Optional[bool]
+ is_prepaid: Optional[bool]
+ is_virtual: Optional[bool]
+ type: Optional[str]
+
__slots__ = ()
_fields = {
"issuer": Issuer,
@@ -508,7 +543,7 @@ class CreditCard(object):
@_inflate_to_namedtuple
-class BillingAddress(object):
+class BillingAddress:
"""Information about the billing address.
.. attribute:: distance_to_ip_location
@@ -551,6 +586,12 @@ class BillingAddress(object):
"""
+ is_postal_in_city: Optional[bool]
+ latitude: Optional[float]
+ longitude: Optional[float]
+ distance_to_ip_location: Optional[int]
+ is_in_ip_country: Optional[bool]
+
__slots__ = ()
_fields = {
"is_postal_in_city": None,
@@ -562,7 +603,7 @@ class BillingAddress(object):
@_inflate_to_namedtuple
-class ShippingAddress(object):
+class ShippingAddress:
"""Information about the shipping address.
.. attribute:: distance_to_ip_location
@@ -622,6 +663,14 @@ class ShippingAddress(object):
"""
+ is_postal_in_city: Optional[bool]
+ latitude: Optional[float]
+ longitude: Optional[float]
+ distance_to_ip_location: Optional[int]
+ is_in_ip_country: Optional[bool]
+ is_high_risk: Optional[bool]
+ distance_to_billing_address: Optional[int]
+
__slots__ = ()
_fields = {
"is_postal_in_city": None,
@@ -635,7 +684,7 @@ class ShippingAddress(object):
@_inflate_to_namedtuple
-class ServiceWarning(object):
+class ServiceWarning:
"""Warning from the web service.
.. attribute:: code
@@ -645,7 +694,7 @@ class ServiceWarning(object):
`_
for the current list of of warning codes.
- :type: str
+ :type: str | None
.. attribute:: warning
@@ -653,7 +702,7 @@ class ServiceWarning(object):
warning. The description may change at any time and should not be matched
against.
- :type: str
+ :type: str | None
.. attribute:: input_pointer
@@ -661,10 +710,14 @@ class ServiceWarning(object):
the warning is associated with. For instance, if the warning was about
the billing city, the string would be ``"/billing/city"``.
- :type: str
+ :type: str | None
"""
+ code: Optional[str]
+ warning: Optional[str]
+ input_pointer: Optional[str]
+
__slots__ = ()
_fields = {
"code": None,
@@ -673,8 +726,14 @@ class ServiceWarning(object):
}
+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(object):
+class Subscores:
"""Subscores used in calculating the overall risk score.
.. attribute:: avs_result
@@ -843,6 +902,27 @@ class Subscores(object):
"""
+ avs_result: Optional[float]
+ billing_address: Optional[float]
+ billing_address_distance_to_ip_location: Optional[float]
+ browser: Optional[float]
+ chargeback: Optional[float]
+ country: Optional[float]
+ country_mismatch: Optional[float]
+ cvv_result: Optional[float]
+ device: Optional[float]
+ email_address: Optional[float]
+ email_domain: Optional[float]
+ email_local_part: Optional[float]
+ email_tenure: Optional[float]
+ ip_tenure: Optional[float]
+ issuer_id_number: Optional[float]
+ order_amount: Optional[float]
+ phone_number: Optional[float]
+ shipping_address: Optional[float]
+ shipping_address_distance_to_ip_location: Optional[float]
+ time_of_day: Optional[float]
+
__slots__ = ()
_fields = {
"avs_result": None,
@@ -869,7 +949,7 @@ class Subscores(object):
@_inflate_to_namedtuple
-class Factors(object):
+class Factors:
"""Model for Factors response.
.. attribute:: id
@@ -966,6 +1046,20 @@ class Factors(object):
individual components that are used to calculate the overall risk score.
"""
+ billing_address: BillingAddress
+ credit_card: CreditCard
+ disposition: Disposition
+ funds_remaining: float
+ device: Device
+ email: Email
+ id: str
+ ip_address: IPAddress
+ queries_remaining: int
+ risk_score: float
+ shipping_address: ShippingAddress
+ subscores: Subscores
+ warnings: List[ServiceWarning]
+
__slots__ = ()
_fields = {
"billing_address": BillingAddress,
@@ -985,7 +1079,7 @@ class Factors(object):
@_inflate_to_namedtuple
-class Insights(object):
+class Insights:
"""Model for Insights response.
.. attribute:: id
@@ -1077,6 +1171,19 @@ class Insights(object):
minFraud data related to the shipping address used in the transaction.
"""
+ billing_address: BillingAddress
+ credit_card: CreditCard
+ device: Device
+ disposition: Disposition
+ email: Email
+ funds_remaining: float
+ id: str
+ ip_address: IPAddress
+ queries_remaining: int
+ risk_score: float
+ shipping_address: ShippingAddress
+ warnings: List[ServiceWarning]
+
__slots__ = ()
_fields = {
"billing_address": BillingAddress,
@@ -1095,7 +1202,7 @@ class Insights(object):
@_inflate_to_namedtuple
-class Score(object):
+class Score:
"""Model for Score response.
.. attribute:: id
@@ -1153,6 +1260,14 @@ class Score(object):
:type: IPAddress
"""
+ disposition: Disposition
+ funds_remaining: float
+ id: str
+ ip_address: ScoreIPAddress
+ queries_remaining: int
+ risk_score: float
+ warnings: List[ServiceWarning]
+
__slots__ = ()
_fields = {
"disposition": Disposition,
diff --git a/minfraud/validation.py b/minfraud/validation.py
index ba0281f..8f78477 100644
--- a/minfraud/validation.py
+++ b/minfraud/validation.py
@@ -1,22 +1,5 @@
-"""This is an internal module used for validating the minFraud request."""
+"""This is an internal module used for validating the minFraud request.
-import re
-import sys
-import uuid
-from decimal import Decimal
-
-import rfc3987
-from geoip2.compat import compat_ip_address
-from strict_rfc3339 import validate_rfc3339
-
-# I can't reproduce the failure locally and the order looks right to me.
-# It is failing on pylint 1.8.3 on Travis. We should try removing this
-# when a new version of pylint is released.
-# pylint: disable=wrong-import-order
-from validate_email import validate_email
-from voluptuous import All, Any, In, Match, Range, Required, Schema
-
-"""
Internal code for validating the transaction dictionary.
This code is only intended for internal use and is subject to change in ways
@@ -24,60 +7,63 @@
"""
+
+import ipaddress
+import re
+import uuid
+import urllib.parse
+from decimal import Decimal
+from typing import Optional
+
+from email_validator import validate_email # type: ignore
+from voluptuous import All, Any, In, Match, Range, Required, Schema
+from voluptuous.error import UrlInvalid
+
# Pylint doesn't like the private function type naming for the callable
# objects below. Given the consistent use of them, the current names seem
# preferable to blindly following pylint.
#
# pylint: disable=invalid-name,undefined-variable
-if sys.version_info[0] >= 3:
- _unicode = str
- _unicode_or_printable_ascii = str
- long = int
-else:
- _unicode = unicode
- _unicode_or_printable_ascii = Any(unicode, Match(r"^[\x20-\x7E]*$"))
-
-_any_string = Any(_unicode_or_printable_ascii, str)
-_any_number = Any(float, int, long, Decimal)
+_any_number = Any(float, int, Decimal)
-_custom_input_key = All(_any_string, Match(r"^[a-z0-9_]{1,25}$"))
+_custom_input_key = All(str, Match(r"^[a-z0-9_]{1,25}$"))
_custom_input_value = Any(
- All(_any_string, Match(r"^[^\n]{1,255}\Z")),
+ All(str, Match(r"^[^\n]{1,255}\Z")),
All(
_any_number, Range(min=-1e13, max=1e13, min_included=False, max_included=False)
),
bool,
)
-_md5 = All(_any_string, Match(r"^[0-9A-Fa-f]{32}$"))
+_md5 = All(str, Match(r"^[0-9A-Fa-f]{32}$"))
-_country_code = All(_any_string, Match(r"^[A-Z]{2}$"))
+_country_code = All(str, Match(r"^[A-Z]{2}$"))
_telephone_country_code = Any(
- All(_any_string, Match("^[0-9]{1,4}$")), All(int, Range(min=1, max=9999))
+ All(str, Match("^[0-9]{1,4}$")), All(int, Range(min=1, max=9999))
)
-_subdivision_iso_code = All(_any_string, Match(r"^[0-9A-Z]{1,4}$"))
+_subdivision_iso_code = All(str, Match(r"^[0-9A-Z]{1,4}$"))
-def _ip_address(s):
+def _ip_address(s: Optional[str]) -> str:
# ipaddress accepts numeric IPs, which we don't want.
- if isinstance(s, (str, _unicode)) and not re.match(r"^\d+$", s):
- return str(compat_ip_address(s))
+ if isinstance(s, str) and not re.match(r"^\d+$", s):
+ return str(ipaddress.ip_address(s))
raise ValueError
-def _email_or_md5(s):
- if validate_email(s) or re.match(r"^[0-9A-Fa-f]{32}$", s):
+def _email_or_md5(s: str) -> str:
+ if re.match(r"^[0-9A-Fa-f]{32}$", s):
return s
- raise ValueError
+ return validate_email(s, check_deliverability=False).email
# based off of:
# http://stackoverflow.com/questions/2532053/validate-a-hostname-string
-def _hostname(hostname):
+def _hostname(hostname: str) -> str:
if len(hostname) > 255:
raise ValueError
allowed = re.compile(r"(?!-)[A-Z\d-]{1,63}(? str:
if re.match("^[\x21-\x7E]{1,255}$", s) and not re.match("^[0-9]{1,19}$", s):
return s
raise ValueError
-def _rfc3339_datetime(s):
- if validate_rfc3339(s):
- return s
- raise ValueError
+_rfc3339_datetime = Match(
+ r"(?a)\A\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})\Z"
+)
_event_type = In(
@@ -279,26 +264,27 @@ def _rfc3339_datetime(s):
_price = All(_any_number, Range(min=0, min_included=False))
-def _uri(s):
- if rfc3987.parse(s).get("scheme") in ["http", "https"]:
- return s
- raise ValueError
+def _uri(s: str) -> str:
+ parsed = urllib.parse.urlparse(s)
+ if parsed.scheme not in ["http", "https"] or not parsed.netloc:
+ raise UrlInvalid("URL is invalid")
+ return s
validate_transaction = Schema(
{
- "account": {"user_id": _unicode_or_printable_ascii, "username_md5": _md5,},
+ "account": {"user_id": str, "username_md5": _md5,},
"billing": _address,
"payment": {
"processor": _payment_processor,
"was_authorized": bool,
- "decline_code": _unicode_or_printable_ascii,
+ "decline_code": str,
},
"credit_card": {
"avs_result": _single_char,
- "bank_name": _unicode_or_printable_ascii,
+ "bank_name": str,
"bank_phone_country_code": _telephone_country_code,
- "bank_phone_number": _unicode_or_printable_ascii,
+ "bank_phone_number": str,
"cvv_result": _single_char,
"issuer_id_number": _iin,
"last_4_digits": _credit_card_last_4,
@@ -306,34 +292,34 @@ def _uri(s):
},
"custom_inputs": {_custom_input_key: _custom_input_value},
Required("device"): {
- "accept_language": _unicode_or_printable_ascii,
+ "accept_language": str,
Required("ip_address"): _ip_address,
"session_age": All(_any_number, Range(min=0)),
- "session_id": _unicode_or_printable_ascii,
- "user_agent": _unicode_or_printable_ascii,
+ "session_id": str,
+ "user_agent": str,
},
"email": {"address": _email_or_md5, "domain": _hostname,},
"event": {
- "shop_id": _unicode_or_printable_ascii,
+ "shop_id": str,
"time": _rfc3339_datetime,
"type": _event_type,
- "transaction_id": _unicode_or_printable_ascii,
+ "transaction_id": str,
},
"order": {
- "affiliate_id": _unicode_or_printable_ascii,
+ "affiliate_id": str,
"amount": _price,
"currency": _currency_code,
- "discount_code": _unicode_or_printable_ascii,
+ "discount_code": str,
"has_gift_message": bool,
"is_gift": bool,
"referrer_uri": _uri,
- "subaffiliate_id": _unicode_or_printable_ascii,
+ "subaffiliate_id": str,
},
"shipping": _shipping_address,
"shopping_cart": [
{
- "category": _unicode_or_printable_ascii,
- "item_id": _unicode_or_printable_ascii,
+ "category": str,
+ "item_id": str,
"price": _price,
"quantity": All(int, Range(min=1)),
},
@@ -342,8 +328,8 @@ def _uri(s):
)
-def _maxmind_id(s):
- if isinstance(s, (str, _unicode)) and len(s) == 8:
+def _maxmind_id(s: Optional[str]) -> str:
+ if isinstance(s, str) and len(s) == 8:
return s
raise ValueError
@@ -351,22 +337,22 @@ def _maxmind_id(s):
_tag = In(["chargeback", "not_fraud", "spam_or_abuse", "suspected_fraud"])
-def _uuid(s):
+def _uuid(s: str) -> str:
if isinstance(s, uuid.UUID):
return str(s)
- if isinstance(s, (str, _unicode)):
+ if isinstance(s, str):
return str(uuid.UUID(s))
raise ValueError
validate_report = Schema(
{
- "chargeback_code": _unicode_or_printable_ascii,
+ "chargeback_code": str,
Required("ip_address"): _ip_address,
"maxmind_id": _maxmind_id,
"minfraud_id": _uuid,
- "notes": _unicode_or_printable_ascii,
+ "notes": str,
Required("tag"): _tag,
- "transaction_id": _unicode_or_printable_ascii,
+ "transaction_id": str,
},
)
diff --git a/minfraud/webservice.py b/minfraud/webservice.py
index 18a18b7..bea37dd 100644
--- a/minfraud/webservice.py
+++ b/minfraud/webservice.py
@@ -6,7 +6,10 @@
"""
+from typing import Any, cast, Dict, Optional, Tuple, Type, Union
+
import requests
+from requests.models import Response
from requests.utils import default_user_agent
from voluptuous import MultipleInvalid
@@ -23,20 +26,17 @@
from .validation import validate_report, validate_transaction
-class Client(object):
- """Client for accessing the minFraud Score and Insights web services."""
+class Client:
+ """Client for accessing the minFraud web services."""
- def __init__(
+ def __init__( # pylint: disable=too-many-arguments
self,
- account_id=None,
- license_key=None,
- host="minfraud.maxmind.com",
- locales=("en",),
- timeout=None,
- # This is deprecated and not documented for that reason.
- # It can be removed if we do a major release in the future.
- user_id=None,
- ):
+ account_id: int,
+ license_key: str,
+ host: str = "minfraud.maxmind.com",
+ locales: Tuple[str] = ("en",),
+ timeout: Optional[float] = None,
+ ) -> None:
"""Constructor for Client.
:param account_id: Your MaxMind account ID
@@ -52,27 +52,16 @@ def __init__(
:return: Client object
:rtype: Client
"""
- # pylint: disable=too-many-arguments
- if account_id is None:
- account_id = user_id
-
- if account_id is None:
- raise TypeError("The account_id is a required parameter")
- if license_key is None:
- raise TypeError("The license_key is a required parameter")
- # pylint: disable=too-many-arguments
self._locales = locales
# requests 2.12.2 requires that the username passed to auth be a
# string
- self._account_id = (
- account_id if isinstance(account_id, bytes) else str(account_id)
- )
+ self._account_id = str(account_id)
self._license_key = license_key
self._base_uri = u"https://{0:s}/minfraud/v2.0".format(host)
self._timeout = timeout
- def factors(self, transaction, validate=True):
+ def factors(self, transaction: Dict[str, Any], validate: bool = True) -> Factors:
"""Query Factors endpoint with transaction data.
:param transaction: A dictionary containing the transaction to be
@@ -90,9 +79,11 @@ def factors(self, transaction, validate=True):
:raises: AuthenticationError, InsufficientFundsError,
InvalidRequestError, HTTPError, MinFraudError,
"""
- return self._response_for("factors", Factors, transaction, validate)
+ return cast(
+ Factors, self._response_for("factors", Factors, transaction, validate)
+ )
- def insights(self, transaction, validate=True):
+ def insights(self, transaction: Dict[str, Any], validate: bool = True) -> Insights:
"""Query Insights endpoint with transaction data.
:param transaction: A dictionary containing the transaction to be
@@ -110,9 +101,11 @@ def insights(self, transaction, validate=True):
:raises: AuthenticationError, InsufficientFundsError,
InvalidRequestError, HTTPError, MinFraudError,
"""
- return self._response_for("insights", Insights, transaction, validate)
+ return cast(
+ Insights, self._response_for("insights", Insights, transaction, validate)
+ )
- def score(self, transaction, validate=True):
+ def score(self, transaction: Dict[str, Any], validate: bool = True) -> Score:
"""Query Score endpoint with transaction data.
:param transaction: A dictionary containing the transaction to be
@@ -130,9 +123,15 @@ def score(self, transaction, validate=True):
:raises: AuthenticationError, InsufficientFundsError,
InvalidRequestError, HTTPError, MinFraudError,
"""
- return self._response_for("score", Score, transaction, validate)
+ return cast(Score, self._response_for("score", Score, transaction, validate))
- def _response_for(self, path, model_class, request, validate):
+ def _response_for(
+ self,
+ path: str,
+ model_class: Union[Type[Factors], Type[Score], Type[Insights]],
+ request: Dict[str, Any],
+ validate: bool,
+ ) -> Union[Score, Factors, Insights]:
"""Send request and create response object."""
cleaned_request = self._copy_and_clean(request)
if validate:
@@ -146,7 +145,7 @@ def _response_for(self, path, model_class, request, validate):
raise self._exception_for_error(response, uri)
return self._handle_success(response, uri, model_class)
- def report(self, report, validate=True):
+ def report(self, report: Dict[str, Optional[str]], validate: bool = True) -> None:
"""Send a transaction report to the Report Transaction endpoint.
:param report: A dictionary containing the transaction report to be sent
@@ -176,7 +175,7 @@ def report(self, report, validate=True):
if response.status_code != 204:
raise self._exception_for_error(response, uri)
- def _do_request(self, uri, data):
+ def _do_request(self, uri: str, data: Dict[str, Any]) -> Response:
return requests.post(
uri,
json=data,
@@ -185,7 +184,7 @@ def _do_request(self, uri, data):
timeout=self._timeout,
)
- def _copy_and_clean(self, data):
+ def _copy_and_clean(self, data: Any) -> Any:
"""Create a copy of the data structure with Nones removed."""
if isinstance(data, dict):
return dict(
@@ -195,11 +194,17 @@ def _copy_and_clean(self, data):
return [self._copy_and_clean(x) for x in data if x is not None]
return data
- def _user_agent(self):
+ @staticmethod
+ def _user_agent() -> str:
"""Create User-Agent header."""
return "minFraud-API/%s %s" % (__version__, default_user_agent())
- def _handle_success(self, response, uri, model_class):
+ def _handle_success(
+ self,
+ response: Response,
+ uri: str,
+ model_class: Union[Type[Factors], Type[Score], Type[Insights]],
+ ) -> Union[Score, Factors, Insights]:
"""Handle successful response."""
try:
body = response.json()
@@ -207,15 +212,23 @@ def _handle_success(self, response, uri, model_class):
raise MinFraudError(
"Received a 200 response"
" but could not decode the response as "
- "JSON: {0}".format(response.content),
+ "JSON: {0}".format(str(response.content)),
200,
uri,
)
if "ip_address" in body:
body["ip_address"]["_locales"] = self._locales
- return model_class(body)
+ return model_class(body) # type: ignore
- def _exception_for_error(self, response, uri):
+ def _exception_for_error(
+ self, response: Response, uri: str
+ ) -> Union[
+ AuthenticationError,
+ InsufficientFundsError,
+ InvalidRequestError,
+ HTTPError,
+ PermissionRequiredError,
+ ]:
"""Returns the exception for the error responses."""
status = response.status_code
@@ -225,7 +238,15 @@ def _exception_for_error(self, response, uri):
return self._exception_for_5xx_status(status, uri)
return self._exception_for_unexpected_status(status, uri)
- def _exception_for_4xx_status(self, response, status, uri):
+ def _exception_for_4xx_status(
+ self, response: Response, status: int, uri: str
+ ) -> Union[
+ AuthenticationError,
+ InsufficientFundsError,
+ InvalidRequestError,
+ HTTPError,
+ PermissionRequiredError,
+ ]:
"""Returns exception for error responses with 4xx status codes."""
if not response.content:
return HTTPError(
@@ -234,7 +255,7 @@ def _exception_for_4xx_status(self, response, status, uri):
if response.headers.get("Content-Type", "").find("json") == -1:
return HTTPError(
"Received a {0} with the following "
- "body: {1}".format(status, response.content),
+ "body: {1}".format(status, str(response.content)),
status,
uri,
)
@@ -244,7 +265,7 @@ def _exception_for_4xx_status(self, response, status, uri):
return HTTPError(
"Received a {status:d} error but it did not include"
" the expected JSON body: {content}".format(
- status=status, content=response.content
+ status=status, content=str(response.content)
),
status,
uri,
@@ -256,12 +277,20 @@ def _exception_for_4xx_status(self, response, status, uri):
)
return HTTPError(
"Error response contains JSON but it does not specify code"
- " or error keys: {0}".format(response.content),
+ " or error keys: {0}".format(str(response.content)),
status,
uri,
)
- def _exception_for_web_service_error(self, message, code, status, uri):
+ @staticmethod
+ def _exception_for_web_service_error(
+ message: str, code: str, status: int, uri: str
+ ) -> Union[
+ InvalidRequestError,
+ AuthenticationError,
+ PermissionRequiredError,
+ InsufficientFundsError,
+ ]:
"""Returns exception for error responses with the JSON body."""
if code in (
"ACCOUNT_ID_REQUIRED",
@@ -277,7 +306,8 @@ def _exception_for_web_service_error(self, message, code, status, uri):
return InvalidRequestError(message, code, status, uri)
- def _exception_for_5xx_status(self, status, uri):
+ @staticmethod
+ def _exception_for_5xx_status(status: int, uri: str) -> HTTPError:
"""Returns exception for error response with 5xx status codes."""
return HTTPError(
u"Received a server error ({0}) for " u"{1}".format(status, uri),
@@ -285,7 +315,8 @@ def _exception_for_5xx_status(self, status, uri):
uri,
)
- def _exception_for_unexpected_status(self, status, uri):
+ @staticmethod
+ def _exception_for_unexpected_status(status: int, uri: str) -> HTTPError:
"""Returns exception for responses with unexpected status codes."""
return HTTPError(
u"Received an unexpected HTTP status " u"({0}) for {1}".format(status, uri),
diff --git a/pylintrc b/pylintrc
index 4c925b6..32fff1f 100644
--- a/pylintrc
+++ b/pylintrc
@@ -1,5 +1,5 @@
[MESSAGES CONTROL]
-disable=C0330,R0201,R0205,W0105,locally-disabled,too-few-public-methods
+disable=bad-continuation,too-few-public-methods
[BASIC]
diff --git a/setup.cfg b/setup.cfg
index f038699..cdfe10f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -2,5 +2,9 @@
build_html = build_sphinx -b html --build-dir docs
sdist = build_html sdist
+[flake8]
+# black uses 88 : ¯\_(ツ)_/¯
+max-line-length = 88
+
[wheel]
universal = 1
diff --git a/setup.py b/setup.py
index 7a6d998..4ec854e 100644
--- a/setup.py
+++ b/setup.py
@@ -17,12 +17,10 @@
_readme = f.read()
requirements = [
- "geoip2>=3.0.0,<4.0.0",
+ "email_validator",
+ "geoip2>=4.0.0,<5.0.0",
"requests>=2.22.0",
- "rfc3987",
- "strict-rfc3339",
"urllib3>=1.25.2",
- "validate_email",
"voluptuous",
]
@@ -54,9 +52,7 @@
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: Apache Software License",
- "Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
diff --git a/tests/test_models.py b/tests/test_models.py
index 7329292..543f714 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -1,15 +1,6 @@
-import sys
-
from minfraud.models import *
-if sys.version_info[:2] == (2, 6):
- import unittest2 as unittest
-else:
- import unittest
-
-if sys.version_info[0] == 2:
- unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
- unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches
+import unittest
class TestModels(unittest.TestCase):
@@ -32,9 +23,9 @@ def test_model_immutability(self):
for model in models:
for attr in (model.attr, "does_not_exist"):
with self.assertRaises(
- AttributeError, msg="{0!s} - {0}".format(model.obj, attr)
+ AttributeError, msg="{0!s} - {1}".format(model.obj, attr)
):
- setattr(model.obj, attr, 5)
+ setattr(model.obj, attr, 5) # type: ignore
def test_billing_address(self):
address = BillingAddress(self.address_dict)
diff --git a/tests/test_validation.py b/tests/test_validation.py
index 72e795c..5c101d9 100644
--- a/tests/test_validation.py
+++ b/tests/test_validation.py
@@ -1,20 +1,12 @@
from decimal import Decimal
-import sys
from voluptuous import MultipleInvalid
from minfraud.validation import validate_transaction, validate_report
-if sys.version_info[:2] == (2, 6):
- import unittest2 as unittest
-else:
- import unittest
+import unittest
-if sys.version_info[0] == 2:
- unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
- unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches
-
-class ValidationBase(object):
+class ValidationBase:
def setup_transaction(self, transaction):
if "device" not in transaction:
transaction["device"] = {}
diff --git a/tests/test_webservice.py b/tests/test_webservice.py
index 65046a3..a847cbb 100644
--- a/tests/test_webservice.py
+++ b/tests/test_webservice.py
@@ -1,8 +1,7 @@
import os
-import sys
import json
-import requests_mock
+import requests_mock # type: ignore
from io import open
from minfraud.errors import (
HTTPError,
@@ -15,17 +14,10 @@
from minfraud.models import Factors, Insights, Score
from minfraud.webservice import Client
-if sys.version_info[:2] == (2, 6):
- import unittest2 as unittest
-else:
- import unittest
+import unittest
-if sys.version_info[0] == 2:
- unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
- unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches
-
-class BaseTest(object):
+class BaseTest:
def setUp(self):
self.client = Client(42, "abcdef123456")
@@ -152,7 +144,7 @@ def test_named_constructor_args(self):
key = "1234567890ab"
for client in (
Client(account_id=id, license_key=key),
- Client(user_id=id, license_key=key),
+ Client(account_id=id, license_key=key),
):
self.assertEqual(client._account_id, id)
self.assertEqual(client._license_key, key)