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,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)
|