meterbase 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.
- meterbase/__init__.py +59 -0
- meterbase/_async/__init__.py +0 -0
- meterbase/_async/_io.py +22 -0
- meterbase/_async/_transport.py +181 -0
- meterbase/_async/client.py +427 -0
- meterbase/_async/resources/__init__.py +16 -0
- meterbase/_async/resources/customer_allowances.py +89 -0
- meterbase/_async/resources/customer_periods.py +41 -0
- meterbase/_async/resources/customer_plan.py +228 -0
- meterbase/_async/resources/customers.py +176 -0
- meterbase/_async/resources/meters.py +102 -0
- meterbase/_async/resources/plans.py +140 -0
- meterbase/_http.py +205 -0
- meterbase/_json.py +18 -0
- meterbase/_keys.py +39 -0
- meterbase/_models.py +177 -0
- meterbase/_not_given.py +32 -0
- meterbase/_sync/__init__.py +4 -0
- meterbase/_sync/_io.py +21 -0
- meterbase/_sync/_transport.py +185 -0
- meterbase/_sync/client.py +431 -0
- meterbase/_sync/resources/__init__.py +20 -0
- meterbase/_sync/resources/customer_allowances.py +93 -0
- meterbase/_sync/resources/customer_periods.py +45 -0
- meterbase/_sync/resources/customer_plan.py +230 -0
- meterbase/_sync/resources/customers.py +176 -0
- meterbase/_sync/resources/meters.py +106 -0
- meterbase/_sync/resources/plans.py +144 -0
- meterbase/_time.py +49 -0
- meterbase/_version.py +1 -0
- meterbase/errors.py +309 -0
- meterbase/py.typed +0 -0
- meterbase/resources.py +41 -0
- meterbase/types.py +600 -0
- meterbase/webhooks.py +154 -0
- meterbase-0.1.0.dist-info/METADATA +867 -0
- meterbase-0.1.0.dist-info/RECORD +39 -0
- meterbase-0.1.0.dist-info/WHEEL +4 -0
- meterbase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Generated by scripts/unasync.py from
|
|
2
|
+
# src/meterbase/_async/resources/plans.py.
|
|
3
|
+
# Do not edit: change the async source, then run `make unasync`.
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from ..._http import segment
|
|
8
|
+
from ..._models import ListResult, list_of
|
|
9
|
+
from ..._not_given import NOT_GIVEN, NotGiven
|
|
10
|
+
from ...types import (
|
|
11
|
+
ApplyMode,
|
|
12
|
+
Plan,
|
|
13
|
+
PlanAllowance,
|
|
14
|
+
PlanAllowanceKind,
|
|
15
|
+
PlanCycle,
|
|
16
|
+
Validity,
|
|
17
|
+
ValidityParam,
|
|
18
|
+
)
|
|
19
|
+
from .._transport import Requester
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PlanAllowances:
|
|
23
|
+
"""
|
|
24
|
+
A plan's entitlement, versioned. There is no update and no delete: an edit
|
|
25
|
+
appends the next version, so which amount applied when stays derivable.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, api: Requester) -> None:
|
|
29
|
+
self._api = api
|
|
30
|
+
|
|
31
|
+
def set(
|
|
32
|
+
self,
|
|
33
|
+
plan_id: str,
|
|
34
|
+
*,
|
|
35
|
+
meter_id: str,
|
|
36
|
+
amount: int | NotGiven = NOT_GIVEN,
|
|
37
|
+
no_cap: bool | NotGiven = NOT_GIVEN,
|
|
38
|
+
allow_overage: bool | NotGiven = NOT_GIVEN,
|
|
39
|
+
kind: PlanAllowanceKind | NotGiven = NOT_GIVEN,
|
|
40
|
+
valid_for: ValidityParam | Validity | NotGiven = NOT_GIVEN,
|
|
41
|
+
apply_mode: ApplyMode | NotGiven = NOT_GIVEN,
|
|
42
|
+
) -> PlanAllowance:
|
|
43
|
+
"""
|
|
44
|
+
An allowance says how much, never how often: the period is the plan's
|
|
45
|
+
cycle. Give exactly one of `amount` or `no_cap`.
|
|
46
|
+
|
|
47
|
+
`allow_overage` is a soft cap: past `amount`, admit rather than refuse.
|
|
48
|
+
Defaults to `False`; refused beside `no_cap` and on `one_time`. `kind`
|
|
49
|
+
defaults to `recurring`. `valid_for` is `one_time` only. `apply_mode`
|
|
50
|
+
defaults to `next_cycle`, except `one_time` which is always immediate.
|
|
51
|
+
"""
|
|
52
|
+
return self._api.request(
|
|
53
|
+
"POST",
|
|
54
|
+
f"/v1/plans/{segment(plan_id)}/allowances",
|
|
55
|
+
PlanAllowance,
|
|
56
|
+
body={
|
|
57
|
+
"meter_id": meter_id,
|
|
58
|
+
"amount": amount,
|
|
59
|
+
"no_cap": no_cap,
|
|
60
|
+
"allow_overage": allow_overage,
|
|
61
|
+
"kind": kind,
|
|
62
|
+
"valid_for": valid_for,
|
|
63
|
+
"apply_mode": apply_mode,
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def list(
|
|
68
|
+
self, plan_id: str, *, meter_id: str | NotGiven = NOT_GIVEN
|
|
69
|
+
) -> ListResult[PlanAllowance]:
|
|
70
|
+
"""Every version of every meter, newest first — history, not just
|
|
71
|
+
current."""
|
|
72
|
+
return self._api.request(
|
|
73
|
+
"GET",
|
|
74
|
+
f"/v1/plans/{segment(plan_id)}/allowances",
|
|
75
|
+
list_of(PlanAllowance),
|
|
76
|
+
query={"meter_id": meter_id},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Plans:
|
|
81
|
+
allowances: PlanAllowances
|
|
82
|
+
|
|
83
|
+
def __init__(self, api: Requester) -> None:
|
|
84
|
+
self._api = api
|
|
85
|
+
self.allowances = PlanAllowances(api)
|
|
86
|
+
|
|
87
|
+
def create(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
key: str,
|
|
91
|
+
name: str,
|
|
92
|
+
cycle: PlanCycle,
|
|
93
|
+
description: str | NotGiven = NOT_GIVEN,
|
|
94
|
+
) -> Plan:
|
|
95
|
+
"""A new plan grants nothing; attach entitlement with
|
|
96
|
+
`allowances.set`."""
|
|
97
|
+
return self._api.request(
|
|
98
|
+
"POST",
|
|
99
|
+
"/v1/plans",
|
|
100
|
+
Plan,
|
|
101
|
+
body={"key": key, "name": name, "description": description, "cycle": cycle},
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def list(
|
|
105
|
+
self, *, include_archived: bool | NotGiven = NOT_GIVEN
|
|
106
|
+
) -> ListResult[Plan]:
|
|
107
|
+
return self._api.request(
|
|
108
|
+
"GET",
|
|
109
|
+
"/v1/plans",
|
|
110
|
+
list_of(Plan),
|
|
111
|
+
query={"include_archived": include_archived},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def retrieve(self, id: str) -> Plan:
|
|
115
|
+
return self._api.request("GET", f"/v1/plans/{segment(id)}", Plan)
|
|
116
|
+
|
|
117
|
+
def update(
|
|
118
|
+
self,
|
|
119
|
+
id: str,
|
|
120
|
+
*,
|
|
121
|
+
key: str | NotGiven = NOT_GIVEN,
|
|
122
|
+
name: str | NotGiven = NOT_GIVEN,
|
|
123
|
+
description: str | NotGiven = NOT_GIVEN,
|
|
124
|
+
) -> Plan:
|
|
125
|
+
"""
|
|
126
|
+
An omitted field is left alone; `""` clears the description. There is
|
|
127
|
+
no `cycle`: moving a plan's period would re-date every period its
|
|
128
|
+
customers were already measured against, so a different cadence is a
|
|
129
|
+
different plan.
|
|
130
|
+
"""
|
|
131
|
+
return self._api.request(
|
|
132
|
+
"PATCH",
|
|
133
|
+
f"/v1/plans/{segment(id)}",
|
|
134
|
+
Plan,
|
|
135
|
+
body={"key": key, "name": name, "description": description},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def archive(self, id: str) -> Plan:
|
|
139
|
+
"""
|
|
140
|
+
Stops new assignments without withdrawing capacity from anyone already
|
|
141
|
+
on the plan, which is why an archived plan still resolves by id.
|
|
142
|
+
Idempotent.
|
|
143
|
+
"""
|
|
144
|
+
return self._api.request("DELETE", f"/v1/plans/{segment(id)}", Plan)
|
meterbase/_time.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
# RFC 3339, which is what the engine sends: `2026-09-12T12:00:05.482913Z`, or
|
|
7
|
+
# an offset for the Z. Parsed here rather than by `datetime.fromisoformat`,
|
|
8
|
+
# which before Python 3.11 reads neither the Z nor a fraction that is not
|
|
9
|
+
# three or six digits long.
|
|
10
|
+
_RFC3339 = re.compile(
|
|
11
|
+
r"(\d{4})-(\d{2})-(\d{2})[Tt ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?"
|
|
12
|
+
r"(?:([Zz])|([+-])(\d{2}):(\d{2}))"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_instant(text: object) -> datetime | None:
|
|
17
|
+
"""
|
|
18
|
+
An RFC 3339 timestamp as an aware `datetime`, or `None` for anything that
|
|
19
|
+
is not one. A fraction past microseconds is truncated: `datetime` holds no
|
|
20
|
+
finer.
|
|
21
|
+
"""
|
|
22
|
+
if not isinstance(text, str):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
match = _RFC3339.fullmatch(text.strip())
|
|
26
|
+
if match is None:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
year, month, day, hour, minute, second = (int(part) for part in match.groups()[:6])
|
|
30
|
+
fraction, zulu, sign, offset_hours, offset_minutes = match.groups()[6:]
|
|
31
|
+
microsecond = int((fraction or "0")[:6].ljust(6, "0"))
|
|
32
|
+
|
|
33
|
+
if zulu:
|
|
34
|
+
tz = timezone.utc
|
|
35
|
+
else:
|
|
36
|
+
offset = timedelta(hours=int(offset_hours), minutes=int(offset_minutes))
|
|
37
|
+
tz = timezone(-offset if sign == "-" else offset)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return datetime(year, month, day, hour, minute, second, microsecond, tz)
|
|
41
|
+
except ValueError:
|
|
42
|
+
# A day or an hour out of range, which matches the pattern and is
|
|
43
|
+
# still no instant.
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_ms(at: datetime) -> float:
|
|
48
|
+
"""Unix milliseconds, fractional below one."""
|
|
49
|
+
return at.timestamp() * 1000
|
meterbase/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
meterbase/errors.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Every failure is a `MeterbaseError`, so a caller can catch one type and still
|
|
3
|
+
tell the specific one apart. `code` is stable; `message` may change.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._json import is_object
|
|
12
|
+
from ._time import parse_instant
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"APIConnectionError",
|
|
16
|
+
"APIError",
|
|
17
|
+
"APITimeoutError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"ConflictError",
|
|
20
|
+
"CycleChangeRequiresResetError",
|
|
21
|
+
"InvalidIdempotencyKeyError",
|
|
22
|
+
"InvalidRequestError",
|
|
23
|
+
"MeterbaseError",
|
|
24
|
+
"NotFoundError",
|
|
25
|
+
"PeriodSettlingError",
|
|
26
|
+
"PermissionDeniedError",
|
|
27
|
+
"RateLimitError",
|
|
28
|
+
"ReservationMismatchError",
|
|
29
|
+
"ServerError",
|
|
30
|
+
"TooLateError",
|
|
31
|
+
"WebhookVerificationError",
|
|
32
|
+
"error_from_response",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MeterbaseError(Exception):
|
|
37
|
+
"""The base of every error this SDK raises."""
|
|
38
|
+
|
|
39
|
+
def __reduce__(self) -> tuple[Any, ...]:
|
|
40
|
+
# Rebuilt from its state rather than from `args`, which is the one
|
|
41
|
+
# thing the default reads: the subclasses take keyword arguments, so a
|
|
42
|
+
# worker pool or a task queue could not pickle them back otherwise.
|
|
43
|
+
return (_rebuild, (type(self), self.args, self.__dict__))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _rebuild(
|
|
47
|
+
cls: type[MeterbaseError], args: tuple[Any, ...], state: dict[str, Any]
|
|
48
|
+
) -> MeterbaseError:
|
|
49
|
+
error = cls.__new__(cls)
|
|
50
|
+
error.args = args
|
|
51
|
+
error.__dict__.update(state)
|
|
52
|
+
return error
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class APIError(MeterbaseError):
|
|
56
|
+
"""The engine answered, with an error."""
|
|
57
|
+
|
|
58
|
+
status: int
|
|
59
|
+
code: str
|
|
60
|
+
message: str
|
|
61
|
+
request_id: str | None
|
|
62
|
+
"""From `X-Request-Id`, when the engine sends one."""
|
|
63
|
+
body: Any
|
|
64
|
+
"""Parsed JSON when the body is JSON, the raw text otherwise, `None` when
|
|
65
|
+
empty."""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
status: int,
|
|
71
|
+
code: str,
|
|
72
|
+
message: str,
|
|
73
|
+
request_id: str | None = None,
|
|
74
|
+
body: Any = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.status = status
|
|
78
|
+
self.code = code
|
|
79
|
+
self.message = message
|
|
80
|
+
self.request_id = request_id
|
|
81
|
+
self.body = body
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return (
|
|
85
|
+
f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, "
|
|
86
|
+
f"message={self.message!r})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class AuthenticationError(APIError):
|
|
91
|
+
"""`401`: an unknown, revoked or malformed key."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class PermissionDeniedError(APIError):
|
|
95
|
+
"""`403`: the key may not use this route."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class NotFoundError(APIError):
|
|
99
|
+
"""`404`. `code` says what was missing."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class ConflictError(APIError):
|
|
103
|
+
"""`409`: a key or external id is taken, among others."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class InvalidRequestError(APIError):
|
|
107
|
+
"""`422`, a field that failed validation; or `400 invalid_query`, a query
|
|
108
|
+
parameter that did."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class InvalidIdempotencyKeyError(InvalidRequestError):
|
|
112
|
+
"""
|
|
113
|
+
`422 invalid_idempotency_key`: the key was not a UUID version 7.
|
|
114
|
+
`uuid.uuid4()` mints a v4, so omit `idempotency_key` and let this SDK mint
|
|
115
|
+
the right thing.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class TooLateError(InvalidRequestError):
|
|
120
|
+
"""
|
|
121
|
+
`422 too_late`: the key's timestamp is more than an hour from the engine's
|
|
122
|
+
clock, so a retry can no longer be told from a new event. **Nothing was
|
|
123
|
+
recorded** — the check runs before any write.
|
|
124
|
+
|
|
125
|
+
The usual cause is this machine's clock. When this SDK minted the key it
|
|
126
|
+
corrects the offset from `server_time` and retries once, so you only see
|
|
127
|
+
this for a key you supplied. If that key is a retry of a call made over an
|
|
128
|
+
hour ago, do not resend it under a new one: it may already be recorded.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
server_time: datetime | None
|
|
132
|
+
"""The engine's clock when it refused."""
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
*,
|
|
137
|
+
status: int,
|
|
138
|
+
code: str,
|
|
139
|
+
message: str,
|
|
140
|
+
request_id: str | None = None,
|
|
141
|
+
body: Any = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
super().__init__(
|
|
144
|
+
status=status,
|
|
145
|
+
code=code,
|
|
146
|
+
message=message,
|
|
147
|
+
request_id=request_id,
|
|
148
|
+
body=body,
|
|
149
|
+
)
|
|
150
|
+
self.server_time = _server_time(body)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ReservationMismatchError(InvalidRequestError):
|
|
154
|
+
"""
|
|
155
|
+
`422 reservation_mismatch`: the reservation id names a hold made for a
|
|
156
|
+
different customer or meter than the call sends. A caller bug, refused
|
|
157
|
+
before anything is written — nothing was recorded and the hold still
|
|
158
|
+
stands.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CycleChangeRequiresResetError(InvalidRequestError):
|
|
163
|
+
"""
|
|
164
|
+
`422 cycle_change_requires_reset`: the two plans measure different cycles,
|
|
165
|
+
so a `next_cycle` change has no shared boundary to wait for and a
|
|
166
|
+
`prorate` no shared period to weight. The move is `customers.plan.restart`,
|
|
167
|
+
which closes the current period and opens a fresh one on the new cycle —
|
|
168
|
+
or `change_now` with the default `reconciliation="none"` to keep the
|
|
169
|
+
period that is running. The engine's `message` says as much.
|
|
170
|
+
|
|
171
|
+
An `InvalidRequestError` still, so an existing `except` keeps working.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class PeriodSettlingError(ConflictError):
|
|
176
|
+
"""
|
|
177
|
+
`409 period_settling`: the period ended under a minute ago and may still be
|
|
178
|
+
taking writes. Read again after `retry_after`; the SDK never retries it.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
retry_after: float | None
|
|
182
|
+
"""Seconds until the period is final, from `Retry-After`."""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
*,
|
|
187
|
+
status: int,
|
|
188
|
+
code: str,
|
|
189
|
+
message: str,
|
|
190
|
+
request_id: str | None = None,
|
|
191
|
+
retry_after: float | None = None,
|
|
192
|
+
body: Any = None,
|
|
193
|
+
) -> None:
|
|
194
|
+
super().__init__(
|
|
195
|
+
status=status,
|
|
196
|
+
code=code,
|
|
197
|
+
message=message,
|
|
198
|
+
request_id=request_id,
|
|
199
|
+
body=body,
|
|
200
|
+
)
|
|
201
|
+
self.retry_after = retry_after
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class RateLimitError(APIError):
|
|
205
|
+
"""`429`. A customer's rate limit is not one: `check` and `reserve` report
|
|
206
|
+
that as `allowed=False`."""
|
|
207
|
+
|
|
208
|
+
retry_after: float | None
|
|
209
|
+
"""Seconds to wait, from `Retry-After`, when the engine sends one."""
|
|
210
|
+
|
|
211
|
+
def __init__(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
status: int,
|
|
215
|
+
code: str,
|
|
216
|
+
message: str,
|
|
217
|
+
request_id: str | None = None,
|
|
218
|
+
retry_after: float | None = None,
|
|
219
|
+
body: Any = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
super().__init__(
|
|
222
|
+
status=status,
|
|
223
|
+
code=code,
|
|
224
|
+
message=message,
|
|
225
|
+
request_id=request_id,
|
|
226
|
+
body=body,
|
|
227
|
+
)
|
|
228
|
+
self.retry_after = retry_after
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ServerError(APIError):
|
|
232
|
+
"""`500` and above."""
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class APIConnectionError(MeterbaseError):
|
|
236
|
+
"""
|
|
237
|
+
The request never produced an answer: DNS, TLS, a reset. Named so it does
|
|
238
|
+
not shadow Python's own `ConnectionError`.
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class APITimeoutError(APIConnectionError):
|
|
243
|
+
"""An attempt ran past its `timeout`."""
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class WebhookVerificationError(MeterbaseError):
|
|
247
|
+
"""A webhook that is not one Meterbase signed recently: answer it with a
|
|
248
|
+
400."""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def error_from_response(
|
|
252
|
+
*,
|
|
253
|
+
status: int,
|
|
254
|
+
code: str,
|
|
255
|
+
message: str,
|
|
256
|
+
request_id: str | None = None,
|
|
257
|
+
retry_after: float | None = None,
|
|
258
|
+
body: Any = None,
|
|
259
|
+
) -> APIError:
|
|
260
|
+
"""The error this SDK raises for a response, which also makes realistic
|
|
261
|
+
errors for tests."""
|
|
262
|
+
fields: dict[str, Any] = {
|
|
263
|
+
"status": status,
|
|
264
|
+
"code": code,
|
|
265
|
+
"message": message,
|
|
266
|
+
"request_id": request_id,
|
|
267
|
+
"body": body,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if status == 400:
|
|
271
|
+
# A parameter that failed validation, as a 422 is a field that did.
|
|
272
|
+
if code == "invalid_query":
|
|
273
|
+
return InvalidRequestError(**fields)
|
|
274
|
+
return APIError(**fields)
|
|
275
|
+
if status == 401:
|
|
276
|
+
return AuthenticationError(**fields)
|
|
277
|
+
if status == 403:
|
|
278
|
+
return PermissionDeniedError(**fields)
|
|
279
|
+
if status == 404:
|
|
280
|
+
return NotFoundError(**fields)
|
|
281
|
+
if status == 409:
|
|
282
|
+
if code == "period_settling":
|
|
283
|
+
return PeriodSettlingError(**fields, retry_after=retry_after)
|
|
284
|
+
return ConflictError(**fields)
|
|
285
|
+
if status == 422:
|
|
286
|
+
return _UNPROCESSABLE.get(code, InvalidRequestError)(**fields)
|
|
287
|
+
if status == 429:
|
|
288
|
+
return RateLimitError(**fields, retry_after=retry_after)
|
|
289
|
+
if status >= 500:
|
|
290
|
+
return ServerError(**fields)
|
|
291
|
+
return APIError(**fields)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
_UNPROCESSABLE: dict[str, type[InvalidRequestError]] = {
|
|
295
|
+
"cycle_change_requires_reset": CycleChangeRequiresResetError,
|
|
296
|
+
"invalid_idempotency_key": InvalidIdempotencyKeyError,
|
|
297
|
+
"reservation_mismatch": ReservationMismatchError,
|
|
298
|
+
"too_late": TooLateError,
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _server_time(body: Any) -> datetime | None:
|
|
303
|
+
"""The engine carries it beside `code` and `message`, inside `error`."""
|
|
304
|
+
if not is_object(body):
|
|
305
|
+
return None
|
|
306
|
+
error = body.get("error")
|
|
307
|
+
if not is_object(error):
|
|
308
|
+
return None
|
|
309
|
+
return parse_instant(error.get("server_time"))
|
meterbase/py.typed
ADDED
|
File without changes
|
meterbase/resources.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The namespaces a client's calls hang off, for annotating code that is handed
|
|
3
|
+
one: `def bill(customers: Customers) -> None`. Reach them through a client —
|
|
4
|
+
`meterbase.customers` — rather than constructing them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._async.resources import (
|
|
8
|
+
AsyncCustomerAllowances,
|
|
9
|
+
AsyncCustomerPeriods,
|
|
10
|
+
AsyncCustomerPlan,
|
|
11
|
+
AsyncCustomers,
|
|
12
|
+
AsyncMeters,
|
|
13
|
+
AsyncPlanAllowances,
|
|
14
|
+
AsyncPlans,
|
|
15
|
+
)
|
|
16
|
+
from ._sync.resources import (
|
|
17
|
+
CustomerAllowances,
|
|
18
|
+
CustomerPeriods,
|
|
19
|
+
CustomerPlan,
|
|
20
|
+
Customers,
|
|
21
|
+
Meters,
|
|
22
|
+
PlanAllowances,
|
|
23
|
+
Plans,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"AsyncCustomerAllowances",
|
|
28
|
+
"AsyncCustomerPeriods",
|
|
29
|
+
"AsyncCustomerPlan",
|
|
30
|
+
"AsyncCustomers",
|
|
31
|
+
"AsyncMeters",
|
|
32
|
+
"AsyncPlanAllowances",
|
|
33
|
+
"AsyncPlans",
|
|
34
|
+
"CustomerAllowances",
|
|
35
|
+
"CustomerPeriods",
|
|
36
|
+
"CustomerPlan",
|
|
37
|
+
"Customers",
|
|
38
|
+
"Meters",
|
|
39
|
+
"PlanAllowances",
|
|
40
|
+
"Plans",
|
|
41
|
+
]
|