useyona-einvoice 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- useyona/einvoice/__init__.py +97 -0
- useyona/einvoice/_base.py +82 -0
- useyona/einvoice/_transport.py +399 -0
- useyona/einvoice/_version.py +3 -0
- useyona/einvoice/client.py +152 -0
- useyona/einvoice/errors.py +185 -0
- useyona/einvoice/excluded_operations.py +30 -0
- useyona/einvoice/py.typed +0 -0
- useyona/einvoice/services/__init__.py +52 -0
- useyona/einvoice/services/billing.py +136 -0
- useyona/einvoice/services/catalogue.py +86 -0
- useyona/einvoice/services/invoices.py +126 -0
- useyona/einvoice/services/organization.py +36 -0
- useyona/einvoice/services/output.py +44 -0
- useyona/einvoice/services/parties.py +89 -0
- useyona/einvoice/services/received.py +61 -0
- useyona/einvoice/services/webhooks.py +81 -0
- useyona/einvoice/types/__init__.py +130 -0
- useyona/einvoice/types/generated.py +3825 -0
- useyona/einvoice/types/webhook.py +174 -0
- useyona/einvoice/webhooks.py +170 -0
- useyona_einvoice-0.1.0.dist-info/METADATA +352 -0
- useyona_einvoice-0.1.0.dist-info/RECORD +25 -0
- useyona_einvoice-0.1.0.dist-info/WHEEL +4 -0
- useyona_einvoice-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Official Python SDK for the Yona e-invoicing API: what an API key may call.
|
|
2
|
+
|
|
3
|
+
from useyona.einvoice import EInvoice
|
|
4
|
+
|
|
5
|
+
client = EInvoice(api_key=os.environ["YONA_API_KEY"])
|
|
6
|
+
invoice = client.invoices.create({...})
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import types
|
|
10
|
+
from ._transport import (
|
|
11
|
+
API_KEY_PATTERN,
|
|
12
|
+
DEFAULT_BASE_URL,
|
|
13
|
+
IDEMPOTENCY_KEY_PATTERN,
|
|
14
|
+
HttpClient,
|
|
15
|
+
RawResponse,
|
|
16
|
+
RetryConfig,
|
|
17
|
+
generate_idempotency_key,
|
|
18
|
+
mode_of_api_key,
|
|
19
|
+
parse_retry_after,
|
|
20
|
+
)
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
from .client import EInvoice, paginate
|
|
23
|
+
from .errors import (
|
|
24
|
+
ApiErrorDetail,
|
|
25
|
+
EInvoiceApiError,
|
|
26
|
+
EInvoiceAuthenticationError,
|
|
27
|
+
EInvoiceConfigError,
|
|
28
|
+
EInvoiceConflictError,
|
|
29
|
+
EInvoiceConnectionError,
|
|
30
|
+
EInvoiceError,
|
|
31
|
+
EInvoiceInsufficientCreditsError,
|
|
32
|
+
EInvoiceNotFoundError,
|
|
33
|
+
EInvoicePermissionError,
|
|
34
|
+
EInvoiceRateLimitError,
|
|
35
|
+
EInvoiceServerError,
|
|
36
|
+
EInvoiceTimeoutError,
|
|
37
|
+
EInvoiceValidationError,
|
|
38
|
+
EInvoiceWebhookError,
|
|
39
|
+
)
|
|
40
|
+
from .excluded_operations import EXCLUDED_OPERATIONS, ExcludedOperation
|
|
41
|
+
from .types import ApiMode, BinaryResponse, Page, Paginated, PaginationMeta, RequestOptions, WebhookEvent
|
|
42
|
+
from .webhooks import (
|
|
43
|
+
DEFAULT_TOLERANCE_SECONDS,
|
|
44
|
+
WEBHOOK_SIGNATURE_HEADER,
|
|
45
|
+
SignatureHeader,
|
|
46
|
+
compute_webhook_signature,
|
|
47
|
+
parse_signature_header,
|
|
48
|
+
sign_webhook_payload,
|
|
49
|
+
verify_webhook,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"API_KEY_PATTERN",
|
|
54
|
+
"DEFAULT_BASE_URL",
|
|
55
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
56
|
+
"EXCLUDED_OPERATIONS",
|
|
57
|
+
"IDEMPOTENCY_KEY_PATTERN",
|
|
58
|
+
"WEBHOOK_SIGNATURE_HEADER",
|
|
59
|
+
"ApiErrorDetail",
|
|
60
|
+
"ApiMode",
|
|
61
|
+
"BinaryResponse",
|
|
62
|
+
"EInvoice",
|
|
63
|
+
"EInvoiceApiError",
|
|
64
|
+
"EInvoiceAuthenticationError",
|
|
65
|
+
"EInvoiceConfigError",
|
|
66
|
+
"EInvoiceConflictError",
|
|
67
|
+
"EInvoiceConnectionError",
|
|
68
|
+
"EInvoiceError",
|
|
69
|
+
"EInvoiceInsufficientCreditsError",
|
|
70
|
+
"EInvoiceNotFoundError",
|
|
71
|
+
"EInvoicePermissionError",
|
|
72
|
+
"EInvoiceRateLimitError",
|
|
73
|
+
"EInvoiceServerError",
|
|
74
|
+
"EInvoiceTimeoutError",
|
|
75
|
+
"EInvoiceValidationError",
|
|
76
|
+
"EInvoiceWebhookError",
|
|
77
|
+
"ExcludedOperation",
|
|
78
|
+
"HttpClient",
|
|
79
|
+
"Page",
|
|
80
|
+
"Paginated",
|
|
81
|
+
"PaginationMeta",
|
|
82
|
+
"RawResponse",
|
|
83
|
+
"RequestOptions",
|
|
84
|
+
"RetryConfig",
|
|
85
|
+
"SignatureHeader",
|
|
86
|
+
"WebhookEvent",
|
|
87
|
+
"__version__",
|
|
88
|
+
"compute_webhook_signature",
|
|
89
|
+
"generate_idempotency_key",
|
|
90
|
+
"mode_of_api_key",
|
|
91
|
+
"paginate",
|
|
92
|
+
"parse_retry_after",
|
|
93
|
+
"parse_signature_header",
|
|
94
|
+
"sign_webhook_payload",
|
|
95
|
+
"types",
|
|
96
|
+
"verify_webhook",
|
|
97
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Shared plumbing of every service: unwraps the ``{meta, data}`` envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
from ._transport import HttpClient
|
|
10
|
+
from .types import EMPTY_PAGINATION, BinaryResponse, Page, Paginated, RequestOptions
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def seg(value: str | int) -> str:
|
|
14
|
+
"""Encodes one path segment."""
|
|
15
|
+
return quote(str(value), safe="")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseService:
|
|
19
|
+
"""Shared plumbing of every service."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, http: HttpClient) -> None:
|
|
22
|
+
self._http = http
|
|
23
|
+
|
|
24
|
+
def _call(
|
|
25
|
+
self,
|
|
26
|
+
method: str,
|
|
27
|
+
path: str,
|
|
28
|
+
*,
|
|
29
|
+
query: Mapping[str, Any] | None = None,
|
|
30
|
+
body: Any = None,
|
|
31
|
+
idempotent: bool = False,
|
|
32
|
+
headers: Mapping[str, str] | None = None,
|
|
33
|
+
options: RequestOptions | None = None,
|
|
34
|
+
) -> Any:
|
|
35
|
+
"""One call; returns ``data``."""
|
|
36
|
+
return self._http.request(method, path, query=query, body=body, idempotent=idempotent, headers=headers, options=options).data
|
|
37
|
+
|
|
38
|
+
def _page(
|
|
39
|
+
self,
|
|
40
|
+
method: str,
|
|
41
|
+
path: str,
|
|
42
|
+
*,
|
|
43
|
+
query: Mapping[str, Any] | None = None,
|
|
44
|
+
options: RequestOptions | None = None,
|
|
45
|
+
) -> Page[Any]:
|
|
46
|
+
"""A list call; returns the items and ``meta.pagination``."""
|
|
47
|
+
res = self._http.request(method, path, query=query, options=options)
|
|
48
|
+
pagination = (res.meta or {}).get("pagination") or EMPTY_PAGINATION
|
|
49
|
+
return Page(data=list(res.data) if isinstance(res.data, list) else [], pagination=pagination, request_id=res.request_id)
|
|
50
|
+
|
|
51
|
+
def _paginated(
|
|
52
|
+
self,
|
|
53
|
+
method: str,
|
|
54
|
+
path: str,
|
|
55
|
+
*,
|
|
56
|
+
query: Mapping[str, Any] | None = None,
|
|
57
|
+
options: RequestOptions | None = None,
|
|
58
|
+
) -> Paginated[Any]:
|
|
59
|
+
"""A call whose ``data`` is an object and whose ``meta`` also carries pagination."""
|
|
60
|
+
res = self._http.request(method, path, query=query, options=options)
|
|
61
|
+
return Paginated(data=res.data, pagination=(res.meta or {}).get("pagination"), request_id=res.request_id)
|
|
62
|
+
|
|
63
|
+
def _binary(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
query: Mapping[str, Any] | None = None,
|
|
69
|
+
idempotent: bool = False,
|
|
70
|
+
options: RequestOptions | None = None,
|
|
71
|
+
) -> BinaryResponse:
|
|
72
|
+
"""A binary download."""
|
|
73
|
+
res = self._http.request(
|
|
74
|
+
method, path, query=query, idempotent=idempotent, binary=True, headers={"Accept": "application/pdf"}, options=options
|
|
75
|
+
)
|
|
76
|
+
data = res.data
|
|
77
|
+
if isinstance(data, BinaryResponse):
|
|
78
|
+
return data
|
|
79
|
+
# The route answered JSON (e.g. a mocked transport): hand it back as bytes so the caller sees what came.
|
|
80
|
+
import json
|
|
81
|
+
|
|
82
|
+
return BinaryResponse(data=json.dumps(data).encode("utf-8"), content_type="application/json", request_id=res.request_id)
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""The transport: authentication, the envelope, errors, idempotency keys, retries and timeouts.
|
|
2
|
+
Services call :meth:`HttpClient.request`; integrators normally never touch it."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from email.utils import parsedate_to_datetime
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlencode, urlsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._version import __version__
|
|
20
|
+
from .errors import (
|
|
21
|
+
ApiErrorDetail,
|
|
22
|
+
EInvoiceApiError,
|
|
23
|
+
EInvoiceConfigError,
|
|
24
|
+
EInvoiceConnectionError,
|
|
25
|
+
EInvoiceTimeoutError,
|
|
26
|
+
api_error_for,
|
|
27
|
+
)
|
|
28
|
+
from .types import ApiMode, ApiResponseMeta, BinaryResponse, RequestOptions
|
|
29
|
+
|
|
30
|
+
# ═══════════════════════════════════════════
|
|
31
|
+
# Constants
|
|
32
|
+
# ═══════════════════════════════════════════
|
|
33
|
+
|
|
34
|
+
#: The production gateway, for sandbox and live keys alike (the key's prefix picks the mode).
|
|
35
|
+
#:
|
|
36
|
+
#: THIS NAME IS PERMANENT. It is compiled into every installed copy of the SDK, so infrastructure
|
|
37
|
+
#: moves by repointing DNS behind it, never by changing it here.
|
|
38
|
+
DEFAULT_BASE_URL = "https://gp.useyona.com"
|
|
39
|
+
|
|
40
|
+
#: A Yona API key: ``sk_test_`` (sandbox) or ``sk_live_`` (live), a 16-character public id
|
|
41
|
+
#: (base32, lower case), then a 43-character secret (base64url).
|
|
42
|
+
API_KEY_PATTERN = re.compile(r"^sk_(test|live)_([a-z2-7]{16})_([A-Za-z0-9_-]{43})$")
|
|
43
|
+
|
|
44
|
+
#: What the API accepts in ``Idempotency-Key``.
|
|
45
|
+
IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
|
|
46
|
+
|
|
47
|
+
RETRYABLE_STATUS = frozenset({408, 429, 500, 502, 503, 504})
|
|
48
|
+
IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "PUT", "DELETE"})
|
|
49
|
+
HttpMethod = str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ═══════════════════════════════════════════
|
|
53
|
+
# Configuration
|
|
54
|
+
# ═══════════════════════════════════════════
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class RetryConfig:
|
|
59
|
+
"""Retry behaviour."""
|
|
60
|
+
|
|
61
|
+
#: Retries after the first attempt. Only requests that are safe to repeat are retried: GET, PUT
|
|
62
|
+
#: and DELETE, and writes that carry an ``Idempotency-Key``. Retried on a network error, a
|
|
63
|
+
#: timeout, 408, 429 and 5xx.
|
|
64
|
+
max_retries: int = 2
|
|
65
|
+
#: First backoff delay in seconds; doubles each retry, with jitter.
|
|
66
|
+
base_delay: float = 0.5
|
|
67
|
+
#: Ceiling of one backoff delay in seconds.
|
|
68
|
+
max_delay: float = 8.0
|
|
69
|
+
#: The longest ``Retry-After`` (seconds) the SDK will wait on 429/503 before retrying. A longer
|
|
70
|
+
#: one is raised at once, with ``retry_after`` set.
|
|
71
|
+
max_retry_after: int = 60
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class RawResponse:
|
|
76
|
+
"""A successful answer: ``data``, ``meta``, status and headers."""
|
|
77
|
+
|
|
78
|
+
status: int
|
|
79
|
+
data: Any
|
|
80
|
+
meta: ApiResponseMeta | None
|
|
81
|
+
request_id: str | None
|
|
82
|
+
headers: httpx.Headers
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ═══════════════════════════════════════════
|
|
86
|
+
# Helpers
|
|
87
|
+
# ═══════════════════════════════════════════
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def mode_of_api_key(api_key: object) -> ApiMode:
|
|
91
|
+
"""The mode a key belongs to, or raises :class:`EInvoiceConfigError` for a malformed key (never echoing it)."""
|
|
92
|
+
match = API_KEY_PATTERN.match(api_key.strip()) if isinstance(api_key, str) else None
|
|
93
|
+
if not match:
|
|
94
|
+
raise EInvoiceConfigError(
|
|
95
|
+
"The API key is malformed: expected sk_test_<16 characters>_<43 characters> (sandbox) or "
|
|
96
|
+
"sk_live_… (live). Copy it again from the dashboard (API keys)."
|
|
97
|
+
)
|
|
98
|
+
return "live" if match.group(1) == "live" else "sandbox"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def generate_idempotency_key() -> str:
|
|
102
|
+
"""A fresh idempotency key (a UUID)."""
|
|
103
|
+
return str(uuid.uuid4())
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_retry_after(value: str | None, now: float | None = None) -> int | None:
|
|
107
|
+
"""Seconds from a ``Retry-After`` header (delta-seconds or an HTTP date)."""
|
|
108
|
+
if not value:
|
|
109
|
+
return None
|
|
110
|
+
trimmed = value.strip()
|
|
111
|
+
if re.fullmatch(r"\d+", trimmed):
|
|
112
|
+
return int(trimmed)
|
|
113
|
+
try:
|
|
114
|
+
at = parsedate_to_datetime(trimmed).timestamp()
|
|
115
|
+
except (TypeError, ValueError, IndexError):
|
|
116
|
+
return None
|
|
117
|
+
current = time.time() if now is None else now
|
|
118
|
+
return max(0, int(-(-(at - current) // 1))) # ceil
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _unpack_errors(errors: object) -> list[ApiErrorDetail]:
|
|
122
|
+
"""``{field: message}`` wire entries → ``ApiErrorDetail(field, message)``."""
|
|
123
|
+
out: list[ApiErrorDetail] = []
|
|
124
|
+
if not isinstance(errors, list):
|
|
125
|
+
return out
|
|
126
|
+
for entry in errors:
|
|
127
|
+
if isinstance(entry, dict):
|
|
128
|
+
for field, message in entry.items():
|
|
129
|
+
out.append(ApiErrorDetail(field=str(field), message=str(message)))
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_json(text: str) -> Any:
|
|
134
|
+
return json.loads(text) if text else None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _AttemptTimeout(Exception):
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_FILENAME = re.compile(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)\"?", re.IGNORECASE)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ═══════════════════════════════════════════
|
|
145
|
+
# HTTP client
|
|
146
|
+
# ═══════════════════════════════════════════
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class HttpClient:
|
|
150
|
+
"""The transport. Pass an ``httpx.Client`` (e.g. with ``httpx.MockTransport``) to control the network."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
api_key: str,
|
|
155
|
+
*,
|
|
156
|
+
base_url: str | None = None,
|
|
157
|
+
assert_mode: ApiMode | None = None,
|
|
158
|
+
timeout: float = 30.0,
|
|
159
|
+
retry: RetryConfig | None = None,
|
|
160
|
+
headers: Mapping[str, str] | None = None,
|
|
161
|
+
http_client: httpx.Client | None = None,
|
|
162
|
+
user_agent: str | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
if not api_key:
|
|
165
|
+
raise EInvoiceConfigError('An API key is required: EInvoice(api_key="sk_test_…").')
|
|
166
|
+
#: The key's mode, from its prefix.
|
|
167
|
+
self.mode: ApiMode = mode_of_api_key(api_key)
|
|
168
|
+
if assert_mode is not None and assert_mode != self.mode:
|
|
169
|
+
prefix = "sk_live_" if self.mode == "live" else "sk_test_"
|
|
170
|
+
raise EInvoiceConfigError(f"This client was asserted to be {assert_mode}, but the API key is a {self.mode} key ({prefix}…).")
|
|
171
|
+
self._api_key = api_key.strip()
|
|
172
|
+
base = base_url if base_url is not None else DEFAULT_BASE_URL
|
|
173
|
+
parts = urlsplit(base)
|
|
174
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
175
|
+
raise EInvoiceConfigError(f"base_url is not a URL: {base}")
|
|
176
|
+
#: The host requests go to.
|
|
177
|
+
self.base_url = base.rstrip("/")
|
|
178
|
+
self.timeout = float(timeout)
|
|
179
|
+
self.retry = retry or RetryConfig()
|
|
180
|
+
self._default_headers = dict(headers or {})
|
|
181
|
+
self._user_agent = user_agent or f"useyona-einvoice-python/{__version__}"
|
|
182
|
+
self._owns_client = http_client is None
|
|
183
|
+
# The SDK's own deadline covers the whole attempt (see _attempt_once); httpx's per-operation
|
|
184
|
+
# timeouts are set per request so a stalled read also ends within the deadline.
|
|
185
|
+
self._http = http_client if http_client is not None else httpx.Client(timeout=self.timeout)
|
|
186
|
+
|
|
187
|
+
# ── lifecycle ──
|
|
188
|
+
|
|
189
|
+
def close(self) -> None:
|
|
190
|
+
"""Closes the underlying connection pool (only one the SDK created)."""
|
|
191
|
+
if self._owns_client:
|
|
192
|
+
self._http.close()
|
|
193
|
+
|
|
194
|
+
def __enter__(self) -> HttpClient:
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, *exc: object) -> None:
|
|
198
|
+
self.close()
|
|
199
|
+
|
|
200
|
+
# ── requests ──
|
|
201
|
+
|
|
202
|
+
def request(
|
|
203
|
+
self,
|
|
204
|
+
method: HttpMethod,
|
|
205
|
+
path: str,
|
|
206
|
+
*,
|
|
207
|
+
query: Mapping[str, Any] | None = None,
|
|
208
|
+
body: Any = None,
|
|
209
|
+
idempotent: bool = False,
|
|
210
|
+
binary: bool = False,
|
|
211
|
+
headers: Mapping[str, str] | None = None,
|
|
212
|
+
options: RequestOptions | None = None,
|
|
213
|
+
) -> RawResponse:
|
|
214
|
+
"""Sends one API call and returns the parsed success, or raises an :class:`EInvoiceApiError` subclass.
|
|
215
|
+
|
|
216
|
+
``idempotent`` marks a route that honours ``Idempotency-Key``: the SDK sends one (generated
|
|
217
|
+
unless ``options.idempotency_key`` is given). ``binary`` expects a PDF instead of the JSON envelope.
|
|
218
|
+
"""
|
|
219
|
+
ro = options or RequestOptions()
|
|
220
|
+
url = self._build_url(path, query)
|
|
221
|
+
timeout = ro.timeout if ro.timeout is not None else self.timeout
|
|
222
|
+
req_headers = self._build_headers(body is not None, idempotent, headers, ro)
|
|
223
|
+
keyed = "Idempotency-Key" in req_headers
|
|
224
|
+
retryable = method in IDEMPOTENT_METHODS or keyed
|
|
225
|
+
max_retries = (ro.max_retries if ro.max_retries is not None else self.retry.max_retries) if retryable else 0
|
|
226
|
+
content = None if body is None else json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
227
|
+
|
|
228
|
+
attempt = 0
|
|
229
|
+
while True:
|
|
230
|
+
try:
|
|
231
|
+
outcome = self._attempt_once(url, method, req_headers, content, timeout, binary)
|
|
232
|
+
except EInvoiceApiError:
|
|
233
|
+
raise
|
|
234
|
+
except _AttemptTimeout:
|
|
235
|
+
if attempt < max_retries:
|
|
236
|
+
self._sleep(self._backoff(attempt))
|
|
237
|
+
attempt += 1
|
|
238
|
+
continue
|
|
239
|
+
raise EInvoiceTimeoutError(timeout) from None
|
|
240
|
+
except httpx.HTTPError as error:
|
|
241
|
+
if attempt < max_retries:
|
|
242
|
+
self._sleep(self._backoff(attempt))
|
|
243
|
+
attempt += 1
|
|
244
|
+
continue
|
|
245
|
+
raise EInvoiceConnectionError(f"Could not reach {self.base_url}: {error}", error) from error
|
|
246
|
+
|
|
247
|
+
if isinstance(outcome, RawResponse):
|
|
248
|
+
return outcome
|
|
249
|
+
|
|
250
|
+
refusal = outcome
|
|
251
|
+
if attempt < max_retries and refusal.status in RETRYABLE_STATUS:
|
|
252
|
+
wait = refusal.retry_after
|
|
253
|
+
if wait is not None and refusal.status in (429, 503):
|
|
254
|
+
if wait > self.retry.max_retry_after:
|
|
255
|
+
raise refusal
|
|
256
|
+
self._sleep(float(wait))
|
|
257
|
+
else:
|
|
258
|
+
self._sleep(self._backoff(attempt))
|
|
259
|
+
attempt += 1
|
|
260
|
+
continue
|
|
261
|
+
raise refusal
|
|
262
|
+
|
|
263
|
+
def _build_headers(self, has_body: bool, idempotent: bool, extra: Mapping[str, str] | None, ro: RequestOptions) -> dict[str, str]:
|
|
264
|
+
merged: dict[str, str] = {"Accept": "application/json"}
|
|
265
|
+
for source in (self._default_headers, extra or {}, ro.headers or {}):
|
|
266
|
+
for name, value in source.items():
|
|
267
|
+
# Later sources win; names compare case-insensitively.
|
|
268
|
+
for existing in [k for k in merged if k.lower() == name.lower()]:
|
|
269
|
+
del merged[existing]
|
|
270
|
+
merged[name] = value
|
|
271
|
+
for name in [k for k in merged if k.lower() in ("authorization", "x-api-key", "idempotency-key")]:
|
|
272
|
+
del merged[name]
|
|
273
|
+
merged["Authorization"] = f"Bearer {self._api_key}"
|
|
274
|
+
if self._user_agent and not any(k.lower() == "user-agent" for k in merged):
|
|
275
|
+
merged["User-Agent"] = self._user_agent
|
|
276
|
+
if has_body:
|
|
277
|
+
merged["Content-Type"] = "application/json"
|
|
278
|
+
if idempotent:
|
|
279
|
+
key = ro.idempotency_key if ro.idempotency_key is not None else generate_idempotency_key()
|
|
280
|
+
if not IDEMPOTENCY_KEY_PATTERN.match(key):
|
|
281
|
+
raise EInvoiceConfigError("idempotency_key must be 8–128 characters of A–Z, a–z, 0–9, _ or -.")
|
|
282
|
+
merged["Idempotency-Key"] = key
|
|
283
|
+
return merged
|
|
284
|
+
|
|
285
|
+
def _attempt_once(
|
|
286
|
+
self, url: str, method: str, headers: dict[str, str], content: bytes | None, timeout: float, binary: bool
|
|
287
|
+
) -> RawResponse | EInvoiceApiError:
|
|
288
|
+
"""One attempt: the request AND reading its body, all under one deadline, so a body that stalls
|
|
289
|
+
after the headers arrived times out like a request that never answered."""
|
|
290
|
+
deadline = time.monotonic() + timeout
|
|
291
|
+
request = self._http.build_request(method, url, headers=headers, content=content, timeout=httpx.Timeout(timeout))
|
|
292
|
+
try:
|
|
293
|
+
response = self._http.send(request, stream=True)
|
|
294
|
+
except httpx.TimeoutException as error:
|
|
295
|
+
raise _AttemptTimeout() from error
|
|
296
|
+
try:
|
|
297
|
+
try:
|
|
298
|
+
chunks: list[bytes] = []
|
|
299
|
+
for chunk in response.iter_bytes():
|
|
300
|
+
chunks.append(chunk)
|
|
301
|
+
if time.monotonic() > deadline:
|
|
302
|
+
raise _AttemptTimeout()
|
|
303
|
+
payload = b"".join(chunks)
|
|
304
|
+
except httpx.TimeoutException as error:
|
|
305
|
+
raise _AttemptTimeout() from error
|
|
306
|
+
finally:
|
|
307
|
+
response.close()
|
|
308
|
+
if response.is_success:
|
|
309
|
+
return self._parse_success(response, payload, binary)
|
|
310
|
+
return self._parse_error(response, payload)
|
|
311
|
+
|
|
312
|
+
def _parse_success(self, response: httpx.Response, payload: bytes, binary: bool) -> RawResponse:
|
|
313
|
+
header_request_id = response.headers.get("x-request-id")
|
|
314
|
+
content_type = response.headers.get("content-type", "")
|
|
315
|
+
if binary and "json" not in content_type:
|
|
316
|
+
disposition = response.headers.get("content-disposition", "")
|
|
317
|
+
match = _FILENAME.search(disposition)
|
|
318
|
+
name = match.group(1) if match else None
|
|
319
|
+
if name is not None:
|
|
320
|
+
from urllib.parse import unquote
|
|
321
|
+
|
|
322
|
+
name = unquote(name)
|
|
323
|
+
file = BinaryResponse(data=payload, content_type=content_type or "application/octet-stream", file_name=name, request_id=header_request_id)
|
|
324
|
+
return RawResponse(status=response.status_code, data=file, meta=None, request_id=header_request_id, headers=response.headers)
|
|
325
|
+
if response.status_code == 204:
|
|
326
|
+
return RawResponse(status=204, data=None, meta=None, request_id=header_request_id, headers=response.headers)
|
|
327
|
+
text = payload.decode("utf-8", errors="replace")
|
|
328
|
+
try:
|
|
329
|
+
parsed = _parse_json(text)
|
|
330
|
+
except ValueError:
|
|
331
|
+
raise EInvoiceApiError(
|
|
332
|
+
status=response.status_code,
|
|
333
|
+
message=f"The API answered {response.status_code} with a body that is not JSON",
|
|
334
|
+
request_id=header_request_id,
|
|
335
|
+
body=text,
|
|
336
|
+
) from None
|
|
337
|
+
if isinstance(parsed, dict) and "meta" in parsed and "data" in parsed:
|
|
338
|
+
meta = parsed.get("meta")
|
|
339
|
+
meta_dict: ApiResponseMeta | None = meta if isinstance(meta, dict) else None # type: ignore[assignment]
|
|
340
|
+
request_id = (meta_dict or {}).get("requestId") or header_request_id
|
|
341
|
+
return RawResponse(status=response.status_code, data=parsed["data"], meta=meta_dict, request_id=request_id, headers=response.headers)
|
|
342
|
+
return RawResponse(status=response.status_code, data=parsed, meta=None, request_id=header_request_id, headers=response.headers)
|
|
343
|
+
|
|
344
|
+
def _parse_error(self, response: httpx.Response, payload: bytes) -> EInvoiceApiError:
|
|
345
|
+
retry_after = parse_retry_after(response.headers.get("retry-after"))
|
|
346
|
+
header_request_id = response.headers.get("x-request-id")
|
|
347
|
+
text = payload.decode("utf-8", errors="replace")
|
|
348
|
+
try:
|
|
349
|
+
parsed = _parse_json(text)
|
|
350
|
+
except ValueError:
|
|
351
|
+
parsed = None
|
|
352
|
+
meta = parsed.get("meta") if isinstance(parsed, dict) else None
|
|
353
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
354
|
+
message = meta.get("message")
|
|
355
|
+
error_code = meta.get("errorCode")
|
|
356
|
+
request_id = meta.get("requestId")
|
|
357
|
+
return api_error_for(
|
|
358
|
+
status=response.status_code,
|
|
359
|
+
message=message if isinstance(message, str) and message else f"HTTP {response.status_code}",
|
|
360
|
+
error_code=error_code if isinstance(error_code, str) else None,
|
|
361
|
+
errors=_unpack_errors(meta.get("errors")),
|
|
362
|
+
request_id=request_id if isinstance(request_id, str) else header_request_id,
|
|
363
|
+
retry_after=retry_after,
|
|
364
|
+
body=parsed,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def _build_url(self, path: str, query: Mapping[str, Any] | None) -> str:
|
|
368
|
+
url = f"{self.base_url}{path}"
|
|
369
|
+
if not query:
|
|
370
|
+
return url
|
|
371
|
+
pairs: list[tuple[str, str]] = []
|
|
372
|
+
for key, value in query.items():
|
|
373
|
+
if value is None or value == "":
|
|
374
|
+
continue
|
|
375
|
+
values = value if isinstance(value, list | tuple) else [value]
|
|
376
|
+
for v in values:
|
|
377
|
+
pairs.append((key, "true" if v is True else "false" if v is False else str(v)))
|
|
378
|
+
return f"{url}?{urlencode(pairs)}" if pairs else url
|
|
379
|
+
|
|
380
|
+
def _backoff(self, attempt: int) -> float:
|
|
381
|
+
base = min(self.retry.max_delay, self.retry.base_delay * (2.0**attempt))
|
|
382
|
+
return base / 2 + random.random() * (base / 2)
|
|
383
|
+
|
|
384
|
+
def _sleep(self, seconds: float) -> None:
|
|
385
|
+
"""Waits ``seconds``. Overridable in tests."""
|
|
386
|
+
time.sleep(seconds)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
__all__ = [
|
|
390
|
+
"API_KEY_PATTERN",
|
|
391
|
+
"DEFAULT_BASE_URL",
|
|
392
|
+
"IDEMPOTENCY_KEY_PATTERN",
|
|
393
|
+
"HttpClient",
|
|
394
|
+
"RawResponse",
|
|
395
|
+
"RetryConfig",
|
|
396
|
+
"generate_idempotency_key",
|
|
397
|
+
"mode_of_api_key",
|
|
398
|
+
"parse_retry_after",
|
|
399
|
+
]
|