Skip to content

Commit 8ba3265

Browse files
authored
Minor/v0.4.0 more entities (#19)
* refactor: implement Synchronizable mixin and restructure model hierarchy to improve extensibility and API coverage * refactor: implement idempotent-aware retries, add raw download support, and improve model update and synchronization logic
1 parent 6f84c5e commit 8ba3265

20 files changed

Lines changed: 1374 additions & 246 deletions

moneysnake/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .client import make_request as make_request
44
from .client import paginate as paginate
55
from .client import set_admin_id as set_admin_id
6+
from .client import set_max_retries as set_max_retries
67
from .client import set_timeout as set_timeout
78
from .client import set_token as set_token
89
from .contact import Contact as Contact
@@ -23,7 +24,12 @@
2324
from .model import Loadable as Loadable
2425
from .model import MoneybirdModel as MoneybirdModel
2526
from .model import Saveable as Saveable
27+
from .model import Synchronizable as Synchronizable
2628
from .payment import Payment as Payment
29+
from .sales_invoice import SalesInvoice as SalesInvoice
30+
from .sales_invoice import (
31+
SalesInvoiceDetailsAttribute as SalesInvoiceDetailsAttribute,
32+
)
2733
from .tax_rate import TaxRate as TaxRate
2834

2935
__all__ = [
@@ -32,6 +38,7 @@
3238
"make_request",
3339
"paginate",
3440
"set_admin_id",
41+
"set_max_retries",
3542
"set_timeout",
3643
"set_token",
3744
"Contact",
@@ -50,6 +57,9 @@
5057
"MoneybirdRateLimitError",
5158
"MoneybirdValidationError",
5259
"Payment",
60+
"SalesInvoice",
61+
"SalesInvoiceDetailsAttribute",
5362
"Saveable",
63+
"Synchronizable",
5464
"TaxRate",
5565
]

moneysnake/client.py

Lines changed: 105 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import logging
2+
import time
23
from typing import Any
34

45
import httpx
6+
from httpx import Response
57

68
from .exceptions import (
79
MoneybirdAPIError,
@@ -19,6 +21,7 @@
1921
admin_id_ = 0
2022
token_ = ""
2123
timeout_ = 20
24+
max_retries_ = 3
2225

2326

2427
def set_admin_id(admin_id: int) -> None:
@@ -36,6 +39,22 @@ def set_timeout(timeout: int) -> None:
3639
timeout_ = timeout
3740

3841

42+
def set_max_retries(max_retries: int) -> None:
43+
global max_retries_
44+
max_retries_ = max_retries
45+
46+
47+
_IDEMPOTENT_METHODS = frozenset({"get", "put", "delete", "head", "options"})
48+
49+
50+
def _is_retryable(status_code: int, method: str) -> bool:
51+
if status_code == 429:
52+
return True
53+
if status_code >= 500 and method.lower() in _IDEMPOTENT_METHODS:
54+
return True
55+
return False
56+
57+
3958
def make_request(
4059
path: str,
4160
data: dict[str, Any] | None = None,
@@ -47,49 +66,101 @@ def make_request(
4766
"Content-Type": "application/json",
4867
}
4968
fullpath = f"{MB_URL}/{MB_VERSION_ID}/{admin_id_}/{path}"
50-
logger.debug("%s %s", method.upper(), fullpath)
51-
response = httpx.request(
52-
method,
53-
fullpath,
54-
json=data,
55-
headers=headers,
56-
timeout=timeout_,
57-
params=params,
58-
)
59-
60-
try:
61-
response.raise_for_status()
62-
except httpx.HTTPStatusError as exc:
63-
body = response.text
64-
logger.warning(
65-
"%s %s returned %d: %s",
66-
method.upper(),
69+
70+
last_exc: httpx.HTTPStatusError | None = None
71+
for attempt in range(max_retries_ + 1):
72+
logger.debug("%s %s (attempt %d)", method.upper(), fullpath, attempt + 1)
73+
response = httpx.request(
74+
method,
6775
fullpath,
68-
response.status_code,
69-
body,
76+
json=data,
77+
headers=headers,
78+
timeout=timeout_,
79+
params=params,
7080
)
71-
error_cls: type[MoneybirdAPIError] = {
72-
404: MoneybirdNotFoundError,
73-
422: MoneybirdValidationError,
74-
429: MoneybirdRateLimitError,
75-
}.get(response.status_code, MoneybirdAPIError)
76-
raise error_cls(
77-
status_code=response.status_code,
78-
response_body=body,
79-
method=method,
80-
path=path,
81-
) from exc
8281

83-
logger.debug("%s %s returned %d", method.upper(), fullpath, response.status_code)
84-
85-
# return json if there is content
86-
return response.json() if response.content else {}
82+
try:
83+
response.raise_for_status()
84+
except httpx.HTTPStatusError as exc:
85+
last_exc = exc
86+
if _is_retryable(response.status_code, method) and attempt < max_retries_:
87+
retry_after = response.headers.get("Retry-After")
88+
if retry_after:
89+
try:
90+
delay = int(retry_after)
91+
except ValueError:
92+
delay = 2 ** attempt
93+
delay = max(delay, 1)
94+
else:
95+
delay = 2 ** attempt
96+
logger.warning(
97+
"%s %s returned %d, retrying in %ds (attempt %d/%d)",
98+
method.upper(),
99+
fullpath,
100+
response.status_code,
101+
delay,
102+
attempt + 1,
103+
max_retries_ + 1,
104+
)
105+
time.sleep(delay)
106+
continue
107+
108+
body = response.text
109+
logger.warning(
110+
"%s %s returned %d: %s",
111+
method.upper(),
112+
fullpath,
113+
response.status_code,
114+
body,
115+
)
116+
error_cls: type[MoneybirdAPIError] = {
117+
404: MoneybirdNotFoundError,
118+
422: MoneybirdValidationError,
119+
429: MoneybirdRateLimitError,
120+
}.get(response.status_code, MoneybirdAPIError)
121+
raise error_cls(
122+
status_code=response.status_code,
123+
response_body=body,
124+
method=method,
125+
path=path,
126+
) from exc
127+
128+
logger.debug("%s %s returned %d", method.upper(), fullpath, response.status_code)
129+
return response.json() if response.content else {}
130+
131+
# All retries exhausted — raise from last exception
132+
assert last_exc is not None
133+
body = last_exc.response.text
134+
status_code = last_exc.response.status_code
135+
error_cls = {
136+
404: MoneybirdNotFoundError,
137+
422: MoneybirdValidationError,
138+
429: MoneybirdRateLimitError,
139+
}.get(status_code, MoneybirdAPIError)
140+
raise error_cls(
141+
status_code=status_code,
142+
response_body=body,
143+
method=method,
144+
path=path,
145+
) from last_exc
87146

88147

89148
def http_get(path: str, params: dict[str, Any] | None = None) -> Any:
90149
return make_request(path, method="get", params=params)
91150

92151

152+
def http_get_raw(path: str) -> Response:
153+
"""Perform a GET and return the raw httpx Response (for binary downloads)."""
154+
headers = {
155+
"Authorization": f"Bearer {token_}",
156+
}
157+
fullpath = f"{MB_URL}/{MB_VERSION_ID}/{admin_id_}/{path}"
158+
logger.debug("GET %s (raw)", fullpath)
159+
response = httpx.get(fullpath, headers=headers, timeout=timeout_)
160+
response.raise_for_status()
161+
return response
162+
163+
93164
def http_post(path: str, data: dict[str, Any] | None = None) -> Any:
94165
return make_request(path, method="post", data=data)
95166

moneysnake/contact.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,15 @@
44

55
from .client import http_get
66
from .custom_field_model import CustomFieldModel
7-
from .model import ensure_list_of
7+
from .model import Synchronizable, ensure_list_of
88

99

1010
class ContactPerson(BaseModel):
1111
firstname: str | None = None
1212
lastname: str | None = None
1313

1414

15-
class Contact(CustomFieldModel):
15+
class Contact(Synchronizable, CustomFieldModel):
1616
company_name: str | None = None
1717
address1: str | None = None
1818
address2: str | None = None

moneysnake/external_sales_invoice.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
from typing import Any
1+
from typing import Any, Self
22

33
from pydantic import BaseModel, Field, field_validator
44

55
from .client import http_delete, http_patch, http_post, paginate
6-
from .model import CrudModel, ensure_list_of
6+
from .model import CrudModel, Synchronizable, ensure_list_of
77
from .payment import Payment
88

99

@@ -22,13 +22,13 @@ class ExternalSalesInvoiceDetailsAttribute(BaseModel):
2222
project_id: str | None = None
2323

2424
def update(self, data: dict[str, Any]) -> None:
25-
for key, value in data.items():
26-
if hasattr(self, key):
27-
setattr(self, key, value)
25+
validated = self.model_validate({**self.model_dump(), **data})
26+
for key in self.__class__.model_fields:
27+
object.__setattr__(self, key, getattr(validated, key))
2828

2929

3030

31-
class ExternalSalesInvoice(CrudModel):
31+
class ExternalSalesInvoice(Synchronizable, CrudModel):
3232
"""
3333
Represents an external sales invoice in Moneybird.
3434
"""
@@ -115,20 +115,22 @@ def delete_detail(self, detail_id: int) -> None:
115115
if self.details:
116116
self.details = [detail for detail in self.details if detail.id != detail_id]
117117

118+
@classmethod
118119
def list_all_by_contact_id(
119-
self,
120+
cls,
120121
contact_id: int,
121122
state: str | None = "all",
122123
period: str | None = "this_year",
123-
) -> list["ExternalSalesInvoice"]:
124+
) -> list[Self]:
124125
"""
125126
List all external sales invoices for a contact.
126127
"""
128+
endpoint = cls._sync_endpoint()
127129
data = paginate(
128-
f"{self.endpoint}s",
130+
f"{endpoint}s",
129131
params={"filter": f"contact_id:{contact_id},state:{state},period:{period}"},
130132
)
131-
return [ExternalSalesInvoice(**item) for item in data]
133+
return [cls(**item) for item in data]
132134

133135
def create_payment(self, payment: Payment) -> None:
134136
"""

moneysnake/financial_mutation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import Field
77

88
from .client import http_delete, http_patch, paginate
9-
from .model import Loadable, MoneybirdModel
9+
from .model import Loadable, MoneybirdModel, Synchronizable
1010

1111

1212
class LinkBookingType(Enum):
@@ -37,7 +37,7 @@ class UnlinkBookingType(Enum):
3737
Payment = auto()
3838

3939

40-
class FinancialMutation(Loadable, MoneybirdModel):
40+
class FinancialMutation(Synchronizable, Loadable, MoneybirdModel):
4141
"""
4242
Represents a financial mutation in Moneybird.
4343
"""

moneysnake/model.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from pydantic import BaseModel, ConfigDict
44

5-
from .client import http_delete, http_get, http_patch, http_post
5+
from .client import http_delete, http_get, http_patch, http_post, paginate
66

77
T = TypeVar("T", bound=BaseModel)
88

@@ -96,5 +96,47 @@ def delete_by_id(cls: type[Self], id: int) -> Self:
9696
return entity
9797

9898

99+
class Synchronizable(MoneybirdModel):
100+
"""Mixin that adds synchronization endpoints for efficient bulk data access."""
101+
102+
@classmethod
103+
def sync_list(cls: type[Self], filter: str | None = None) -> list[dict[str, Any]]:
104+
"""Get all IDs and versions for this resource.
105+
106+
Returns a list of dicts with 'id' and 'version' keys, useful for
107+
checking which records have changed since last sync.
108+
"""
109+
params: dict[str, str] = {}
110+
if filter:
111+
params["filter"] = filter
112+
return paginate(f"{cls._sync_endpoint()}s/synchronization", params=params or None)
113+
114+
@classmethod
115+
def sync_fetch(cls: type[Self], ids: list[int]) -> list[Self]:
116+
"""Fetch full records by IDs (max 100 per request).
117+
118+
Use sync_list() first to get IDs, then fetch changed records in bulk.
119+
"""
120+
if len(ids) > 100:
121+
raise ValueError("sync_fetch supports a maximum of 100 IDs per request")
122+
data = http_post(
123+
f"{cls._sync_endpoint()}s/synchronization",
124+
data={"ids": ids},
125+
)
126+
if not isinstance(data, list):
127+
return []
128+
return [cls(**item) for item in data]
129+
130+
@classmethod
131+
def _sync_endpoint(cls) -> str:
132+
"""Derive the endpoint name for synchronization."""
133+
pattr = cls.__private_attributes__.get("_endpoint")
134+
if pattr is not None and pattr.default is not None:
135+
return pattr.default
136+
return "".join(
137+
"_" + c.lower() if c.isupper() else c for c in cls.__name__
138+
).lstrip("_")
139+
140+
99141
class CrudModel(Loadable, Saveable, Deletable, MoneybirdModel):
100142
"""Full CRUD model with load, save, and delete capabilities."""

0 commit comments

Comments
 (0)