diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index eb9e8f3..1a1d0b8 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -11,18 +11,41 @@ on: push: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" - .github/workflows/cd.yml release: types: - published jobs: + test-build-matrix: + name: Test build on Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5.6.0 + with: + python-version: ${{ matrix.python-version }} + + - name: Test package build + uses: hynek/build-and-inspect-python-package@v2.12.0 + with: + attest-build-provenance-github: "false" + build-package: name: Build & verify package runs-on: ubuntu-latest + needs: test-build-matrix permissions: attestations: write id-token: write diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 07bba15..2fd955a 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -10,9 +10,9 @@ on: pull_request: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" - .flake8 - .isort.cfg - .github/workflows/linting.yml @@ -20,9 +20,9 @@ on: push: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" - .flake8 - .isort.cfg - .github/workflows/linting.yml @@ -41,7 +41,7 @@ jobs: - name: Lint Code Base uses: super-linter/super-linter/slim@v7.4.0 env: - LOG_LEVEL: ERROR + LOG_LEVEL: INFO VALIDATE_ALL_CODEBASE: false VALIDATE_SHELL_SHFMT: false VALIDATE_JSCPD: false @@ -50,6 +50,9 @@ jobs: VALIDATE_MARKDOWN: false VALIDATE_JAVASCRIPT_ES: false VALIDATE_JAVASCRIPT_STANDARD: false + VALIDATE_PYTHON_PYINK: false + VALIDATE_PYTHON_PYLINT: false + VALIDATE_PYTHON_MYPY: false LINTER_RULES_PATH: / DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests-e2e.yaml b/.github/workflows/tests-e2e.yaml index 845d28a..05ccf56 100644 --- a/.github/workflows/tests-e2e.yaml +++ b/.github/workflows/tests-e2e.yaml @@ -11,17 +11,17 @@ on: pull_request: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' - - '.github/workflows/tests-e2e.yaml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" + - ".github/workflows/tests-e2e.yaml" push: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' - - '.github/workflows/tests-e2e.yaml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" + - ".github/workflows/tests-e2e.yaml" env: PYTHONUNBUFFERED: 1 @@ -37,14 +37,18 @@ env: jobs: e2e-tests: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + name: e2e-tests-python-${{ matrix.python-version }} steps: - name: Checkout Code Repository uses: actions/checkout@v4.2.2 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5.6.0 with: - python-version: '3.12' + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/.github/workflows/tests-unit.yaml b/.github/workflows/tests-unit.yaml index fdaf9db..376b65b 100644 --- a/.github/workflows/tests-unit.yaml +++ b/.github/workflows/tests-unit.yaml @@ -11,29 +11,33 @@ on: pull_request: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' - - '.github/workflows/tests-unit.yaml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" + - ".github/workflows/tests-unit.yaml" push: branches: [main] paths: - - '**/*.py' - - 'requirements.txt' - - 'pyproject.toml' - - '.github/workflows/tests-unit.yaml' + - "**/*.py" + - "requirements.txt" + - "pyproject.toml" + - ".github/workflows/tests-unit.yaml" jobs: unit-tests: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + name: unit-tests-python-${{ matrix.python-version }} steps: - name: Checkout Code Repository uses: actions/checkout@v4.2.2 - - name: Set up Python 3.12 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5.6.0 with: - python-version: '3.12' + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/pyazul/api/client.py b/pyazul/api/client.py index bda22ee..cfb50d4 100644 --- a/pyazul/api/client.py +++ b/pyazul/api/client.py @@ -127,7 +127,7 @@ def _handle_response(self, response: httpx.Response) -> Dict[str, Any]: """ try: response.raise_for_status() - data = response.json() + data: Dict[str, Any] = response.json() _logger.debug(f"Received response: {json.dumps(data, indent=2)}") self._check_for_errors(data) return data diff --git a/pyazul/core/config.py b/pyazul/core/config.py index 497bb60..602527d 100644 --- a/pyazul/core/config.py +++ b/pyazul/core/config.py @@ -8,9 +8,10 @@ import base64 import os +import sys from functools import lru_cache from pathlib import Path -from typing import Any, Optional, Self, Tuple +from typing import Any, Optional, Tuple from dotenv import load_dotenv from pydantic import model_validator @@ -18,6 +19,11 @@ from pyazul.api.constants import AzulEndpoints +if sys.version_info >= (3, 11): + from typing import Self +else: + from typing_extensions import Self + # Load .env file with override=True to ensure values are loaded load_dotenv(override=True) diff --git a/pyproject.toml b/pyproject.toml index 90c09a6..2a9f5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,10 @@ authors = [{ name = "INDEXA Inc.", email = "info@indexa.do" }] license = { text = "MIT License" } classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] @@ -18,8 +22,9 @@ dependencies = [ "pydantic>=2.11.5", "pydantic-settings>=2.9.1", "python-dotenv>=1.1.0", + "typing-extensions>=4.0.0", ] -requires-python = ">=3.12" +requires-python = ">=3.10" readme = "README.md" [project.urls] @@ -57,7 +62,7 @@ python_functions = ["test_*"] [tool.black] line-length = 88 -target-version = ["py312"] +target-version = ["py310", "py311", "py312", "py313"] [tool.pydocstyle] convention = "google" @@ -70,3 +75,9 @@ fail-under = 70 verbose = 0 generate-badge = "docs/interrogate_badge.svg" badge-format = "svg" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = false diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..38b3be7 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,94 @@ +"""Helper functions for e2e tests.""" + +import json +from typing import Any, Dict, Literal, Optional, Tuple + +import pytest + + +def assert_3ds_response( + result: Dict[str, Any], + expected_type: Literal[ + "redirect", "wrapped_approval", "direct_approval", "any" + ] = "any", +) -> Tuple[Optional[str], Optional[Dict[str, Any]]]: + """ + Validate and extract data from 3DS response. + + Handles three 3DS response types: + 1. `redirect: true` - 3DS Method/Challenge required + 2. `value: {...}` - Wrapped approval response + 3. Top-level `IsoCode: "00"` - Direct frictionless approval + + Args: + result: Response dict from secure_sale/secure_token_sale + expected_type: Expected response type. "any" accepts any valid type. + + Returns: + Tuple of (secure_id, response_data): + - secure_id: The secure_id if redirect is required, None otherwise + - response_data: The actual response dict (from value field or top-level) + + Raises: + pytest.fail: If the response doesn't match expected type or is invalid + """ + if not isinstance(result, dict): + pytest.fail(f"Expected dict response, got: {type(result)}") + + # Case 1: Redirect required (3DS Method/Challenge) + if result.get("redirect") and result.get("html") and result.get("id"): + secure_id = result["id"] + if expected_type not in ("redirect", "any"): + response_dump = ( + json.dumps(result, indent=2) + if isinstance(result, dict) + else str(result) + ) + pytest.fail( + f"Expected {expected_type}, but got redirect response. " + f"Response: {response_dump}" + ) + return secure_id, None + + # Case 2: Wrapped approval response (value field) + if result.get("value") and isinstance(result["value"], dict): + response = result["value"] + if response.get("IsoCode") == "00": + if expected_type not in ("wrapped_approval", "direct_approval", "any"): + response_dump = json.dumps(result, indent=2) + pytest.fail( + f"Expected {expected_type}, but got wrapped approval. " + f"Response: {response_dump}" + ) + assert ( + response.get("ResponseMessage") == "APROBADA" + ), f"Expected APROBADA, got: {response.get('ResponseMessage')}" + return None, response + # If value exists but IsoCode is not "00", it might be an error + # Let the caller handle it + + # Case 3: Direct approval at top level + if result.get("IsoCode") == "00": + if expected_type not in ("direct_approval", "any"): + response_dump = ( + json.dumps(result, indent=2) + if isinstance(result, dict) + else str(result) + ) + pytest.fail( + f"Expected {expected_type}, but got direct approval (top-level). " + f"Response: {response_dump}" + ) + assert ( + result.get("ResponseMessage") == "APROBADA" + ), f"Expected APROBADA, got: {result.get('ResponseMessage')}" + return None, result + + # If we get here, the response doesn't match any expected pattern + response_dump = ( + json.dumps(result, indent=2) if isinstance(result, dict) else str(result) + ) + pytest.fail( + f"Unexpected 3DS response format. Expected one of: redirect, " + f"wrapped_approval, or direct_approval. Got: {response_dump}" + ) diff --git a/tests/e2e/services/test_datavault_integration.py b/tests/e2e/services/test_datavault_integration.py index 3d81229..e18d096 100644 --- a/tests/e2e/services/test_datavault_integration.py +++ b/tests/e2e/services/test_datavault_integration.py @@ -12,6 +12,7 @@ SecureTokenSale, ThreeDSAuth, ) +from tests.e2e.helpers import assert_3ds_response from tests.fixtures.cards import get_card from tests.fixtures.order import generate_order_number @@ -166,24 +167,18 @@ async def test_create_sale_datavault_3ds( assert result is not None, "3DS token sale result should not be None" # Handle different possible outcomes - if result.get("redirect"): + secure_id, response_data = assert_3ds_response(result, expected_type="any") + + if secure_id: # 3DS method or challenge required - secure_id = result["id"] print(f"3DS token sale initiated with redirect. ID: {secure_id}") # This validates that the initial request was successful # (if SaveToDataVault was missing, we'd get an error here) assert "html" in result, "HTML form should be provided for redirect" - - elif result.get("value") and isinstance(result["value"], dict): + elif response_data: # Direct approval (frictionless) - response = result["value"] - assert response.get("IsoCode") == "00", f"3DS token sale failed: {response}" - assert response.get("ResponseMessage") == "APROBADA" - print(f"3DS token sale approved directly: {response.get('AuthorizationCode')}") - - else: - pytest.fail(f"Unexpected 3DS token sale result: {result}") + print(f"3DS token sale frictionless: {response_data.get('AuthorizationCode')}") print("3DS token sale completed successfully") return result @@ -252,14 +247,12 @@ async def test_token_sale_comparison_3ds_vs_non_3ds( assert three_ds_result is not None, "3DS token sale should not be None" - if three_ds_result.get("redirect"): - print(f"3DS token sale initiated: {three_ds_result['id']}") - elif three_ds_result.get("value") and isinstance(three_ds_result["value"], dict): - response = three_ds_result["value"] - assert response.get("IsoCode") == "00", f"3DS failed: {response}" - print(f"3DS token sale approved: {response.get('AuthorizationCode')}") - else: - pytest.fail(f"Unexpected 3DS result: {three_ds_result}") + secure_id, response_data = assert_3ds_response(three_ds_result, expected_type="any") + + if secure_id: + print(f"3DS token sale initiated: {secure_id}") + elif response_data: + print(f"3DS token sale approved: {response_data.get('AuthorizationCode')}") print("Both 3DS and non-3DS token sales work correctly") diff --git a/tests/e2e/services/test_secure_integration.py b/tests/e2e/services/test_secure_integration.py index 04c0355..2a7f4e2 100644 --- a/tests/e2e/services/test_secure_integration.py +++ b/tests/e2e/services/test_secure_integration.py @@ -13,6 +13,7 @@ SecureSale, ThreeDSAuth, ) +from tests.e2e.helpers import assert_3ds_response from tests.fixtures.cards import CardDetails, get_card from tests.fixtures.order import generate_order_number @@ -107,12 +108,12 @@ async def test_secure_sale_frictionless_with_3ds_method( ) assert initial_response_dict is not None - if ( - initial_response_dict.get("redirect") - and initial_response_dict.get("html") - and initial_response_dict.get("id") - ): - secure_id = initial_response_dict["id"] + secure_id, response_data = assert_3ds_response( + initial_response_dict, expected_type="any" + ) + + if secure_id: + # Redirect required (3DS Method) print(f"Initial 3DS Sale requires redirect (3DS Method). ID: {secure_id}") session_data = await azul.get_session_info(secure_id) @@ -130,6 +131,7 @@ async def test_secure_sale_frictionless_with_3ds_method( ) assert method_response is not None + # Check if method response is an approval if isinstance(method_response, dict) and method_response.get("IsoCode") == "00": print("Approved after 3DS Method (frictionless).") assert method_response.get("ResponseMessage") == "APROBADA" @@ -141,22 +143,16 @@ async def test_secure_sale_frictionless_with_3ds_method( pytest.fail( f"After 3DS Method, expected direct approval, but got: {response_dump}" ) - elif ( - initial_response_dict.get("value") - and isinstance(initial_response_dict["value"], dict) - and initial_response_dict["value"].get("IsoCode") == "00" - ): - print("Approved directly from secure_sale (frictionless, no method redirect).") - assert initial_response_dict["value"].get("ResponseMessage") == "APROBADA" - elif initial_response_dict.get("IsoCode") == "00": - print("Approved directly (frictionless, no method redirect - top level dict).") - assert initial_response_dict.get("ResponseMessage") == "APROBADA" - else: - response_dump = str(initial_response_dict) - pytest.fail( - "Unexpected initial response. Expected 3DS Method redirect or " - f"direct frictionless approval. Got: {response_dump}" - ) + elif response_data: + # Direct approval (frictionless) + if initial_response_dict.get("value"): + print( + "Approved directly from secure_sale (frictionless, no method redirect)." + ) + else: + print( + "Approved directly (frictionless, no method redirect - top level dict)." + ) @pytest.mark.asyncio @@ -195,25 +191,16 @@ async def test_secure_sale_frictionless_direct_approval( "redirect" ), "Expected no redirect for direct frictionless approval." - value_data = initial_response_dict.get("value") - if value_data is None: - # Check if the response is directly at the top level - if initial_response_dict.get("IsoCode") == "00": - print("Response is at top level, not in 'value' field") - assert initial_response_dict.get("ResponseMessage") == "APROBADA" - return - - assert isinstance( - value_data, dict - ), f"Expected 'value' dict in direct approval, got: {type(value_data)}" + secure_id, response_data = assert_3ds_response( + initial_response_dict, expected_type="any" + ) + assert secure_id is None, "Expected direct approval, not redirect" + assert response_data is not None, "Expected approval response data" - assert ( - value_data.get("IsoCode") == "00" - ), f"Expected IsoCode 00, got: {value_data.get('IsoCode')}" - assert ( - value_data.get("ResponseMessage") == "APROBADA" - ), f"Expected APROBADA, got: {value_data.get('ResponseMessage')}" - print("Approved directly (frictionless, no method/challenge redirect).") + if initial_response_dict.get("value"): + print("Approved directly (frictionless, no method/challenge redirect).") + else: + print("Response is at top level, not in 'value' field") @pytest.mark.asyncio @@ -264,24 +251,26 @@ async def test_secure_sale_direct_to_challenge( print("Direct challenge by secure_sale as expected.") - elif ( - initial_response_dict.get("value") - and isinstance(initial_response_dict["value"], dict) - and initial_response_dict["value"].get("IsoCode") == "00" - ): - print("Unexpected direct approval for a challenge card.") - assert initial_response_dict["value"].get("ResponseMessage") == "APROBADA" - pytest.fail("Expected direct challenge, got direct approval.") - elif initial_response_dict.get("IsoCode") == "00": - print("Unexpected direct approval (top-level) for a challenge card.") - assert initial_response_dict.get("ResponseMessage") == "APROBADA" - pytest.fail("Expected direct challenge, got direct approval (top-level).") else: - response_dump = str(initial_response_dict) - pytest.fail( - "Unexpected initial response. Expected 3DS redirect or approval. " - f"Got: {response_dump}" + # Check if we got an unexpected approval + secure_id, response_data = assert_3ds_response( + initial_response_dict, expected_type="any" ) + if response_data: + if initial_response_dict.get("value"): + print("Unexpected direct approval for a challenge card.") + pytest.fail("Expected direct challenge, got direct approval.") + else: + print("Unexpected direct approval (top-level) for a challenge card.") + pytest.fail( + "Expected direct challenge, got direct approval (top-level)." + ) + else: + response_dump = str(initial_response_dict) + pytest.fail( + "Unexpected initial response. Expected 3DS redirect or approval. " + f"Got: {response_dump}" + ) @pytest.mark.asyncio