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)