Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 114 additions & 13 deletions brenger/client.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
import logging

import requests
from requests import Response
from requests import RequestException, 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__)

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:
Expand Down Expand Up @@ -39,22 +55,107 @@ 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} - "
f"Error description: {error_description} -"
f" Error hint: {error_hint} - "
f" validation errors: {error_validation}"
)
logger.error("Client Error: %s", error_message)
raise APIClientError(f"Client Error: {error_message}")


V2_BASE_URL = "https://external-api.brenger.nl/v2/partners"


class BrengerV2APIClient:
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"{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")
return V2QuoteResponse(**response.json())
Comment on lines +97 to +99
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to create_shipment, response.json() is called twice here - once for logging (line 82) and once for returning the parsed response. While requests.Response.json() typically caches the result, it's more efficient and clearer to store the parsed JSON in a variable and reuse it.

Copilot uses AI. Check for mistakes.

def create_shipment(
self, shipment_data: V2ShipmentCreateRequest
) -> V2ShipmentCreateResponse:
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(
"V2 Shipment created successfully with ID: %s",
response.json().get("shipment_id"),
)
return V2ShipmentCreateResponse(**response.json())
Comment on lines +111 to +115
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the create_shipment and get_quote methods, response.json() is called twice - once for logging (line 94) and once for returning the parsed response (line 96). This is inefficient and could potentially cause issues if the response body is consumed by the first call. Consider storing the parsed JSON in a variable and reusing it for both logging and return value.

Copilot uses AI. Check for mistakes.

def get_shipment_status(self, shipment_id: str) -> V2StatusResponse:
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"{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"{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 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} - "
f"Error description: {error_description} -"
f" Error hint: {error_hint} - "
f" validation errors: {error_validation}"
Comment on lines +155 to +158
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message formatting has inconsistent spacing. Line 130 has an extra space at the beginning (" Status code:"), line 131 has no space after the hyphen ("description: {error_description} -"), and line 132 has spaces around the hyphen ("- Error hint:"). For consistency with the V1 client error formatting (lines 56-59), all parts should follow the same spacing pattern.

Suggested change
f" Status code: {response.status_code} - "
f"Error description: {error_description} -"
f" Error hint: {error_hint} - "
f" validation errors: {error_validation}"
f"Status code: {response.status_code} - "
f"Error description: {error_description} - "
f"Error hint: {error_hint} - "
f"Validation errors: {error_validation}"

Copilot uses AI. Check for mistakes.
)
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()
148 changes: 148 additions & 0 deletions brenger/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,151 @@ 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"
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Address model is missing strip_whitespace validation on the 'country' and 'administrative_area' fields. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, these fields should be included in the validator along with line1, line2, postal_code, and locality.

Suggested change
"line1", "line2", "postal_code", "locality", mode="before"
"country",
"administrative_area",
"line1",
"line2",
"postal_code",
"locality",
mode="before",

Copilot uses AI. Check for mistakes.
)(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"
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Stop model is missing strip_whitespace validation on the 'phone_number' and 'instructions' fields. The V1 Contact model applies strip_whitespace to the 'phone' field, and the codebase convention indicates this is necessary to prevent API failures. For consistency with V1 models and to prevent potential API issues, these fields should also have strip_whitespace validation applied.

Suggested change
"email", "first_name", "last_name", "company_name", mode="before"
"email",
"phone_number",
"instructions",
"first_name",
"last_name",
"company_name",
mode="before",

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Stop model is missing strip_whitespace validation on the 'preferred_locale' field. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, this field should be included in the validator.

Suggested change
"email", "first_name", "last_name", "company_name", mode="before"
"email",
"first_name",
"last_name",
"company_name",
"preferred_locale",
mode="before",

Copilot uses AI. Check for mistakes.
)(strip_whitespace)


Comment on lines +183 to +184
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Stop model is missing the name length validation that exists in the V1 Contact model. The V1 Contact model includes check_name_length validation that pads names shorter than 3 characters with dots to meet Brenger API requirements. The V2Stop model should include similar validation for first_name and last_name fields to prevent API validation errors, unless the V2 API has different requirements.

