Add typed Pydantic models for positions and document payments, enhance EasybillClient with pagination and retry handling, and implement unit tests for workflow helpers.

This commit is contained in:
claudi 2026-04-17 11:13:33 +02:00
parent b324671286
commit 57d6a49986
6 changed files with 375 additions and 5 deletions

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"forgejo.preferredRemote": "forgejo"
}

View file

@ -9,6 +9,8 @@ The initial implementation is in place and includes:
- a project scaffold with packaging and tests - a project scaffold with packaging and tests
- sync and async wrapper clients - sync and async wrapper clients
- authentication helpers for bearer and basic auth - 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 webhook parser for JSON and form payloads
- a reproducible generation script based on the provided Swagger specification - 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 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 ## Structure
- `src/easybill_client`: public package and middleware-friendly helpers - `src/easybill_client`: public package and middleware-friendly helpers

View file

@ -1,17 +1,19 @@
from .auth import EasybillAuth, basic_auth_header, bearer_auth_header from .auth import EasybillAuth, basic_auth_header, bearer_auth_header
from .client import AsyncEasybillClient, EasybillClient 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 from .webhooks import EasybillWebhookParser
__all__ = [ __all__ = [
"AsyncEasybillClient", "AsyncEasybillClient",
"Customer", "Customer",
"Document", "Document",
"DocumentPayment",
"EasybillAuth", "EasybillAuth",
"EasybillClient", "EasybillClient",
"EasybillWebhookParser", "EasybillWebhookParser",
"PagedResult", "PagedResult",
"Pagination", "Pagination",
"Position",
"WebhookEnvelope", "WebhookEnvelope",
"WebhookSubscription", "WebhookSubscription",
"basic_auth_header", "basic_auth_header",

View file

@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import time
from typing import Any from typing import Any
import httpx import httpx
from .auth import EasybillAuth from .auth import EasybillAuth
from .models import Customer, Document, PagedResult, WebhookSubscription from .models import Customer, Document, DocumentPayment, EasybillBaseModel, PagedResult, Position, WebhookSubscription
class EasybillAPIError(RuntimeError): class EasybillAPIError(RuntimeError):
@ -26,12 +28,16 @@ class _BaseEasybillClient:
api_prefix: str = "/rest/v1", api_prefix: str = "/rest/v1",
timeout: float = 30.0, timeout: float = 30.0,
default_limit: int = 100, default_limit: int = 100,
max_retries: int = 0,
retry_backoff: float = 1.0,
transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None, transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
) -> None: ) -> None:
self.base_url = base_url.rstrip("/") self.base_url = base_url.rstrip("/")
self.api_prefix = api_prefix.rstrip("/") self.api_prefix = api_prefix.rstrip("/")
self.timeout = timeout self.timeout = timeout
self.default_limit = default_limit self.default_limit = default_limit
self.max_retries = max_retries
self.retry_backoff = retry_backoff
self.transport = transport self.transport = transport
self.auth = EasybillAuth(api_key, email=email, use_basic_auth=use_basic_auth) self.auth = EasybillAuth(api_key, email=email, use_basic_auth=use_basic_auth)
self.default_headers = { self.default_headers = {
@ -68,7 +74,7 @@ class _BaseEasybillClient:
return cleaned or None return cleaned or None
@staticmethod @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", [])] items = [item_model.model_validate(item) for item in data.get("items", [])]
return PagedResult( return PagedResult(
page=data.get("page", 1), page=data.get("page", 1),
@ -78,6 +84,15 @@ class _BaseEasybillClient:
items=items, 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): class EasybillClient(_BaseEasybillClient):
def __init__(self, api_key: str, **kwargs: Any) -> None: def __init__(self, api_key: str, **kwargs: Any) -> None:
@ -100,7 +115,17 @@ class EasybillClient(_BaseEasybillClient):
self.close() self.close()
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: 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) return self._check_response(response)
def get_json(self, path: str, **kwargs: Any) -> Any: def get_json(self, path: str, **kwargs: Any) -> Any:
@ -125,6 +150,74 @@ class EasybillClient(_BaseEasybillClient):
data = self.get_json("/documents", params=params) data = self.get_json("/documents", params=params)
return self._parse_paged_result(data, Document) 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: def list_webhooks(self, *, page: int = 1, limit: int | None = None) -> PagedResult:
params = self._clean_params({"page": page, "limit": limit or self.default_limit}) params = self._clean_params({"page": page, "limit": limit or self.default_limit})
data = self.get_json("/webhooks", params=params) data = self.get_json("/webhooks", params=params)
@ -173,7 +266,17 @@ class AsyncEasybillClient(_BaseEasybillClient):
await self.aclose() await self.aclose()
async def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: 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) return self._check_response(response)
async def get_json(self, path: str, **kwargs: Any) -> Any: 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) data = await self.get_json("/documents", params=params)
return self._parse_paged_result(data, Document) 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: 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}) params = self._clean_params({"page": page, "limit": limit or self.default_limit})
data = await self.get_json("/webhooks", params=params) data = await self.get_json("/webhooks", params=params)

View file

@ -42,6 +42,25 @@ class Document(EasybillBaseModel):
is_draft: bool | None = None 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): class WebhookSubscription(EasybillBaseModel):
id: int | None = None id: int | None = None
url: str url: str

View file

@ -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