From 57d6a499865d5c3e698ceee563d01a9a33a75d11 Mon Sep 17 00:00:00 2001 From: claudi Date: Fri, 17 Apr 2026 11:13:33 +0200 Subject: [PATCH] Add typed Pydantic models for positions and document payments, enhance EasybillClient with pagination and retry handling, and implement unit tests for workflow helpers. --- .vscode/settings.json | 3 + README.md | 16 +++ src/easybill_client/__init__.py | 4 +- src/easybill_client/client.py | 183 +++++++++++++++++++++++++++++++- src/easybill_client/models.py | 19 ++++ tests/test_workflow_helpers.py | 155 +++++++++++++++++++++++++++ 6 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/test_workflow_helpers.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..75383e2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "forgejo.preferredRemote": "forgejo" +} \ No newline at end of file diff --git a/README.md b/README.md index 32b9004..7f3b254 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ The initial implementation is in place and includes: - a project scaffold with packaging and tests - sync and async wrapper clients - authentication helpers for bearer and basic auth +- typed Pydantic facades for customers, documents, positions, and document payments +- pagination helpers and basic retry handling for HTTP 429 rate limits - a webhook parser for JSON and form payloads - a reproducible generation script based on the provided Swagger specification @@ -26,6 +28,20 @@ Generate the raw clients from the API description: python scripts/generate_client.py --mode both ``` +## Usage + +```python +from easybill_client import EasybillClient + +with EasybillClient(api_key="YOUR_API_TOKEN", max_retries=2, retry_backoff=1.0) as client: + customer_page = client.list_customers(limit=50) + position = client.get_position(5) + payment = client.create_document_payment(document_id=10, amount=1999, reference="INV-10") + + for customer in client.iter_all_customers(limit=100): + print(customer.id, customer.company_name) +``` + ## Structure - `src/easybill_client`: public package and middleware-friendly helpers diff --git a/src/easybill_client/__init__.py b/src/easybill_client/__init__.py index f37a04b..07a24b1 100644 --- a/src/easybill_client/__init__.py +++ b/src/easybill_client/__init__.py @@ -1,17 +1,19 @@ from .auth import EasybillAuth, basic_auth_header, bearer_auth_header from .client import AsyncEasybillClient, EasybillClient -from .models import Customer, Document, PagedResult, Pagination, WebhookEnvelope, WebhookSubscription +from .models import Customer, Document, DocumentPayment, PagedResult, Pagination, Position, WebhookEnvelope, WebhookSubscription from .webhooks import EasybillWebhookParser __all__ = [ "AsyncEasybillClient", "Customer", "Document", + "DocumentPayment", "EasybillAuth", "EasybillClient", "EasybillWebhookParser", "PagedResult", "Pagination", + "Position", "WebhookEnvelope", "WebhookSubscription", "basic_auth_header", diff --git a/src/easybill_client/client.py b/src/easybill_client/client.py index 6867819..467dc45 100644 --- a/src/easybill_client/client.py +++ b/src/easybill_client/client.py @@ -1,11 +1,13 @@ from __future__ import annotations +import asyncio +import time from typing import Any import httpx from .auth import EasybillAuth -from .models import Customer, Document, PagedResult, WebhookSubscription +from .models import Customer, Document, DocumentPayment, EasybillBaseModel, PagedResult, Position, WebhookSubscription class EasybillAPIError(RuntimeError): @@ -26,12 +28,16 @@ class _BaseEasybillClient: api_prefix: str = "/rest/v1", timeout: float = 30.0, default_limit: int = 100, + max_retries: int = 0, + retry_backoff: float = 1.0, transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None, ) -> None: self.base_url = base_url.rstrip("/") self.api_prefix = api_prefix.rstrip("/") self.timeout = timeout self.default_limit = default_limit + self.max_retries = max_retries + self.retry_backoff = retry_backoff self.transport = transport self.auth = EasybillAuth(api_key, email=email, use_basic_auth=use_basic_auth) self.default_headers = { @@ -68,7 +74,7 @@ class _BaseEasybillClient: return cleaned or None @staticmethod - def _parse_paged_result(data: dict[str, Any], item_model: type[Customer] | type[Document] | type[WebhookSubscription]) -> PagedResult: + def _parse_paged_result(data: dict[str, Any], item_model: type[EasybillBaseModel]) -> PagedResult: items = [item_model.model_validate(item) for item in data.get("items", [])] return PagedResult( page=data.get("page", 1), @@ -78,6 +84,15 @@ class _BaseEasybillClient: items=items, ) + def _get_retry_delay(self, response: httpx.Response, attempt: int) -> float: + retry_after = response.headers.get("Retry-After") + if retry_after is not None: + try: + return max(0.0, float(retry_after)) + except ValueError: + pass + return max(0.0, self.retry_backoff * attempt) + class EasybillClient(_BaseEasybillClient): def __init__(self, api_key: str, **kwargs: Any) -> None: @@ -100,7 +115,17 @@ class EasybillClient(_BaseEasybillClient): self.close() def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: - response = self._client.request(method, self._build_path(path), **kwargs) + response: httpx.Response | None = None + for attempt in range(self.max_retries + 1): + response = self._client.request(method, self._build_path(path), **kwargs) + if response.status_code != 429 or attempt >= self.max_retries: + return self._check_response(response) + + delay = self._get_retry_delay(response, attempt + 1) + if delay > 0: + time.sleep(delay) + + assert response is not None return self._check_response(response) def get_json(self, path: str, **kwargs: Any) -> Any: @@ -125,6 +150,74 @@ class EasybillClient(_BaseEasybillClient): data = self.get_json("/documents", params=params) return self._parse_paged_result(data, Document) + def get_position(self, position_id: int) -> Position: + return Position.model_validate(self.get_json(f"/positions/{position_id}")) + + def list_positions(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult: + params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters}) + data = self.get_json("/positions", params=params) + return self._parse_paged_result(data, Position) + + def list_document_payments(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult: + params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters}) + data = self.get_json("/document-payments", params=params) + return self._parse_paged_result(data, DocumentPayment) + + def create_document_payment( + self, + *, + document_id: int, + amount: int, + reference: str | None = None, + type: str = "CASH", + payment_at: str | None = None, + ) -> DocumentPayment: + payload = { + "document_id": document_id, + "amount": amount, + "reference": reference, + "type": type, + "payment_at": payment_at, + } + data = self.post_json("/document-payments", json=self._clean_params(payload)) + return DocumentPayment.model_validate(data) + + def iter_all_customers(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = self.list_customers(page=page, limit=limit, **filters) + yield from result.items + if page >= result.pages: + break + page += 1 + + def iter_all_documents(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = self.list_documents(page=page, limit=limit, **filters) + yield from result.items + if page >= result.pages: + break + page += 1 + + def iter_all_positions(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = self.list_positions(page=page, limit=limit, **filters) + yield from result.items + if page >= result.pages: + break + page += 1 + + def iter_all_document_payments(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = self.list_document_payments(page=page, limit=limit, **filters) + yield from result.items + if page >= result.pages: + break + page += 1 + def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> PagedResult: params = self._clean_params({"page": page, "limit": limit or self.default_limit}) data = self.get_json("/webhooks", params=params) @@ -173,7 +266,17 @@ class AsyncEasybillClient(_BaseEasybillClient): await self.aclose() async def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: - response = await self._client.request(method, self._build_path(path), **kwargs) + response: httpx.Response | None = None + for attempt in range(self.max_retries + 1): + response = await self._client.request(method, self._build_path(path), **kwargs) + if response.status_code != 429 or attempt >= self.max_retries: + return self._check_response(response) + + delay = self._get_retry_delay(response, attempt + 1) + if delay > 0: + await asyncio.sleep(delay) + + assert response is not None return self._check_response(response) async def get_json(self, path: str, **kwargs: Any) -> Any: @@ -200,6 +303,78 @@ class AsyncEasybillClient(_BaseEasybillClient): data = await self.get_json("/documents", params=params) return self._parse_paged_result(data, Document) + async def get_position(self, position_id: int) -> Position: + return Position.model_validate(await self.get_json(f"/positions/{position_id}")) + + async def list_positions(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult: + params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters}) + data = await self.get_json("/positions", params=params) + return self._parse_paged_result(data, Position) + + async def list_document_payments(self, *, page: int = 1, limit: int | None = None, **filters: Any) -> PagedResult: + params = self._clean_params({"page": page, "limit": limit or self.default_limit, **filters}) + data = await self.get_json("/document-payments", params=params) + return self._parse_paged_result(data, DocumentPayment) + + async def create_document_payment( + self, + *, + document_id: int, + amount: int, + reference: str | None = None, + type: str = "CASH", + payment_at: str | None = None, + ) -> DocumentPayment: + payload = { + "document_id": document_id, + "amount": amount, + "reference": reference, + "type": type, + "payment_at": payment_at, + } + data = await self.post_json("/document-payments", json=self._clean_params(payload)) + return DocumentPayment.model_validate(data) + + async def iter_all_customers(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = await self.list_customers(page=page, limit=limit, **filters) + for item in result.items: + yield item + if page >= result.pages: + break + page += 1 + + async def iter_all_documents(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = await self.list_documents(page=page, limit=limit, **filters) + for item in result.items: + yield item + if page >= result.pages: + break + page += 1 + + async def iter_all_positions(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = await self.list_positions(page=page, limit=limit, **filters) + for item in result.items: + yield item + if page >= result.pages: + break + page += 1 + + async def iter_all_document_payments(self, *, limit: int | None = None, **filters: Any): + page = 1 + while True: + result = await self.list_document_payments(page=page, limit=limit, **filters) + for item in result.items: + yield item + if page >= result.pages: + break + page += 1 + async def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> PagedResult: params = self._clean_params({"page": page, "limit": limit or self.default_limit}) data = await self.get_json("/webhooks", params=params) diff --git a/src/easybill_client/models.py b/src/easybill_client/models.py index 164e548..d0c319c 100644 --- a/src/easybill_client/models.py +++ b/src/easybill_client/models.py @@ -42,6 +42,25 @@ class Document(EasybillBaseModel): is_draft: bool | None = None +class Position(EasybillBaseModel): + id: int | None = None + number: str | None = None + type: str | None = None + description: str | None = None + price: int | float | None = None + quantity: int | float | None = None + unit: str | None = None + + +class DocumentPayment(EasybillBaseModel): + id: int | None = None + document_id: int | None = None + amount: int | None = None + reference: str | None = None + type: str | None = None + payment_at: str | None = None + + class WebhookSubscription(EasybillBaseModel): id: int | None = None url: str diff --git a/tests/test_workflow_helpers.py b/tests/test_workflow_helpers.py new file mode 100644 index 0000000..23dd32a --- /dev/null +++ b/tests/test_workflow_helpers.py @@ -0,0 +1,155 @@ +import httpx +import pytest + +from easybill_client import AsyncEasybillClient, EasybillClient + + +class WorkflowTransport(httpx.MockTransport): + def __init__(self): + self.rate_limit_hits = 0 + super().__init__(self._handler) + + def _handler(self, request: httpx.Request) -> httpx.Response: + if request.url.path == "/positions/5": + return httpx.Response( + 200, + json={ + "id": 5, + "number": "ART-5", + "type": "PRODUCT", + "description": "Premium Support", + "price": 4900, + }, + ) + + if request.url.path == "/document-payments": + if request.method == "POST": + body = request.read().decode() + assert "INV-1" in body + return httpx.Response( + 201, + json={ + "id": 88, + "document_id": 10, + "amount": 1999, + "reference": "INV-1", + "type": "CASH", + }, + ) + + return httpx.Response( + 200, + json={ + "page": 1, + "pages": 1, + "limit": 100, + "total": 1, + "items": [ + { + "id": 88, + "document_id": 10, + "amount": 1999, + "reference": "INV-1", + "type": "CASH", + } + ], + }, + ) + + if request.url.path == "/customers": + page = request.url.params.get("page", "1") + if page == "1": + return httpx.Response( + 200, + json={ + "page": 1, + "pages": 2, + "limit": 1, + "total": 2, + "items": [{"id": 1, "company_name": "ACME GmbH"}], + }, + ) + return httpx.Response( + 200, + json={ + "page": 2, + "pages": 2, + "limit": 1, + "total": 2, + "items": [{"id": 2, "company_name": "Example AG"}], + }, + ) + + if request.url.path == "/documents": + if self.rate_limit_hits == 0: + self.rate_limit_hits += 1 + return httpx.Response(429, headers={"Retry-After": "0"}, json={"error": "slow down"}) + + return httpx.Response( + 200, + json={ + "page": 1, + "pages": 1, + "limit": 100, + "total": 1, + "items": [{"id": 10, "number": "RE-10", "amount": 1999}], + }, + ) + + return httpx.Response(404, json={"error": "not found"}) + + +class AsyncWorkflowTransport(httpx.MockTransport): + def __init__(self): + super().__init__(self._handler) + + @staticmethod + def _handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/positions": + return httpx.Response( + 200, + json={ + "page": 1, + "pages": 1, + "limit": 100, + "total": 1, + "items": [{"id": 7, "number": "ART-7", "description": "Hosting"}], + }, + ) + return httpx.Response(404, json={"error": "not found"}) + + +def test_sync_workflow_helpers_cover_positions_payments_pagination_and_retry(): + transport = WorkflowTransport() + client = EasybillClient(api_key="token", transport=transport, max_retries=1, retry_backoff=0) + try: + position = client.get_position(5) + payments = client.list_document_payments(document_id=10) + created_payment = client.create_document_payment(document_id=10, amount=1999, reference="INV-1") + all_customers = list(client.iter_all_customers(limit=1)) + all_payments = list(client.iter_all_document_payments(document_id=10)) + documents = client.list_documents() + finally: + client.close() + + assert position.id == 5 + assert position.price == 4900 + assert payments.items[0].reference == "INV-1" + assert created_payment.id == 88 + assert [customer.id for customer in all_customers] == [1, 2] + assert [payment.id for payment in all_payments] == [88] + assert documents.items[0].number == "RE-10" + + +@pytest.mark.asyncio +async def test_async_workflow_helpers_list_positions_as_models(): + client = AsyncEasybillClient(api_key="token", transport=AsyncWorkflowTransport()) + try: + positions = await client.list_positions() + iterated = [item async for item in client.iter_all_positions()] + finally: + await client.aclose() + + assert positions.total == 1 + assert positions.items[0].number == "ART-7" + assert iterated[0].id == 7