From 21ebe2b0b0026650d73e72f5ecdb5207b587e878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer?= Date: Sun, 8 Feb 2026 22:18:17 +0100 Subject: [PATCH 1/3] Add Brenger API v2 client and models - Add 13 v2 Pydantic models (V2Address, V2Stop, V2Item, V2Price, etc.) - Add BrengerV2APIClient with endpoints: quote, create_shipment, get_shipment_status, cancel_shipment, get_refund - Use exclude_none=True in JSON serialization to avoid sending null for optional fields - V2Item.category defaults to "other" (required by API) - Bump version to 2.0.0 Co-Authored-By: Claude Opus 4.6 --- brenger/client.py | 79 ++++++++++++++++++++++++- brenger/models.py | 144 ++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 223 insertions(+), 2 deletions(-) diff --git a/brenger/client.py b/brenger/client.py index e65d4e0..d58ee6d 100644 --- a/brenger/client.py +++ b/brenger/client.py @@ -4,7 +4,10 @@ from requests import Response from .exceptions import APIClientError, APIServerError -from .models import ShipmentCreateRequest, ShipmentResponse +from .models import (ShipmentCreateRequest, ShipmentResponse, V2QuoteRequest, + V2QuoteResponse, V2RefundResponse, + V2ShipmentCreateRequest, V2ShipmentCreateResponse, + V2StatusResponse) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -58,3 +61,77 @@ def _handle_response_errors(self, response: Response) -> None: logger.error(f"Client Error: {error_message}") raise APIClientError(f"Client Error: {error_message}") response.raise_for_status() + + +V2_BASE_URL = "https://external-api.brenger.nl/v2/partners" + + +class BrengerV2APIClient: + def __init__(self, api_key: str) -> None: + self.api_key = api_key + self.session = requests.Session() + self.session.headers.update({"X-AUTH-TOKEN": self.api_key, **HEADERS}) + + def get_quote(self, quote_data: V2QuoteRequest) -> V2QuoteResponse: + url = f"{V2_BASE_URL}/quote" + response = self.session.post( + url, data=quote_data.model_dump_json(exclude_none=True).encode("utf-8") + ) + self._handle_response_errors(response) + logger.info("Quote retrieved successfully") + return V2QuoteResponse(**response.json()) + + def create_shipment( + self, shipment_data: V2ShipmentCreateRequest + ) -> V2ShipmentCreateResponse: + url = f"{V2_BASE_URL}/shipments" + response = self.session.post( + url, data=shipment_data.model_dump_json(exclude_none=True).encode("utf-8") + ) + self._handle_response_errors(response) + logger.info( + "V2 Shipment created successfully with ID: %s", + response.json().get("shipment_id"), + ) + return V2ShipmentCreateResponse(**response.json()) + + def get_shipment_status(self, shipment_id: str) -> V2StatusResponse: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/status" + response = self.session.get(url) + self._handle_response_errors(response) + logger.info("Shipment status retrieved for ID: %s", shipment_id) + return V2StatusResponse(**response.json()) + + def cancel_shipment(self, shipment_id: str) -> None: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/cancel" + response = self.session.post(url) + self._handle_response_errors(response) + logger.info("Shipment cancelled successfully for ID: %s", shipment_id) + + def get_refund(self, shipment_id: str) -> V2RefundResponse: + url = f"{V2_BASE_URL}/shipments/{shipment_id}/refunds" + response = self.session.get(url) + self._handle_response_errors(response) + logger.info("Refund retrieved for shipment ID: %s", shipment_id) + return V2RefundResponse(**response.json()) + + def _handle_response_errors(self, response: Response) -> None: + if response.status_code == 500: + logger.error("Server Error") + raise APIServerError("Internal Server Error") + elif response.status_code >= 400: + response_json = response.json() + error_description = response_json.get("description", "An error occurred") + error_hint = response_json.get("hint", "Hint not provided") + error_validation = response_json.get( + "validation_errors", "Validation not provided" + ) + error_message = ( + f" Status code: {response.status_code} - " + f"Error description: {error_description} -" + f" Error hint: {error_hint} - " + f" validation errors: {error_validation}" + ) + logger.error(f"Client Error: {error_message}") + raise APIClientError(f"Client Error: {error_message}") + response.raise_for_status() diff --git a/brenger/models.py b/brenger/models.py index 36a664e..2c49639 100644 --- a/brenger/models.py +++ b/brenger/models.py @@ -145,3 +145,147 @@ class ShipmentResponse(BaseModel): class Config: extra = "ignore" + + +# ============================================================================ +# V2 API Models +# ============================================================================ + + +class V2Address(BaseModel): + country: str + administrative_area: Optional[str] = None + locality: str + postal_code: str + line1: str + line2: Optional[str] = None + lat: Optional[float] = None + lng: Optional[float] = None + + _strip_whitespace = field_validator( + "line1", "line2", "postal_code", "locality", mode="before" + )(strip_whitespace) + + +class V2Stop(BaseModel): + email: str + phone_number: Optional[str] = None + instructions: Optional[str] = None + first_name: str + last_name: Optional[str] = None + company_name: Optional[str] = None + preferred_locale: Optional[str] = None + address: V2Address + + _strip_whitespace = field_validator( + "email", "first_name", "last_name", "company_name", mode="before" + )(strip_whitespace) + + +class V2Item(BaseModel): + title: str + category: str = "other" + images: Optional[List[str]] = None + width: int + height: int + length: int + count: int + + _strip_whitespace = field_validator("title", "category", mode="before")( + strip_whitespace + ) + + +class V2Amount(BaseModel): + currency: str + value: str + + +class V2Price(BaseModel): + vat: V2Amount + incl_vat: V2Amount + excl_vat: V2Amount + + +class V2Feasible(BaseModel): + value: bool + reasons: List[str] + + +class V2QuoteRequest(BaseModel): + pickup: Dict + delivery: Dict + external_reference: Optional[str] = None + items: List[V2Item] + + +class V2QuoteResponse(BaseModel): + price: V2Price + feasible: V2Feasible + pickup: Dict + delivery: Dict + external_reference: Optional[str] = None + items: List[V2Item] + + class Config: + extra = "ignore" + + +class V2ShipmentCreateRequest(BaseModel): + pickup: V2Stop + delivery: V2Stop + external_reference: str + external_listing_url: Optional[str] = None + external_private_url: Optional[str] = None + items: List[V2Item] + price: Optional[V2Price] = None + + +class V2ShipmentCreateResponse(BaseModel): + shipment_id: str + pickup: V2Stop + delivery: V2Stop + pickup_url: str + delivery_url: str + shipment_url: str + external_reference: str + external_listing_url: Optional[str] = None + external_private_url: Optional[str] = None + items: List[V2Item] + price: V2Price + + class Config: + extra = "ignore" + + +class V2Event(BaseModel): + id: str + timestamp: str + status: str + + +class V2StatusResponse(BaseModel): + shipment_id: str + external_reference: str + status: str + events: List[V2Event] + + class Config: + extra = "ignore" + + +class V2RefundResponse(BaseModel): + refund_id: str + amount: V2Price + code: str + + class Config: + extra = "ignore" + + +class V2WebhookPayload(BaseModel): + event_id: str + shipment_id: str + external_reference: str + timestamp: str + status: str diff --git a/pyproject.toml b/pyproject.toml index d265afb..e62eebf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "brenger" -version = "0.1.8" +version = "2.0.0" description = "" authors = ["Omer "] readme = "README.md" From 28aea56c2a816cce908b8e914f810b1596da9275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer?= Date: Sun, 15 Feb 2026 13:54:05 +0100 Subject: [PATCH 2/3] =?UTF-8?q?Make=20V2Address=20lat/lng=20optional=20?= =?UTF-8?q?=E2=80=94=20not=20required=20by=20Brenger=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- brenger/models.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/brenger/models.py b/brenger/models.py index 2c49639..7b1dd3e 100644 --- a/brenger/models.py +++ b/brenger/models.py @@ -184,7 +184,7 @@ class V2Stop(BaseModel): class V2Item(BaseModel): title: str - category: str = "other" + category: str images: Optional[List[str]] = None width: int height: int @@ -212,9 +212,13 @@ class V2Feasible(BaseModel): reasons: List[str] +class V2AddressWrapper(BaseModel): + address: V2Address + + class V2QuoteRequest(BaseModel): - pickup: Dict - delivery: Dict + pickup: V2AddressWrapper + delivery: V2AddressWrapper external_reference: Optional[str] = None items: List[V2Item] @@ -222,8 +226,8 @@ class V2QuoteRequest(BaseModel): class V2QuoteResponse(BaseModel): price: V2Price feasible: V2Feasible - pickup: Dict - delivery: Dict + pickup: V2AddressWrapper + delivery: V2AddressWrapper external_reference: Optional[str] = None items: List[V2Item] @@ -238,7 +242,7 @@ class V2ShipmentCreateRequest(BaseModel): external_listing_url: Optional[str] = None external_private_url: Optional[str] = None items: List[V2Item] - price: Optional[V2Price] = None + price: V2Price class V2ShipmentCreateResponse(BaseModel): From b48aae8048ca71fae1beded44a8090bd7d14cc62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96mer?= Date: Tue, 17 Feb 2026 10:47:03 +0100 Subject: [PATCH 3/3] Add timeout, base_url config, request wrapper, and error handling improvements - Configurable timeout (default 30s) and base_url for sandbox/production - _request() wrapper catches network errors and raises APIServerError - _extract_error_details() handles non-JSON response bodies - 5xx range check instead of just status == 500 - Same error handling improvements applied to v1 client Co-Authored-By: Claude Opus 4.6 --- brenger/client.py | 96 +++++++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 36 deletions(-) diff --git a/brenger/client.py b/brenger/client.py index d58ee6d..f6ba785 100644 --- a/brenger/client.py +++ b/brenger/client.py @@ -1,7 +1,7 @@ import logging import requests -from requests import Response +from requests import RequestException, Response from .exceptions import APIClientError, APIServerError from .models import (ShipmentCreateRequest, ShipmentResponse, V2QuoteRequest, @@ -14,6 +14,19 @@ BASE_URL = "https://external-api.brenger.nl/{namespace}" HEADERS = {"Accept": "application/json", "Content-Type": "application/json"} +DEFAULT_TIMEOUT = 30 + + +def _extract_error_details(response: Response) -> tuple[str, str, str]: + try: + response_json = response.json() + except ValueError: + return ("An error occurred", "Hint not provided", "Validation not provided") + + error_description = response_json.get("description", "An error occurred") + error_hint = response_json.get("hint", "Hint not provided") + error_validation = response_json.get("validation_errors", "Validation not provided") + return (error_description, error_hint, error_validation) class BrengerAPIClient: @@ -42,15 +55,14 @@ def get_shipment(self, shipment_id: str) -> ShipmentResponse: return ShipmentResponse(**response.json()) def _handle_response_errors(self, response: Response) -> None: - if response.status_code == 500: - logger.error("Server Error") - raise APIServerError("Internal Server Error") - elif response.status_code >= 400: - response_json = response.json() - error_description = response_json.get("description", "An error occurred") - error_hint = response_json.get("hint", "Hint not provided") - error_validation = response_json.get( - "validation_errors", "Validation not provided" + if 500 <= response.status_code < 600: + logger.error("Server Error: status code %s", response.status_code) + raise APIServerError( + f"Brenger API server error (status code: {response.status_code})" + ) + if response.status_code >= 400: + error_description, error_hint, error_validation = _extract_error_details( + response ) error_message = ( f" Status code: {response.status_code} - " @@ -58,24 +70,29 @@ def _handle_response_errors(self, response: Response) -> None: f" Error hint: {error_hint} - " f" validation errors: {error_validation}" ) - logger.error(f"Client Error: {error_message}") + logger.error("Client Error: %s", error_message) raise APIClientError(f"Client Error: {error_message}") - response.raise_for_status() V2_BASE_URL = "https://external-api.brenger.nl/v2/partners" class BrengerV2APIClient: - def __init__(self, api_key: str) -> None: + def __init__( + self, api_key: str, timeout: int = DEFAULT_TIMEOUT, base_url: str = None + ) -> None: self.api_key = api_key + self.timeout = timeout + self.base_url = base_url or V2_BASE_URL self.session = requests.Session() self.session.headers.update({"X-AUTH-TOKEN": self.api_key, **HEADERS}) def get_quote(self, quote_data: V2QuoteRequest) -> V2QuoteResponse: - url = f"{V2_BASE_URL}/quote" - response = self.session.post( - url, data=quote_data.model_dump_json(exclude_none=True).encode("utf-8") + url = f"{self.base_url}/quote" + response = self._request( + "post", + url, + data=quote_data.model_dump_json(exclude_none=True).encode("utf-8"), ) self._handle_response_errors(response) logger.info("Quote retrieved successfully") @@ -84,9 +101,11 @@ def get_quote(self, quote_data: V2QuoteRequest) -> V2QuoteResponse: def create_shipment( self, shipment_data: V2ShipmentCreateRequest ) -> V2ShipmentCreateResponse: - url = f"{V2_BASE_URL}/shipments" - response = self.session.post( - url, data=shipment_data.model_dump_json(exclude_none=True).encode("utf-8") + url = f"{self.base_url}/shipments" + response = self._request( + "post", + url, + data=shipment_data.model_dump_json(exclude_none=True).encode("utf-8"), ) self._handle_response_errors(response) logger.info( @@ -96,35 +115,41 @@ def create_shipment( return V2ShipmentCreateResponse(**response.json()) def get_shipment_status(self, shipment_id: str) -> V2StatusResponse: - url = f"{V2_BASE_URL}/shipments/{shipment_id}/status" - response = self.session.get(url) + url = f"{self.base_url}/shipments/{shipment_id}/status" + response = self._request("get", url) self._handle_response_errors(response) logger.info("Shipment status retrieved for ID: %s", shipment_id) return V2StatusResponse(**response.json()) def cancel_shipment(self, shipment_id: str) -> None: - url = f"{V2_BASE_URL}/shipments/{shipment_id}/cancel" - response = self.session.post(url) + url = f"{self.base_url}/shipments/{shipment_id}/cancel" + response = self._request("post", url) self._handle_response_errors(response) logger.info("Shipment cancelled successfully for ID: %s", shipment_id) def get_refund(self, shipment_id: str) -> V2RefundResponse: - url = f"{V2_BASE_URL}/shipments/{shipment_id}/refunds" - response = self.session.get(url) + url = f"{self.base_url}/shipments/{shipment_id}/refunds" + response = self._request("get", url) self._handle_response_errors(response) logger.info("Refund retrieved for shipment ID: %s", shipment_id) return V2RefundResponse(**response.json()) + def _request(self, method: str, url: str, **kwargs) -> Response: + try: + return self.session.request(method, url, timeout=self.timeout, **kwargs) + except RequestException as exc: + logger.error("Network error while calling Brenger v2 API", exc_info=True) + raise APIServerError(f"Failed to call Brenger v2 API: {exc}") from exc + def _handle_response_errors(self, response: Response) -> None: - if response.status_code == 500: - logger.error("Server Error") - raise APIServerError("Internal Server Error") - elif response.status_code >= 400: - response_json = response.json() - error_description = response_json.get("description", "An error occurred") - error_hint = response_json.get("hint", "Hint not provided") - error_validation = response_json.get( - "validation_errors", "Validation not provided" + if 500 <= response.status_code < 600: + logger.error("Server Error: status code %s", response.status_code) + raise APIServerError( + f"Brenger API server error (status code: {response.status_code})" + ) + if response.status_code >= 400: + error_description, error_hint, error_validation = _extract_error_details( + response ) error_message = ( f" Status code: {response.status_code} - " @@ -132,6 +157,5 @@ def _handle_response_errors(self, response: Response) -> None: f" Error hint: {error_hint} - " f" validation errors: {error_validation}" ) - logger.error(f"Client Error: {error_message}") + logger.error("Client Error: %s", error_message) raise APIClientError(f"Client Error: {error_message}") - response.raise_for_status()