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,16 @@
1
+ from .customer_allowances import AsyncCustomerAllowances
2
+ from .customer_periods import AsyncCustomerPeriods
3
+ from .customer_plan import AsyncCustomerPlan
4
+ from .customers import AsyncCustomers
5
+ from .meters import AsyncMeters
6
+ from .plans import AsyncPlanAllowances, AsyncPlans
7
+
8
+ __all__ = [
9
+ "AsyncCustomerAllowances",
10
+ "AsyncCustomerPeriods",
11
+ "AsyncCustomerPlan",
12
+ "AsyncCustomers",
13
+ "AsyncMeters",
14
+ "AsyncPlanAllowances",
15
+ "AsyncPlans",
16
+ ]
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from ..._http import segment
7
+ from ..._models import ListResult, list_of
8
+ from ..._not_given import NOT_GIVEN, NotGiven
9
+ from ...types import Allowance, AllowanceSource
10
+ from .._transport import AsyncRequester
11
+
12
+
13
+ class AsyncCustomerAllowances:
14
+ """
15
+ Capacity handed to one customer on top of their plan. A grant with
16
+ `source="plan"` is the engine's own, minted when a plan is assigned, and
17
+ cannot be created here.
18
+ """
19
+
20
+ def __init__(self, api: AsyncRequester) -> None:
21
+ self._api = api
22
+
23
+ async def grant(
24
+ self,
25
+ customer_id: str,
26
+ *,
27
+ meter_id: str,
28
+ source: AllowanceSource,
29
+ amount: int | NotGiven = NOT_GIVEN,
30
+ no_cap: bool | NotGiven = NOT_GIVEN,
31
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
32
+ effective_at: str | NotGiven = NOT_GIVEN,
33
+ expires_at: str | NotGiven = NOT_GIVEN,
34
+ ) -> Allowance:
35
+ """
36
+ Give exactly one of `amount` or `no_cap`. Unlike a plan allowance, a
37
+ grant of 0 is refused: a grant that gives nothing is a no-op, not a
38
+ state, so `amount` is at least 1.
39
+
40
+ `source` is required: the engine will not guess why capacity was handed
41
+ out. `effective_at` defaults to now; dated ahead, the grant is inert
42
+ until then.
43
+ """
44
+ return await self._api.request(
45
+ "POST",
46
+ f"/v1/customers/{segment(customer_id)}/allowances",
47
+ Allowance,
48
+ body={
49
+ "meter_id": meter_id,
50
+ "amount": amount,
51
+ "no_cap": no_cap,
52
+ "source": source,
53
+ "metadata": metadata,
54
+ "effective_at": effective_at,
55
+ "expires_at": expires_at,
56
+ },
57
+ )
58
+
59
+ async def list(
60
+ self,
61
+ customer_id: str,
62
+ *,
63
+ meter_id: str | NotGiven = NOT_GIVEN,
64
+ include_closed: bool | NotGiven = NOT_GIVEN,
65
+ ) -> ListResult[Allowance]:
66
+ """Open grants only, unless `include_closed`: revoked, expired and
67
+ exhausted grants as well."""
68
+ return await self._api.request(
69
+ "GET",
70
+ f"/v1/customers/{segment(customer_id)}/allowances",
71
+ list_of(Allowance),
72
+ query={"meter_id": meter_id, "include_closed": include_closed},
73
+ )
74
+
75
+ async def retrieve(self, customer_id: str, allowance_id: str) -> Allowance:
76
+ return await self._api.request(
77
+ "GET",
78
+ f"/v1/customers/{segment(customer_id)}/allowances/{segment(allowance_id)}",
79
+ Allowance,
80
+ )
81
+
82
+ async def revoke(self, customer_id: str, allowance_id: str) -> Allowance:
83
+ """Withdraws what is left without erasing what was consumed.
84
+ Idempotent."""
85
+ return await self._api.request(
86
+ "DELETE",
87
+ f"/v1/customers/{segment(customer_id)}/allowances/{segment(allowance_id)}",
88
+ Allowance,
89
+ )
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from ..._http import segment
4
+ from ..._not_given import NOT_GIVEN, NotGiven
5
+ from ...errors import NotFoundError
6
+ from ...types import ClosedPeriod
7
+ from .._transport import AsyncRequester
8
+
9
+
10
+ class AsyncCustomerPeriods:
11
+ """A customer's ended periods, exact from the engine's counters: the
12
+ figures to bill from."""
13
+
14
+ def __init__(self, api: AsyncRequester) -> None:
15
+ self._api = api
16
+
17
+ async def last(
18
+ self, id: str, *, before: str | NotGiven = NOT_GIVEN
19
+ ) -> ClosedPeriod | None:
20
+ """
21
+ The newest period that has ended, or that ended at or before `before`;
22
+ `None` when there is none. Walk back by passing each period's `from_`
23
+ as `before`, exactly as it came. Raises `PeriodSettlingError` for a
24
+ minute after a period ends.
25
+
26
+ `before` is an RFC 3339 instant; omitted, now. It is a string rather
27
+ than a `datetime` so that a period's `from_` goes back verbatim: the
28
+ engine's carry microseconds, and a `before` short of one names the
29
+ period before it.
30
+ """
31
+ try:
32
+ return await self._api.request(
33
+ "GET",
34
+ f"/v1/customers/{segment(id)}/periods/last",
35
+ ClosedPeriod,
36
+ query={"before": before},
37
+ )
38
+ except NotFoundError as error:
39
+ if error.code in ("no_plan_assigned", "no_previous_period"):
40
+ return None
41
+ raise
@@ -0,0 +1,228 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+
5
+ from ..._http import segment
6
+ from ..._models import ListResult, list_of
7
+ from ..._not_given import NOT_GIVEN, NotGiven
8
+ from ..._time import parse_instant
9
+ from ...errors import NotFoundError
10
+ from ...types import ApplyMode, Assignment, Reconciliation
11
+ from .._io import both
12
+ from .._transport import AsyncRequester
13
+
14
+
15
+ class AsyncCustomerPlan:
16
+ """
17
+ Which plan a customer holds. The history is append-only: there is no patch
18
+ and no delete, and moving a customer to a different plan is the same call
19
+ as their first.
20
+
21
+ `assign` is that call, mirroring the wire. The rest name the four moves a
22
+ caller actually makes — later, now, now-and-start-over, and never mind — so
23
+ that picking one does not mean knowing what `effective` and
24
+ `reconciliation` do to a half-used period. They name the plan `plan`
25
+ rather than `plan_id`: they sit a layer above the wire, where `assign`
26
+ still mirrors it field for field.
27
+ """
28
+
29
+ def __init__(self, api: AsyncRequester) -> None:
30
+ self._api = api
31
+
32
+ async def assign(
33
+ self,
34
+ customer_id: str,
35
+ *,
36
+ plan_id: str,
37
+ effective: ApplyMode | NotGiven = NOT_GIVEN,
38
+ reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
39
+ cycle_anchor: str | NotGiven = NOT_GIVEN,
40
+ ) -> Assignment:
41
+ """
42
+ Put a customer on a plan. This is the wire call, and the only one that
43
+ takes `cycle_anchor` — a first assignment's one chance to put their
44
+ periods on a date they already have.
45
+
46
+ Returns the instruction as recorded, which is not always as asked: a
47
+ first plan is recorded `immediate` whatever `effective` said.
48
+
49
+ `effective` defaults to `next_cycle` on a change, and is forced to
50
+ `immediate` on a customer's first plan: there is no period to wait
51
+ out. `reconciliation` is `immediate` only; the engine refuses it beside
52
+ `next_cycle`. `cycle_anchor` defaults to the instant the plan lands,
53
+ and is refused beside `reconciliation="reset"`, which anchors at the
54
+ change instant itself.
55
+ """
56
+ return await self._api.request(
57
+ "POST",
58
+ f"/v1/customers/{segment(customer_id)}/plan",
59
+ Assignment,
60
+ body={
61
+ "plan_id": plan_id,
62
+ "effective": effective,
63
+ "reconciliation": reconciliation,
64
+ "cycle_anchor": cycle_anchor,
65
+ },
66
+ )
67
+
68
+ async def change(
69
+ self,
70
+ customer_id: str,
71
+ *,
72
+ plan: str,
73
+ effective: ApplyMode | NotGiven = NOT_GIVEN,
74
+ reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
75
+ ) -> Assignment:
76
+ """
77
+ Move a customer to a different plan, named by its Meterbase id, with
78
+ both knobs in the open. `effective` defaults to `next_cycle`;
79
+ `reconciliation` applies only to an `immediate` change and defaults to
80
+ `none`.
81
+
82
+ Reach for `change_at_next_cycle`, `change_now` or `restart` when one of
83
+ them says what you mean — this is the form to fall back to when the two
84
+ are chosen at runtime.
85
+ """
86
+ return await self.assign(
87
+ customer_id,
88
+ plan_id=plan,
89
+ effective=effective,
90
+ reconciliation=reconciliation,
91
+ )
92
+
93
+ async def change_at_next_cycle(self, customer_id: str, *, plan: str) -> Assignment:
94
+ """
95
+ Schedule the move for the end of the period they are in: they keep the
96
+ plan they are paying for until it runs out, and the new one starts at
97
+ their next boundary. Nothing is pro-rated, because nothing is split.
98
+
99
+ Two plans on different cycles share no boundary, and this answers
100
+ `CycleChangeRequiresResetError` — use `restart` for that move.
101
+ """
102
+ return await self.change(customer_id, plan=plan, effective="next_cycle")
103
+
104
+ async def change_now(
105
+ self,
106
+ customer_id: str,
107
+ *,
108
+ plan: str,
109
+ reconciliation: Reconciliation | NotGiven = NOT_GIVEN,
110
+ ) -> Assignment:
111
+ """
112
+ Move them now, inside the period already running. Their renewal day
113
+ does not move; what changes is this period's cap, and `reconciliation`
114
+ says how:
115
+
116
+ - `none` (the default) — the new plan's amount governs the whole
117
+ period. Usage already spent still counts, so a downgrade can deny
118
+ until the period ends.
119
+ - `prorate` — each plan's amount weighted by the fraction of the period
120
+ it was held for. Refused between plans on different cycles, which
121
+ share no period to weight.
122
+
123
+ To have the period itself start over instead, use `restart`.
124
+ """
125
+ return await self.change(
126
+ customer_id,
127
+ plan=plan,
128
+ effective="immediate",
129
+ reconciliation=reconciliation,
130
+ )
131
+
132
+ async def restart(self, customer_id: str, *, plan: str) -> Assignment:
133
+ """
134
+ Move them now and start a fresh period today: the period they were in
135
+ closes where the change lands, keeping the usage it had, and the new
136
+ plan's full amount opens immediately.
137
+
138
+ This moves the customer's anchor, so their renewal day becomes today.
139
+ It is the move for a customer starting over — a new contract, a
140
+ re-signup — and the only one that can take a customer between plans
141
+ whose cycles differ.
142
+ """
143
+ return await self.change(
144
+ customer_id,
145
+ plan=plan,
146
+ effective="immediate",
147
+ reconciliation="reset",
148
+ )
149
+
150
+ async def cancel_scheduled_change(self, customer_id: str) -> Assignment | None:
151
+ """
152
+ Call off a change that has not landed yet, leaving the customer on the
153
+ plan they hold. Naming the plan already held is what supersedes a
154
+ pending instruction, so this reads the plan in force and names it back.
155
+
156
+ Returns the assignment still in force, or `None` for a customer holding
157
+ no plan — who can have nothing pending, since a first assignment
158
+ always lands at once.
159
+ """
160
+ current = await self.retrieve(customer_id)
161
+ if current is None:
162
+ return None
163
+
164
+ return await self.assign(customer_id, plan_id=current.plan_id)
165
+
166
+ async def retrieve(self, customer_id: str) -> Assignment | None:
167
+ """
168
+ The plan in force now, which is not always the newest instruction: a
169
+ change dated ahead does not govern yet.
170
+
171
+ `None` when the customer holds no plan. That is a default deny rather
172
+ than a failure — they are entitled to nothing — so it is an answer here
173
+ and not a raised `NotFoundError`. A customer who does not exist at all
174
+ still raises one.
175
+ """
176
+ try:
177
+ return await self._api.request(
178
+ "GET", f"/v1/customers/{segment(customer_id)}/plan", Assignment
179
+ )
180
+ except NotFoundError as error:
181
+ if error.code == "no_plan_assigned":
182
+ return None
183
+ raise
184
+
185
+ async def pending(self, customer_id: str) -> Assignment | None:
186
+ """
187
+ The change waiting to land, or `None` when none is. At most one is ever
188
+ live: a newer instruction supersedes the one before it.
189
+
190
+ Read against the assignment in force rather than against the local
191
+ clock, so a change landing seconds from now is not reported as already
192
+ governing.
193
+ """
194
+ current, history = await both(
195
+ self.retrieve(customer_id), self.history(customer_id)
196
+ )
197
+
198
+ if current is None:
199
+ in_force = datetime.min.replace(tzinfo=timezone.utc)
200
+ else:
201
+ parsed = parse_instant(current.effective_at)
202
+ if parsed is None:
203
+ return None
204
+ in_force = parsed
205
+
206
+ return next(
207
+ (
208
+ row
209
+ for row in history.data
210
+ if row.superseded_at is None
211
+ and _lands_after(row.effective_at, in_force)
212
+ ),
213
+ None,
214
+ )
215
+
216
+ async def history(self, customer_id: str) -> ListResult[Assignment]:
217
+ """Every instruction, superseded ones included: it is the whole
218
+ trail."""
219
+ return await self._api.request(
220
+ "GET",
221
+ f"/v1/customers/{segment(customer_id)}/plan/history",
222
+ list_of(Assignment),
223
+ )
224
+
225
+
226
+ def _lands_after(effective_at: str, in_force: datetime) -> bool:
227
+ at = parse_instant(effective_at)
228
+ return at is not None and at > in_force
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any, overload
5
+
6
+ from ..._http import segment
7
+ from ..._models import ListResult, list_of
8
+ from ..._not_given import NOT_GIVEN, NotGiven
9
+ from ...errors import NotFoundError
10
+ from ...types import (
11
+ Customer,
12
+ CustomerEntitlement,
13
+ CustomerUsage,
14
+ CustomerWithPlan,
15
+ UsagePeriod,
16
+ )
17
+ from .._transport import AsyncRequester
18
+ from .customer_allowances import AsyncCustomerAllowances
19
+ from .customer_periods import AsyncCustomerPeriods
20
+ from .customer_plan import AsyncCustomerPlan
21
+
22
+
23
+ class AsyncCustomers:
24
+ plan: AsyncCustomerPlan
25
+ """The plan they hold, and the trail of instructions that got them
26
+ there."""
27
+ allowances: AsyncCustomerAllowances
28
+ """Grants, which sit on top of whatever the plan gives."""
29
+ periods: AsyncCustomerPeriods
30
+ """Periods that have ended, with the figures to bill them from."""
31
+
32
+ def __init__(self, api: AsyncRequester) -> None:
33
+ self._api = api
34
+ self.plan = AsyncCustomerPlan(api)
35
+ self.allowances = AsyncCustomerAllowances(api)
36
+ self.periods = AsyncCustomerPeriods(api)
37
+
38
+ async def create(
39
+ self,
40
+ *,
41
+ external_id: str,
42
+ name: str | NotGiven = NOT_GIVEN,
43
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
44
+ thresholds: Sequence[int] | NotGiven = NOT_GIVEN,
45
+ ) -> Customer:
46
+ """
47
+ `thresholds` is whole percents, 1–1000, at most 32 of them; a mark over
48
+ 100 is an overage alert. Omitted, the customer inherits.
49
+ """
50
+ return await self._api.request(
51
+ "POST",
52
+ "/v1/customers",
53
+ Customer,
54
+ body={
55
+ "external_id": external_id,
56
+ "name": name,
57
+ "metadata": metadata,
58
+ "thresholds": thresholds,
59
+ },
60
+ )
61
+
62
+ async def list(
63
+ self, *, include_deleted: bool | NotGiven = NOT_GIVEN
64
+ ) -> ListResult[Customer]:
65
+ """Lean: a listing does not embed the plan. Read one customer for
66
+ that."""
67
+ return await self._api.request(
68
+ "GET",
69
+ "/v1/customers",
70
+ list_of(Customer),
71
+ query={"include_deleted": include_deleted},
72
+ )
73
+
74
+ async def retrieve(self, id: str) -> CustomerWithPlan:
75
+ """
76
+ One customer, with the plan they hold. `plan` is the one in force now
77
+ and `pending_plan` the change waiting to land, each `None` when there
78
+ is none — so knowing what a customer is on costs no second request.
79
+ """
80
+ return await self._api.request(
81
+ "GET", f"/v1/customers/{segment(id)}", CustomerWithPlan
82
+ )
83
+
84
+ async def retrieve_by_external_id(
85
+ self, external_id: str
86
+ ) -> CustomerWithPlan | None:
87
+ """
88
+ Resolves the tenant's own identifier, or `None`, embedding the plan the
89
+ way `retrieve` does. The engine answers this as a filtered list, so a
90
+ miss is an empty collection rather than a 404.
91
+ """
92
+ found = await self._api.request(
93
+ "GET",
94
+ "/v1/customers",
95
+ list_of(CustomerWithPlan),
96
+ query={"external_id": external_id},
97
+ )
98
+ return found.data[0] if found.data else None
99
+
100
+ async def entitlement(self, id: str) -> CustomerEntitlement | None:
101
+ """`None` for a customer holding no plan, as `plan.retrieve` answers."""
102
+ try:
103
+ return await self._api.request(
104
+ "GET",
105
+ f"/v1/customers/{segment(id)}/entitlement",
106
+ CustomerEntitlement,
107
+ )
108
+ except NotFoundError as error:
109
+ if error.code == "no_plan_assigned":
110
+ return None
111
+ raise
112
+
113
+ @overload
114
+ async def usage(self, id: str, *, days: int) -> CustomerUsage: ...
115
+
116
+ @overload
117
+ async def usage(self, id: str, *, period: UsagePeriod) -> CustomerUsage | None: ...
118
+
119
+ async def usage(
120
+ self,
121
+ id: str,
122
+ *,
123
+ days: int | NotGiven = NOT_GIVEN,
124
+ period: UsagePeriod | NotGiven = NOT_GIVEN,
125
+ ) -> CustomerUsage | None:
126
+ """
127
+ Usage per UTC day: `days` is 1–90 of them ending today, or `period` is
128
+ `"current"` or `"last"`. A period the customer never had, holding no
129
+ plan now or none before this period, is `None`.
130
+ """
131
+ try:
132
+ return await self._api.request(
133
+ "GET",
134
+ f"/v1/customers/{segment(id)}/usage",
135
+ CustomerUsage,
136
+ query={"days": days, "period": period},
137
+ )
138
+ except NotFoundError as error:
139
+ if error.code in ("no_plan_assigned", "no_previous_period"):
140
+ return None
141
+ raise
142
+
143
+ async def update(
144
+ self,
145
+ id: str,
146
+ *,
147
+ external_id: str | NotGiven = NOT_GIVEN,
148
+ name: str | NotGiven = NOT_GIVEN,
149
+ metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
150
+ thresholds: Sequence[int] | NotGiven | None = NOT_GIVEN,
151
+ ) -> Customer:
152
+ """
153
+ An omitted field is left alone. `external_id` cannot be cleared, and
154
+ `metadata` is replaced wholesale rather than merged.
155
+
156
+ `thresholds` reads `None` as a value, not as silence: omit it to keep
157
+ the stored list, pass `None` to go back to inheriting the meter's or
158
+ the workspace's, pass `[]` to turn alerts off for this customer alone.
159
+ """
160
+ return await self._api.request(
161
+ "PATCH",
162
+ f"/v1/customers/{segment(id)}",
163
+ Customer,
164
+ body={
165
+ "external_id": external_id,
166
+ "name": name,
167
+ "metadata": metadata,
168
+ "thresholds": thresholds,
169
+ },
170
+ )
171
+
172
+ async def delete(self, id: str) -> Customer:
173
+ """Soft delete: usage and assignments keep referencing the customer."""
174
+ return await self._api.request(
175
+ "DELETE", f"/v1/customers/{segment(id)}", Customer
176
+ )
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Sequence
4
+
5
+ from ..._http import segment
6
+ from ..._models import ListResult, list_of
7
+ from ..._not_given import NOT_GIVEN, NotGiven
8
+ from ...types import Meter, MeterUsageHistory
9
+ from .._transport import AsyncRequester
10
+
11
+
12
+ class AsyncMeters:
13
+ def __init__(self, api: AsyncRequester) -> None:
14
+ self._api = api
15
+
16
+ async def create(
17
+ self,
18
+ *,
19
+ key: str,
20
+ name: str,
21
+ description: str | NotGiven = NOT_GIVEN,
22
+ unit: str | NotGiven = NOT_GIVEN,
23
+ value: str | NotGiven = NOT_GIVEN,
24
+ thresholds: Sequence[int] | NotGiven = NOT_GIVEN,
25
+ ) -> Meter:
26
+ """
27
+ `thresholds` is whole percents, 1–1000, at most 32 of them; a mark over
28
+ 100 is an overage alert. Omitted, the meter inherits the workspace's.
29
+ """
30
+ return await self._api.request(
31
+ "POST",
32
+ "/v1/meters",
33
+ Meter,
34
+ body={
35
+ "key": key,
36
+ "name": name,
37
+ "description": description,
38
+ "unit": unit,
39
+ "value": value,
40
+ "thresholds": thresholds,
41
+ },
42
+ )
43
+
44
+ async def list(
45
+ self, *, include_archived: bool | NotGiven = NOT_GIVEN
46
+ ) -> ListResult[Meter]:
47
+ return await self._api.request(
48
+ "GET",
49
+ "/v1/meters",
50
+ list_of(Meter),
51
+ query={"include_archived": include_archived},
52
+ )
53
+
54
+ async def retrieve(self, id: str) -> Meter:
55
+ return await self._api.request("GET", f"/v1/meters/{segment(id)}", Meter)
56
+
57
+ async def update(
58
+ self,
59
+ id: str,
60
+ *,
61
+ key: str | NotGiven = NOT_GIVEN,
62
+ name: str | NotGiven = NOT_GIVEN,
63
+ description: str | NotGiven = NOT_GIVEN,
64
+ unit: str | NotGiven = NOT_GIVEN,
65
+ value: str | NotGiven = NOT_GIVEN,
66
+ thresholds: Sequence[int] | NotGiven | None = NOT_GIVEN,
67
+ ) -> Meter:
68
+ """
69
+ An omitted field is left alone; `""` clears an optional one.
70
+
71
+ `thresholds` is the exception on both counts: `None` is a value there,
72
+ not silence. Omit it to keep the stored list, pass `None` to inherit
73
+ the workspace's again, pass `[]` to turn alerts off for this meter.
74
+ """
75
+ return await self._api.request(
76
+ "PATCH",
77
+ f"/v1/meters/{segment(id)}",
78
+ Meter,
79
+ body={
80
+ "key": key,
81
+ "name": name,
82
+ "description": description,
83
+ "unit": unit,
84
+ "value": value,
85
+ "thresholds": thresholds,
86
+ },
87
+ )
88
+
89
+ async def usage(self, id: str, *, days: int) -> MeterUsageHistory:
90
+ """Every customer's usage of the meter per UTC day: `days` is 1–90 of
91
+ them, ending today."""
92
+ return await self._api.request(
93
+ "GET",
94
+ f"/v1/meters/{segment(id)}/usage",
95
+ MeterUsageHistory,
96
+ query={"days": days},
97
+ )
98
+
99
+ async def archive(self, id: str) -> Meter:
100
+ """Soft delete: plans and usage keep referencing the meter.
101
+ Idempotent."""
102
+ return await self._api.request("DELETE", f"/v1/meters/{segment(id)}", Meter)