diff --git a/.coverage b/.coverage index 950e04c..c6d8598 100644 Binary files a/.coverage and b/.coverage differ diff --git a/gavaconnect/__init__.py b/gavaconnect/__init__.py index 5654f5f..d7de796 100644 --- a/gavaconnect/__init__.py +++ b/gavaconnect/__init__.py @@ -1,5 +1,16 @@ """GavaConnect SDK for Python.""" +from ._version import __version__ from .checkers import KRAPINChecker +from .config import SDKConfig +from .errors import APIError, RateLimitError, SDKError, TransportError -__all__ = ["KRAPINChecker"] +__all__ = [ + "__version__", + "SDKConfig", + "SDKError", + "APIError", + "RateLimitError", + "TransportError", + "KRAPINChecker", +] diff --git a/gavaconnect/_version.py b/gavaconnect/_version.py new file mode 100644 index 0000000..6b102df --- /dev/null +++ b/gavaconnect/_version.py @@ -0,0 +1,4 @@ +# The SDK version +# x-release-please-start-version +__version__: str = "0.2.1" +# x-release-please-end diff --git a/gavaconnect/checkers/__init__.py b/gavaconnect/checkers/__init__.py index db6e476..3d29906 100644 --- a/gavaconnect/checkers/__init__.py +++ b/gavaconnect/checkers/__init__.py @@ -1,3 +1,5 @@ +"""Checkers module for various validation utilities.""" + from ._pin import KRAPINChecker __all__ = ["KRAPINChecker"] diff --git a/gavaconnect/checkers/_pin.py b/gavaconnect/checkers/_pin.py index 00d73b6..406ab69 100644 --- a/gavaconnect/checkers/_pin.py +++ b/gavaconnect/checkers/_pin.py @@ -1,7 +1,7 @@ class KRAPINChecker: """Checker for KRA PIN.""" - def __init__(self, id_number: str): + def __init__(self, id_number: str) -> None: self.id_number = id_number def check_by_id_number(self) -> str: diff --git a/gavaconnect/config.py b/gavaconnect/config.py new file mode 100644 index 0000000..9173424 --- /dev/null +++ b/gavaconnect/config.py @@ -0,0 +1,24 @@ +"""Configuration classes for the GavaConnect SDK.""" + +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class RetryPolicy: + """Configuration for retry behavior.""" + + max_attempts: int = 3 + base_backoff_s: float = 0.2 + retry_on_status: tuple[int, ...] = (429, 500, 502, 503, 504) + + +@dataclass(slots=True) +class SDKConfig: + """Main configuration for the GavaConnect SDK.""" + + base_url: str + connect_timeout_s: float = 5.0 + read_timeout_s: float = 30.0 + total_timeout_s: float = 40.0 + retry: RetryPolicy = field(default_factory=RetryPolicy) + user_agent: str = "gavaconnect-py/1.0.0" diff --git a/gavaconnect/errors.py b/gavaconnect/errors.py new file mode 100644 index 0000000..2078d1b --- /dev/null +++ b/gavaconnect/errors.py @@ -0,0 +1,42 @@ +"""Error classes for the GavaConnect SDK.""" + +from __future__ import annotations + + +class SDKError(Exception): + """Base exception for all SDK errors.""" + + +class TransportError(SDKError): + """Exception raised for network/transport related errors.""" + + +class SerializationError(SDKError): + """Exception raised for data serialization/deserialization errors.""" + + +class APIError(SDKError): + """Exception raised for API-related errors.""" + + def __init__( + self, + status: int, + type_: str, + message: str, + code: str | None, + request_id: str | None, + retry_after_s: float | None, + body: bytes | None, + ) -> None: + """Initialize APIError with response details.""" + super().__init__(message) + self.status = status + self.type = type_ + self.code = code + self.request_id = request_id + self.retry_after_s = retry_after_s + self.body = body + + +class RateLimitError(APIError): + """Exception raised when API rate limits are exceeded.""" diff --git a/pyproject.toml b/pyproject.toml index 008a1f1..d1c7ac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ gavaconnect-sdk-python = "gavaconnect:main" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.setuptools.package-data] + # Ruff configuration [tool.ruff] target-version = "py313" @@ -56,24 +58,18 @@ exclude = [ ] [tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "S", # bandit (security) -] +select = ["E","F","I","UP","B","N","ANN","D"] + ignore = [ "E501", # line too long (handled by formatter) "B008", # do not perform function calls in argument defaults "S101", # use of assert detected (pytest uses assert) + "D203", # 1 blank line required before class docstring + "D213", # Multi-line docstring summary should start at the second line ] [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101"] # Allow assert in tests +"tests/*" = ["S101", "ANN201", "D103", "D100"] # Allow assert in tests, missing return types and docstrings # MyPy configuration [tool.mypy] @@ -121,3 +117,12 @@ exclude_lines = [ [tool.hatch.build.targets.wheel] packages = ["gavaconnect"] +include = [ + "gavaconnect/py.typed" +] + +[dependency-groups] +dev = [ + "pytest>=8.4.1", + "pytest-cov>=6.2.1", +] diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..75e0a9f --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,357 @@ +"""Tests for GavaConnect SDK error classes.""" + +import pytest + +from gavaconnect import errors + + +class TestSDKError: + """Tests for the base SDKError exception.""" + + def test_sdk_error_inheritance(self) -> None: + """Test that SDKError inherits from Exception.""" + assert issubclass(errors.SDKError, Exception) + + def test_sdk_error_creation(self) -> None: + """Test that SDKError can be created with a message.""" + message = "Test error message" + error = errors.SDKError(message) + assert str(error) == message + + def test_sdk_error_creation_without_message(self) -> None: + """Test that SDKError can be created without a message.""" + error = errors.SDKError() + assert str(error) == "" + + def test_sdk_error_can_be_raised(self) -> None: + """Test that SDKError can be raised and caught.""" + with pytest.raises(errors.SDKError) as exc_info: + raise errors.SDKError("Test error") + assert str(exc_info.value) == "Test error" + + +class TestTransportError: + """Tests for the TransportError exception.""" + + def test_transport_error_inheritance(self) -> None: + """Test that TransportError inherits from SDKError.""" + assert issubclass(errors.TransportError, errors.SDKError) + assert issubclass(errors.TransportError, Exception) + + def test_transport_error_creation(self) -> None: + """Test that TransportError can be created with a message.""" + message = "Network connection failed" + error = errors.TransportError(message) + assert str(error) == message + + def test_transport_error_can_be_raised(self) -> None: + """Test that TransportError can be raised and caught.""" + with pytest.raises(errors.TransportError) as exc_info: + raise errors.TransportError("Connection timeout") + assert str(exc_info.value) == "Connection timeout" + + def test_transport_error_caught_as_sdk_error(self) -> None: + """Test that TransportError can be caught as SDKError.""" + with pytest.raises(errors.SDKError): + raise errors.TransportError("Network error") + + +class TestSerializationError: + """Tests for the SerializationError exception.""" + + def test_serialization_error_inheritance(self) -> None: + """Test that SerializationError inherits from SDKError.""" + assert issubclass(errors.SerializationError, errors.SDKError) + assert issubclass(errors.SerializationError, Exception) + + def test_serialization_error_creation(self) -> None: + """Test that SerializationError can be created with a message.""" + message = "JSON decode error" + error = errors.SerializationError(message) + assert str(error) == message + + def test_serialization_error_can_be_raised(self) -> None: + """Test that SerializationError can be raised and caught.""" + with pytest.raises(errors.SerializationError) as exc_info: + raise errors.SerializationError("Invalid JSON format") + assert str(exc_info.value) == "Invalid JSON format" + + def test_serialization_error_caught_as_sdk_error(self) -> None: + """Test that SerializationError can be caught as SDKError.""" + with pytest.raises(errors.SDKError): + raise errors.SerializationError("Serialization failed") + + +class TestAPIError: + """Tests for the APIError exception.""" + + def test_api_error_inheritance(self) -> None: + """Test that APIError inherits from SDKError.""" + assert issubclass(errors.APIError, errors.SDKError) + assert issubclass(errors.APIError, Exception) + + def test_api_error_creation_with_all_parameters(self) -> None: + """Test that APIError can be created with all parameters.""" + status = 400 + type_ = "bad_request" + message = "Invalid request parameters" + code = "INVALID_PARAMS" + request_id = "req_123456" + retry_after_s = 30.5 + body = b'{"error": "bad request"}' + + error = errors.APIError( + status=status, + type_=type_, + message=message, + code=code, + request_id=request_id, + retry_after_s=retry_after_s, + body=body, + ) + + assert str(error) == message + assert error.status == status + assert error.type == type_ + assert error.code == code + assert error.request_id == request_id + assert error.retry_after_s == retry_after_s + assert error.body == body + + def test_api_error_creation_with_required_parameters_only(self) -> None: + """Test that APIError can be created with only required parameters.""" + status = 500 + type_ = "internal_error" + message = "Internal server error" + + error = errors.APIError( + status=status, + type_=type_, + message=message, + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + + assert str(error) == message + assert error.status == status + assert error.type == type_ + assert error.code is None + assert error.request_id is None + assert error.retry_after_s is None + assert error.body is None + + def test_api_error_can_be_raised(self) -> None: + """Test that APIError can be raised and caught.""" + with pytest.raises(errors.APIError) as exc_info: + raise errors.APIError( + status=404, + type_="not_found", + message="Resource not found", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + + error = exc_info.value + assert str(error) == "Resource not found" + assert error.status == 404 + assert error.type == "not_found" + + def test_api_error_caught_as_sdk_error(self) -> None: + """Test that APIError can be caught as SDKError.""" + with pytest.raises(errors.SDKError): + raise errors.APIError( + status=403, + type_="forbidden", + message="Access denied", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + + def test_api_error_with_different_status_codes(self) -> None: + """Test APIError with various HTTP status codes.""" + test_cases = [ + (400, "bad_request", "Bad Request"), + (401, "unauthorized", "Unauthorized"), + (403, "forbidden", "Forbidden"), + (404, "not_found", "Not Found"), + (422, "unprocessable_entity", "Validation Error"), + (500, "internal_error", "Internal Server Error"), + (502, "bad_gateway", "Bad Gateway"), + (503, "service_unavailable", "Service Unavailable"), + ] + + for status, type_, message in test_cases: + error = errors.APIError( + status=status, + type_=type_, + message=message, + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + assert error.status == status + assert error.type == type_ + assert str(error) == message + + +class TestRateLimitError: + """Tests for the RateLimitError exception.""" + + def test_rate_limit_error_inheritance(self) -> None: + """Test that RateLimitError inherits from APIError.""" + assert issubclass(errors.RateLimitError, errors.APIError) + assert issubclass(errors.RateLimitError, errors.SDKError) + assert issubclass(errors.RateLimitError, Exception) + + def test_rate_limit_error_creation(self) -> None: + """Test that RateLimitError can be created with typical rate limit parameters.""" + status = 429 + type_ = "rate_limit_exceeded" + message = "Rate limit exceeded" + code = "RATE_LIMIT" + request_id = "req_rate_limit_123" + retry_after_s = 60.0 + body = b'{"error": "rate limit exceeded", "retry_after": 60}' + + error = errors.RateLimitError( + status=status, + type_=type_, + message=message, + code=code, + request_id=request_id, + retry_after_s=retry_after_s, + body=body, + ) + + assert str(error) == message + assert error.status == status + assert error.type == type_ + assert error.code == code + assert error.request_id == request_id + assert error.retry_after_s == retry_after_s + assert error.body == body + + def test_rate_limit_error_can_be_raised(self) -> None: + """Test that RateLimitError can be raised and caught.""" + with pytest.raises(errors.RateLimitError) as exc_info: + raise errors.RateLimitError( + status=429, + type_="rate_limit", + message="Too many requests", + code="TOO_MANY_REQUESTS", + request_id="req_123", + retry_after_s=120.0, + body=None, + ) + + error = exc_info.value + assert str(error) == "Too many requests" + assert error.status == 429 + assert error.retry_after_s == 120.0 + + def test_rate_limit_error_caught_as_api_error(self) -> None: + """Test that RateLimitError can be caught as APIError.""" + with pytest.raises(errors.APIError): + raise errors.RateLimitError( + status=429, + type_="rate_limit", + message="Rate limited", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + + def test_rate_limit_error_caught_as_sdk_error(self) -> None: + """Test that RateLimitError can be caught as SDKError.""" + with pytest.raises(errors.SDKError): + raise errors.RateLimitError( + status=429, + type_="rate_limit", + message="Rate limited", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + + +class TestErrorInteractions: + """Tests for interactions between different error types.""" + + def test_all_errors_inherit_from_sdk_error(self) -> None: + """Test that all custom errors inherit from SDKError.""" + error_classes = [ + errors.TransportError, + errors.SerializationError, + errors.APIError, + errors.RateLimitError, + ] + + for error_class in error_classes: + assert issubclass(error_class, errors.SDKError) + + def test_exception_hierarchy_catch_order(self) -> None: + """Test that exceptions can be caught in the correct hierarchy order.""" + # Most specific first + try: + raise errors.RateLimitError( + status=429, + type_="rate_limit", + message="Rate limited", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + except errors.RateLimitError: + caught_type = "RateLimitError" + except errors.APIError: + caught_type = "APIError" + except errors.SDKError: + caught_type = "SDKError" + except Exception: + caught_type = "Exception" + + assert caught_type == "RateLimitError" + + # Test APIError (but not RateLimitError) is caught as APIError + try: + raise errors.APIError( + status=404, + type_="not_found", + message="Not found", + code=None, + request_id=None, + retry_after_s=None, + body=None, + ) + except errors.RateLimitError: + caught_type = "RateLimitError" + except errors.APIError: + caught_type = "APIError" + except errors.SDKError: + caught_type = "SDKError" + except Exception: + caught_type = "Exception" + + assert caught_type == "APIError" + + def test_error_messages_preserved(self) -> None: + """Test that error messages are properly preserved across inheritance.""" + test_cases = [ + (errors.SDKError, "SDK error message"), + (errors.TransportError, "Transport error message"), + (errors.SerializationError, "Serialization error message"), + ] + + for error_class, message in test_cases: + error = error_class(message) + assert str(error) == message diff --git a/tests/test_pin.py b/tests/test_pin.py index 0bc885a..8604f94 100644 --- a/tests/test_pin.py +++ b/tests/test_pin.py @@ -1,11 +1,15 @@ +"""Tests for KRA PIN checker functionality.""" + from gavaconnect import checkers -def test_kra_pin_checker_valid(): +def test_kra_pin_checker_valid() -> None: + """Test that a valid 6-digit PIN is correctly identified.""" checker = checkers.KRAPINChecker("123456") assert checker.check_by_id_number() == "Valid KRA PIN." -def test_kra_pin_checker_invalid(): +def test_kra_pin_checker_invalid() -> None: + """Test that an invalid PIN (not 6 digits) is correctly identified.""" checker = checkers.KRAPINChecker("12345") assert checker.check_by_id_number() == "Invalid KRA PIN." diff --git a/uv.lock b/uv.lock index 59558df..1cdd770 100644 --- a/uv.lock +++ b/uv.lock @@ -118,6 +118,12 @@ dev = [ { name = "ruff" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + [package.metadata] requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.7.0" }, @@ -129,6 +135,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-cov", specifier = ">=6.2.1" }, +] + [[package]] name = "h11" version = "0.16.0"