meterbase 0.1.5__tar.gz → 0.2.0__tar.gz
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-0.1.5 → meterbase-0.2.0}/PKG-INFO +67 -13
- {meterbase-0.1.5 → meterbase-0.2.0}/README.md +66 -12
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/_transport.py +11 -6
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/client.py +22 -5
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customers.py +51 -1
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_http.py +16 -3
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/_transport.py +11 -6
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/client.py +22 -5
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customers.py +51 -1
- meterbase-0.2.0/src/meterbase/_version.py +1 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/errors.py +1 -1
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/types.py +20 -0
- meterbase-0.1.5/src/meterbase/_version.py +0 -1
- {meterbase-0.1.5 → meterbase-0.2.0}/.gitignore +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/LICENSE +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/pyproject.toml +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/_io.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_allowances.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_periods.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/customer_plan.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/meters.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_async/resources/plans.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_json.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_keys.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_models.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_not_given.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/_io.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/__init__.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_allowances.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_periods.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/customer_plan.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/meters.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_sync/resources/plans.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/_time.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/py.typed +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/resources.py +0 -0
- {meterbase-0.1.5 → meterbase-0.2.0}/src/meterbase/webhooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: meterbase
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for the Meterbase engine
|
|
5
5
|
Project-URL: Homepage, https://meterbase.tech
|
|
6
6
|
Project-URL: Documentation, https://meterbase.tech/docs
|
|
@@ -185,11 +185,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
|
|
|
185
185
|
that was already recorded.
|
|
186
186
|
|
|
187
187
|
**The SDK generates a key when you omit one, and reuses it across that call's
|
|
188
|
-
retries
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
retries.** For a retry from further out than this SDK — a queue, a restarted
|
|
189
|
+
worker — mint the key once the work is done, and store it with the job before
|
|
190
|
+
the first send:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
if job.usage_key is None:
|
|
194
|
+
job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
|
|
195
|
+
save_job(job) # before sending, so a rerun reuses it
|
|
196
|
+
|
|
197
|
+
meterbase.track(
|
|
198
|
+
customer_id=job.customer_id,
|
|
199
|
+
meter_id="ai_tokens",
|
|
200
|
+
quantity=job.tokens,
|
|
201
|
+
idempotency_key=job.usage_key,
|
|
202
|
+
)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
It is dated by the engine's clock as this client knows it, and the hour it is
|
|
206
|
+
admitted for starts then: mint it when the work is queued and a slow queue can
|
|
207
|
+
outlast it.
|
|
193
208
|
|
|
194
209
|
### Work you cannot do twice
|
|
195
210
|
|
|
@@ -256,6 +271,30 @@ afresh rather than finding anything.
|
|
|
256
271
|
|
|
257
272
|
### Customers
|
|
258
273
|
|
|
274
|
+
**At sign-up, ensure the customer.** One call gets or creates them by your own
|
|
275
|
+
id and gives them a first plan, and running it again changes nothing:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
ensured = meterbase.customers.ensure(
|
|
279
|
+
external_id="acct_1",
|
|
280
|
+
name="Acme", # applied only when it creates the customer
|
|
281
|
+
plan="free", # a plan key
|
|
282
|
+
)
|
|
283
|
+
ensured.created, ensured.plan_result, ensured.customer.plan
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`plan_result` says what it did with the plan: `assigned`, `already_held`,
|
|
287
|
+
`kept_other` when the customer holds another plan, or `none` when you named
|
|
288
|
+
none. It never changes a plan the customer holds — a checkout that must move
|
|
289
|
+
them calls [`customers.plan`](#a-customers-plan) — and never cancels a
|
|
290
|
+
scheduled change. `cycle_anchor` puts a first plan's periods on a date the
|
|
291
|
+
customer already has, and `mint_one_time=False` writes the plan's one-time
|
|
292
|
+
grants already spent, for a customer you import with their remaining bonus.
|
|
293
|
+
|
|
294
|
+
`ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
|
|
295
|
+
when the id is missing, empty or whitespace: without one, the lookup would name
|
|
296
|
+
no customer.
|
|
297
|
+
|
|
259
298
|
```python
|
|
260
299
|
customer = meterbase.customers.create(
|
|
261
300
|
external_id="acct_1",
|
|
@@ -811,14 +850,18 @@ SDK would raise for a response, for realistic errors in your own tests.
|
|
|
811
850
|
Meterbase(
|
|
812
851
|
api_key="mb_sk_live_…",
|
|
813
852
|
base_url="https://api.meterbase.dev",
|
|
814
|
-
timeout=
|
|
853
|
+
timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
|
|
815
854
|
max_retries=2,
|
|
816
855
|
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
|
|
817
856
|
)
|
|
818
857
|
```
|
|
819
858
|
|
|
820
859
|
`timeout` bounds each attempt — connecting, sending, and each wait for the
|
|
821
|
-
engine to answer — not the whole call.
|
|
860
|
+
engine to answer — not the whole call. **`check` waits 3 s** unless the client
|
|
861
|
+
sets `timeout`: it gates a request someone is waiting on, and answers in
|
|
862
|
+
milliseconds when the engine is well. Decide what an `APITimeoutError` means
|
|
863
|
+
there — failing open or failing closed — rather than leaving the user waiting
|
|
864
|
+
out the 35 s the writes get. Pass an `http_client` for proxies,
|
|
822
865
|
limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
|
|
823
866
|
`httpx.AsyncClient` for `AsyncMeterbase`.
|
|
824
867
|
|
|
@@ -838,11 +881,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
838
881
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
839
882
|
bound it with `timeout` and `max_retries` instead.
|
|
840
883
|
|
|
841
|
-
**Retries** apply to `GET`, `DELETE`, `track
|
|
842
|
-
failures, timeouts, 408, 429 and 5xx, with
|
|
843
|
-
`Retry-After` is honoured when the engine
|
|
844
|
-
other `POST` and `PATCH` is never
|
|
845
|
-
produce a second row.
|
|
884
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve` and
|
|
885
|
+
`customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
|
|
886
|
+
exponential backoff and full jitter. `Retry-After` is honoured when the engine
|
|
887
|
+
sends it, up to eight seconds. Every other `POST` and `PATCH` is never
|
|
888
|
+
replayed, because a retried create could produce a second row. An `ensure` can
|
|
889
|
+
be, because running it twice changes nothing: one whose first attempt landed
|
|
890
|
+
answers `created` false.
|
|
846
891
|
|
|
847
892
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
848
893
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -871,6 +916,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
871
916
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
872
917
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
873
918
|
|
|
919
|
+
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
920
|
+
behaviours:
|
|
921
|
+
|
|
922
|
+
- `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
|
|
923
|
+
on the client, or through `with_options`, still wins.
|
|
924
|
+
- `retrieve_by_external_id` raises before sending when the id is missing, empty
|
|
925
|
+
or whitespace. Before, a `None` was dropped from the query, and the engine
|
|
926
|
+
answered with every customer, the newest first, which this returned.
|
|
927
|
+
|
|
874
928
|
## Development
|
|
875
929
|
|
|
876
930
|
```bash
|
|
@@ -158,11 +158,26 @@ see the error. When **you** supply the key, you get `TooLateError` with
|
|
|
158
158
|
that was already recorded.
|
|
159
159
|
|
|
160
160
|
**The SDK generates a key when you omit one, and reuses it across that call's
|
|
161
|
-
retries
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
retries.** For a retry from further out than this SDK — a queue, a restarted
|
|
162
|
+
worker — mint the key once the work is done, and store it with the job before
|
|
163
|
+
the first send:
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
if job.usage_key is None:
|
|
167
|
+
job.usage_key = meterbase.idempotency_key() # a UUIDv7, by the engine's clock
|
|
168
|
+
save_job(job) # before sending, so a rerun reuses it
|
|
169
|
+
|
|
170
|
+
meterbase.track(
|
|
171
|
+
customer_id=job.customer_id,
|
|
172
|
+
meter_id="ai_tokens",
|
|
173
|
+
quantity=job.tokens,
|
|
174
|
+
idempotency_key=job.usage_key,
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
It is dated by the engine's clock as this client knows it, and the hour it is
|
|
179
|
+
admitted for starts then: mint it when the work is queued and a slow queue can
|
|
180
|
+
outlast it.
|
|
166
181
|
|
|
167
182
|
### Work you cannot do twice
|
|
168
183
|
|
|
@@ -229,6 +244,30 @@ afresh rather than finding anything.
|
|
|
229
244
|
|
|
230
245
|
### Customers
|
|
231
246
|
|
|
247
|
+
**At sign-up, ensure the customer.** One call gets or creates them by your own
|
|
248
|
+
id and gives them a first plan, and running it again changes nothing:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
ensured = meterbase.customers.ensure(
|
|
252
|
+
external_id="acct_1",
|
|
253
|
+
name="Acme", # applied only when it creates the customer
|
|
254
|
+
plan="free", # a plan key
|
|
255
|
+
)
|
|
256
|
+
ensured.created, ensured.plan_result, ensured.customer.plan
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
`plan_result` says what it did with the plan: `assigned`, `already_held`,
|
|
260
|
+
`kept_other` when the customer holds another plan, or `none` when you named
|
|
261
|
+
none. It never changes a plan the customer holds — a checkout that must move
|
|
262
|
+
them calls [`customers.plan`](#a-customers-plan) — and never cancels a
|
|
263
|
+
scheduled change. `cycle_anchor` puts a first plan's periods on a date the
|
|
264
|
+
customer already has, and `mint_one_time=False` writes the plan's one-time
|
|
265
|
+
grants already spent, for a customer you import with their remaining bonus.
|
|
266
|
+
|
|
267
|
+
`ensure` and `retrieve_by_external_id` raise `MeterbaseError` before sending
|
|
268
|
+
when the id is missing, empty or whitespace: without one, the lookup would name
|
|
269
|
+
no customer.
|
|
270
|
+
|
|
232
271
|
```python
|
|
233
272
|
customer = meterbase.customers.create(
|
|
234
273
|
external_id="acct_1",
|
|
@@ -784,14 +823,18 @@ SDK would raise for a response, for realistic errors in your own tests.
|
|
|
784
823
|
Meterbase(
|
|
785
824
|
api_key="mb_sk_live_…",
|
|
786
825
|
base_url="https://api.meterbase.dev",
|
|
787
|
-
timeout=
|
|
826
|
+
timeout=10.0, # seconds, per attempt, on every call; unset, 35, and 3 for check
|
|
788
827
|
max_retries=2,
|
|
789
828
|
http_client=httpx.Client(proxy="http://localhost:8030"), # yours to close
|
|
790
829
|
)
|
|
791
830
|
```
|
|
792
831
|
|
|
793
832
|
`timeout` bounds each attempt — connecting, sending, and each wait for the
|
|
794
|
-
engine to answer — not the whole call.
|
|
833
|
+
engine to answer — not the whole call. **`check` waits 3 s** unless the client
|
|
834
|
+
sets `timeout`: it gates a request someone is waiting on, and answers in
|
|
835
|
+
milliseconds when the engine is well. Decide what an `APITimeoutError` means
|
|
836
|
+
there — failing open or failing closed — rather than leaving the user waiting
|
|
837
|
+
out the 35 s the writes get. Pass an `http_client` for proxies,
|
|
795
838
|
limits or a transport of your own: an `httpx.Client` for `Meterbase`, an
|
|
796
839
|
`httpx.AsyncClient` for `AsyncMeterbase`.
|
|
797
840
|
|
|
@@ -811,11 +854,13 @@ an attempt already on the wire may still land, but it is never replayed, so a
|
|
|
811
854
|
cancelled `track` cannot record usage twice. A sync call cannot be cancelled;
|
|
812
855
|
bound it with `timeout` and `max_retries` instead.
|
|
813
856
|
|
|
814
|
-
**Retries** apply to `GET`, `DELETE`, `track
|
|
815
|
-
failures, timeouts, 408, 429 and 5xx, with
|
|
816
|
-
`Retry-After` is honoured when the engine
|
|
817
|
-
other `POST` and `PATCH` is never
|
|
818
|
-
produce a second row.
|
|
857
|
+
**Retries** apply to `GET`, `DELETE`, `track`, `reserve` and
|
|
858
|
+
`customers.ensure`, on connection failures, timeouts, 408, 429 and 5xx, with
|
|
859
|
+
exponential backoff and full jitter. `Retry-After` is honoured when the engine
|
|
860
|
+
sends it, up to eight seconds. Every other `POST` and `PATCH` is never
|
|
861
|
+
replayed, because a retried create could produce a second row. An `ensure` can
|
|
862
|
+
be, because running it twice changes nothing: one whose first attempt landed
|
|
863
|
+
answers `created` false.
|
|
819
864
|
|
|
820
865
|
`track` and `reserve` are the exception because they carry an idempotency key,
|
|
821
866
|
and because the SDK fixes that key **before** the retry loop starts: every
|
|
@@ -844,6 +889,15 @@ Pre-1.0, the minor version is where breaking changes land — deliberately, so a
|
|
|
844
889
|
change that will not run against your code cannot arrive as a patch. The SDK
|
|
845
890
|
follows the engine's API, as the TypeScript SDK (`mbase-sdk`) does.
|
|
846
891
|
|
|
892
|
+
**0.2.0** adds `customers.ensure` and `idempotency_key()`, and changes two
|
|
893
|
+
behaviours:
|
|
894
|
+
|
|
895
|
+
- `check` waits 3 s by default. The other calls keep 35 s, and a `timeout` set
|
|
896
|
+
on the client, or through `with_options`, still wins.
|
|
897
|
+
- `retrieve_by_external_id` raises before sending when the id is missing, empty
|
|
898
|
+
or whitespace. Before, a `None` was dropped from the query, and the engine
|
|
899
|
+
answered with every customer, the newest first, which this returned.
|
|
900
|
+
|
|
847
901
|
## Development
|
|
848
902
|
|
|
849
903
|
```bash
|
|
@@ -13,6 +13,7 @@ from typing import Any, TypeVar
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
15
|
from .._http import (
|
|
16
|
+
DEFAULT_TIMEOUT,
|
|
16
17
|
Body,
|
|
17
18
|
Query,
|
|
18
19
|
RequestOptions,
|
|
@@ -70,17 +71,21 @@ class AsyncTransport:
|
|
|
70
71
|
query: Query | None = None,
|
|
71
72
|
body: Body | None = None,
|
|
72
73
|
idempotent: bool = False,
|
|
74
|
+
default_timeout: float = DEFAULT_TIMEOUT,
|
|
73
75
|
) -> Any:
|
|
74
76
|
"""
|
|
75
77
|
`idempotent` replays a call even though its method is not safe. Only a
|
|
76
78
|
route that carries an idempotency key may set it, and the key has to be
|
|
77
79
|
in `body` already: the loop re-sends one body, so a key generated per
|
|
78
80
|
attempt would count the same usage twice.
|
|
81
|
+
|
|
82
|
+
`default_timeout` applies when the options set none.
|
|
79
83
|
"""
|
|
80
84
|
# Safe methods, plus the routes that carry an idempotency key and say
|
|
81
85
|
# so. Any other POST or PATCH could create a second row on a replay.
|
|
82
86
|
retryable = method in ("GET", "DELETE") or idempotent
|
|
83
87
|
|
|
88
|
+
timeout = default_timeout if options.timeout is None else options.timeout
|
|
84
89
|
url = build_url(self._base_url, path, query)
|
|
85
90
|
content = None if body is None else encode_body(body)
|
|
86
91
|
headers = request_headers(self._api_key, has_body=content is not None)
|
|
@@ -91,7 +96,7 @@ class AsyncTransport:
|
|
|
91
96
|
await sleep(backoff(attempt, last_error))
|
|
92
97
|
|
|
93
98
|
try:
|
|
94
|
-
response = await self._send(method, url, headers, content,
|
|
99
|
+
response = await self._send(method, url, headers, content, timeout)
|
|
95
100
|
except APIConnectionError as unanswered:
|
|
96
101
|
# A connection failure proves nothing about whether the server
|
|
97
102
|
# acted, so it is replayed under the same idempotency rule.
|
|
@@ -122,7 +127,7 @@ class AsyncTransport:
|
|
|
122
127
|
url: str,
|
|
123
128
|
headers: dict[str, str],
|
|
124
129
|
content: bytes | None,
|
|
125
|
-
|
|
130
|
+
timeout: float,
|
|
126
131
|
) -> httpx.Response:
|
|
127
132
|
# Neither this nor the loop catches a BaseException — a cancelled task,
|
|
128
133
|
# a Ctrl-C — so one ends the call where it stands and nothing is sent
|
|
@@ -134,12 +139,10 @@ class AsyncTransport:
|
|
|
134
139
|
url,
|
|
135
140
|
headers=headers,
|
|
136
141
|
content=content,
|
|
137
|
-
timeout=
|
|
142
|
+
timeout=timeout,
|
|
138
143
|
)
|
|
139
144
|
except httpx.TimeoutException as error:
|
|
140
|
-
raise APITimeoutError(
|
|
141
|
-
f"Request timed out after {options.timeout:g}s."
|
|
142
|
-
) from error
|
|
145
|
+
raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
|
|
143
146
|
except httpx.TransportError as error:
|
|
144
147
|
raise APIConnectionError(f"Could not reach {self._origin}.") from error
|
|
145
148
|
|
|
@@ -169,6 +172,7 @@ class AsyncRequester:
|
|
|
169
172
|
query: Query | None = None,
|
|
170
173
|
body: Body | None = None,
|
|
171
174
|
idempotent: bool = False,
|
|
175
|
+
default_timeout: float = DEFAULT_TIMEOUT,
|
|
172
176
|
) -> _T:
|
|
173
177
|
data = await self.transport.request(
|
|
174
178
|
method,
|
|
@@ -177,5 +181,6 @@ class AsyncRequester:
|
|
|
177
181
|
query=query,
|
|
178
182
|
body=body,
|
|
179
183
|
idempotent=idempotent,
|
|
184
|
+
default_timeout=default_timeout,
|
|
180
185
|
)
|
|
181
186
|
return construct(cast_to, data)
|
|
@@ -8,8 +8,8 @@ from typing import Any, TypeVar
|
|
|
8
8
|
import httpx
|
|
9
9
|
|
|
10
10
|
from .._http import (
|
|
11
|
+
CHECK_TIMEOUT,
|
|
11
12
|
DEFAULT_MAX_RETRIES,
|
|
12
|
-
DEFAULT_TIMEOUT,
|
|
13
13
|
RequestOptions,
|
|
14
14
|
normalize_base_url,
|
|
15
15
|
)
|
|
@@ -34,9 +34,9 @@ class AsyncMeterbase:
|
|
|
34
34
|
there is no workspace to pass.
|
|
35
35
|
|
|
36
36
|
`timeout` is in seconds and applies to each attempt rather than to the
|
|
37
|
-
whole call.
|
|
38
|
-
`http_client` to configure proxies,
|
|
39
|
-
stays yours to close.
|
|
37
|
+
whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
|
|
38
|
+
the attempts after the first. Pass an `http_client` to configure proxies,
|
|
39
|
+
limits or transports yourself; it stays yours to close.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
customers: AsyncCustomers
|
|
@@ -48,7 +48,7 @@ class AsyncMeterbase:
|
|
|
48
48
|
*,
|
|
49
49
|
api_key: str,
|
|
50
50
|
base_url: str | None = None,
|
|
51
|
-
timeout: float =
|
|
51
|
+
timeout: float | None = None,
|
|
52
52
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
53
53
|
http_client: httpx.AsyncClient | None = None,
|
|
54
54
|
) -> None:
|
|
@@ -121,6 +121,11 @@ class AsyncMeterbase:
|
|
|
121
121
|
|
|
122
122
|
`quantity` is what is about to be consumed, `1 … 100000000000`.
|
|
123
123
|
Omitted, it is 1, which asks whether there is any capacity at all.
|
|
124
|
+
|
|
125
|
+
Waits 3 s unless the client sets `timeout`: it gates a request someone
|
|
126
|
+
is waiting on, and answers in milliseconds when the engine is well.
|
|
127
|
+
Decide what an `APITimeoutError` means here, failing open or closed,
|
|
128
|
+
rather than waiting out the 35 s the writes are given.
|
|
124
129
|
"""
|
|
125
130
|
return await self._api.request(
|
|
126
131
|
"POST",
|
|
@@ -131,6 +136,7 @@ class AsyncMeterbase:
|
|
|
131
136
|
"meter_id": meter_id,
|
|
132
137
|
"quantity": quantity,
|
|
133
138
|
},
|
|
139
|
+
default_timeout=CHECK_TIMEOUT,
|
|
134
140
|
)
|
|
135
141
|
|
|
136
142
|
async def track(
|
|
@@ -246,6 +252,17 @@ class AsyncMeterbase:
|
|
|
246
252
|
"""Reports which workspace this key acts for."""
|
|
247
253
|
return await self._api.request("GET", "/v1/whoami", WhoAmI)
|
|
248
254
|
|
|
255
|
+
def idempotency_key(self) -> str:
|
|
256
|
+
"""
|
|
257
|
+
A UUIDv7 dated by the engine's clock as this client knows it: an
|
|
258
|
+
`idempotency_key` for `track`, or a `reservation_id`. For a retry from
|
|
259
|
+
further out than this SDK, mint one once the work is done, store it
|
|
260
|
+
with the job before the first send, and send it on every retry. The
|
|
261
|
+
engine admits a key for an hour from its date, and a reservation id
|
|
262
|
+
for 30 minutes.
|
|
263
|
+
"""
|
|
264
|
+
return mint(self._api.transport.now_ms())
|
|
265
|
+
|
|
249
266
|
async def aclose(self) -> None:
|
|
250
267
|
"""Closes the connections this client opened. An `http_client` passed
|
|
251
268
|
in stays open: it is yours."""
|
|
@@ -3,12 +3,13 @@ from __future__ import annotations
|
|
|
3
3
|
from collections.abc import Mapping, Sequence
|
|
4
4
|
from typing import Any, overload
|
|
5
5
|
|
|
6
|
-
from ..._http import segment
|
|
6
|
+
from ..._http import require_external_id, segment
|
|
7
7
|
from ..._models import ListResult, list_of
|
|
8
8
|
from ..._not_given import NOT_GIVEN, NotGiven
|
|
9
9
|
from ...errors import NotFoundError
|
|
10
10
|
from ...types import (
|
|
11
11
|
Customer,
|
|
12
|
+
CustomerEnsureResult,
|
|
12
13
|
CustomerEntitlement,
|
|
13
14
|
CustomerUsage,
|
|
14
15
|
CustomerWithPlan,
|
|
@@ -59,6 +60,50 @@ class AsyncCustomers:
|
|
|
59
60
|
},
|
|
60
61
|
)
|
|
61
62
|
|
|
63
|
+
async def ensure(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
external_id: str,
|
|
67
|
+
name: str | NotGiven = NOT_GIVEN,
|
|
68
|
+
metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
|
|
69
|
+
plan: str | NotGiven = NOT_GIVEN,
|
|
70
|
+
cycle_anchor: str | NotGiven = NOT_GIVEN,
|
|
71
|
+
mint_one_time: bool | NotGiven = NOT_GIVEN,
|
|
72
|
+
) -> CustomerEnsureResult:
|
|
73
|
+
"""
|
|
74
|
+
Gets or creates the customer by `external_id`, and gives one who holds
|
|
75
|
+
no plan the `plan` named, in one transaction. Safe on every sign-up and
|
|
76
|
+
safe to retry: rerunning it changes nothing, and it never changes a
|
|
77
|
+
plan the customer already holds. `plan_result` says which of those
|
|
78
|
+
happened.
|
|
79
|
+
|
|
80
|
+
`name` and `metadata` apply only to a customer this creates. `plan` is
|
|
81
|
+
a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
|
|
82
|
+
only with `plan` and never in the future, is used only when this call
|
|
83
|
+
assigns the plan. `mint_one_time=False` writes the plan's one-time
|
|
84
|
+
grants already spent, for an imported customer whose remaining bonus
|
|
85
|
+
you grant yourself.
|
|
86
|
+
|
|
87
|
+
Raises `MeterbaseError` before sending when `external_id` is missing,
|
|
88
|
+
empty or whitespace.
|
|
89
|
+
"""
|
|
90
|
+
require_external_id("ensure", external_id)
|
|
91
|
+
|
|
92
|
+
return await self._api.request(
|
|
93
|
+
"POST",
|
|
94
|
+
"/v1/customers/ensure",
|
|
95
|
+
CustomerEnsureResult,
|
|
96
|
+
body={
|
|
97
|
+
"external_id": external_id,
|
|
98
|
+
"name": name,
|
|
99
|
+
"metadata": metadata,
|
|
100
|
+
"plan": plan,
|
|
101
|
+
"cycle_anchor": cycle_anchor,
|
|
102
|
+
"mint_one_time": mint_one_time,
|
|
103
|
+
},
|
|
104
|
+
idempotent=True,
|
|
105
|
+
)
|
|
106
|
+
|
|
62
107
|
async def list(
|
|
63
108
|
self, *, include_deleted: bool | NotGiven = NOT_GIVEN
|
|
64
109
|
) -> ListResult[Customer]:
|
|
@@ -88,7 +133,12 @@ class AsyncCustomers:
|
|
|
88
133
|
Resolves the tenant's own identifier, or `None`, embedding the plan the
|
|
89
134
|
way `retrieve` does. The engine answers this as a filtered list, so a
|
|
90
135
|
miss is an empty collection rather than a 404.
|
|
136
|
+
|
|
137
|
+
Raises `MeterbaseError` before sending when `external_id` is missing,
|
|
138
|
+
empty or whitespace.
|
|
91
139
|
"""
|
|
140
|
+
require_external_id("retrieve_by_external_id", external_id)
|
|
141
|
+
|
|
92
142
|
found = await self._api.request(
|
|
93
143
|
"GET",
|
|
94
144
|
"/v1/customers",
|
|
@@ -24,6 +24,7 @@ from .errors import APIError, MeterbaseError, error_from_response
|
|
|
24
24
|
|
|
25
25
|
DEFAULT_BASE_URL = "https://api.meterbase.tech"
|
|
26
26
|
DEFAULT_TIMEOUT = 35.0
|
|
27
|
+
CHECK_TIMEOUT = 3.0
|
|
27
28
|
DEFAULT_MAX_RETRIES = 2
|
|
28
29
|
MAX_BACKOFF = 8.0
|
|
29
30
|
|
|
@@ -35,8 +36,9 @@ Body = Mapping[str, object]
|
|
|
35
36
|
|
|
36
37
|
@dataclass(frozen=True)
|
|
37
38
|
class RequestOptions:
|
|
38
|
-
timeout: float
|
|
39
|
-
"""Seconds, per attempt rather than per call.
|
|
39
|
+
timeout: float | None
|
|
40
|
+
"""Seconds, per attempt rather than per call. `None` leaves each call its
|
|
41
|
+
own default."""
|
|
40
42
|
max_retries: int
|
|
41
43
|
|
|
42
44
|
def __post_init__(self) -> None:
|
|
@@ -44,7 +46,7 @@ class RequestOptions:
|
|
|
44
46
|
# makes no attempt has nothing to answer with.
|
|
45
47
|
if self.max_retries < 0:
|
|
46
48
|
raise MeterbaseError("max_retries should be 0 or more.")
|
|
47
|
-
if not self.timeout > 0:
|
|
49
|
+
if self.timeout is not None and not self.timeout > 0:
|
|
48
50
|
raise MeterbaseError("timeout should be a number of seconds above 0.")
|
|
49
51
|
|
|
50
52
|
|
|
@@ -73,6 +75,17 @@ def segment(value: str) -> str:
|
|
|
73
75
|
return quote(value, safe="")
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
def require_external_id(method: str, external_id: object) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Without an id the lookup becomes an unfiltered list, which older engines
|
|
81
|
+
answered newest first: the caller got another customer back.
|
|
82
|
+
"""
|
|
83
|
+
if external_id is None or str(external_id).strip() == "":
|
|
84
|
+
raise MeterbaseError(
|
|
85
|
+
f"customers.{method} needs an external id, and got {external_id!r}."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
76
89
|
def build_url(base_url: str, path: str, query: Query | None) -> str:
|
|
77
90
|
pairs = [
|
|
78
91
|
(key, _query_value(value))
|
|
@@ -17,6 +17,7 @@ from typing import Any, TypeVar
|
|
|
17
17
|
import httpx
|
|
18
18
|
|
|
19
19
|
from .._http import (
|
|
20
|
+
DEFAULT_TIMEOUT,
|
|
20
21
|
Body,
|
|
21
22
|
Query,
|
|
22
23
|
RequestOptions,
|
|
@@ -74,17 +75,21 @@ class Transport:
|
|
|
74
75
|
query: Query | None = None,
|
|
75
76
|
body: Body | None = None,
|
|
76
77
|
idempotent: bool = False,
|
|
78
|
+
default_timeout: float = DEFAULT_TIMEOUT,
|
|
77
79
|
) -> Any:
|
|
78
80
|
"""
|
|
79
81
|
`idempotent` replays a call even though its method is not safe. Only a
|
|
80
82
|
route that carries an idempotency key may set it, and the key has to be
|
|
81
83
|
in `body` already: the loop re-sends one body, so a key generated per
|
|
82
84
|
attempt would count the same usage twice.
|
|
85
|
+
|
|
86
|
+
`default_timeout` applies when the options set none.
|
|
83
87
|
"""
|
|
84
88
|
# Safe methods, plus the routes that carry an idempotency key and say
|
|
85
89
|
# so. Any other POST or PATCH could create a second row on a replay.
|
|
86
90
|
retryable = method in ("GET", "DELETE") or idempotent
|
|
87
91
|
|
|
92
|
+
timeout = default_timeout if options.timeout is None else options.timeout
|
|
88
93
|
url = build_url(self._base_url, path, query)
|
|
89
94
|
content = None if body is None else encode_body(body)
|
|
90
95
|
headers = request_headers(self._api_key, has_body=content is not None)
|
|
@@ -95,7 +100,7 @@ class Transport:
|
|
|
95
100
|
sleep(backoff(attempt, last_error))
|
|
96
101
|
|
|
97
102
|
try:
|
|
98
|
-
response = self._send(method, url, headers, content,
|
|
103
|
+
response = self._send(method, url, headers, content, timeout)
|
|
99
104
|
except APIConnectionError as unanswered:
|
|
100
105
|
# A connection failure proves nothing about whether the server
|
|
101
106
|
# acted, so it is replayed under the same idempotency rule.
|
|
@@ -126,7 +131,7 @@ class Transport:
|
|
|
126
131
|
url: str,
|
|
127
132
|
headers: dict[str, str],
|
|
128
133
|
content: bytes | None,
|
|
129
|
-
|
|
134
|
+
timeout: float,
|
|
130
135
|
) -> httpx.Response:
|
|
131
136
|
# Neither this nor the loop catches a BaseException — a cancelled task,
|
|
132
137
|
# a Ctrl-C — so one ends the call where it stands and nothing is sent
|
|
@@ -138,12 +143,10 @@ class Transport:
|
|
|
138
143
|
url,
|
|
139
144
|
headers=headers,
|
|
140
145
|
content=content,
|
|
141
|
-
timeout=
|
|
146
|
+
timeout=timeout,
|
|
142
147
|
)
|
|
143
148
|
except httpx.TimeoutException as error:
|
|
144
|
-
raise APITimeoutError(
|
|
145
|
-
f"Request timed out after {options.timeout:g}s."
|
|
146
|
-
) from error
|
|
149
|
+
raise APITimeoutError(f"Request timed out after {timeout:g}s.") from error
|
|
147
150
|
except httpx.TransportError as error:
|
|
148
151
|
raise APIConnectionError(f"Could not reach {self._origin}.") from error
|
|
149
152
|
|
|
@@ -173,6 +176,7 @@ class Requester:
|
|
|
173
176
|
query: Query | None = None,
|
|
174
177
|
body: Body | None = None,
|
|
175
178
|
idempotent: bool = False,
|
|
179
|
+
default_timeout: float = DEFAULT_TIMEOUT,
|
|
176
180
|
) -> _T:
|
|
177
181
|
data = self.transport.request(
|
|
178
182
|
method,
|
|
@@ -181,5 +185,6 @@ class Requester:
|
|
|
181
185
|
query=query,
|
|
182
186
|
body=body,
|
|
183
187
|
idempotent=idempotent,
|
|
188
|
+
default_timeout=default_timeout,
|
|
184
189
|
)
|
|
185
190
|
return construct(cast_to, data)
|
|
@@ -12,8 +12,8 @@ from typing import Any, TypeVar
|
|
|
12
12
|
import httpx
|
|
13
13
|
|
|
14
14
|
from .._http import (
|
|
15
|
+
CHECK_TIMEOUT,
|
|
15
16
|
DEFAULT_MAX_RETRIES,
|
|
16
|
-
DEFAULT_TIMEOUT,
|
|
17
17
|
RequestOptions,
|
|
18
18
|
normalize_base_url,
|
|
19
19
|
)
|
|
@@ -38,9 +38,9 @@ class Meterbase:
|
|
|
38
38
|
there is no workspace to pass.
|
|
39
39
|
|
|
40
40
|
`timeout` is in seconds and applies to each attempt rather than to the
|
|
41
|
-
whole call.
|
|
42
|
-
`http_client` to configure proxies,
|
|
43
|
-
stays yours to close.
|
|
41
|
+
whole call. Unset, it is 35 s, and 3 s for `check`. `max_retries` counts
|
|
42
|
+
the attempts after the first. Pass an `http_client` to configure proxies,
|
|
43
|
+
limits or transports yourself; it stays yours to close.
|
|
44
44
|
"""
|
|
45
45
|
|
|
46
46
|
customers: Customers
|
|
@@ -52,7 +52,7 @@ class Meterbase:
|
|
|
52
52
|
*,
|
|
53
53
|
api_key: str,
|
|
54
54
|
base_url: str | None = None,
|
|
55
|
-
timeout: float =
|
|
55
|
+
timeout: float | None = None,
|
|
56
56
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
57
57
|
http_client: httpx.Client | None = None,
|
|
58
58
|
) -> None:
|
|
@@ -125,6 +125,11 @@ class Meterbase:
|
|
|
125
125
|
|
|
126
126
|
`quantity` is what is about to be consumed, `1 … 100000000000`.
|
|
127
127
|
Omitted, it is 1, which asks whether there is any capacity at all.
|
|
128
|
+
|
|
129
|
+
Waits 3 s unless the client sets `timeout`: it gates a request someone
|
|
130
|
+
is waiting on, and answers in milliseconds when the engine is well.
|
|
131
|
+
Decide what an `APITimeoutError` means here, failing open or closed,
|
|
132
|
+
rather than waiting out the 35 s the writes are given.
|
|
128
133
|
"""
|
|
129
134
|
return self._api.request(
|
|
130
135
|
"POST",
|
|
@@ -135,6 +140,7 @@ class Meterbase:
|
|
|
135
140
|
"meter_id": meter_id,
|
|
136
141
|
"quantity": quantity,
|
|
137
142
|
},
|
|
143
|
+
default_timeout=CHECK_TIMEOUT,
|
|
138
144
|
)
|
|
139
145
|
|
|
140
146
|
def track(
|
|
@@ -250,6 +256,17 @@ class Meterbase:
|
|
|
250
256
|
"""Reports which workspace this key acts for."""
|
|
251
257
|
return self._api.request("GET", "/v1/whoami", WhoAmI)
|
|
252
258
|
|
|
259
|
+
def idempotency_key(self) -> str:
|
|
260
|
+
"""
|
|
261
|
+
A UUIDv7 dated by the engine's clock as this client knows it: an
|
|
262
|
+
`idempotency_key` for `track`, or a `reservation_id`. For a retry from
|
|
263
|
+
further out than this SDK, mint one once the work is done, store it
|
|
264
|
+
with the job before the first send, and send it on every retry. The
|
|
265
|
+
engine admits a key for an hour from its date, and a reservation id
|
|
266
|
+
for 30 minutes.
|
|
267
|
+
"""
|
|
268
|
+
return mint(self._api.transport.now_ms())
|
|
269
|
+
|
|
253
270
|
def close(self) -> None:
|
|
254
271
|
"""Closes the connections this client opened. An `http_client` passed
|
|
255
272
|
in stays open: it is yours."""
|
|
@@ -7,12 +7,13 @@ from __future__ import annotations
|
|
|
7
7
|
from collections.abc import Mapping, Sequence
|
|
8
8
|
from typing import Any, overload
|
|
9
9
|
|
|
10
|
-
from ..._http import segment
|
|
10
|
+
from ..._http import require_external_id, segment
|
|
11
11
|
from ..._models import ListResult, list_of
|
|
12
12
|
from ..._not_given import NOT_GIVEN, NotGiven
|
|
13
13
|
from ...errors import NotFoundError
|
|
14
14
|
from ...types import (
|
|
15
15
|
Customer,
|
|
16
|
+
CustomerEnsureResult,
|
|
16
17
|
CustomerEntitlement,
|
|
17
18
|
CustomerUsage,
|
|
18
19
|
CustomerWithPlan,
|
|
@@ -63,6 +64,50 @@ class Customers:
|
|
|
63
64
|
},
|
|
64
65
|
)
|
|
65
66
|
|
|
67
|
+
def ensure(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
external_id: str,
|
|
71
|
+
name: str | NotGiven = NOT_GIVEN,
|
|
72
|
+
metadata: Mapping[str, Any] | NotGiven = NOT_GIVEN,
|
|
73
|
+
plan: str | NotGiven = NOT_GIVEN,
|
|
74
|
+
cycle_anchor: str | NotGiven = NOT_GIVEN,
|
|
75
|
+
mint_one_time: bool | NotGiven = NOT_GIVEN,
|
|
76
|
+
) -> CustomerEnsureResult:
|
|
77
|
+
"""
|
|
78
|
+
Gets or creates the customer by `external_id`, and gives one who holds
|
|
79
|
+
no plan the `plan` named, in one transaction. Safe on every sign-up and
|
|
80
|
+
safe to retry: rerunning it changes nothing, and it never changes a
|
|
81
|
+
plan the customer already holds. `plan_result` says which of those
|
|
82
|
+
happened.
|
|
83
|
+
|
|
84
|
+
`name` and `metadata` apply only to a customer this creates. `plan` is
|
|
85
|
+
a plan key; omit it to leave the plan alone. `cycle_anchor`, RFC 3339,
|
|
86
|
+
only with `plan` and never in the future, is used only when this call
|
|
87
|
+
assigns the plan. `mint_one_time=False` writes the plan's one-time
|
|
88
|
+
grants already spent, for an imported customer whose remaining bonus
|
|
89
|
+
you grant yourself.
|
|
90
|
+
|
|
91
|
+
Raises `MeterbaseError` before sending when `external_id` is missing,
|
|
92
|
+
empty or whitespace.
|
|
93
|
+
"""
|
|
94
|
+
require_external_id("ensure", external_id)
|
|
95
|
+
|
|
96
|
+
return self._api.request(
|
|
97
|
+
"POST",
|
|
98
|
+
"/v1/customers/ensure",
|
|
99
|
+
CustomerEnsureResult,
|
|
100
|
+
body={
|
|
101
|
+
"external_id": external_id,
|
|
102
|
+
"name": name,
|
|
103
|
+
"metadata": metadata,
|
|
104
|
+
"plan": plan,
|
|
105
|
+
"cycle_anchor": cycle_anchor,
|
|
106
|
+
"mint_one_time": mint_one_time,
|
|
107
|
+
},
|
|
108
|
+
idempotent=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
66
111
|
def list(
|
|
67
112
|
self, *, include_deleted: bool | NotGiven = NOT_GIVEN
|
|
68
113
|
) -> ListResult[Customer]:
|
|
@@ -90,7 +135,12 @@ class Customers:
|
|
|
90
135
|
Resolves the tenant's own identifier, or `None`, embedding the plan the
|
|
91
136
|
way `retrieve` does. The engine answers this as a filtered list, so a
|
|
92
137
|
miss is an empty collection rather than a 404.
|
|
138
|
+
|
|
139
|
+
Raises `MeterbaseError` before sending when `external_id` is missing,
|
|
140
|
+
empty or whitespace.
|
|
93
141
|
"""
|
|
142
|
+
require_external_id("retrieve_by_external_id", external_id)
|
|
143
|
+
|
|
94
144
|
found = self._api.request(
|
|
95
145
|
"GET",
|
|
96
146
|
"/v1/customers",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
|
@@ -269,7 +269,7 @@ def error_from_response(
|
|
|
269
269
|
|
|
270
270
|
if status == 400:
|
|
271
271
|
# A parameter that failed validation, as a 422 is a field that did.
|
|
272
|
-
if code
|
|
272
|
+
if code in ("invalid_query", "invalid_external_id"):
|
|
273
273
|
return InvalidRequestError(**fields)
|
|
274
274
|
return APIError(**fields)
|
|
275
275
|
if status == 401:
|
|
@@ -27,11 +27,13 @@ __all__ = [
|
|
|
27
27
|
"ClosedPeriod",
|
|
28
28
|
"ClosedPeriodMeter",
|
|
29
29
|
"Customer",
|
|
30
|
+
"CustomerEnsureResult",
|
|
30
31
|
"CustomerEntitlement",
|
|
31
32
|
"CustomerUsage",
|
|
32
33
|
"CustomerWithPlan",
|
|
33
34
|
"DayUsage",
|
|
34
35
|
"EmbeddedPlan",
|
|
36
|
+
"EnsurePlanResult",
|
|
35
37
|
"EntitlementReachedEvent",
|
|
36
38
|
"EntitlementState",
|
|
37
39
|
"IncomingVersion",
|
|
@@ -140,6 +142,24 @@ class CustomerWithPlan(Customer):
|
|
|
140
142
|
pending_plan: EmbeddedPlan | None
|
|
141
143
|
|
|
142
144
|
|
|
145
|
+
EnsurePlanResult = Literal["assigned", "already_held", "kept_other", "none"]
|
|
146
|
+
"""
|
|
147
|
+
What `ensure` did with the plan it named. It never changes a plan: a customer
|
|
148
|
+
holding another one is `kept_other`, and moving them stays `customers.plan`'s
|
|
149
|
+
job. `none` when no plan was named.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CustomerEnsureResult(Model):
|
|
154
|
+
customer: CustomerWithPlan
|
|
155
|
+
"""As the call left it, with the plan embedded as `retrieve` embeds it."""
|
|
156
|
+
created: bool
|
|
157
|
+
plan_result: EnsurePlanResult
|
|
158
|
+
cycle_anchor_differs: bool
|
|
159
|
+
"""`cycle_anchor` was sent, and the plan the customer already held runs on
|
|
160
|
+
another anchor. Nothing changed: `customer.plan.anchor_at` is in force."""
|
|
161
|
+
|
|
162
|
+
|
|
143
163
|
# Check ----------------------------------------------------------------------
|
|
144
164
|
|
|
145
165
|
CheckReason = Literal["entitlement", "rate_limit"] | str
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.5"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|