From c2e366259d2bcec9d1e4f83ad205f287976c5dff Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Fri, 22 Aug 2025 17:02:49 +0300 Subject: [PATCH 1/8] Update .gitignore to include .env and improve README formatting for clarity --- .gitignore | 1 + gavaconnect/auth/README.md | 35 ++++++++++++++++++----------------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 505a3b1..9562d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ wheels/ # Virtual environments .venv +.env \ No newline at end of file diff --git a/gavaconnect/auth/README.md b/gavaconnect/auth/README.md index d204d08..4242629 100644 --- a/gavaconnect/auth/README.md +++ b/gavaconnect/auth/README.md @@ -4,8 +4,8 @@ The SDK implements authentication as a **pluggable policy** so each endpoint family (`checkers`, `tax`, `payments`, `authorization`) can use the scheme it requires while sharing a common transport layer. The SDK supports: -* **Basic** (static header from `client_id:client_secret`) -* **Bearer** (OAuth2 Client Credentials) with **concurrency-safe caching**, **early refresh**, and **401-triggered single retry** +- **Basic** (static header from `client_id:client_secret`) for getting the token. +- **Bearer** (OAuth2 Client Credentials) with **concurrency-safe caching**, **early refresh**, and **401-triggered single retry** for making API calls. Design goals: **credential isolation per resource**, **safe token lifecycle**, **consistent retries/timeouts**, and **extensibility** (e.g., API-Key, HMAC, mTLS) without changing call sites. @@ -13,27 +13,28 @@ Design goals: **credential isolation per resource**, **safe token lifecycle**, * ## High-Level Architecture -* Each resource client is constructed with an **`AuthPolicy`**: `BasicAuthPolicy` or `BearerAuthPolicy(TokenProvider)`. -* The shared **AsyncTransport**: +- Each resource client is constructed with an **`AuthPolicy`**: `BasicAuthPolicy` or `BearerAuthPolicy(TokenProvider)`. +- The shared **AsyncTransport**: - * Calls `authorize(request)` before send. - * On **401**, calls `on_unauthorized()` (Bearer refresh), then **retries once**. - * Applies **timeouts** and **retry/backoff** for **429/5xx** (honors `Retry-After`). -* Hooks provide **logging** (with redaction) and **OpenTelemetry** spans. + - Calls `authorize(request)` before send. + - On **401**, calls `on_unauthorized()` (Bearer refresh), then **retries once**. + - Applies **timeouts** and **retry/backoff** for **429/5xx** (honors `Retry-After`). + +- Hooks provide **logging** (with redaction) and **OpenTelemetry** spans. ```mermaid flowchart LR - A[Your code] -->|calls| R[Resource Client (e.g., payments)] + A[Your code] -->|calls| R[Resource Client like checkers] R -->|build request| T[AsyncTransport] T -->|authorize(request)| AP[AuthPolicy
Basic or Bearer] AP -->|add Authorization header| T - T -->|HTTP send| API[(Service API)] + T -->|HTTP send| API[Service API] API -- 200/2xx --> T T -- return --> R --> A API -- 401 Unauthorized --> T - T -->|on_unauthorized()| AP - AP -->|refresh token (Bearer only)| T + T -->|on_unauthorized called| AP + AP -->|refresh token for Bearer only| T T -->|retry once| API ``` @@ -41,8 +42,8 @@ flowchart LR ## Why Per-Resource Auth? -* **Safety by construction:** Credentials for `payments` cannot be sent to `tax` endpoints (and vice versa). This prevents cross-tenant or scope leakage. -* **Clarity & DX:** The chosen auth scheme is explicit at the resource constructor—no hidden URL regex routing or magic defaults. -* **Heterogeneous schemes:** Some families can remain on **Basic** while others adopt **Bearer** with scopes/rotation, without affecting call sites. -* **Testability:** You can unit-test each resource with its auth policy, mock token refresh, and assert no credential cross-talk. -* **Compliance & least privilege:** Bind the **minimal** credentials/scopes to only the endpoints that require them, simplifying audits and rotation. +- **Safety by construction:** Credentials for `payments` cannot be sent to `tax` endpoints (and vice versa). This prevents cross-tenant or scope leakage. +- **Clarity & DX:** The chosen auth scheme is explicit at the resource constructor—no hidden URL regex routing or magic defaults. +- **Heterogeneous schemes:** Some families can remain on **Basic** while others adopt **Bearer** with scopes/rotation, without affecting call sites. +- **Testability:** You can unit-test each resource with its auth policy, mock token refresh, and assert no credential cross-talk. +- **Compliance & least privilege:** Bind the **minimal** credentials/scopes to only the endpoints that require them, simplifying audits and rotation. From 4741108674e8c8a03156965bcc617b496a790048 Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Fri, 22 Aug 2025 18:05:05 +0300 Subject: [PATCH 2/8] Enhance KRA PIN validation and telemetry handling; add tests for graceful degradation and improve token management --- .coverage | Bin 69632 -> 69632 bytes gavaconnect/auth/providers.py | 10 +++- gavaconnect/checkers/_pin.py | 45 +++++++++++++--- gavaconnect/http/telemetry.py | 18 ++++++- pyproject.toml | 4 ++ tests/test_pin.py | 42 +++++++++++++-- tests/test_telemetry_degradation.py | 76 ++++++++++++++++++++++++++++ uv.lock | 8 ++- 8 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 tests/test_telemetry_degradation.py diff --git a/.coverage b/.coverage index 62f818d3e40ddc63544c97d3343e03a9da8d9c4f..fa5bdc4343bc9d07c56e44c55d06d6af41803ccf 100644 GIT binary patch literal 69632 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 literal 69632 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 bb5Vv51-$Pl$nb#x5r1=0h7SXX!$ None: """Initialize the client credentials provider. @@ -27,6 +31,7 @@ def __init__( scope: Optional scope for the token. early_refresh_s: Seconds before expiry to refresh token. client: Optional HTTP client to use. + token_timeout_s: Timeout for token requests in seconds. """ self._url, self._cid, self._sec, self._scope = ( @@ -37,9 +42,10 @@ def __init__( ) self._early, self._client = ( early_refresh_s, - (client or httpx.AsyncClient(timeout=10)), + (client or httpx.AsyncClient(timeout=token_timeout_s)), ) self._lock = asyncio.Lock() + # Security note: tokens stored in memory - consider using keyring for production self._token, self._exp = "", 0.0 async def _fetch(self) -> tuple[str, float]: @@ -55,7 +61,7 @@ async def _fetch(self) -> tuple[str, float]: r.raise_for_status() p = r.json() ttl = float(p.get("expires_in", 3600)) - return p["access_token"], time.time() + max(30.0, ttl - self._early) + return p["access_token"], time.time() + max(MIN_TOKEN_TTL_S, ttl - self._early) async def get_token(self) -> str: """Get the current access token, refreshing if necessary. diff --git a/gavaconnect/checkers/_pin.py b/gavaconnect/checkers/_pin.py index 406ab69..1928433 100644 --- a/gavaconnect/checkers/_pin.py +++ b/gavaconnect/checkers/_pin.py @@ -1,10 +1,43 @@ +"""KRA PIN validation utilities.""" + +import re + + class KRAPINChecker: - """Checker for KRA PIN.""" + """Checker for KRA PIN with improved validation.""" - def __init__(self, id_number: str) -> None: - self.id_number = id_number + def __init__(self, id_number: str | None) -> None: + """Initialize with ID number for PIN validation. + + Args: + id_number: The ID number to validate as KRA PIN. + + """ + self.id_number = id_number.strip() if id_number else "" def check_by_id_number(self) -> str: - if len(self.id_number) == 6: - return "Valid KRA PIN." - return "Invalid KRA PIN." + """Validate KRA PIN format and content. + + Returns: + Validation result message. + + """ + if not self.id_number: + return "Invalid KRA PIN: Empty value." + + # Remove any whitespace + pin = self.id_number.strip() + + # Check basic length requirement + if len(pin) != 6: + return "Invalid KRA PIN: Must be exactly 6 characters." + + # Check if contains only alphanumeric characters (typical for KRA PINs) + if not re.match(r'^[A-Za-z0-9]{6}$', pin): + return "Invalid KRA PIN: Must contain only alphanumeric characters." + + # Additional validation: KRA PINs typically start with letter + if not pin[0].isalpha(): + return "Invalid KRA PIN: Must start with a letter." + + return "Valid KRA PIN." diff --git a/gavaconnect/http/telemetry.py b/gavaconnect/http/telemetry.py index 9f7dd07..cdd6cd8 100644 --- a/gavaconnect/http/telemetry.py +++ b/gavaconnect/http/telemetry.py @@ -1,9 +1,17 @@ """OpenTelemetry tracing utilities for HTTP requests.""" +from typing import Any + import httpx -from opentelemetry import trace -tracer = trace.get_tracer("gavaconnect") +try: + from opentelemetry import trace + tracer = trace.get_tracer("gavaconnect") + OTEL_AVAILABLE = True +except ImportError: + # OpenTelemetry is optional - graceful degradation + tracer: Any = None + OTEL_AVAILABLE = False async def otel_request_span(req: httpx.Request) -> None: @@ -13,6 +21,9 @@ async def otel_request_span(req: httpx.Request) -> None: req: The HTTP request to trace. """ + if not OTEL_AVAILABLE or tracer is None: + return + span = tracer.start_span( "http.client", attributes={"http.method": req.method, "http.url": str(req.url)} ) @@ -27,6 +38,9 @@ async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None: resp: The HTTP response. """ + if not OTEL_AVAILABLE: + return + span = req.extensions.pop("otel_span", None) if span: span.set_attribute("http.status_code", resp.status_code) diff --git a/pyproject.toml b/pyproject.toml index c8ab5f3..4914356 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,10 @@ dev = [ "ruff>=0.2.0", "bandit>=1.7.0", ] +otel = [ + "opentelemetry-api>=1.36.0", + "opentelemetry-sdk>=1.36.0", +] [project.urls] Homepage = "https://github.com/acoruss/gavaconnect-sdk-python" diff --git a/tests/test_pin.py b/tests/test_pin.py index 8604f94..01ca5cc 100644 --- a/tests/test_pin.py +++ b/tests/test_pin.py @@ -1,15 +1,47 @@ """Tests for KRA PIN checker functionality.""" + from gavaconnect import checkers def test_kra_pin_checker_valid() -> None: - """Test that a valid 6-digit PIN is correctly identified.""" - checker = checkers.KRAPINChecker("123456") + """Test that a valid 6-character PIN starting with letter is correctly identified.""" + checker = checkers.KRAPINChecker("A12345") assert checker.check_by_id_number() == "Valid KRA PIN." -def test_kra_pin_checker_invalid() -> None: - """Test that an invalid PIN (not 6 digits) is correctly identified.""" +def test_kra_pin_checker_invalid_length() -> None: + """Test that an invalid PIN (not 6 characters) is correctly identified.""" checker = checkers.KRAPINChecker("12345") - assert checker.check_by_id_number() == "Invalid KRA PIN." + assert "Must be exactly 6 characters" in checker.check_by_id_number() + + +def test_kra_pin_checker_invalid_start_with_number() -> None: + """Test that a PIN starting with number is rejected.""" + checker = checkers.KRAPINChecker("123456") + assert "Must start with a letter" in checker.check_by_id_number() + + +def test_kra_pin_checker_invalid_special_chars() -> None: + """Test that a PIN with special characters is rejected.""" + checker = checkers.KRAPINChecker("A123@#") + assert "Must contain only alphanumeric characters" in checker.check_by_id_number() + + +def test_kra_pin_checker_empty() -> None: + """Test that empty PIN is rejected.""" + checker = checkers.KRAPINChecker("") + assert "Empty value" in checker.check_by_id_number() + + +def test_kra_pin_checker_whitespace_handling() -> None: + """Test that PIN with surrounding whitespace is handled correctly.""" + checker = checkers.KRAPINChecker(" A12345 ") + assert checker.check_by_id_number() == "Valid KRA PIN." + + +def test_kra_pin_checker_none_input() -> None: + """Test that None input is handled gracefully.""" + checker = checkers.KRAPINChecker(None) # type: ignore[arg-type] + result = checker.check_by_id_number() + assert "Empty value" in result diff --git a/tests/test_telemetry_degradation.py b/tests/test_telemetry_degradation.py new file mode 100644 index 0000000..0c8b2fd --- /dev/null +++ b/tests/test_telemetry_degradation.py @@ -0,0 +1,76 @@ +"""Tests for telemetry graceful degradation without OpenTelemetry.""" + +from unittest.mock import Mock + +import httpx +import pytest + +from gavaconnect.http.telemetry import ( + OTEL_AVAILABLE, + otel_request_span, + otel_response_span, +) + + +class TestTelemetryGracefulDegradation: + """Test telemetry functions work without OpenTelemetry installed.""" + + @pytest.mark.asyncio + async def test_otel_request_span_without_opentelemetry(self): + """Test that otel_request_span doesn't fail when OpenTelemetry is unavailable.""" + # Create a mock request + request = Mock(spec=httpx.Request) + request.method = "GET" + request.url = "https://api.example.com/test" + request.extensions = {} + + # Should not raise any exception + await otel_request_span(request) + + # If OpenTelemetry is available, span should be set + if OTEL_AVAILABLE: + assert "otel_span" in request.extensions + else: + # If not available, no span should be set + assert "otel_span" not in request.extensions + + @pytest.mark.asyncio + async def test_otel_response_span_without_opentelemetry(self): + """Test that otel_response_span doesn't fail when OpenTelemetry is unavailable.""" + # Create mock request and response + request = Mock(spec=httpx.Request) + request.extensions = {} + + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.headers = {"x-request-id": "test-123"} + + # Should not raise any exception + await otel_response_span(request, response) + + # Extensions should be empty since no span was created + assert "otel_span" not in request.extensions + + @pytest.mark.asyncio + async def test_otel_response_span_with_existing_span(self): + """Test otel_response_span with existing span in extensions.""" + if not OTEL_AVAILABLE: + pytest.skip("OpenTelemetry not available") + + # Create mock request with span + request = Mock(spec=httpx.Request) + mock_span = Mock() + request.extensions = {"otel_span": mock_span} + + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.headers = {"x-request-id": "test-123"} + + await otel_response_span(request, response) + + # Verify span methods were called + mock_span.set_attribute.assert_called() + mock_span.end.assert_called_once() + + # Span should be removed from extensions + assert "otel_span" not in request.extensions diff --git a/uv.lock b/uv.lock index a68613b..6087faa 100644 --- a/uv.lock +++ b/uv.lock @@ -119,6 +119,10 @@ dev = [ { name = "respx" }, { name = "ruff" }, ] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] [package.dev-dependencies] dev = [ @@ -136,13 +140,15 @@ requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.36.0" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.36.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, { name = "respx", marker = "extra == 'dev'", specifier = ">=0.22.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "otel"] [package.metadata.requires-dev] dev = [ From a90332ccf1967b39e6ed3c8449fc426cd57053d7 Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Fri, 22 Aug 2025 18:40:51 +0300 Subject: [PATCH 3/8] feat: Introduce AsyncGavaConnect and CheckersClient for asynchronous PIN validation - Added AsyncGavaConnect class for async operations with checkers API. - Implemented CheckersClient for KRA PIN validation with methods for validating PINs. - Created BasicTokenEndpointProvider for handling token retrieval with Basic auth. - Introduced BasicPair data class for managing client credentials. - Updated auth module to include new classes and methods. - Added smoke tests and unit tests for validation and error handling. - Updated dependencies in pyproject.toml to include pydantic. - Enhanced error handling for API responses, including rate limiting and unauthorized access. --- README.md | 69 +++++++++++- gavaconnect/__init__.py | 4 + gavaconnect/auth/__init__.py | 7 +- gavaconnect/auth/credentials.py | 11 ++ gavaconnect/auth/providers.py | 74 +++++++++++++ gavaconnect/facade_async.py | 60 +++++++++++ gavaconnect/resources/__init__.py | 6 ++ gavaconnect/resources/checkers/__init__.py | 5 + gavaconnect/resources/checkers/_pin.py | 90 ++++++++++++++++ pyproject.toml | 1 + smoke_test.py | 41 +++++++ test_imports.py | 56 ++++++++++ tests/test_auth_module.py | 4 + tests/unit/test_checkers_error_surface.py | 102 ++++++++++++++++++ ..._checkers_validate_pin_401_then_refresh.py | 62 +++++++++++ .../test_checkers_validate_pin_get_variant.py | 92 ++++++++++++++++ .../test_checkers_validate_pin_success.py | 67 ++++++++++++ uv.lock | 66 ++++++++++++ 18 files changed, 812 insertions(+), 5 deletions(-) create mode 100644 gavaconnect/auth/credentials.py create mode 100644 gavaconnect/facade_async.py create mode 100644 gavaconnect/resources/__init__.py create mode 100644 gavaconnect/resources/checkers/__init__.py create mode 100644 gavaconnect/resources/checkers/_pin.py create mode 100644 smoke_test.py create mode 100644 test_imports.py create mode 100644 tests/unit/test_checkers_error_surface.py create mode 100644 tests/unit/test_checkers_validate_pin_401_then_refresh.py create mode 100644 tests/unit/test_checkers_validate_pin_get_variant.py create mode 100644 tests/unit/test_checkers_validate_pin_success.py diff --git a/README.md b/README.md index aa528c7..43ed367 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,74 @@ uv install ## Quick Start +### Basic SDK Usage + +```python +import gavaconnect + +# Create configuration +config = gavaconnect.SDKConfig(base_url="https://sbx.kra.go.ke") + +# Work with errors and basic types +try: + # Your API calls here + pass +except gavaconnect.APIError as e: + print(f"API Error: {e.status} - {e.message}") +``` + +### PIN Validation (requires httpx and pydantic) + ```python -from gavaconnect import main +from gavaconnect.facade_async import AsyncGavaConnect +from gavaconnect.config import SDKConfig + +async def validate_pin(): + config = SDKConfig(base_url="https://sbx.kra.go.ke") + + async with AsyncGavaConnect( + config, + checkers_client_id="your_client_id", + checkers_client_secret="your_client_secret" + ) as sdk: + result = await sdk.checkers.validate_pin(pin="A000000000B") + print(result.model_dump(by_alias=True)) + # Output: {"PIN": "A000000000B", "TaxPayerName": "...", "status": "VALID", "valid": true} + +# Run the async function +import asyncio +asyncio.run(validate_pin()) +``` -# Basic usage example -main() +### Advanced Usage + +```python +from gavaconnect.resources.checkers import CheckersClient, PinCheckResult +from gavaconnect.auth import BasicTokenEndpointProvider, BasicPair, BearerAuthPolicy +from gavaconnect.http.transport import AsyncTransport + +# Manual client setup for advanced use cases +async def advanced_usage(): + config = SDKConfig(base_url="https://sbx.kra.go.ke") + transport = AsyncTransport(config) + + # Setup authentication + provider = BasicTokenEndpointProvider( + token_url="https://sbx.kra.go.ke/v1/token/generate", + basic=BasicPair("client_id", "client_secret"), + method="GET" + ) + auth = BearerAuthPolicy(provider) + + # Create client + client = CheckersClient(transport, auth) + + # Use different validation methods + result1 = await client.validate_pin(pin="A000000000B") + result2 = await client.validate_pin_get(pin="A000000000B", query_key="PIN") + result3 = await client.validate_pin_raw({"PIN": "A000000000B", "extra": "data"}) + + await transport.close() ``` ## Development diff --git a/gavaconnect/__init__.py b/gavaconnect/__init__.py index d7de796..212844a 100644 --- a/gavaconnect/__init__.py +++ b/gavaconnect/__init__.py @@ -5,6 +5,10 @@ from .config import SDKConfig from .errors import APIError, RateLimitError, SDKError, TransportError +# Note: AsyncGavaConnect and CheckersClient require httpx and pydantic +# Import them explicitly: from gavaconnect.facade_async import AsyncGavaConnect +# or from gavaconnect.resources.checkers import CheckersClient + __all__ = [ "__version__", "SDKConfig", diff --git a/gavaconnect/auth/__init__.py b/gavaconnect/auth/__init__.py index faaa1dd..0999a47 100644 --- a/gavaconnect/auth/__init__.py +++ b/gavaconnect/auth/__init__.py @@ -2,13 +2,16 @@ from .basic import BasicAuthPolicy, BasicCredentials from .bearer import AuthPolicy, BearerAuthPolicy, TokenProvider -from .providers import ClientCredentialsProvider +from .credentials import BasicPair +from .providers import BasicTokenEndpointProvider, ClientCredentialsProvider __all__ = [ "AuthPolicy", - "BasicAuthPolicy", + "BasicAuthPolicy", "BasicCredentials", + "BasicPair", "BearerAuthPolicy", "TokenProvider", + "BasicTokenEndpointProvider", "ClientCredentialsProvider", ] diff --git a/gavaconnect/auth/credentials.py b/gavaconnect/auth/credentials.py new file mode 100644 index 0000000..eb52b00 --- /dev/null +++ b/gavaconnect/auth/credentials.py @@ -0,0 +1,11 @@ +"""Basic credential types for authentication.""" + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class BasicPair: + """Basic auth credential pair for token endpoints.""" + + client_id: str + client_secret: str diff --git a/gavaconnect/auth/providers.py b/gavaconnect/auth/providers.py index 9c1d5fe..2e9c7d8 100644 --- a/gavaconnect/auth/providers.py +++ b/gavaconnect/auth/providers.py @@ -2,9 +2,12 @@ import asyncio import time +from typing import Literal import httpx +from .credentials import BasicPair + # Minimum token TTL to prevent rapid refresh cycles MIN_TOKEN_TTL_S = 30.0 @@ -86,3 +89,74 @@ async def refresh(self) -> str: async with self._lock: self._token, self._exp = await self._fetch() return self._token + + +class BasicTokenEndpointProvider: + """Token provider using HTTP Basic auth against a token endpoint.""" + + def __init__( + self, + token_url: str, + basic: BasicPair, + method: Literal["GET", "POST"] = "POST", + early_refresh_s: int = 60, + client: httpx.AsyncClient | None = None, + token_timeout_s: float = 10.0, + ) -> None: + """Initialize the basic token endpoint provider. + + Args: + token_url: Token endpoint URL. + basic: Basic auth credentials. + method: HTTP method to use (GET or POST). + early_refresh_s: Seconds before expiry to refresh token. + client: Optional HTTP client to use. + token_timeout_s: Timeout for token requests in seconds. + + """ + self._url = token_url + self._basic = basic + self._method = method + self._early = early_refresh_s + self._client = client or httpx.AsyncClient(timeout=token_timeout_s) + self._lock = asyncio.Lock() + # Security note: tokens stored in memory - consider using keyring for production + self._token, self._exp = "", 0.0 + + async def _fetch(self) -> tuple[str, float]: + """Fetch a new token from the endpoint.""" + auth = (self._basic.client_id, self._basic.client_secret) + + if self._method == "GET": + resp = await self._client.get(self._url, auth=auth) + else: + resp = await self._client.post(self._url, auth=auth) + + resp.raise_for_status() + payload = resp.json() + ttl = float(payload.get("expires_in", 3600)) + return payload["access_token"], time.time() + max(MIN_TOKEN_TTL_S, ttl - self._early) + + async def get_token(self) -> str: + """Get the current access token, refreshing if necessary. + + Returns: + The access token. + + """ + async with self._lock: + if self._token and time.time() < self._exp: + return self._token + self._token, self._exp = await self._fetch() + return self._token + + async def refresh(self) -> str: + """Force refresh the access token. + + Returns: + The new access token. + + """ + async with self._lock: + self._token, self._exp = await self._fetch() + return self._token diff --git a/gavaconnect/facade_async.py b/gavaconnect/facade_async.py new file mode 100644 index 0000000..40a9c27 --- /dev/null +++ b/gavaconnect/facade_async.py @@ -0,0 +1,60 @@ +"""Async facade for GavaConnect SDK.""" + +from types import TracebackType + +from gavaconnect.auth import BasicPair, BasicTokenEndpointProvider, BearerAuthPolicy +from gavaconnect.config import SDKConfig +from gavaconnect.http.transport import AsyncTransport +from gavaconnect.resources.checkers import CheckersClient + +__all__ = ["AsyncGavaConnect"] + + +class AsyncGavaConnect: + """Async facade for GavaConnect SDK with per-family credentials.""" + + def __init__( + self, + config: SDKConfig, + *, + checkers_client_id: str, + checkers_client_secret: str, + token_url: str = "https://sbx.kra.go.ke/v1/token/generate", + ) -> None: + """Initialize the async GavaConnect client. + + Args: + config: SDK configuration. + checkers_client_id: Client ID for checkers API. + checkers_client_secret: Client secret for checkers API. + token_url: Token endpoint URL. + + """ + self._config = config + self._tr = AsyncTransport(config) + + # Setup checkers client with Basic -> Bearer flow + provider = BasicTokenEndpointProvider( + token_url=token_url, + basic=BasicPair(checkers_client_id, checkers_client_secret), + method="GET", + early_refresh_s=60, + ) + self.checkers = CheckersClient(self._tr, BearerAuthPolicy(provider)) + + async def __aenter__(self) -> "AsyncGavaConnect": + """Async context manager entry.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Async context manager exit.""" + await self.close() + + async def close(self) -> None: + """Close the client and cleanup resources.""" + await self._tr.close() diff --git a/gavaconnect/resources/__init__.py b/gavaconnect/resources/__init__.py new file mode 100644 index 0000000..adb9021 --- /dev/null +++ b/gavaconnect/resources/__init__.py @@ -0,0 +1,6 @@ +"""Resources package for GavaConnect SDK.""" + +# Checkers resources require httpx and pydantic dependencies +# Import explicitly: from gavaconnect.resources.checkers import CheckersClient + +__all__ = [] # Explicit imports required due to dependencies diff --git a/gavaconnect/resources/checkers/__init__.py b/gavaconnect/resources/checkers/__init__.py new file mode 100644 index 0000000..13f0d9c --- /dev/null +++ b/gavaconnect/resources/checkers/__init__.py @@ -0,0 +1,5 @@ +"""Checkers resource package.""" + +from ._pin import CheckersClient, PinCheckResult + +__all__ = ["CheckersClient", "PinCheckResult"] diff --git a/gavaconnect/resources/checkers/_pin.py b/gavaconnect/resources/checkers/_pin.py new file mode 100644 index 0000000..38dc15f --- /dev/null +++ b/gavaconnect/resources/checkers/_pin.py @@ -0,0 +1,90 @@ +"""PIN validation client for KRA checkers.""" + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from gavaconnect.auth.bearer import BearerAuthPolicy +from gavaconnect.helpers.idempotency import idempotency_headers +from gavaconnect.http.transport import AsyncTransport + + +class PinCheckResult(BaseModel): + """Result of PIN validation check.""" + + pin: str | None = Field(default=None, alias="PIN") + taxpayer_name: str | None = Field(default=None, alias="TaxPayerName") + status: str | None = None + valid: bool | None = None + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + +class CheckersClient: + """Client for KRA PIN validation endpoints.""" + + def __init__(self, tr: AsyncTransport, auth: BearerAuthPolicy) -> None: + """Initialize the checkers client. + + Args: + tr: Transport instance for HTTP requests. + auth: Bearer authentication policy. + + """ + self._tr = tr + self._auth = auth + + async def validate_pin(self, *, pin: str, pin_key: str = "PIN") -> PinCheckResult: + """Validate a KRA PIN using POST with JSON payload. + + Args: + pin: The PIN to validate. + pin_key: The JSON key name for the PIN field. + + Returns: + PIN validation result. + + """ + payload = {pin_key: pin} + return await self.validate_pin_raw(payload) + + async def validate_pin_get(self, *, pin: str, query_key: str = "PIN") -> PinCheckResult: + """Validate a KRA PIN using GET with query parameters. + + Args: + pin: The PIN to validate. + query_key: The query parameter name for the PIN field. + + Returns: + PIN validation result. + + """ + resp = await self._tr.request( + "GET", + "/checker/v1/pinbypin", + auth=self._auth, + params={query_key: pin}, + ) + self._tr.raise_for_api_error(resp) + return PinCheckResult.model_validate(resp.json()) + + async def validate_pin_raw(self, payload: dict[str, Any]) -> PinCheckResult: + """Validate a PIN using raw payload. + + Args: + payload: Raw JSON payload to send. + + Returns: + PIN validation result. + + """ + headers = idempotency_headers() # Make POST requests retryable + resp = await self._tr.request( + "POST", + "/checker/v1/pinbypin", + auth=self._auth, + json=payload, + headers=headers, + ) + self._tr.raise_for_api_error(resp) + return PinCheckResult.model_validate(resp.json()) diff --git a/pyproject.toml b/pyproject.toml index 4914356..abf60f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ authors = [ requires-python = ">=3.13" dependencies = [ "httpx>=0.28.1", + "pydantic>=2.0.0", ] license = { text = "MIT" } keywords = ["gavaconnect", "sdk", "api"] diff --git a/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..eab95c3 --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Smoke test for the PIN validation checker.""" + +import asyncio +from gavaconnect import AsyncGavaConnect, SDKConfig + + +async def smoke_test(): + """Basic smoke test for the PIN checker implementation.""" + config = SDKConfig(base_url="https://sbx.kra.go.ke") + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + print("✓ SDK initialized successfully") + print("✓ Checkers client created") + print("✓ Context manager works") + + # Check that the client has the expected methods + assert hasattr(sdk.checkers, 'validate_pin') + assert hasattr(sdk.checkers, 'validate_pin_get') + assert hasattr(sdk.checkers, 'validate_pin_raw') + print("✓ All required methods available") + + # Test PinCheckResult model + from gavaconnect.resources.checkers import PinCheckResult + + # Test model with alias support + result = PinCheckResult(PIN="A000000000B", TaxPayerName="ACME LTD", status="VALID", valid=True) + dumped = result.model_dump(by_alias=True) + assert dumped["PIN"] == "A000000000B" + assert dumped["TaxPayerName"] == "ACME LTD" + print("✓ PinCheckResult model works with aliases") + + print("🎉 Smoke test passed!") + + +if __name__ == "__main__": + asyncio.run(smoke_test()) diff --git a/test_imports.py b/test_imports.py new file mode 100644 index 0000000..bcee684 --- /dev/null +++ b/test_imports.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Test import behavior in different scenarios.""" + +def test_basic_imports(): + """Test that basic imports work without httpx/pydantic.""" + try: + import gavaconnect + print("✓ Basic gavaconnect import successful") + + from gavaconnect import SDKConfig, APIError + print("✓ Basic classes import successful") + + # Test that we can import credentials without httpx + from gavaconnect.auth.credentials import BasicPair + pair = BasicPair("test", "secret") + print(f"✓ BasicPair created: client_id={pair.client_id}") + + except Exception as e: + print(f"✗ Basic import failed: {e}") + return False + + return True + + +def test_full_imports(): + """Test that full imports work with dependencies.""" + try: + from gavaconnect.facade_async import AsyncGavaConnect + print("✓ AsyncGavaConnect import successful") + + from gavaconnect.resources.checkers import CheckersClient, PinCheckResult + print("✓ CheckersClient and PinCheckResult import successful") + + # Test creating a model + result = PinCheckResult(PIN="A000000000B", valid=True) + print(f"✓ PinCheckResult created: pin={result.pin}") + + except Exception as e: + print(f"✗ Full import failed: {e}") + return False + + return True + + +if __name__ == "__main__": + print("Testing gavaconnect imports...") + basic_ok = test_basic_imports() + print() + full_ok = test_full_imports() + print() + + if basic_ok and full_ok: + print("🎉 All import tests passed!") + else: + print("❌ Some import tests failed") + exit(1) diff --git a/tests/test_auth_module.py b/tests/test_auth_module.py index 9b83238..e290a90 100644 --- a/tests/test_auth_module.py +++ b/tests/test_auth_module.py @@ -31,8 +31,10 @@ def test_module_has_all_attribute(self): "AuthPolicy", "BasicAuthPolicy", "BasicCredentials", + "BasicPair", "BearerAuthPolicy", "TokenProvider", + "BasicTokenEndpointProvider", "ClientCredentialsProvider", } @@ -47,7 +49,9 @@ def test_classes_importable_from_module(self): """Test that classes can be imported from the module.""" assert hasattr(auth, "BasicAuthPolicy") assert hasattr(auth, "BasicCredentials") + assert hasattr(auth, "BasicPair") assert hasattr(auth, "BearerAuthPolicy") + assert hasattr(auth, "BasicTokenEndpointProvider") assert hasattr(auth, "TokenProvider") assert hasattr(auth, "ClientCredentialsProvider") diff --git a/tests/unit/test_checkers_error_surface.py b/tests/unit/test_checkers_error_surface.py new file mode 100644 index 0000000..d128b86 --- /dev/null +++ b/tests/unit/test_checkers_error_surface.py @@ -0,0 +1,102 @@ +"""Test error handling and rate limiting.""" + +import httpx +import pytest +import respx + +from gavaconnect.config import SDKConfig +from gavaconnect.errors import RateLimitError +from gavaconnect.facade_async import AsyncGavaConnect + + +@pytest.mark.asyncio +async def test_checkers_error_surface(): + """Test that API errors are properly surfaced with retry behavior.""" + # Use faster retry config for testing + from gavaconnect.config import RetryPolicy + retry_policy = RetryPolicy(max_attempts=2, base_backoff_s=0.01, max_cap_s=0.1) + config = SDKConfig(base_url="https://test.example.com", retry=retry_policy) + + with respx.mock: + # Mock token endpoint + respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + return_value=httpx.Response( + 200, json={"access_token": "tok1", "expires_in": 3600} + ) + ) + + # Mock PIN validation endpoint - always returns 429 with shorter Retry-After + pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( + return_value=httpx.Response( + 429, + headers={"retry-after": "0.01", "x-request-id": "req-123"}, + json={ + "error": { + "type": "rate_limit_exceeded", + "message": "Too many requests", + "code": "RATE_LIMIT", + "retry_after": 0.01 + } + } + ) + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + with pytest.raises(RateLimitError) as exc_info: + await sdk.checkers.validate_pin(pin="A000000000B") + + error = exc_info.value + + # Verify error details are captured + assert error.status == 429 + assert error.type == "rate_limit_exceeded" + assert error.code == "RATE_LIMIT" + assert error.request_id == "req-123" + assert error.retry_after_s == 0.01 + assert error.body is not None + + # Verify multiple retry attempts were made due to Retry-After + # (Should retry up to max_attempts from config) + assert len(pin_route.calls) > 1 # Multiple retries with idempotency key + + +@pytest.mark.asyncio +async def test_checkers_error_missing_request_id(): + """Test error handling when request ID is missing.""" + config = SDKConfig(base_url="https://test.example.com") + + with respx.mock: + # Mock token endpoint + respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + return_value=httpx.Response( + 200, json={"access_token": "tok1", "expires_in": 3600} + ) + ) + + # Mock PIN validation endpoint - 500 error without request ID + respx.post("https://test.example.com/checker/v1/pinbypin").mock( + return_value=httpx.Response( + 500, + json={ + "error": { + "type": "internal_error", + "message": "Internal server error" + } + } + ) + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + with pytest.raises(Exception) as exc_info: # Should be APIError but imported from errors + await sdk.checkers.validate_pin(pin="A000000000B") + + # Verify error is raised (exact type depends on import structure) + assert exc_info.value is not None diff --git a/tests/unit/test_checkers_validate_pin_401_then_refresh.py b/tests/unit/test_checkers_validate_pin_401_then_refresh.py new file mode 100644 index 0000000..ec3775c --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_401_then_refresh.py @@ -0,0 +1,62 @@ +"""Test 401 handling with auth refresh.""" + +import httpx +import pytest +import respx + +from gavaconnect.config import SDKConfig +from gavaconnect.facade_async import AsyncGavaConnect + + +@pytest.mark.asyncio +async def test_checkers_validate_pin_401_then_refresh(): + """Test 401 response triggers auth refresh and retry.""" + config = SDKConfig(base_url="https://test.example.com") + + with respx.mock: + # Mock token endpoint - returns different tokens on subsequent calls + token_responses = [ + httpx.Response(200, json={"access_token": "tokA", "expires_in": 3600}), + httpx.Response(200, json={"access_token": "tokB", "expires_in": 3600}), + ] + token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + side_effect=token_responses + ) + + # Mock PIN validation endpoint - 401 first, then success + pin_responses = [ + httpx.Response(401, json={"error": {"type": "unauthorized", "message": "Invalid token"}}), + httpx.Response(200, json={ + "PIN": "A000000000B", + "TaxPayerName": "ACME LTD", + "status": "VALID", + "valid": True + }) + ] + pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( + side_effect=pin_responses + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + result = await sdk.checkers.validate_pin(pin="A000000000B") + + # Should eventually succeed after retry + assert result.pin == "A000000000B" + assert result.valid is True + + # Verify token endpoint called twice (initial + refresh) + assert len(token_route.calls) == 2 + + # Verify PIN endpoint called twice (401 + retry) + assert len(pin_route.calls) == 2 + + # Verify second request used new token + first_auth = pin_route.calls[0].request.headers["authorization"] + second_auth = pin_route.calls[1].request.headers["authorization"] + assert first_auth.startswith("Bearer tokA") + assert second_auth.startswith("Bearer tokB") + assert first_auth != second_auth diff --git a/tests/unit/test_checkers_validate_pin_get_variant.py b/tests/unit/test_checkers_validate_pin_get_variant.py new file mode 100644 index 0000000..96920ed --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_get_variant.py @@ -0,0 +1,92 @@ +"""Test GET variant of PIN validation.""" + +import httpx +import pytest +import respx + +from gavaconnect.config import SDKConfig +from gavaconnect.facade_async import AsyncGavaConnect + + +@pytest.mark.asyncio +async def test_checkers_validate_pin_get_variant(): + """Test PIN validation using GET with query parameters.""" + config = SDKConfig(base_url="https://test.example.com") + + with respx.mock: + # Mock token endpoint + token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + return_value=httpx.Response( + 200, json={"access_token": "tok1", "expires_in": 3600} + ) + ) + + # Mock PIN validation endpoint with GET + pin_route = respx.get("https://test.example.com/checker/v1/pinbypin").mock( + return_value=httpx.Response( + 200, + json={ + "PIN": "A000000000B", + "TaxPayerName": "ACME LTD", + "status": "VALID", + "valid": True + } + ) + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + result = await sdk.checkers.validate_pin_get(pin="A000000000B") + + # Verify result is correct + assert result.pin == "A000000000B" + assert result.taxpayer_name == "ACME LTD" + assert result.status == "VALID" + assert result.valid is True + + # Verify calls were made + assert token_route.called + assert pin_route.called + + # Verify GET request with query parameters + pin_request = pin_route.calls[0].request + assert pin_request.method == "GET" + assert pin_request.headers["authorization"].startswith("Bearer ") + + # Check query parameters + assert "PIN=A000000000B" in str(pin_request.url) + + +@pytest.mark.asyncio +async def test_checkers_validate_pin_get_custom_query_key(): + """Test GET variant with custom query key.""" + config = SDKConfig(base_url="https://test.example.com") + + with respx.mock: + # Mock token endpoint + respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + return_value=httpx.Response( + 200, json={"access_token": "tok1", "expires_in": 3600} + ) + ) + + # Mock PIN validation endpoint + pin_route = respx.get("https://test.example.com/checker/v1/pinbypin").mock( + return_value=httpx.Response( + 200, json={"PIN": "A000000000B", "status": "VALID", "valid": True} + ) + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + await sdk.checkers.validate_pin_get(pin="A000000000B", query_key="pin_number") + + # Verify custom query key was used + pin_request = pin_route.calls[0].request + assert "pin_number=A000000000B" in str(pin_request.url) diff --git a/tests/unit/test_checkers_validate_pin_success.py b/tests/unit/test_checkers_validate_pin_success.py new file mode 100644 index 0000000..4562d20 --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_success.py @@ -0,0 +1,67 @@ +"""Test successful PIN validation.""" + +import httpx +import pytest +import respx + +from gavaconnect.config import SDKConfig +from gavaconnect.facade_async import AsyncGavaConnect + + +@pytest.mark.asyncio +async def test_checkers_validate_pin_success(): + """Test successful PIN validation with proper response mapping.""" + config = SDKConfig(base_url="https://test.example.com") + + with respx.mock: + # Mock token endpoint + token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( + return_value=httpx.Response( + 200, json={"access_token": "tok1", "expires_in": 3600} + ) + ) + + # Mock PIN validation endpoint + pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( + return_value=httpx.Response( + 200, + json={ + "PIN": "A000000000B", + "TaxPayerName": "ACME LTD", + "status": "VALID", + "valid": True + } + ) + ) + + async with AsyncGavaConnect( + config, + checkers_client_id="test_client", + checkers_client_secret="test_secret" + ) as sdk: + result = await sdk.checkers.validate_pin(pin="A000000000B") + + # Test that fields are properly mapped + assert result.pin == "A000000000B" + assert result.taxpayer_name == "ACME LTD" + assert result.status == "VALID" + assert result.valid is True + + # Test that model_dump preserves aliases + dumped = result.model_dump(by_alias=True) + assert dumped["PIN"] == "A000000000B" + assert dumped["TaxPayerName"] == "ACME LTD" + + # Verify API calls were made + assert token_route.called + assert pin_route.called + + # Verify request content + pin_request = pin_route.calls[0].request + assert pin_request.method == "POST" + assert pin_request.headers["authorization"].startswith("Bearer ") + + # Check JSON payload + import json + payload = json.loads(pin_request.content) + assert payload == {"PIN": "A000000000B"} diff --git a/uv.lock b/uv.lock index 6087faa..5b14bd3 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 2 requires-python = ">=3.13" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -107,6 +116,7 @@ version = "0.2.1" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "pydantic" }, ] [package.optional-dependencies] @@ -142,6 +152,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.36.0" }, { name = "opentelemetry-sdk", marker = "extra == 'otel'", specifier = ">=1.36.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.25.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, @@ -394,6 +405,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -552,6 +606,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + [[package]] name = "zipp" version = "3.23.0" From 1521bab41d1f812aa0967a96f5a5bef1ef7e777f Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Mon, 25 Aug 2025 13:00:42 +0300 Subject: [PATCH 4/8] Update gavaconnect/checkers/_pin.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- gavaconnect/checkers/_pin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gavaconnect/checkers/_pin.py b/gavaconnect/checkers/_pin.py index 1928433..92d0a7b 100644 --- a/gavaconnect/checkers/_pin.py +++ b/gavaconnect/checkers/_pin.py @@ -13,7 +13,7 @@ def __init__(self, id_number: str | None) -> None: id_number: The ID number to validate as KRA PIN. """ - self.id_number = id_number.strip() if id_number else "" + self.id_number = id_number.strip() if id_number is not None else "" def check_by_id_number(self) -> str: """Validate KRA PIN format and content. From 0c6e4bd213e85313b1723881a9880dc8a88649fb Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Mon, 25 Aug 2025 14:15:02 +0300 Subject: [PATCH 5/8] Refactor code for improved readability and maintainability; update coverage reports, enhance KRA PIN validation, and streamline async handling in various modules. Add tests for error handling and successful PIN validation scenarios. --- .coverage | Bin 69632 -> 77824 bytes coverage.xml | 209 ++++++++++++++---- gavaconnect/auth/__init__.py | 2 +- gavaconnect/auth/providers.py | 8 +- gavaconnect/checkers/_pin.py | 16 +- gavaconnect/facade_async.py | 6 +- gavaconnect/http/telemetry.py | 11 +- gavaconnect/resources/__init__.py | 2 +- gavaconnect/resources/checkers/_pin.py | 8 +- smoke_test.py | 5 +- test_imports.py | 9 +- tests/test_pin.py | 1 - tests/test_telemetry_degradation.py | 26 +-- tests/unit/test_checkers_error_surface.py | 41 ++-- ..._checkers_validate_pin_401_then_refresh.py | 36 +-- .../test_checkers_validate_pin_get_variant.py | 40 ++-- .../test_checkers_validate_pin_success.py | 25 ++- 17 files changed, 296 insertions(+), 149 deletions(-) diff --git a/.coverage b/.coverage index fa5bdc4343bc9d07c56e44c55d06d6af41803ccf..78dd11fd73352796b4dc31f8bdd9b063de1f3ae0 100644 GIT binary patch literal 77824 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 aw+Ng7vDPR02vODReSCnWn*}~F;r{`iKbA58 literal 69632 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 diff --git a/coverage.xml b/coverage.xml index 6cbb616..682dfde 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -15,7 +15,7 @@ - + @@ -64,9 +64,32 @@ + + + + + + + + + + + + + + + + + + + + + + + - + @@ -74,7 +97,8 @@ - + + @@ -117,35 +141,76 @@ - + - + + + + + + + + + + + + - - - - - + + + - + + + - - - + + + - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -162,13 +227,21 @@ - - - - - + + - + + + + + + + + + + + + @@ -219,7 +292,7 @@ - + @@ -247,23 +320,32 @@ - + - + + + + + - - - - + + + + - - - - + + + + + + + + + @@ -385,5 +467,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gavaconnect/auth/__init__.py b/gavaconnect/auth/__init__.py index 0999a47..f99a97d 100644 --- a/gavaconnect/auth/__init__.py +++ b/gavaconnect/auth/__init__.py @@ -7,7 +7,7 @@ __all__ = [ "AuthPolicy", - "BasicAuthPolicy", + "BasicAuthPolicy", "BasicCredentials", "BasicPair", "BearerAuthPolicy", diff --git a/gavaconnect/auth/providers.py b/gavaconnect/auth/providers.py index 2e9c7d8..b9d96ce 100644 --- a/gavaconnect/auth/providers.py +++ b/gavaconnect/auth/providers.py @@ -126,16 +126,18 @@ def __init__( async def _fetch(self) -> tuple[str, float]: """Fetch a new token from the endpoint.""" auth = (self._basic.client_id, self._basic.client_secret) - + if self._method == "GET": resp = await self._client.get(self._url, auth=auth) else: resp = await self._client.post(self._url, auth=auth) - + resp.raise_for_status() payload = resp.json() ttl = float(payload.get("expires_in", 3600)) - return payload["access_token"], time.time() + max(MIN_TOKEN_TTL_S, ttl - self._early) + return payload["access_token"], time.time() + max( + MIN_TOKEN_TTL_S, ttl - self._early + ) async def get_token(self) -> str: """Get the current access token, refreshing if necessary. diff --git a/gavaconnect/checkers/_pin.py b/gavaconnect/checkers/_pin.py index 1928433..b949f51 100644 --- a/gavaconnect/checkers/_pin.py +++ b/gavaconnect/checkers/_pin.py @@ -8,7 +8,7 @@ class KRAPINChecker: def __init__(self, id_number: str | None) -> None: """Initialize with ID number for PIN validation. - + Args: id_number: The ID number to validate as KRA PIN. @@ -17,27 +17,27 @@ def __init__(self, id_number: str | None) -> None: def check_by_id_number(self) -> str: """Validate KRA PIN format and content. - + Returns: Validation result message. """ if not self.id_number: return "Invalid KRA PIN: Empty value." - + # Remove any whitespace pin = self.id_number.strip() - + # Check basic length requirement if len(pin) != 6: return "Invalid KRA PIN: Must be exactly 6 characters." - + # Check if contains only alphanumeric characters (typical for KRA PINs) - if not re.match(r'^[A-Za-z0-9]{6}$', pin): + if not re.match(r"^[A-Za-z0-9]{6}$", pin): return "Invalid KRA PIN: Must contain only alphanumeric characters." - + # Additional validation: KRA PINs typically start with letter if not pin[0].isalpha(): return "Invalid KRA PIN: Must start with a letter." - + return "Valid KRA PIN." diff --git a/gavaconnect/facade_async.py b/gavaconnect/facade_async.py index 40a9c27..722d48e 100644 --- a/gavaconnect/facade_async.py +++ b/gavaconnect/facade_async.py @@ -32,7 +32,7 @@ def __init__( """ self._config = config self._tr = AsyncTransport(config) - + # Setup checkers client with Basic -> Bearer flow provider = BasicTokenEndpointProvider( token_url=token_url, @@ -47,9 +47,9 @@ async def __aenter__(self) -> "AsyncGavaConnect": return self async def __aexit__( - self, + self, exc_type: type[BaseException] | None, - exc_val: BaseException | None, + exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: """Async context manager exit.""" diff --git a/gavaconnect/http/telemetry.py b/gavaconnect/http/telemetry.py index cdd6cd8..cd2e5fb 100644 --- a/gavaconnect/http/telemetry.py +++ b/gavaconnect/http/telemetry.py @@ -1,16 +1,15 @@ """OpenTelemetry tracing utilities for HTTP requests.""" -from typing import Any - import httpx try: from opentelemetry import trace - tracer = trace.get_tracer("gavaconnect") + + tracer: trace.Tracer | None = trace.get_tracer("gavaconnect") OTEL_AVAILABLE = True except ImportError: # OpenTelemetry is optional - graceful degradation - tracer: Any = None + tracer = None OTEL_AVAILABLE = False @@ -23,7 +22,7 @@ async def otel_request_span(req: httpx.Request) -> None: """ if not OTEL_AVAILABLE or tracer is None: return - + span = tracer.start_span( "http.client", attributes={"http.method": req.method, "http.url": str(req.url)} ) @@ -40,7 +39,7 @@ async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None: """ if not OTEL_AVAILABLE: return - + span = req.extensions.pop("otel_span", None) if span: span.set_attribute("http.status_code", resp.status_code) diff --git a/gavaconnect/resources/__init__.py b/gavaconnect/resources/__init__.py index adb9021..adcf709 100644 --- a/gavaconnect/resources/__init__.py +++ b/gavaconnect/resources/__init__.py @@ -3,4 +3,4 @@ # Checkers resources require httpx and pydantic dependencies # Import explicitly: from gavaconnect.resources.checkers import CheckersClient -__all__ = [] # Explicit imports required due to dependencies +__all__: list[str] = [] # Explicit imports required due to dependencies diff --git a/gavaconnect/resources/checkers/_pin.py b/gavaconnect/resources/checkers/_pin.py index 38dc15f..baa36b1 100644 --- a/gavaconnect/resources/checkers/_pin.py +++ b/gavaconnect/resources/checkers/_pin.py @@ -16,7 +16,7 @@ class PinCheckResult(BaseModel): taxpayer_name: str | None = Field(default=None, alias="TaxPayerName") status: str | None = None valid: bool | None = None - + model_config = ConfigDict(populate_by_name=True, extra="allow") @@ -48,7 +48,9 @@ async def validate_pin(self, *, pin: str, pin_key: str = "PIN") -> PinCheckResul payload = {pin_key: pin} return await self.validate_pin_raw(payload) - async def validate_pin_get(self, *, pin: str, query_key: str = "PIN") -> PinCheckResult: + async def validate_pin_get( + self, *, pin: str, query_key: str = "PIN" + ) -> PinCheckResult: """Validate a KRA PIN using GET with query parameters. Args: @@ -80,7 +82,7 @@ async def validate_pin_raw(self, payload: dict[str, Any]) -> PinCheckResult: """ headers = idempotency_headers() # Make POST requests retryable resp = await self._tr.request( - "POST", + "POST", "/checker/v1/pinbypin", auth=self._auth, json=payload, diff --git a/smoke_test.py b/smoke_test.py index eab95c3..4cd5b1f 100644 --- a/smoke_test.py +++ b/smoke_test.py @@ -2,11 +2,12 @@ """Smoke test for the PIN validation checker.""" import asyncio + from gavaconnect import AsyncGavaConnect, SDKConfig -async def smoke_test(): - """Basic smoke test for the PIN checker implementation.""" +async def smoke_test() -> None: + """Run basic smoke test for the PIN checker implementation.""" config = SDKConfig(base_url="https://sbx.kra.go.ke") async with AsyncGavaConnect( diff --git a/test_imports.py b/test_imports.py index bcee684..9a1260c 100644 --- a/test_imports.py +++ b/test_imports.py @@ -1,13 +1,11 @@ #!/usr/bin/env python3 """Test import behavior in different scenarios.""" -def test_basic_imports(): +def test_basic_imports() -> bool: """Test that basic imports work without httpx/pydantic.""" try: - import gavaconnect print("✓ Basic gavaconnect import successful") - from gavaconnect import SDKConfig, APIError print("✓ Basic classes import successful") # Test that we can import credentials without httpx @@ -22,13 +20,12 @@ def test_basic_imports(): return True -def test_full_imports(): +def test_full_imports() -> bool: """Test that full imports work with dependencies.""" try: - from gavaconnect.facade_async import AsyncGavaConnect print("✓ AsyncGavaConnect import successful") - from gavaconnect.resources.checkers import CheckersClient, PinCheckResult + from gavaconnect.resources.checkers import PinCheckResult print("✓ CheckersClient and PinCheckResult import successful") # Test creating a model diff --git a/tests/test_pin.py b/tests/test_pin.py index 01ca5cc..001cdc1 100644 --- a/tests/test_pin.py +++ b/tests/test_pin.py @@ -1,6 +1,5 @@ """Tests for KRA PIN checker functionality.""" - from gavaconnect import checkers diff --git a/tests/test_telemetry_degradation.py b/tests/test_telemetry_degradation.py index 0c8b2fd..adbd7e9 100644 --- a/tests/test_telemetry_degradation.py +++ b/tests/test_telemetry_degradation.py @@ -14,7 +14,7 @@ class TestTelemetryGracefulDegradation: """Test telemetry functions work without OpenTelemetry installed.""" - + @pytest.mark.asyncio async def test_otel_request_span_without_opentelemetry(self): """Test that otel_request_span doesn't fail when OpenTelemetry is unavailable.""" @@ -23,54 +23,54 @@ async def test_otel_request_span_without_opentelemetry(self): request.method = "GET" request.url = "https://api.example.com/test" request.extensions = {} - + # Should not raise any exception await otel_request_span(request) - + # If OpenTelemetry is available, span should be set if OTEL_AVAILABLE: assert "otel_span" in request.extensions else: # If not available, no span should be set assert "otel_span" not in request.extensions - + @pytest.mark.asyncio async def test_otel_response_span_without_opentelemetry(self): """Test that otel_response_span doesn't fail when OpenTelemetry is unavailable.""" # Create mock request and response request = Mock(spec=httpx.Request) request.extensions = {} - + response = Mock(spec=httpx.Response) response.status_code = 200 response.headers = {"x-request-id": "test-123"} - + # Should not raise any exception await otel_response_span(request, response) - + # Extensions should be empty since no span was created assert "otel_span" not in request.extensions - + @pytest.mark.asyncio async def test_otel_response_span_with_existing_span(self): """Test otel_response_span with existing span in extensions.""" if not OTEL_AVAILABLE: pytest.skip("OpenTelemetry not available") - + # Create mock request with span request = Mock(spec=httpx.Request) mock_span = Mock() request.extensions = {"otel_span": mock_span} - + response = Mock(spec=httpx.Response) response.status_code = 200 response.headers = {"x-request-id": "test-123"} - + await otel_response_span(request, response) - + # Verify span methods were called mock_span.set_attribute.assert_called() mock_span.end.assert_called_once() - + # Span should be removed from extensions assert "otel_span" not in request.extensions diff --git a/tests/unit/test_checkers_error_surface.py b/tests/unit/test_checkers_error_surface.py index d128b86..50fbe82 100644 --- a/tests/unit/test_checkers_error_surface.py +++ b/tests/unit/test_checkers_error_surface.py @@ -14,17 +14,18 @@ async def test_checkers_error_surface(): """Test that API errors are properly surfaced with retry behavior.""" # Use faster retry config for testing from gavaconnect.config import RetryPolicy + retry_policy = RetryPolicy(max_attempts=2, base_backoff_s=0.01, max_cap_s=0.1) config = SDKConfig(base_url="https://test.example.com", retry=retry_policy) - + with respx.mock: - # Mock token endpoint + # Mock token endpoint respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( return_value=httpx.Response( 200, json={"access_token": "tok1", "expires_in": 3600} ) ) - + # Mock PIN validation endpoint - always returns 429 with shorter Retry-After pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( return_value=httpx.Response( @@ -35,22 +36,22 @@ async def test_checkers_error_surface(): "type": "rate_limit_exceeded", "message": "Too many requests", "code": "RATE_LIMIT", - "retry_after": 0.01 + "retry_after": 0.01, } - } + }, ) ) - + async with AsyncGavaConnect( config, checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_secret="test_secret", ) as sdk: with pytest.raises(RateLimitError) as exc_info: await sdk.checkers.validate_pin(pin="A000000000B") - + error = exc_info.value - + # Verify error details are captured assert error.status == 429 assert error.type == "rate_limit_exceeded" @@ -58,9 +59,9 @@ async def test_checkers_error_surface(): assert error.request_id == "req-123" assert error.retry_after_s == 0.01 assert error.body is not None - + # Verify multiple retry attempts were made due to Retry-After - # (Should retry up to max_attempts from config) + # (Should retry up to max_attempts from config) assert len(pin_route.calls) > 1 # Multiple retries with idempotency key @@ -68,7 +69,7 @@ async def test_checkers_error_surface(): async def test_checkers_error_missing_request_id(): """Test error handling when request ID is missing.""" config = SDKConfig(base_url="https://test.example.com") - + with respx.mock: # Mock token endpoint respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( @@ -76,7 +77,7 @@ async def test_checkers_error_missing_request_id(): 200, json={"access_token": "tok1", "expires_in": 3600} ) ) - + # Mock PIN validation endpoint - 500 error without request ID respx.post("https://test.example.com/checker/v1/pinbypin").mock( return_value=httpx.Response( @@ -84,19 +85,21 @@ async def test_checkers_error_missing_request_id(): json={ "error": { "type": "internal_error", - "message": "Internal server error" + "message": "Internal server error", } - } + }, ) ) - + async with AsyncGavaConnect( config, checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_secret="test_secret", ) as sdk: - with pytest.raises(Exception) as exc_info: # Should be APIError but imported from errors + with pytest.raises( + Exception + ) as exc_info: # Should be APIError but imported from errors await sdk.checkers.validate_pin(pin="A000000000B") - + # Verify error is raised (exact type depends on import structure) assert exc_info.value is not None diff --git a/tests/unit/test_checkers_validate_pin_401_then_refresh.py b/tests/unit/test_checkers_validate_pin_401_then_refresh.py index ec3775c..29b7411 100644 --- a/tests/unit/test_checkers_validate_pin_401_then_refresh.py +++ b/tests/unit/test_checkers_validate_pin_401_then_refresh.py @@ -12,7 +12,7 @@ async def test_checkers_validate_pin_401_then_refresh(): """Test 401 response triggers auth refresh and retry.""" config = SDKConfig(base_url="https://test.example.com") - + with respx.mock: # Mock token endpoint - returns different tokens on subsequent calls token_responses = [ @@ -22,38 +22,44 @@ async def test_checkers_validate_pin_401_then_refresh(): token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( side_effect=token_responses ) - + # Mock PIN validation endpoint - 401 first, then success pin_responses = [ - httpx.Response(401, json={"error": {"type": "unauthorized", "message": "Invalid token"}}), - httpx.Response(200, json={ - "PIN": "A000000000B", - "TaxPayerName": "ACME LTD", - "status": "VALID", - "valid": True - }) + httpx.Response( + 401, + json={"error": {"type": "unauthorized", "message": "Invalid token"}}, + ), + httpx.Response( + 200, + json={ + "PIN": "A000000000B", + "TaxPayerName": "ACME LTD", + "status": "VALID", + "valid": True, + }, + ), ] pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( side_effect=pin_responses ) - + async with AsyncGavaConnect( config, checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_secret="test_secret", ) as sdk: result = await sdk.checkers.validate_pin(pin="A000000000B") - + # Should eventually succeed after retry assert result.pin == "A000000000B" assert result.valid is True - + # Verify token endpoint called twice (initial + refresh) assert len(token_route.calls) == 2 - + # Verify PIN endpoint called twice (401 + retry) assert len(pin_route.calls) == 2 - + # Verify second request used new token first_auth = pin_route.calls[0].request.headers["authorization"] second_auth = pin_route.calls[1].request.headers["authorization"] diff --git a/tests/unit/test_checkers_validate_pin_get_variant.py b/tests/unit/test_checkers_validate_pin_get_variant.py index 96920ed..f102973 100644 --- a/tests/unit/test_checkers_validate_pin_get_variant.py +++ b/tests/unit/test_checkers_validate_pin_get_variant.py @@ -12,7 +12,7 @@ async def test_checkers_validate_pin_get_variant(): """Test PIN validation using GET with query parameters.""" config = SDKConfig(base_url="https://test.example.com") - + with respx.mock: # Mock token endpoint token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( @@ -20,7 +20,7 @@ async def test_checkers_validate_pin_get_variant(): 200, json={"access_token": "tok1", "expires_in": 3600} ) ) - + # Mock PIN validation endpoint with GET pin_route = respx.get("https://test.example.com/checker/v1/pinbypin").mock( return_value=httpx.Response( @@ -28,43 +28,43 @@ async def test_checkers_validate_pin_get_variant(): json={ "PIN": "A000000000B", "TaxPayerName": "ACME LTD", - "status": "VALID", - "valid": True - } + "status": "VALID", + "valid": True, + }, ) ) - + async with AsyncGavaConnect( config, checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_secret="test_secret", ) as sdk: result = await sdk.checkers.validate_pin_get(pin="A000000000B") - + # Verify result is correct assert result.pin == "A000000000B" assert result.taxpayer_name == "ACME LTD" assert result.status == "VALID" assert result.valid is True - + # Verify calls were made assert token_route.called assert pin_route.called - + # Verify GET request with query parameters pin_request = pin_route.calls[0].request assert pin_request.method == "GET" assert pin_request.headers["authorization"].startswith("Bearer ") - + # Check query parameters assert "PIN=A000000000B" in str(pin_request.url) -@pytest.mark.asyncio +@pytest.mark.asyncio async def test_checkers_validate_pin_get_custom_query_key(): """Test GET variant with custom query key.""" config = SDKConfig(base_url="https://test.example.com") - + with respx.mock: # Mock token endpoint respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( @@ -72,21 +72,23 @@ async def test_checkers_validate_pin_get_custom_query_key(): 200, json={"access_token": "tok1", "expires_in": 3600} ) ) - + # Mock PIN validation endpoint pin_route = respx.get("https://test.example.com/checker/v1/pinbypin").mock( return_value=httpx.Response( 200, json={"PIN": "A000000000B", "status": "VALID", "valid": True} ) ) - + async with AsyncGavaConnect( config, - checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_id="test_client", + checkers_client_secret="test_secret", ) as sdk: - await sdk.checkers.validate_pin_get(pin="A000000000B", query_key="pin_number") - + await sdk.checkers.validate_pin_get( + pin="A000000000B", query_key="pin_number" + ) + # Verify custom query key was used pin_request = pin_route.calls[0].request assert "pin_number=A000000000B" in str(pin_request.url) diff --git a/tests/unit/test_checkers_validate_pin_success.py b/tests/unit/test_checkers_validate_pin_success.py index 4562d20..1cc5a24 100644 --- a/tests/unit/test_checkers_validate_pin_success.py +++ b/tests/unit/test_checkers_validate_pin_success.py @@ -12,7 +12,7 @@ async def test_checkers_validate_pin_success(): """Test successful PIN validation with proper response mapping.""" config = SDKConfig(base_url="https://test.example.com") - + with respx.mock: # Mock token endpoint token_route = respx.get("https://sbx.kra.go.ke/v1/token/generate").mock( @@ -20,48 +20,49 @@ async def test_checkers_validate_pin_success(): 200, json={"access_token": "tok1", "expires_in": 3600} ) ) - + # Mock PIN validation endpoint pin_route = respx.post("https://test.example.com/checker/v1/pinbypin").mock( return_value=httpx.Response( - 200, + 200, json={ "PIN": "A000000000B", "TaxPayerName": "ACME LTD", "status": "VALID", - "valid": True - } + "valid": True, + }, ) ) - + async with AsyncGavaConnect( config, checkers_client_id="test_client", - checkers_client_secret="test_secret" + checkers_client_secret="test_secret", ) as sdk: result = await sdk.checkers.validate_pin(pin="A000000000B") - + # Test that fields are properly mapped assert result.pin == "A000000000B" assert result.taxpayer_name == "ACME LTD" assert result.status == "VALID" assert result.valid is True - + # Test that model_dump preserves aliases dumped = result.model_dump(by_alias=True) assert dumped["PIN"] == "A000000000B" assert dumped["TaxPayerName"] == "ACME LTD" - + # Verify API calls were made assert token_route.called assert pin_route.called - + # Verify request content pin_request = pin_route.calls[0].request assert pin_request.method == "POST" assert pin_request.headers["authorization"].startswith("Bearer ") - + # Check JSON payload import json + payload = json.loads(pin_request.content) assert payload == {"PIN": "A000000000B"} From 61de94fb2208be642b5717f1ca980ad737827bdf Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Mon, 25 Aug 2025 15:29:02 +0300 Subject: [PATCH 6/8] chore: Add Makefile for test automation and update coverage reports; enhance telemetry tests for graceful degradation --- .coverage | Bin 77824 -> 77824 bytes Makefile | 10 ++++ coverage.xml | 37 +++++---------- gavaconnect/http/telemetry.py | 34 ++++++++------ tests/test_auth_providers.py | 70 ++++++++++++++++++++++++++++ tests/test_telemetry_degradation.py | 49 ++++++++++++++++++- 6 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 Makefile diff --git a/.coverage b/.coverage index 78dd11fd73352796b4dc31f8bdd9b063de1f3ae0..4f6ce45470bdd5c434f1275ae7cc8a990e227a8f 100644 GIT binary patch delta 13994 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 Cd}O`= delta 13603 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 H7l-~E^YbVg diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9a7b4b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: test + +test: + uv run pytest -v --disable-warnings --maxfail=1 + +build: + uv build + +testcoverage: + uv run pytest --cov=src --cov-report=xml --disable-warnings --maxfail=1 \ No newline at end of file diff --git a/coverage.xml b/coverage.xml index 682dfde..9ab3fbb 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -89,7 +89,7 @@ - + @@ -151,7 +151,7 @@ - + @@ -194,9 +194,9 @@ - + - + @@ -292,7 +292,7 @@ - + @@ -320,32 +320,17 @@ - + - - - - - - - - - - - - - - - + + + + - - - - diff --git a/gavaconnect/http/telemetry.py b/gavaconnect/http/telemetry.py index cd2e5fb..4f3d721 100644 --- a/gavaconnect/http/telemetry.py +++ b/gavaconnect/http/telemetry.py @@ -3,11 +3,11 @@ import httpx try: - from opentelemetry import trace + from opentelemetry import trace # pragma: no cover - tracer: trace.Tracer | None = trace.get_tracer("gavaconnect") - OTEL_AVAILABLE = True -except ImportError: + tracer: trace.Tracer | None = trace.get_tracer("gavaconnect") # pragma: no cover + OTEL_AVAILABLE = True # pragma: no cover +except ImportError: # pragma: no cover # OpenTelemetry is optional - graceful degradation tracer = None OTEL_AVAILABLE = False @@ -23,10 +23,14 @@ async def otel_request_span(req: httpx.Request) -> None: if not OTEL_AVAILABLE or tracer is None: return - span = tracer.start_span( - "http.client", attributes={"http.method": req.method, "http.url": str(req.url)} - ) - req.extensions["otel_span"] = span + span = tracer.start_span( # pragma: no cover + "http.client", + attributes={ + "http.method": req.method, + "http.url": str(req.url), + }, # pragma: no cover + ) # pragma: no cover + req.extensions["otel_span"] = span # pragma: no cover async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None: @@ -40,10 +44,10 @@ async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None: if not OTEL_AVAILABLE: return - span = req.extensions.pop("otel_span", None) - if span: - span.set_attribute("http.status_code", resp.status_code) - rid = resp.headers.get("x-request-id") - if rid: - span.set_attribute("http.response.request_id", rid) - span.end() + span = req.extensions.pop("otel_span", None) # pragma: no cover + if span: # pragma: no cover + span.set_attribute("http.status_code", resp.status_code) # pragma: no cover + rid = resp.headers.get("x-request-id") # pragma: no cover + if rid: # pragma: no cover + span.set_attribute("http.response.request_id", rid) # pragma: no cover + span.end() # pragma: no cover diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index d0ac81d..57793f6 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -461,3 +461,73 @@ async def test_full_integration_flow(self): token3 = await provider.refresh() assert token3 == "integration_token" assert token_route.call_count == 2 # One additional call + + +class TestBasicTokenEndpointProvider: + """Test BasicTokenEndpointProvider class.""" + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_with_get_method(self): + """Test token fetch using GET method.""" + from gavaconnect.auth.credentials import BasicPair + from gavaconnect.auth.providers import BasicTokenEndpointProvider + + # Mock the token endpoint for GET request + token_route = respx.get("https://auth.example.com/token").mock( + return_value=httpx.Response( + 200, json={"access_token": "get_method_token", "expires_in": 3600} + ) + ) + + basic_creds = BasicPair(client_id="test_client", client_secret="test_secret") + provider = BasicTokenEndpointProvider( + token_url="https://auth.example.com/token", + basic=basic_creds, + method="GET", + ) + + with patch("time.time", return_value=1000.0): + token, exp_time = await provider._fetch() + + assert token == "get_method_token" + assert exp_time == 1000.0 + max(30.0, 3600 - 60) # 4540.0 + + # Verify the request was made correctly + assert token_route.called + request = token_route.calls[0].request + assert request.method == "GET" + assert request.url == "https://auth.example.com/token" + + @respx.mock + @pytest.mark.asyncio + async def test_fetch_with_post_method(self): + """Test token fetch using POST method (default).""" + from gavaconnect.auth.credentials import BasicPair + from gavaconnect.auth.providers import BasicTokenEndpointProvider + + # Mock the token endpoint for POST request + token_route = respx.post("https://auth.example.com/token").mock( + return_value=httpx.Response( + 200, json={"access_token": "post_method_token", "expires_in": 3600} + ) + ) + + basic_creds = BasicPair(client_id="test_client", client_secret="test_secret") + provider = BasicTokenEndpointProvider( + token_url="https://auth.example.com/token", + basic=basic_creds, + method="POST", + ) + + with patch("time.time", return_value=1000.0): + token, exp_time = await provider._fetch() + + assert token == "post_method_token" + assert exp_time == 1000.0 + max(30.0, 3600 - 60) # 4540.0 + + # Verify the request was made correctly + assert token_route.called + request = token_route.calls[0].request + assert request.method == "POST" + assert request.url == "https://auth.example.com/token" diff --git a/tests/test_telemetry_degradation.py b/tests/test_telemetry_degradation.py index adbd7e9..157d97e 100644 --- a/tests/test_telemetry_degradation.py +++ b/tests/test_telemetry_degradation.py @@ -1,6 +1,6 @@ """Tests for telemetry graceful degradation without OpenTelemetry.""" -from unittest.mock import Mock +from unittest.mock import Mock, patch import httpx import pytest @@ -74,3 +74,50 @@ async def test_otel_response_span_with_existing_span(self): # Span should be removed from extensions assert "otel_span" not in request.extensions + + @pytest.mark.asyncio + async def test_otel_request_span_early_return_otel_unavailable(self): + """Test otel_request_span returns early when OTEL_AVAILABLE is False.""" + # Temporarily patch OTEL_AVAILABLE to False + with patch("gavaconnect.http.telemetry.OTEL_AVAILABLE", False): + # Create a real request object + request = httpx.Request("GET", "https://api.example.com/test") + request.extensions = {} + + # Should return early and not add span + await otel_request_span(request) + + # No span should be added + assert "otel_span" not in request.extensions + + @pytest.mark.asyncio + async def test_otel_request_span_early_return_tracer_none(self): + """Test otel_request_span returns early when tracer is None.""" + # Temporarily patch tracer to None while keeping OTEL_AVAILABLE True + with patch("gavaconnect.http.telemetry.OTEL_AVAILABLE", True): + with patch("gavaconnect.http.telemetry.tracer", None): + # Create a real request object + request = httpx.Request("GET", "https://api.example.com/test") + request.extensions = {} + + # Should return early and not add span + await otel_request_span(request) + + # No span should be added + assert "otel_span" not in request.extensions + + @pytest.mark.asyncio + async def test_otel_response_span_early_return_otel_unavailable(self): + """Test otel_response_span returns early when OTEL_AVAILABLE is False.""" + # Temporarily patch OTEL_AVAILABLE to False + with patch("gavaconnect.http.telemetry.OTEL_AVAILABLE", False): + # Create real request and response objects + request = httpx.Request("GET", "https://api.example.com/test") + request.extensions = {"some_other_extension": "value"} + response = httpx.Response(status_code=200) + + # Should return early and not modify extensions + await otel_response_span(request, response) + + # Extensions should remain unchanged + assert request.extensions == {"some_other_extension": "value"} From 288010a322f202d67249400c4a2eb0f981b72e05 Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Mon, 25 Aug 2025 15:31:54 +0300 Subject: [PATCH 7/8] chore: Update Makefile to include additional tasks for coverage, linting, and type checking --- Makefile | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 9a7b4b2..56691ba 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,25 @@ -.PHONY: test +.PHONY: test build testcoverage dev-build ruff-check ruff-format mypy bandit test: - uv run pytest -v --disable-warnings --maxfail=1 + uv run pytest -v --disable-warnings --maxfail=1 + +test-coverage: + uv run pytest --cov=src --cov-report=xml --disable-warnings --maxfail=1 build: uv build -testcoverage: - uv run pytest --cov=src --cov-report=xml --disable-warnings --maxfail=1 \ No newline at end of file +dev-build: + uv build --wheel + +ruff-check: + uv run ruff check . + +ruff-format: + uv run ruff format . + +mypy: + uv run mypy . + +bandit: + uv run bandit -r src/ \ No newline at end of file From d10923aaf91b0de9068c2aa17eb98073c6f8ffa8 Mon Sep 17 00:00:00 2001 From: Musale Martin Date: Mon, 25 Aug 2025 15:32:28 +0300 Subject: [PATCH 8/8] chore: Update bandit target in Makefile to scan the 'gavaconnect' directory --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 56691ba..b1d2964 100644 --- a/Makefile +++ b/Makefile @@ -22,4 +22,4 @@ mypy: uv run mypy . bandit: - uv run bandit -r src/ \ No newline at end of file + uv run bandit -r gavaconnect/ \ No newline at end of file