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.
Files changed (39) hide show
  1. meterbase/__init__.py +59 -0
  2. meterbase/_async/__init__.py +0 -0
  3. meterbase/_async/_io.py +22 -0
  4. meterbase/_async/_transport.py +181 -0
  5. meterbase/_async/client.py +427 -0
  6. meterbase/_async/resources/__init__.py +16 -0
  7. meterbase/_async/resources/customer_allowances.py +89 -0
  8. meterbase/_async/resources/customer_periods.py +41 -0
  9. meterbase/_async/resources/customer_plan.py +228 -0
  10. meterbase/_async/resources/customers.py +176 -0
  11. meterbase/_async/resources/meters.py +102 -0
  12. meterbase/_async/resources/plans.py +140 -0
  13. meterbase/_http.py +205 -0
  14. meterbase/_json.py +18 -0
  15. meterbase/_keys.py +39 -0
  16. meterbase/_models.py +177 -0
  17. meterbase/_not_given.py +32 -0
  18. meterbase/_sync/__init__.py +4 -0
  19. meterbase/_sync/_io.py +21 -0
  20. meterbase/_sync/_transport.py +185 -0
  21. meterbase/_sync/client.py +431 -0
  22. meterbase/_sync/resources/__init__.py +20 -0
  23. meterbase/_sync/resources/customer_allowances.py +93 -0
  24. meterbase/_sync/resources/customer_periods.py +45 -0
  25. meterbase/_sync/resources/customer_plan.py +230 -0
  26. meterbase/_sync/resources/customers.py +176 -0
  27. meterbase/_sync/resources/meters.py +106 -0
  28. meterbase/_sync/resources/plans.py +144 -0
  29. meterbase/_time.py +49 -0
  30. meterbase/_version.py +1 -0
  31. meterbase/errors.py +309 -0
  32. meterbase/py.typed +0 -0
  33. meterbase/resources.py +41 -0
  34. meterbase/types.py +600 -0
  35. meterbase/webhooks.py +154 -0
  36. meterbase-0.1.0.dist-info/METADATA +867 -0
  37. meterbase-0.1.0.dist-info/RECORD +39 -0
  38. meterbase-0.1.0.dist-info/WHEEL +4 -0
  39. 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
+ ]