Suggested change
@field_validator("first_name", "last_name", mode="before")
@classmethod
def check_name_length(cls, value: Optional[str]) -> Optional[str]:
"""
Brenger API has validation for first name and last name to be min 3 characters.
Validation makes sure that if the length of the name is less than 3, it is padded with dots.
"""
if value is None:
return value
if len(value) < 3:
value += "." * (4 - len(value))
return value

Copilot uses AI. Check for mistakes.
class V2Item(BaseModel):
title: str
category: str
images: Optional[List[str]] = None
width: int
height: int
length: int
count: int

_strip_whitespace = field_validator("title", "category", mode="before")(
strip_whitespace
)


Comment on lines +197 to +198
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Item model is missing the strip_whitespace validation on the 'images' field. While 'images' is a list of strings, the individual strings in the list could contain trailing whitespace. For consistency with V1 models and to prevent API failures (as noted in the strip_whitespace function comment), consider adding validation to strip whitespace from individual image URLs.

Suggested change
@field_validator("images", mode="before")
@classmethod
def strip_images(cls, value: Optional[List[str]]) -> Optional[List[str]]:
if value is None:
return value
return [v.rstrip() if isinstance(v, str) else v for v in value]

Copilot uses AI. Check for mistakes.
class V2Amount(BaseModel):
currency: str
value: str


Comment on lines +202 to +203
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Amount model is missing strip_whitespace validation on string fields 'currency' and 'value'. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, these fields should have the validator applied.

Suggested change
_strip_whitespace = field_validator("currency", "value", mode="before")(
strip_whitespace
)

Copilot uses AI. Check for mistakes.
class V2Price(BaseModel):
vat: V2Amount
incl_vat: V2Amount
excl_vat: V2Amount


class V2Feasible(BaseModel):
value: bool
reasons: List[str]


class V2AddressWrapper(BaseModel):
address: V2Address


class V2QuoteRequest(BaseModel):
pickup: V2AddressWrapper
delivery: V2AddressWrapper
external_reference: Optional[str] = None
items: List[V2Item]


Comment on lines +224 to +225
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2QuoteRequest model is missing strip_whitespace validation on the 'external_reference' field. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, this field should have the validator applied.

Suggested change
_strip_whitespace = field_validator("external_reference", mode="before")(
strip_whitespace
)

Copilot uses AI. Check for mistakes.
class V2QuoteResponse(BaseModel):
price: V2Price
feasible: V2Feasible
pickup: V2AddressWrapper
delivery: V2AddressWrapper
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: V2Price


Comment on lines +246 to +247
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2ShipmentCreateRequest model is missing strip_whitespace validation on string fields 'external_reference', 'external_listing_url', and 'external_private_url'. Following the codebase convention that all string fields should have strip_whitespace validation to prevent Brenger API failures, these fields should have the validator applied.

Suggested change
_strip_whitespace = field_validator(
"external_reference",
"external_listing_url",
"external_private_url",
mode="before",
)(strip_whitespace)

Copilot uses AI. Check for mistakes.
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
Comment on lines +265 to +268
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The V2Event and V2WebhookPayload models use string types for the 'timestamp' field. This is less type-safe and makes it harder to work with dates/times programmatically. Consider using datetime type with proper validators (similar to the shipping_date validation in ShipmentCreateRequest) to ensure timestamp parsing and validation, unless the API specifically requires string format in a non-standard way.

Copilot uses AI. Check for mistakes.


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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "brenger"
version = "0.1.8"
version = "2.0.0"
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The version bump from 0.1.8 to 2.0.0 appears to be too aggressive. According to semantic versioning, a major version increment (0.x.x to 2.0.0) should only occur when there are backward-incompatible changes. Since the PR description explicitly states "Keep v1 client and models intact for backward compatibility", this suggests the changes are backward compatible. A more appropriate version would be 0.2.0 or 1.0.0 (if graduating from beta), not 2.0.0.

Suggested change
version = "2.0.0"
version = "0.2.0"

Copilot uses AI. Check for mistakes.
description = ""
authors = ["Omer <omer@whoppah.com>"]
readme = "README.md"
Expand Down