diff --git a/.coverage b/.coverage index 62f818d..4f6ce45 100644 Binary files a/.coverage and b/.coverage differ 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/Makefile b/Makefile new file mode 100644 index 0000000..b1d2964 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +.PHONY: test build testcoverage dev-build ruff-check ruff-format mypy bandit + +test: + 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 + +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 gavaconnect/ \ No newline at end of file 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/coverage.xml b/coverage.xml index 6cbb616..9ab3fbb 100644 --- a/coverage.xml +++ b/coverage.xml @@ -1,5 +1,5 @@ - + @@ -15,7 +15,7 @@ - + @@ -64,6 +64,29 @@ + + + + + + + + + + + + + + + + + + + + + + + @@ -74,7 +97,8 @@ - + + @@ -117,35 +141,76 @@ + + + + + + + + + + - + + - - - - - + + + - + + + - - - + + + - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -162,13 +227,21 @@ - - - - - + + - + + + + + + + + + + + + @@ -251,19 +324,13 @@ - - - + - - - - - - - - + + + + @@ -385,5 +452,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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. diff --git a/gavaconnect/auth/__init__.py b/gavaconnect/auth/__init__.py index faaa1dd..f99a97d 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", "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 9117fa5..b9d96ce 100644 --- a/gavaconnect/auth/providers.py +++ b/gavaconnect/auth/providers.py @@ -2,9 +2,15 @@ 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 + class ClientCredentialsProvider: """OAuth2 client credentials token provider.""" @@ -17,6 +23,7 @@ def __init__( scope: str | None = None, early_refresh_s: int = 60, client: httpx.AsyncClient | None = None, + token_timeout_s: float = 10.0, ) -> None: """Initialize the client credentials provider. @@ -27,6 +34,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 +45,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 +64,80 @@ 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. + + 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 + + +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. diff --git a/gavaconnect/checkers/_pin.py b/gavaconnect/checkers/_pin.py index 406ab69..7661fd6 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 is not None 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/facade_async.py b/gavaconnect/facade_async.py new file mode 100644 index 0000000..722d48e --- /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/http/telemetry.py b/gavaconnect/http/telemetry.py index 9f7dd07..4f3d721 100644 --- a/gavaconnect/http/telemetry.py +++ b/gavaconnect/http/telemetry.py @@ -1,9 +1,16 @@ """OpenTelemetry tracing utilities for HTTP requests.""" import httpx -from opentelemetry import trace -tracer = trace.get_tracer("gavaconnect") +try: + from opentelemetry import trace # pragma: no cover + + 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 async def otel_request_span(req: httpx.Request) -> None: @@ -13,10 +20,17 @@ async def otel_request_span(req: httpx.Request) -> None: req: The HTTP request to trace. """ - span = tracer.start_span( - "http.client", attributes={"http.method": req.method, "http.url": str(req.url)} - ) - req.extensions["otel_span"] = span + if not OTEL_AVAILABLE or tracer is None: + return + + 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: @@ -27,10 +41,13 @@ async def otel_response_span(req: httpx.Request, resp: httpx.Response) -> None: resp: The HTTP response. """ - 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() + if not OTEL_AVAILABLE: + return + + 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/gavaconnect/resources/__init__.py b/gavaconnect/resources/__init__.py new file mode 100644 index 0000000..adcf709 --- /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__: list[str] = [] # 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..baa36b1 --- /dev/null +++ b/gavaconnect/resources/checkers/_pin.py @@ -0,0 +1,92 @@ +"""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 c8ab5f3..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"] @@ -31,6 +32,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/smoke_test.py b/smoke_test.py new file mode 100644 index 0000000..4cd5b1f --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Smoke test for the PIN validation checker.""" + +import asyncio + +from gavaconnect import AsyncGavaConnect, SDKConfig + + +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( + 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..9a1260c --- /dev/null +++ b/test_imports.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Test import behavior in different scenarios.""" + +def test_basic_imports() -> bool: + """Test that basic imports work without httpx/pydantic.""" + try: + print("✓ Basic gavaconnect import successful") + + 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() -> bool: + """Test that full imports work with dependencies.""" + try: + print("✓ AsyncGavaConnect import successful") + + from gavaconnect.resources.checkers import 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/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_pin.py b/tests/test_pin.py index 8604f94..001cdc1 100644 --- a/tests/test_pin.py +++ b/tests/test_pin.py @@ -4,12 +4,43 @@ 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..157d97e --- /dev/null +++ b/tests/test_telemetry_degradation.py @@ -0,0 +1,123 @@ +"""Tests for telemetry graceful degradation without OpenTelemetry.""" + +from unittest.mock import Mock, patch + +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 + + @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"} diff --git a/tests/unit/test_checkers_error_surface.py b/tests/unit/test_checkers_error_surface.py new file mode 100644 index 0000000..50fbe82 --- /dev/null +++ b/tests/unit/test_checkers_error_surface.py @@ -0,0 +1,105 @@ +"""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..29b7411 --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_401_then_refresh.py @@ -0,0 +1,68 @@ +"""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..f102973 --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_get_variant.py @@ -0,0 +1,94 @@ +"""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..1cc5a24 --- /dev/null +++ b/tests/unit/test_checkers_validate_pin_success.py @@ -0,0 +1,68 @@ +"""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 a68613b..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] @@ -119,6 +129,10 @@ dev = [ { name = "respx" }, { name = "ruff" }, ] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] [package.dev-dependencies] dev = [ @@ -136,13 +150,16 @@ 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 = "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" }, { 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 = [ @@ -388,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" @@ -546,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"