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,185 @@
1
+ # Generated by scripts/unasync.py from
2
+ # src/meterbase/_async/_transport.py.
3
+ # Do not edit: change the async source, then run `make unasync`.
4
+
5
+ """
6
+ Everything goes through `request`, so authentication, timeouts, retries and
7
+ error mapping are defined once.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+ from collections.abc import Callable
14
+ from datetime import datetime
15
+ from typing import Any, TypeVar
16
+
17
+ import httpx
18
+
19
+ from .._http import (
20
+ Body,
21
+ Query,
22
+ RequestOptions,
23
+ backoff,
24
+ build_url,
25
+ encode_body,
26
+ error_from,
27
+ origin_of,
28
+ parse_body,
29
+ request_headers,
30
+ should_retry,
31
+ )
32
+ from .._models import construct
33
+ from .._time import to_ms
34
+ from ..errors import APIConnectionError, APITimeoutError, MeterbaseError
35
+ from ._io import sleep
36
+
37
+
38
+ class Transport:
39
+ def __init__(
40
+ self,
41
+ *,
42
+ api_key: str,
43
+ base_url: str,
44
+ http_client: httpx.Client | None,
45
+ ) -> None:
46
+ self._api_key = api_key
47
+ self._base_url = base_url
48
+ self._origin = origin_of(base_url)
49
+ self._http = http_client if http_client is not None else httpx.Client()
50
+ # A client the caller passed in is theirs to close.
51
+ self._owns_http = http_client is None
52
+
53
+ # How far the engine's clock is ahead of this machine's, in
54
+ # milliseconds. Idempotency keys are minted here and the engine refuses
55
+ # one dated more than an hour from its own clock, so without this a
56
+ # device with a wrong clock would fail every call forever.
57
+ self._clock_offset_ms = 0.0
58
+
59
+ def now_ms(self) -> float:
60
+ """The engine's clock, as well as this client knows it."""
61
+ return time.time() * 1000 + self._clock_offset_ms
62
+
63
+ def observe_server_time(self, at: datetime) -> None:
64
+ """Off by up to one round trip, which a window in hours does not
65
+ notice."""
66
+ self._clock_offset_ms = to_ms(at) - time.time() * 1000
67
+
68
+ def request(
69
+ self,
70
+ method: str,
71
+ path: str,
72
+ *,
73
+ options: RequestOptions,
74
+ query: Query | None = None,
75
+ body: Body | None = None,
76
+ idempotent: bool = False,
77
+ ) -> Any:
78
+ """
79
+ `idempotent` replays a call even though its method is not safe. Only a
80
+ route that carries an idempotency key may set it, and the key has to be
81
+ in `body` already: the loop re-sends one body, so a key generated per
82
+ attempt would count the same usage twice.
83
+ """
84
+ # Safe methods, plus the routes that carry an idempotency key and say
85
+ # so. Any other POST or PATCH could create a second row on a replay.
86
+ retryable = method in ("GET", "DELETE") or idempotent
87
+
88
+ url = build_url(self._base_url, path, query)
89
+ content = None if body is None else encode_body(body)
90
+ headers = request_headers(self._api_key, has_body=content is not None)
91
+
92
+ last_error: MeterbaseError | None = None
93
+ for attempt in range(options.max_retries + 1):
94
+ if attempt > 0:
95
+ sleep(backoff(attempt, last_error))
96
+
97
+ try:
98
+ response = self._send(method, url, headers, content, options)
99
+ except APIConnectionError as unanswered:
100
+ # A connection failure proves nothing about whether the server
101
+ # acted, so it is replayed under the same idempotency rule.
102
+ if not retryable or attempt >= options.max_retries:
103
+ raise
104
+ last_error = unanswered
105
+ continue
106
+
107
+ if response.is_success:
108
+ return parse_body(response)
109
+
110
+ refused = error_from(response)
111
+ if (
112
+ retryable
113
+ and attempt < options.max_retries
114
+ and should_retry(response.status_code)
115
+ ):
116
+ last_error = refused
117
+ continue
118
+ raise refused
119
+
120
+ # The loop always returns or raises.
121
+ raise AssertionError("unreachable") # pragma: no cover
122
+
123
+ def _send(
124
+ self,
125
+ method: str,
126
+ url: str,
127
+ headers: dict[str, str],
128
+ content: bytes | None,
129
+ options: RequestOptions,
130
+ ) -> httpx.Response:
131
+ # Neither this nor the loop catches a BaseException — a cancelled task,
132
+ # a Ctrl-C — so one ends the call where it stands and nothing is sent
133
+ # after it: a replay would do the work the caller called off, and for
134
+ # `track` record usage they cancelled.
135
+ try:
136
+ return self._http.request(
137
+ method,
138
+ url,
139
+ headers=headers,
140
+ content=content,
141
+ timeout=options.timeout,
142
+ )
143
+ except httpx.TimeoutException as error:
144
+ raise APITimeoutError(
145
+ f"Request timed out after {options.timeout:g}s."
146
+ ) from error
147
+ except httpx.TransportError as error:
148
+ raise APIConnectionError(f"Could not reach {self._origin}.") from error
149
+
150
+ def close(self) -> None:
151
+ if self._owns_http:
152
+ self._http.close()
153
+
154
+
155
+ _T = TypeVar("_T")
156
+
157
+
158
+ class Requester:
159
+ """A transport, and the options its calls run with. `with_options` makes
160
+ another over the same transport, which shares its connections and its
161
+ clock."""
162
+
163
+ def __init__(self, transport: Transport, options: RequestOptions) -> None:
164
+ self.transport = transport
165
+ self.options = options
166
+
167
+ def request(
168
+ self,
169
+ method: str,
170
+ path: str,
171
+ cast_to: Callable[[dict[str, Any]], _T],
172
+ *,
173
+ query: Query | None = None,
174
+ body: Body | None = None,
175
+ idempotent: bool = False,
176
+ ) -> _T:
177
+ data = self.transport.request(
178
+ method,
179
+ path,
180
+ options=self.options,
181
+ query=query,
182
+ body=body,
183
+ idempotent=idempotent,
184
+ )
185
+ return construct(cast_to, data)
@@ -0,0 +1,431 @@
1
+ # Generated by scripts/unasync.py from
2
+ # src/meterbase/_async/client.py.
3
+ # Do not edit: change the async source, then run `make unasync`.
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from typing import Any, TypeVar
11
+
12
+ import httpx
13
+
14
+ from .._http import (
15
+ DEFAULT_MAX_RETRIES,
16
+ DEFAULT_TIMEOUT,
17
+ RequestOptions,
18
+ normalize_base_url,
19
+ )
20
+ from .._keys import IDEMPOTENCY_WINDOW_MS, RESERVE_WINDOW_MS, mint, minted_at_ms
21
+ from .._not_given import NOT_GIVEN, NotGiven
22
+ from .._time import parse_instant, to_ms
23
+ from ..errors import MeterbaseError, NotFoundError, TooLateError
24
+ from ..types import CheckResult, ReleaseResult, ReserveResponse, TrackResult, WhoAmI
25
+ from ._transport import Requester, Transport
26
+ from .resources.customers import Customers
27
+ from .resources.meters import Meters
28
+ from .resources.plans import Plans
29
+
30
+ _T = TypeVar("_T")
31
+
32
+ logger = logging.getLogger("meterbase")
33
+
34
+
35
+ class Meterbase:
36
+ """
37
+ The client every call hangs off. An API key names its own workspace, so
38
+ there is no workspace to pass.
39
+
40
+ `timeout` is in seconds and applies to each attempt rather than to the
41
+ whole call. `max_retries` counts the attempts after the first. Pass an
42
+ `http_client` to configure proxies, limits or transports yourself; it
43
+ stays yours to close.
44
+ """
45
+
46
+ customers: Customers
47
+ meters: Meters
48
+ plans: Plans
49
+
50
+ def __init__(
51
+ self,
52
+ *,
53
+ api_key: str,
54
+ base_url: str | None = None,
55
+ timeout: float = DEFAULT_TIMEOUT,
56
+ max_retries: int = DEFAULT_MAX_RETRIES,
57
+ http_client: httpx.Client | None = None,
58
+ ) -> None:
59
+ if not api_key:
60
+ raise MeterbaseError(
61
+ "An API key is required: Meterbase(api_key='mb_sk_live_…')."
62
+ )
63
+
64
+ options = RequestOptions(timeout=timeout, max_retries=max_retries)
65
+ transport = Transport(
66
+ api_key=api_key,
67
+ base_url=normalize_base_url(base_url),
68
+ http_client=http_client,
69
+ )
70
+ self._bind(Requester(transport, options))
71
+
72
+ def _bind(self, api: Requester) -> None:
73
+ self._api = api
74
+ self.customers = Customers(api)
75
+ self.meters = Meters(api)
76
+ self.plans = Plans(api)
77
+
78
+ def with_options(
79
+ self,
80
+ *,
81
+ timeout: float | NotGiven = NOT_GIVEN,
82
+ max_retries: int | NotGiven = NOT_GIVEN,
83
+ ) -> Meterbase:
84
+ """
85
+ This client with other settings, for the calls made through it:
86
+ `meterbase.with_options(timeout=2).check(...)`. It shares this client's
87
+ connections, so closing either closes both.
88
+ """
89
+ current = self._api.options
90
+ options = RequestOptions(
91
+ timeout=current.timeout if isinstance(timeout, NotGiven) else timeout,
92
+ max_retries=(
93
+ current.max_retries
94
+ if isinstance(max_retries, NotGiven)
95
+ else max_retries
96
+ ),
97
+ )
98
+ derived = type(self).__new__(type(self))
99
+ derived._bind(Requester(self._api.transport, options))
100
+ return derived
101
+
102
+ def check(
103
+ self,
104
+ *,
105
+ customer_id: str,
106
+ meter_id: str,
107
+ quantity: int | NotGiven = NOT_GIVEN,
108
+ ) -> CheckResult:
109
+ """
110
+ Asks whether a customer may consume `quantity` of a meter, named by the
111
+ tenant's own external id and the meter's key: an SDK never holds
112
+ Meterbase ids. Absence of entitlement is not permission: a customer
113
+ with no plan and no grant is denied.
114
+
115
+ This is the gate, and the only call that ever refuses. Run it before
116
+ the work; record the work with `track` afterwards.
117
+
118
+ Two gates answer it, and `allowed` needs both: the customer's capacity
119
+ has to cover the quantity, and every rate-limit window in force has to
120
+ admit it. `reason` names the one that said no. `rate_limits` reports
121
+ each window whether or not it refused, so a caller can ease off as its
122
+ headroom closes instead of discovering the ceiling by hitting it.
123
+ Under a soft cap (`allow_overage`) the capacity admits any quantity, so
124
+ only a window can refuse.
125
+
126
+ `quantity` is what is about to be consumed, `1 … 100000000000`.
127
+ Omitted, it is 1, which asks whether there is any capacity at all.
128
+ """
129
+ return self._api.request(
130
+ "POST",
131
+ "/v1/check",
132
+ CheckResult,
133
+ body={
134
+ "customer_id": customer_id,
135
+ "meter_id": meter_id,
136
+ "quantity": quantity,
137
+ },
138
+ )
139
+
140
+ def track(
141
+ self,
142
+ *,
143
+ customer_id: str,
144
+ meter_id: str,
145
+ quantity: int | NotGiven = NOT_GIVEN,
146
+ idempotency_key: str | NotGiven = NOT_GIVEN,
147
+ ) -> TrackResult:
148
+ """
149
+ Records usage that already happened — the tokens were spent, the image
150
+ was generated — so it never refuses on capacity. A quantity beyond the
151
+ customer's capacity is recorded, drives `available` to 0, and the next
152
+ `check` says no, unless the plan's cap is soft. The answer is a
153
+ receipt, not a verdict.
154
+
155
+ `quantity` is an integer, `1 … 100000000000`, and 1 when omitted,
156
+ which is what a counting meter sends.
157
+
158
+ `idempotency_key` is a UUIDv7, generated when omitted, so retrying this
159
+ call — the SDK's own retries included — replays the original rather
160
+ than counting the same work twice. The engine keeps that claim for an
161
+ hour, which is how long a retry stays recognisable. Supply your own
162
+ when the caller already has an id for the work — a job id, a request
163
+ id — so a retry from further out replays rather than counts twice.
164
+ """
165
+ return self._keyed(
166
+ "/v1/usage/track",
167
+ "idempotency_key",
168
+ idempotency_key,
169
+ IDEMPOTENCY_WINDOW_MS,
170
+ {"customer_id": customer_id, "meter_id": meter_id, "quantity": quantity},
171
+ TrackResult,
172
+ )
173
+
174
+ def reserve(
175
+ self,
176
+ *,
177
+ customer_id: str,
178
+ meter_id: str,
179
+ quantity: int | NotGiven = NOT_GIVEN,
180
+ reservation_id: str | NotGiven = NOT_GIVEN,
181
+ expires_in_seconds: int | NotGiven = NOT_GIVEN,
182
+ ) -> Hold:
183
+ """
184
+ Holds capacity for work that cannot be done twice, or refuses. The same
185
+ decision `check` makes, and then a hold on the capacity until the work
186
+ is committed, released, or expires.
187
+
188
+ The rule for choosing: can you afford to do the work twice? `check`.
189
+ No? `reserve`. A reserve costs a transaction where a check costs a
190
+ cached read, refusals included, so it is not a per-request gate on
191
+ cheap work.
192
+
193
+ Two concurrent reserves for the same customer and meter cannot both be
194
+ granted the same capacity; that is the whole point, and it is what
195
+ `check` → work → `track` could never promise.
196
+
197
+ `quantity` is what the hold sets aside, `1 … 100000000000`; 1 when
198
+ omitted. `reservation_id` is the hold's id, and the event's id once a
199
+ track commits it: a UUIDv7, which the SDK mints when it is omitted. An
200
+ id is for one reserve and its retries. Once its hold is committed or
201
+ released the row is gone, so sending the same id again reserves afresh
202
+ rather than finding anything.
203
+
204
+ `expires_in_seconds` is how long the hold counts against the pair
205
+ before it comes back on its own: `1 … 900`, defaulting to `60`. It is
206
+ a timeout on your own work, and the field callers get wrong. Too short
207
+ and the hold evaporates mid-operation, which is worse than no hold at
208
+ all because you believe you are protected; too long and a crash wedges
209
+ the customer's capacity until it expires.
210
+ """
211
+ return self._keyed(
212
+ "/v1/usage/reserve",
213
+ "reservation_id",
214
+ reservation_id,
215
+ RESERVE_WINDOW_MS,
216
+ {
217
+ "customer_id": customer_id,
218
+ "meter_id": meter_id,
219
+ "quantity": quantity,
220
+ "expires_in_seconds": expires_in_seconds,
221
+ },
222
+ # A retry under the same id finds the hold it already made rather
223
+ # than making a second one.
224
+ lambda raw: Hold(
225
+ raw,
226
+ client=self,
227
+ clock=self._api.transport,
228
+ customer_id=customer_id,
229
+ meter_id=meter_id,
230
+ quantity=quantity,
231
+ ),
232
+ )
233
+
234
+ def release(self, *, reservation_id: str) -> ReleaseResult:
235
+ """
236
+ Gives a hold back: the work did not happen, so nothing is recorded.
237
+
238
+ Raises `NotFoundError` when the id names nothing — committed, released
239
+ already, or never made, which are one absence with one meaning. Prefer
240
+ `hold.release()`, which treats that as the success it is.
241
+ """
242
+ return self._api.request(
243
+ "POST",
244
+ "/v1/usage/release",
245
+ ReleaseResult,
246
+ body={"reservation_id": reservation_id},
247
+ )
248
+
249
+ def whoami(self) -> WhoAmI:
250
+ """Reports which workspace this key acts for."""
251
+ return self._api.request("GET", "/v1/whoami", WhoAmI)
252
+
253
+ def close(self) -> None:
254
+ """Closes the connections this client opened. An `http_client` passed
255
+ in stays open: it is yours."""
256
+ self._api.transport.close()
257
+
258
+ def __enter__(self) -> Meterbase:
259
+ return self
260
+
261
+ def __exit__(self, *exc_info: object) -> None:
262
+ self.close()
263
+
264
+ def _keyed(
265
+ self,
266
+ path: str,
267
+ field: str,
268
+ supplied: str | NotGiven,
269
+ window_ms: int,
270
+ body: dict[str, Any],
271
+ cast_to: Callable[[dict[str, Any]], _T],
272
+ ) -> _T:
273
+ """
274
+ Sends a call that carries a caller-minted UUIDv7, minting one when the
275
+ caller supplied none and re-minting once if this machine's clock put it
276
+ outside the engine's window.
277
+
278
+ A key the caller supplied is never re-minted: this cannot know whether
279
+ it names work already recorded, and replacing it could count that work
280
+ twice.
281
+ """
282
+ if not isinstance(supplied, NotGiven):
283
+ return self._api.request(
284
+ "POST", path, cast_to, body={**body, field: supplied}, idempotent=True
285
+ )
286
+
287
+ clock = self._api.transport
288
+ key = mint(clock.now_ms())
289
+ started = time.monotonic()
290
+
291
+ try:
292
+ # Fixed before the retry loop is entered: every attempt of this
293
+ # call carries the same key, which is what makes replaying it safe.
294
+ return self._api.request(
295
+ "POST", path, cast_to, body={**body, field: key}, idempotent=True
296
+ )
297
+ except TooLateError as error:
298
+ # The only failure this can fix by itself: a clock that disagrees
299
+ # with the engine's.
300
+ if error.server_time is None:
301
+ raise
302
+
303
+ # Re-minting is safe only if no attempt under the old key can have
304
+ # landed. The transport retries, so this 422 may have come from a
305
+ # later attempt while an earlier one succeeded. If the key was
306
+ # already outside the window when the call began, every attempt
307
+ # was refused.
308
+ elapsed_ms = (time.monotonic() - started) * 1000
309
+ server_at_start = to_ms(error.server_time) - elapsed_ms
310
+ if abs(server_at_start - minted_at_ms(key)) <= window_ms:
311
+ raise
312
+
313
+ clock.observe_server_time(error.server_time)
314
+
315
+ return self._api.request(
316
+ "POST",
317
+ path,
318
+ cast_to,
319
+ body={**body, field: mint(clock.now_ms())},
320
+ idempotent=True,
321
+ )
322
+
323
+
324
+ class Hold(ReserveResponse):
325
+ """
326
+ What a reserve answers: everything `check` answers, and a hold on the
327
+ capacity when `allowed`, with the two calls that close it. `commit` is
328
+ `track` under the hold's own id; `release` gives the capacity back.
329
+
330
+ A refusal holds nothing, so it carries no `reservation_id` and no
331
+ `expires_at`: `commit` raises and `release` answers `None`.
332
+ """
333
+
334
+ _client: Meterbase
335
+ _clock: Transport
336
+ _customer_id: str
337
+ _meter_id: str
338
+
339
+ def __init__(
340
+ self,
341
+ raw: Mapping[str, Any],
342
+ *,
343
+ client: Meterbase,
344
+ clock: Transport,
345
+ customer_id: str,
346
+ meter_id: str,
347
+ quantity: int | NotGiven = NOT_GIVEN,
348
+ ) -> None:
349
+ super().__init__(raw)
350
+ self.__dict__.update(
351
+ _client=client,
352
+ _clock=clock,
353
+ _customer_id=customer_id,
354
+ _meter_id=meter_id,
355
+ )
356
+
357
+ if not self.allowed:
358
+ return
359
+
360
+ # A grant without them is not a hold this can hand back: `commit`
361
+ # would have no id to commit under, and the caller would find that out
362
+ # at the one moment they are relying on it. Louder here.
363
+ if self.reservation_id is None or self.expires_at is None:
364
+ raise MeterbaseError(
365
+ "The engine granted a reservation without an id or an expiry, so "
366
+ "there is no hold to commit or release. The capacity it set "
367
+ "aside comes back on its own."
368
+ )
369
+ if self.quantity is None:
370
+ self.__dict__["quantity"] = (
371
+ 1 if isinstance(quantity, NotGiven) else quantity
372
+ )
373
+
374
+ def commit(self, quantity: int | NotGiven = NOT_GIVEN) -> TrackResult:
375
+ """
376
+ Records what the work actually cost and releases the rest. Omit the
377
+ quantity to record what was held. Going over is allowed: the hold only
378
+ ever guaranteed what it reserved.
379
+
380
+ Warns, on the `meterbase` logger, when it runs after `expires_at`: the
381
+ hold stopped holding anything at that instant, so the work was no
382
+ longer protected. The event is still recorded. That warning is the only
383
+ feedback there is on an `expires_in_seconds` guessed too short.
384
+
385
+ Raises `MeterbaseError` on a refusal, which held nothing to commit.
386
+ """
387
+ reservation_id = self.reservation_id
388
+ if not self.allowed or reservation_id is None:
389
+ raise MeterbaseError(
390
+ "This reserve was refused, so it holds nothing to commit. Record "
391
+ "work that ran anyway with track."
392
+ )
393
+
394
+ expires_at = parse_instant(self.expires_at)
395
+ if expires_at is not None and self._clock.now_ms() > to_ms(expires_at):
396
+ logger.warning(
397
+ "Meterbase: committing reservation %s after it expired at %s. The "
398
+ "event is still recorded, but the capacity was no longer held — "
399
+ "raise expires_in_seconds for this work.",
400
+ reservation_id,
401
+ self.expires_at,
402
+ )
403
+
404
+ return self._client.track(
405
+ customer_id=self._customer_id,
406
+ meter_id=self._meter_id,
407
+ quantity=quantity,
408
+ # The hold's own id, so the track commits it rather than recording
409
+ # a second event beside it.
410
+ idempotency_key=reservation_id,
411
+ )
412
+
413
+ def release(self) -> ReleaseResult | None:
414
+ """
415
+ Gives the hold back. Nothing is recorded.
416
+
417
+ Answers `None` rather than raising when there is nothing left to
418
+ release — already committed, already released, or refused and so never
419
+ held. This is the one call meant to run on the failure path, where
420
+ raising would mask the error the caller is already handling, and
421
+ "nothing left to release" is the outcome a release wanted anyway.
422
+ `meterbase.release` raises the 404, as every other call does.
423
+ """
424
+ reservation_id = self.reservation_id
425
+ if not self.allowed or reservation_id is None:
426
+ return None
427
+
428
+ try:
429
+ return self._client.release(reservation_id=reservation_id)
430
+ except NotFoundError:
431
+ return None
@@ -0,0 +1,20 @@
1
+ # Generated by scripts/unasync.py from
2
+ # src/meterbase/_async/resources/__init__.py.
3
+ # Do not edit: change the async source, then run `make unasync`.
4
+
5
+ from .customer_allowances import CustomerAllowances
6
+ from .customer_periods import CustomerPeriods
7
+ from .customer_plan import CustomerPlan
8
+ from .customers import Customers
9
+ from .meters import Meters
10
+ from .plans import PlanAllowances, Plans
11
+
12
+ __all__ = [
13
+ "CustomerAllowances",
14
+ "CustomerPeriods",
15
+ "CustomerPlan",
16
+ "Customers",
17
+ "Meters",
18
+ "PlanAllowances",
19
+ "Plans",
20
+ ]