From 56f3d7439a94bbc8fb4a5753ed2ea226cf2a89ec Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Nov 2025 13:37:22 -0800 Subject: [PATCH 1/2] Require Python 3.10; test on 3.14 --- .github/workflows/test.yml | 2 +- HISTORY.rst | 2 ++ pyproject.toml | 12 ++++++------ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 856a741..305603f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - env: [3.9, "3.10", 3.11, 3.12, 3.13] + env: ["3.10", 3.11, 3.12, 3.13, 3.14] os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] steps: - uses: actions/checkout@v5 diff --git a/HISTORY.rst b/HISTORY.rst index 905d3e4..c5b9e30 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,8 @@ History 3.2.0 ++++++++++++++++++ +* IMPORTANT: Python 3.10 or greater is required. If you are using an older + version, please use an earlier release. * Setuptools has been replaced with the uv build backend for building the package. * Added ``securepay`` to the ``/payment/processor`` validation. diff --git a/pyproject.toml b/pyproject.toml index 6b5f273..386e8c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "typing-extensions>=4.13.2", "voluptuous", ] -requires-python = ">=3.9" +requires-python = ">=3.10" readme = "README.rst" license = "Apache-2.0" license-files = ["LICENSE"] @@ -23,11 +23,11 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Internet", "Topic :: Internet :: Proxy Servers", "Topic :: Internet :: WWW/HTTP", @@ -100,11 +100,11 @@ ignorelist = ["id"] [tool.tox] env_list = [ - "3.9", "3.10", "3.11", "3.12", "3.13", + "3.14", "lint", ] skip_missing_interpreters = false @@ -119,7 +119,7 @@ commands = [ [tool.tox.env.lint] description = "Code linting" -python = "3.13" +python = "3.14" dependency_groups = [ "dev", "lint", @@ -131,8 +131,8 @@ commands = [ ] [tool.tox.gh.python] -"3.13" = ["3.13", "lint"] +"3.14" = ["3.14", "lint"] +"3.13" = ["3.13"] "3.12" = ["3.12"] "3.11" = ["3.11"] "3.10" = ["3.10"] -"3.9" = ["3.9"] From 79a6adf99adab40fecfeb4d837747f402ed3a577 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 5 Nov 2025 13:54:31 -0800 Subject: [PATCH 2/2] Improve our Pyright type coverage score --- pyproject.toml | 6 +-- src/minfraud/models.py | 100 ++++++++++++++++++------------------- src/minfraud/validation.py | 5 +- src/minfraud/webservice.py | 6 +-- tests/test_validation.py | 5 +- tests/test_webservice.py | 4 +- 6 files changed, 65 insertions(+), 61 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 386e8c8..fe37712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,9 +67,6 @@ source-include = [ [tool.ruff.lint] select = ["ALL"] ignore = [ - # Skip type annotation on **_ - "ANN003", - # Redundant as the formatter handles missing trailing commas. "COM812", @@ -95,7 +92,8 @@ ignorelist = ["id"] [tool.ruff.lint.per-file-ignores] "docs/*" = ["ALL"] -"src/minfraud/models.py" = [ "PLR0913" ] +"src/minfraud/models.py" = ["ANN401", "PLR0913"] +"src/minfraud/webservice.py" = ["ANN401"] "tests/*" = ["ANN201", "D"] [tool.tox] diff --git a/src/minfraud/models.py b/src/minfraud/models.py index 282e715..b1dcfca 100644 --- a/src/minfraud/models.py +++ b/src/minfraud/models.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import geoip2.models import geoip2.records @@ -23,7 +23,7 @@ def __hash__(self) -> int: # This is not particularly efficient, but I don't expect it to be used much. return hash(json.dumps(self.to_dict(), sort_keys=True)) - def to_dict(self) -> dict: # noqa: C901 + def to_dict(self) -> dict[str, Any]: # noqa: C901 """Return a dict of the object suitable for serialization.""" result = {} for key, value in self.__dict__.items(): @@ -100,7 +100,7 @@ class GeoIP2Location(geoip2.records.Location): `RFC 3339 `_. For instance, the local time in Boston might be returned as 2015-04-27T19:17:24-04:00.""" - def __init__(self, *args, **kwargs) -> None: # noqa: ANN002 + def __init__(self, *args: Any, **kwargs: Any) -> None: """Initialize a GeoIP2Location instance.""" self.local_time = kwargs.get("local_time") super().__init__(*args, **kwargs) @@ -131,11 +131,11 @@ def __init__( self, locales: Sequence[str] | None, *, - country: dict | None = None, - location: dict | None = None, + country: dict[str, Any] | None = None, + location: dict[str, Any] | None = None, risk: float | None = None, - risk_reasons: list[dict] | None = None, - **kwargs, + risk_reasons: list[dict[str, Any]] | None = None, + **kwargs: Any, ) -> None: """Initialize an IPAddress instance.""" # For raw attribute @@ -161,7 +161,7 @@ class ScoreIPAddress(_Serializable): """This field contains the risk associated with the IP address. The value ranges from 0.01 to 99. A higher score indicates a higher risk.""" - def __init__(self, *, risk: float | None = None, **_) -> None: + def __init__(self, *, risk: float | None = None, **_: Any) -> None: """Initialize a ScoreIPAddress instance.""" self.risk = risk @@ -197,7 +197,7 @@ def __init__( matches_provided_name: bool | None = None, phone_number: str | None = None, matches_provided_phone_number: bool | None = None, - **_, + **_: Any, ) -> None: """Initialize an Issuer instance.""" self.name = name @@ -239,7 +239,7 @@ def __init__( id: str | None = None, last_seen: str | None = None, local_time: str | None = None, - **_, + **_: Any, ) -> None: """Initialize a Device instance.""" self.confidence = confidence @@ -277,7 +277,7 @@ def __init__( action: str | None = None, reason: str | None = None, rule_label: str | None = None, - **_, + **_: Any, ) -> None: """Initialize a Disposition instance.""" self.action = action @@ -293,7 +293,7 @@ class EmailDomain(_Serializable): was first seen by MaxMind. This is expressed using the ISO 8601 date format.""" - def __init__(self, *, first_seen: str | None = None, **_) -> None: + def __init__(self, *, first_seen: str | None = None, **_: Any) -> None: """Initialize an EmailDomain instance.""" self.first_seen = first_seen @@ -325,7 +325,7 @@ class Email(_Serializable): def __init__( self, - domain: dict | None = None, + domain: dict[str, Any] | None = None, first_seen: str | None = None, is_disposable: bool | None = None, # noqa: FBT001 is_free: bool | None = None, # noqa: FBT001 @@ -378,7 +378,7 @@ class CreditCard(_Serializable): def __init__( self, - issuer: dict | None = None, + issuer: dict[str, Any] | None = None, country: str | None = None, brand: str | None = None, is_business: bool | None = None, # noqa: FBT001 @@ -432,7 +432,7 @@ def __init__( longitude: float | None = None, distance_to_ip_location: int | None = None, is_in_ip_country: bool | None = None, - **_, + **_: Any, ) -> None: """Initialize a BillingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -487,7 +487,7 @@ def __init__( is_in_ip_country: bool | None = None, is_high_risk: bool | None = None, distance_to_billing_address: int | None = None, - **_, + **_: Any, ) -> None: """Initialize a ShippingAddress instance.""" self.is_postal_in_city = is_postal_in_city @@ -538,7 +538,7 @@ def __init__( matches_postal: bool | None = None, network_operator: str | None = None, number_type: str | None = None, - **_, + **_: Any, ) -> None: """Initialize a Phone instance.""" self.country = country @@ -573,7 +573,7 @@ def __init__( code: str | None = None, warning: str | None = None, input_pointer: str | None = None, - **_, + **_: Any, ) -> None: """Initialize a ServiceWarning instance.""" self.code = code @@ -717,7 +717,7 @@ def __init__( shipping_address: float | None = None, shipping_address_distance_to_ip_location: float | None = None, time_of_day: float | None = None, - **_, + **_: Any, ) -> None: """Initialize a Subscores instance.""" self.avs_result = avs_result @@ -831,7 +831,7 @@ def __init__( *, code: str | None = None, reason: str | None = None, - **_, + **_: Any, ) -> None: """Initialize a Reason instance.""" self.code = code @@ -855,8 +855,8 @@ def __init__( self, *, multiplier: float, - reasons: list | None = None, - **_, + reasons: list[dict[str, Any]] | None = None, + **_: Any, ) -> None: """Initialize a RiskScoreReason instance.""" self.multiplier = multiplier @@ -948,23 +948,23 @@ def __init__( self, locales: Sequence[str], *, - billing_address: dict | None = None, - billing_phone: dict | None = None, - credit_card: dict | None = None, - disposition: dict | None = None, + billing_address: dict[str, Any] | None = None, + billing_phone: dict[str, Any] | None = None, + credit_card: dict[str, Any] | None = None, + disposition: dict[str, Any] | None = None, funds_remaining: float, - device: dict | None = None, - email: dict | None = None, + device: dict[str, Any] | None = None, + email: dict[str, Any] | None = None, id: str, - ip_address: dict | None = None, + ip_address: dict[str, Any] | None = None, queries_remaining: int, risk_score: float, - shipping_address: dict | None = None, - shipping_phone: dict | None = None, - subscores: dict | None = None, - warnings: list[dict] | None = None, - risk_score_reasons: list[dict] | None = None, - **_, + shipping_address: dict[str, Any] | None = None, + shipping_phone: dict[str, Any] | None = None, + subscores: dict[str, Any] | None = None, + warnings: list[dict[str, Any]] | None = None, + risk_score_reasons: list[dict[str, Any]] | None = None, + **_: Any, ) -> None: """Initialize a Factors instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1056,21 +1056,21 @@ def __init__( self, locales: Sequence[str], *, - billing_address: dict | None = None, - billing_phone: dict | None = None, - credit_card: dict | None = None, - device: dict | None = None, - disposition: dict | None = None, - email: dict | None = None, + billing_address: dict[str, Any] | None = None, + billing_phone: dict[str, Any] | None = None, + credit_card: dict[str, Any] | None = None, + device: dict[str, Any] | None = None, + disposition: dict[str, Any] | None = None, + email: dict[str, Any] | None = None, funds_remaining: float, id: str, - ip_address: dict | None = None, + ip_address: dict[str, Any] | None = None, queries_remaining: int, risk_score: float, - shipping_address: dict | None = None, - shipping_phone: dict | None = None, - warnings: list[dict] | None = None, - **_, + shipping_address: dict[str, Any] | None = None, + shipping_phone: dict[str, Any] | None = None, + warnings: list[dict[str, Any]] | None = None, + **_: Any, ) -> None: """Initialize an Insights instance.""" self.billing_address = BillingAddress(**(billing_address or {})) @@ -1128,14 +1128,14 @@ class Score(_Serializable): def __init__( self, *, - disposition: dict | None = None, + disposition: dict[str, Any] | None = None, funds_remaining: float, id: str, - ip_address: dict | None = None, + ip_address: dict[str, Any] | None = None, queries_remaining: int, risk_score: float, - warnings: list[dict] | None = None, - **_, + warnings: list[dict[str, Any]] | None = None, + **_: Any, ) -> None: """Initialize a Score instance.""" self.disposition = Disposition(**(disposition or {})) diff --git a/src/minfraud/validation.py b/src/minfraud/validation.py index 6dc9be9..a8de913 100644 --- a/src/minfraud/validation.py +++ b/src/minfraud/validation.py @@ -14,6 +14,7 @@ import urllib.parse import uuid from decimal import Decimal +from typing import Any as AnyType from email_validator import validate_email from voluptuous import ( @@ -327,7 +328,7 @@ def _uri(s: str) -> str: return s -validate_transaction = Schema( +validate_transaction: Schema = Schema( { "account": { "user_id": str, @@ -459,7 +460,7 @@ def _validate_at_least_one_identifier_field(report: dict) -> bool: return True -def validate_report(report: dict) -> bool: +def validate_report(report: dict[str, AnyType]) -> bool: """Validate minFraud Transaction Report fields.""" _validate_report_schema(report) _validate_at_least_one_identifier_field(report) diff --git a/src/minfraud/webservice.py b/src/minfraud/webservice.py index 1678bea..77fb2d3 100644 --- a/src/minfraud/webservice.py +++ b/src/minfraud/webservice.py @@ -4,7 +4,7 @@ import json from functools import partial -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast import aiohttp import aiohttp.http @@ -26,7 +26,7 @@ if TYPE_CHECKING: import sys import types - from collections.abc import Sequence + from collections.abc import Callable, Sequence from requests.models import Response @@ -78,7 +78,7 @@ def _handle_success( self, raw_body: str, uri: str, - model_class: Callable, + model_class: Callable[..., Score | Factors | Insights], ) -> Score | Factors | Insights: """Handle successful response.""" try: diff --git a/tests/test_validation.py b/tests/test_validation.py index 0d31a53..4318f68 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2,12 +2,15 @@ import unittest from decimal import Decimal -from typing import Any, Callable +from typing import TYPE_CHECKING, Any from voluptuous import MultipleInvalid from minfraud.validation import validate_report, validate_transaction +if TYPE_CHECKING: + from collections.abc import Callable + class ValidationBase(unittest.TestCase): def setup_transaction(self, transaction: dict[str, Any]) -> None: diff --git a/tests/test_webservice.py b/tests/test_webservice.py index ca2ddae..919c7ac 100644 --- a/tests/test_webservice.py +++ b/tests/test_webservice.py @@ -6,7 +6,7 @@ import os import unittest from functools import partial -from typing import TYPE_CHECKING, Any, Callable, cast +from typing import TYPE_CHECKING, Any, cast import pytest @@ -23,6 +23,8 @@ from minfraud.webservice import AsyncClient, Client if TYPE_CHECKING: + from collections.abc import Callable + from pytest_httpserver import HTTPServer minfraud.webservice._SCHEME = "http" # noqa: SLF001