Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions .github/workflows/cd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions .github/workflows/linting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ on:
pull_request:
branches: [main]
paths:
- '**/*.py'
- 'requirements.txt'
- 'pyproject.toml'
- "**/*.py"
- "requirements.txt"
- "pyproject.toml"
- .flake8
- .isort.cfg
- .github/workflows/linting.yml

push:
branches: [main]
paths:
- '**/*.py'
- 'requirements.txt'
- 'pyproject.toml'
- "**/*.py"
- "requirements.txt"
- "pyproject.toml"
- .flake8
- .isort.cfg
- .github/workflows/linting.yml
Expand All @@ -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
Expand All @@ -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 }}
26 changes: 15 additions & 11 deletions .github/workflows/tests-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
24 changes: 14 additions & 10 deletions .github/workflows/tests-unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 1 addition & 1 deletion pyazul/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion pyazul/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@

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
from pydantic_settings import BaseSettings, SettingsConfigDict

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)

Expand Down
15 changes: 13 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand All @@ -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]
Expand Down Expand Up @@ -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"
Expand All @@ -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
Empty file added tests/e2e/__init__.py
Empty file.
94 changes: 94 additions & 0 deletions tests/e2e/helpers.py
Original file line number Diff line number Diff line change
@@ -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}"
)
Loading