meterflow 0.6.0__tar.gz → 0.7.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.
Files changed (26) hide show
  1. {meterflow-0.6.0 → meterflow-0.7.0}/PKG-INFO +11 -5
  2. {meterflow-0.6.0 → meterflow-0.7.0}/README.md +10 -4
  3. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/__init__.py +6 -0
  4. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_entitlement_cache.py +11 -2
  5. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_http.py +51 -13
  6. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/_retry.py +19 -7
  7. meterflow-0.7.0/src/meterflow/_version.py +2 -0
  8. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/client.py +55 -17
  9. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/errors.py +25 -15
  10. meterflow-0.7.0/src/meterflow/resources/__init__.py +1 -0
  11. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/credits.py +95 -29
  12. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/entitlements.py +33 -20
  13. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/plans.py +23 -5
  14. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/portal.py +2 -4
  15. meterflow-0.7.0/src/meterflow/resources/subscriptions.py +190 -0
  16. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/usage.py +59 -22
  17. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/resources/webhooks.py +27 -32
  18. meterflow-0.7.0/src/meterflow/webhook.py +22 -0
  19. meterflow-0.6.0/src/meterflow/_version.py +0 -3
  20. meterflow-0.6.0/src/meterflow/resources/__init__.py +0 -1
  21. meterflow-0.6.0/src/meterflow/resources/subscriptions.py +0 -124
  22. meterflow-0.6.0/src/meterflow/webhook.py +0 -21
  23. {meterflow-0.6.0 → meterflow-0.7.0}/LICENSE +0 -0
  24. {meterflow-0.6.0 → meterflow-0.7.0}/pyproject.toml +0 -0
  25. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/py.typed +0 -0
  26. {meterflow-0.6.0 → meterflow-0.7.0}/src/meterflow/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: meterflow
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Official Python SDK for MeterFlow — usage-based billing, credit management, and metering.
5
5
  Project-URL: Homepage, https://meter-flow.com
6
6
  Project-URL: Documentation, https://meter-flow.com/guide
@@ -264,10 +264,14 @@ while True:
264
264
  cursor = page["next_cursor"]
265
265
  if cursor is None:
266
266
  break
267
+
268
+ # Another order: `sort` is "created_at" (the default) or "amount", `order` "desc" (the default) or "asc".
269
+ largest_first = client.credits.transactions("customer_123", sort="amount", order="desc")
267
270
  ```
268
271
 
269
- > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you; do not build one, and do
270
- > not read anything out of it. `limit` is 1–200 and defaults to 50.
272
+ > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you, with the same `sort` and `order`
273
+ > (another order with it is a `ValidationError`); do not build one, and do not read anything out of it.
274
+ > `limit` is 1–200 and defaults to 50.
271
275
 
272
276
  > **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
273
277
 
@@ -303,12 +307,13 @@ Record an event every time a customer does the thing you charge for. Events are
303
307
  **Record one event:**
304
308
 
305
309
  ```python
310
+
306
311
  client.usage.record({
307
312
  "event_name": "images.generated",
308
313
  "customer_external_id": "customer_123",
309
314
  "value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
310
315
  "properties": {}, # free-form context; {} when unused
311
- "timestamp": "2026-09-30T10:00:00Z", # optional — defaults to arrival time on the server
316
+ # "timestamp": leave it out — the API stamps arrival time; one more than 300 s old or 60 s ahead is refused (422)
312
317
  })
313
318
  ```
314
319
 
@@ -410,6 +415,7 @@ page = client.subscriptions.list() # whole project
410
415
  theirs = client.subscriptions.list(customer_id="customer_123") # one customer
411
416
  more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
412
417
  behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
418
+ by_status = client.subscriptions.list(sort="status", order="asc") # or "created_at" (default), "customer_external_id"
413
419
 
414
420
  for subscription in page["data"]:
415
421
  print(subscription["customer_external_id"], subscription["status"])
@@ -670,7 +676,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
670
676
  |---|---|---|
671
677
  | `usage.recorded` | a usage event is ingested (single or batch) | `customer_id`, `event_name`, `value`, `event_id` |
672
678
  | `credit.granted` | credits are granted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
673
- | `credit.deducted` | credits are deducted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
679
+ | `credit.deducted` | credits are deducted: by a deduct call, or by a metered usage charge | `customer_id`, `amount`, `balance_after`, `transaction_id`, `transaction_type` (`deduct` or `usage`), `usage_event_id` (the billed event for `usage`, else null) |
674
680
  | `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
675
681
  | `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
676
682
  | `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
@@ -212,10 +212,14 @@ while True:
212
212
  cursor = page["next_cursor"]
213
213
  if cursor is None:
214
214
  break
215
+
216
+ # Another order: `sort` is "created_at" (the default) or "amount", `order` "desc" (the default) or "asc".
217
+ largest_first = client.credits.transactions("customer_123", sort="amount", order="desc")
215
218
  ```
216
219
 
217
- > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you; do not build one, and do
218
- > not read anything out of it. `limit` is 1–200 and defaults to 50.
220
+ > **The cursor is opaque.** Pass back exactly what `next_cursor` gave you, with the same `sort` and `order`
221
+ > (another order with it is a `ValidationError`); do not build one, and do not read anything out of it.
222
+ > `limit` is 1–200 and defaults to 50.
219
223
 
220
224
  > **Amounts are decimal strings.** Balances and amounts come back as strings (`"3545.000000"`) to avoid floating-point drift on money-like values. Parse deliberately (`decimal.Decimal(...)`) when you need arithmetic. When *sending* an amount, a number or a numeric string are both accepted.
221
225
 
@@ -251,12 +255,13 @@ Record an event every time a customer does the thing you charge for. Events are
251
255
  **Record one event:**
252
256
 
253
257
  ```python
258
+
254
259
  client.usage.record({
255
260
  "event_name": "images.generated",
256
261
  "customer_external_id": "customer_123",
257
262
  "value": 1, # optional — what the meter aggregates: 1 for counts, seconds/MB/etc. for sums
258
263
  "properties": {}, # free-form context; {} when unused
259
- "timestamp": "2026-09-30T10:00:00Z", # optional — defaults to arrival time on the server
264
+ # "timestamp": leave it out — the API stamps arrival time; one more than 300 s old or 60 s ahead is refused (422)
260
265
  })
261
266
  ```
262
267
 
@@ -358,6 +363,7 @@ page = client.subscriptions.list() # whole project
358
363
  theirs = client.subscriptions.list(customer_id="customer_123") # one customer
359
364
  more = client.subscriptions.list(limit=200, cursor=page["next_cursor"])
360
365
  behind = client.subscriptions.list(q="acme", status=["past_due", "paused"]) # ids containing "acme", in either status
366
+ by_status = client.subscriptions.list(sort="status", order="asc") # or "created_at" (default), "customer_external_id"
361
367
 
362
368
  for subscription in page["data"]:
363
369
  print(subscription["customer_external_id"], subscription["status"])
@@ -618,7 +624,7 @@ Every delivery is a flat JSON object (`Content-Type: application/json`) with two
618
624
  |---|---|---|
619
625
  | `usage.recorded` | a usage event is ingested (single or batch) | `customer_id`, `event_name`, `value`, `event_id` |
620
626
  | `credit.granted` | credits are granted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
621
- | `credit.deducted` | credits are deducted | `customer_id`, `amount`, `balance_after`, `transaction_id` |
627
+ | `credit.deducted` | credits are deducted: by a deduct call, or by a metered usage charge | `customer_id`, `amount`, `balance_after`, `transaction_id`, `transaction_type` (`deduct` or `usage`), `usage_event_id` (the billed event for `usage`, else null) |
622
628
  | `subscription.created` | a subscription is created | `customer_id`, `plan_id`, `subscription_id`, `status` |
623
629
  | `subscription.updated` | a subscription is updated | `customer_id`, `subscription_id`, `status` |
624
630
  | `limit.reached` | a customer first reaches a **hard** or **soft** limit's included units this billing period (once per meter per period; metered limits report through `credit.deducted` instead) | `customer_id`, `subscription_id`, `feature` (the meter's `event_name`), `meter_id`, `limit_type`, `included`, `used`, `period_start`, `period_end` |
@@ -7,6 +7,7 @@ client.credits.grant({"customer_external_id": "customer_123", "amount": 5000, "m
7
7
  """
8
8
 
9
9
  from ._entitlement_cache import EntitlementDefault, EntitlementFallbackContext
10
+ from ._http import SortOrder
10
11
  from ._version import __version__
11
12
  from .client import AsyncMeterFlow, ClientOptions, MeterFlow
12
13
  from .errors import (
@@ -21,7 +22,9 @@ from .errors import (
21
22
  ValidationError,
22
23
  ValidationFieldError,
23
24
  )
25
+ from .resources.credits import TransactionSort
24
26
  from .resources.entitlements import EntitlementCheck, EntitlementList
27
+ from .resources.subscriptions import SubscriptionSort
25
28
  from .resources.usage import MAX_BATCH_EVENTS
26
29
  from .webhook import verify_webhook
27
30
 
@@ -45,5 +48,8 @@ __all__ = [
45
48
  "EntitlementList",
46
49
  "EntitlementDefault",
47
50
  "EntitlementFallbackContext",
51
+ "SortOrder",
52
+ "SubscriptionSort",
53
+ "TransactionSort",
48
54
  "verify_webhook",
49
55
  ]
@@ -1,4 +1,4 @@
1
- """The entitlement answers one client keeps, and the rules for falling back on them. Mirrors ``sdks/node/src/utils/entitlement-cache.ts``.
1
+ """The entitlement answers one client keeps, and the rules for falling back on them.
2
2
 
3
3
  Each answer is fresh for the ``max-age`` the API sends and kept past it as the fallback for an outage.
4
4
  Bounded, least recently used out first.
@@ -54,6 +54,13 @@ class CacheRead:
54
54
 
55
55
  @dataclass
56
56
  class _CachedAnswer:
57
+ """One stored answer.
58
+
59
+ customer_id: whose answer it is.
60
+ value: the answer itself.
61
+ fresh_until: the clock reading after which it is no longer fresh; it is still kept as a fallback.
62
+ """
63
+
57
64
  customer_id: str
58
65
  value: Any
59
66
  fresh_until: float
@@ -71,7 +78,9 @@ def max_age_seconds(header: str | None) -> int:
71
78
 
72
79
 
73
80
  def is_outage(error: BaseException) -> bool:
74
- """Whether a failed call is MeterFlow being unreachable rather than an answer: no response at all, a 5xx after the retries, or a 429.
81
+ """Whether a failed call is MeterFlow being unreachable rather than an answer: no response, a 5xx after the retries, or a 429.
82
+
83
+ A 401, 403, 404 or 422 is an answer.
75
84
 
76
85
  error: what the call raised.
77
86
  """
@@ -1,7 +1,6 @@
1
- """The HTTP layer both clients share: URL/header building, error-body parsing, status → error mapping.
1
+ """The HTTP layer both clients share: headers, query parameters, reading error answers and choosing the error class.
2
2
 
3
- Only the *waiting* differs between sync and async, so the request loops live in ``client.py`` and
4
- everything they call lives here, once. Mirrors ``sdks/node/src/utils/http.ts``.
3
+ Only the waiting differs between the sync and async clients, so their request loops live in ``client.py``.
5
4
  """
6
5
 
7
6
  from __future__ import annotations
@@ -11,7 +10,7 @@ import json
11
10
  import time
12
11
  from collections.abc import Sequence
13
12
  from dataclasses import dataclass, field
14
- from typing import Any
13
+ from typing import Any, Literal
15
14
  from urllib.parse import quote
16
15
 
17
16
  import httpx
@@ -34,7 +33,7 @@ USER_AGENT = f"meterflow-python/{__version__}"
34
33
 
35
34
  QueryValue = str | int | float | bool | Sequence[str] | None
36
35
 
37
- # Indirections so tests can make the retry loop instantaneous without touching the clock.
36
+ # Indirections, so tests can make the retry loop instant.
38
37
  _sleep = time.sleep
39
38
 
40
39
 
@@ -42,9 +41,21 @@ async def _async_sleep(seconds: float) -> None:
42
41
  await asyncio.sleep(seconds)
43
42
 
44
43
 
44
+ # Which way a list is ordered: ``desc`` (each list's default) or ``asc``.
45
+ SortOrder = Literal["asc", "desc"]
46
+
47
+
45
48
  @dataclass(frozen=True)
46
49
  class RequestSpec:
47
- """One API call, described by a resource method and executed by a client."""
50
+ """One API call, described by a resource method and made by a client.
51
+
52
+ method: the HTTP method.
53
+ path: the endpoint, relative to the base URL.
54
+ body: the JSON body; None for none.
55
+ query: the query parameters; a None one is not sent.
56
+ idempotency_key: sent as the ``Idempotency-Key`` header.
57
+ timeout: this call's timeout in seconds; None for the client's.
58
+ """
48
59
 
49
60
  method: str
50
61
  path: str
@@ -55,11 +66,19 @@ class RequestSpec:
55
66
 
56
67
 
57
68
  def encode_path_segment(value: str) -> str:
58
- """Customer ids are caller-chosen strings (emails, ``user/42``…) — they must never split the path."""
69
+ """A path segment, percent-encoded: a customer id is any string (an email, ``user/42``) and must never split the path.
70
+
71
+ value: the id to encode.
72
+ """
59
73
  return quote(value, safe="")
60
74
 
61
75
 
62
76
  def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
77
+ """The request's headers: the key, the SDK's User-Agent, JSON, and the idempotency key when there is one.
78
+
79
+ api_key: the client's key.
80
+ spec: the call.
81
+ """
63
82
  headers = {"Authorization": f"Bearer {api_key}", "User-Agent": USER_AGENT, "Accept": "application/json"}
64
83
  if spec.body is not None:
65
84
  headers["Content-Type"] = "application/json"
@@ -69,7 +88,10 @@ def build_headers(api_key: str, spec: RequestSpec) -> dict[str, str]:
69
88
 
70
89
 
71
90
  def build_params(query: dict[str, QueryValue]) -> dict[str, str | list[str]]:
72
- """Unset (``None``) filters are simply not sent; booleans go as ``true``/``false`` like the Node SDK; a list repeats its key."""
91
+ """The query parameters as sent: a None one is left out, a boolean is ``true`` / ``false``, a list repeats its key.
92
+
93
+ query: the call's parameters.
94
+ """
73
95
  params: dict[str, str | list[str]] = {}
74
96
  for key, value in query.items():
75
97
  if value is None:
@@ -85,19 +107,24 @@ def build_params(query: dict[str, QueryValue]) -> dict[str, str | list[str]]:
85
107
 
86
108
  @dataclass(frozen=True)
87
109
  class ParsedError:
110
+ """An error answer's message, and the fields a 422 named."""
111
+
88
112
  message: str
89
113
  fields: list[ValidationFieldError]
90
114
 
91
115
 
92
116
  def _is_field_error(value: object) -> bool:
117
+ """Whether a value is one ``{field, message}`` entry of a 422's ``fields``.
118
+
119
+ value: one entry, as parsed.
120
+ """
93
121
  return isinstance(value, dict) and isinstance(value.get("field"), str) and isinstance(value.get("message"), str)
94
122
 
95
123
 
96
124
  def parse_error_body(response: httpx.Response) -> ParsedError:
97
- """The API answers every error as ``{"error": {"code", "message", "fields"?}}`` (``app/api/src/exceptions.py``).
125
+ """Read an error answer, ``{"error": {"code", "message", "fields"?}}``; a bare ``{"detail"}`` or any other body still gives a message.
98
126
 
99
- ``detail`` is FastAPI's default shape, kept as a fallback so an unexpected passthrough still
100
- yields a readable message rather than a JSON blob.
127
+ response: the failed response.
101
128
  """
102
129
  try:
103
130
  payload = response.json()
@@ -124,6 +151,10 @@ def parse_error_body(response: httpx.Response) -> ParsedError:
124
151
 
125
152
 
126
153
  def map_response_error(response: httpx.Response) -> MeterFlowError:
154
+ """The error class for a failed response's status, carrying its message, request id and, for a 429, its ``Retry-After``.
155
+
156
+ response: the failed response.
157
+ """
127
158
  status = response.status_code
128
159
  parsed = parse_error_body(response)
129
160
  request_id = response.headers.get("x-request-id")
@@ -150,14 +181,21 @@ def map_response_error(response: httpx.Response) -> MeterFlowError:
150
181
 
151
182
 
152
183
  def decode_response(response: httpx.Response) -> Any:
153
- """2xx → parsed JSON; ``204`` / an empty body → ``None`` (``subscriptions.delete``)."""
184
+ """A successful response's JSON body; None for a 204 or an empty body.
185
+
186
+ response: the successful response.
187
+ """
154
188
  if response.status_code == 204 or not response.content:
155
189
  return None
156
190
  return response.json()
157
191
 
158
192
 
159
193
  def request_kwargs(api_key: str, spec: RequestSpec) -> dict[str, Any]:
160
- """Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path."""
194
+ """Everything ``httpx.Client.request`` / ``AsyncClient.request`` needs beyond the method and path.
195
+
196
+ api_key: the client's key.
197
+ spec: the call.
198
+ """
161
199
  kwargs: dict[str, Any] = {"headers": build_headers(api_key, spec), "params": build_params(spec.query)}
162
200
  if spec.body is not None:
163
201
  kwargs["content"] = json.dumps(spec.body).encode("utf-8")
@@ -1,9 +1,6 @@
1
- """Retry policy shared by the sync and async clients.
1
+ """The retry policy both clients use: it decides how long to wait, and the client does the waiting.
2
2
 
3
- The policy only *decides* — how long to wait before attempt N+1, or that the caller should give up.
4
- The clients do the sleeping (``time.sleep`` vs ``asyncio.sleep``), so the backoff maths lives once.
5
- Mirrors the Node SDK: exponential base (1 s × factor^attempt, capped), ±25 % jitter, and a 429's
6
- ``Retry-After`` taken verbatim instead of the computed delay.
3
+ Exponential backoff (1 s × factor^attempt, capped) with ±25 % jitter; a 429 waits its ``Retry-After`` instead.
7
4
  """
8
5
 
9
6
  from __future__ import annotations
@@ -16,19 +13,34 @@ from .errors import MeterFlowError, RateLimitError
16
13
 
17
14
  @dataclass(frozen=True)
18
15
  class RetryPolicy:
16
+ """How many times a failed request is tried again, and the backoff between attempts.
17
+
18
+ retries: the attempts after the first.
19
+ factor: the backoff's growth per attempt.
20
+ jitter: whether each delay is spread by ±25 %.
21
+ max_delay_seconds: the longest computed delay.
22
+ """
23
+
19
24
  retries: int = 3
20
25
  factor: float = 2.0
21
26
  jitter: bool = True
22
27
  max_delay_seconds: float = 10.0
23
28
 
24
29
  def should_retry(self, error: BaseException) -> bool:
25
- """API errors say for themselves whether a retry can help; anything else is a transport failure and can."""
30
+ """Whether a retry can help: an API error says so itself; anything else is a network failure, which can.
31
+
32
+ error: what the attempt raised.
33
+ """
26
34
  if isinstance(error, MeterFlowError):
27
35
  return error.retryable
28
36
  return True
29
37
 
30
38
  def delay_before_retry(self, attempt: int, error: BaseException) -> float | None:
31
- """Seconds to wait before retrying ``attempt`` (0-based), or ``None`` when the caller must re-raise."""
39
+ """Seconds to wait before the next attempt, or ``None`` when the caller must re-raise.
40
+
41
+ attempt: the attempt that failed, from 0.
42
+ error: what it raised.
43
+ """
32
44
  is_last = attempt >= self.retries
33
45
  if is_last or not self.should_retry(error):
34
46
  return None
@@ -0,0 +1,2 @@
1
+ # The package's version: pyproject.toml reads it, and the User-Agent sends it. A release edits only this line.
2
+ __version__ = "0.7.0"
@@ -1,9 +1,4 @@
1
- """``MeterFlow`` (sync) and ``AsyncMeterFlow`` — same options, same resources, same errors.
2
-
3
- Resources describe a call as a ``RequestSpec``; the client executes it with retries. Everything that
4
- does not depend on *how* you wait lives in ``_http.py`` and ``_retry.py``, so the two clients are
5
- thin and cannot drift apart.
6
- """
1
+ """``MeterFlow`` (sync) and ``AsyncMeterFlow``: the same options, resources and errors."""
7
2
 
8
3
  from __future__ import annotations
9
4
 
@@ -34,7 +29,16 @@ KEY_PREFIXES = ("mf_live_", "mf_test_")
34
29
 
35
30
  @dataclass(frozen=True)
36
31
  class ClientOptions:
37
- """The resolved configuration — what the Node SDK exposes as ``client.options``."""
32
+ """The options in force, with every default filled in, as ``client.options``.
33
+
34
+ api_key: the client's key.
35
+ base_url: the API root, ending in ``/``.
36
+ timeout: one attempt's timeout, in seconds.
37
+ retries: the attempts after the first.
38
+ entitlements_cache: whether entitlement answers are reused.
39
+ entitlement_defaults: what ``check()`` answers per feature while MeterFlow is unreachable.
40
+ on_error: told when an entitlements call answers from a fallback.
41
+ """
38
42
 
39
43
  api_key: str
40
44
  base_url: str
@@ -46,6 +50,8 @@ class ClientOptions:
46
50
 
47
51
 
48
52
  class _BaseClient:
53
+ """What both clients share: the key check, the options, the retry policy and the entitlement cache."""
54
+
49
55
  def __init__(
50
56
  self,
51
57
  api_key: str,
@@ -68,20 +74,34 @@ class _BaseClient:
68
74
  on_error=on_error,
69
75
  )
70
76
  self._retry = RetryPolicy(retries=self.options.retries)
71
- # Internal: the entitlement answers this client has read; None when ``entitlements_cache`` is False.
77
+ # The entitlement answers this client has read; None when ``entitlements_cache`` is False.
72
78
  self.entitlement_cache = EntitlementCache() if entitlements_cache else None
73
79
 
74
80
  def _kwargs(self, spec: RequestSpec) -> dict[str, Any]:
81
+ """The arguments ``httpx`` needs for a call.
82
+
83
+ spec: the call.
84
+ """
75
85
  return request_kwargs(self.options.api_key, spec)
76
86
 
77
87
 
78
88
  class MeterFlow(_BaseClient):
79
- """Synchronous client.
80
-
81
- ``transport`` injects an ``httpx`` transport (tests, proxies) — the Python counterpart of the
82
- Node SDK's ``fetch`` option. Use it as a context manager or call ``close()`` to release connections.
83
- ``entitlements_cache``, ``entitlement_defaults`` and ``on_error`` set how ``entitlements`` caches its
84
- answers and what it answers while MeterFlow is unreachable (``resources/entitlements.py``).
89
+ """The MeterFlow client, synchronous: one per API key, with an attribute per resource.
90
+
91
+ Use it in a ``with`` block, or call ``close()``, to release its connections. Raises ``ValueError`` when the key is
92
+ neither ``mf_live_…`` nor ``mf_test_…``.
93
+
94
+ api_key: your secret API key: ``mf_live_…`` reads and writes live data, ``mf_test_…`` test data. Keep it on your server.
95
+ base_url: the API root, ``https://api.meter-flow.com/api/v1`` by default; change it only for another MeterFlow server.
96
+ timeout: how long one attempt may take, in seconds (30 by default).
97
+ retries: how many times a failed request is tried again (3 by default; 0 turns retries off). Only network errors,
98
+ 429s and 5xx are retried.
99
+ transport: an ``httpx`` transport to send through, for tests or a proxy.
100
+ entitlements_cache: reuse ``entitlements.check()`` / ``get()`` answers for the ``max-age`` the API sends (15 s today).
101
+ entitlement_defaults: per feature, what ``check()`` answers while MeterFlow is unreachable and nothing is cached;
102
+ ``"*"`` covers every other feature.
103
+ on_error: called before an entitlements call answers from a fallback, with the error that caused it. What it raises
104
+ is ignored.
85
105
  """
86
106
 
87
107
  def __init__(
@@ -107,7 +127,7 @@ class MeterFlow(_BaseClient):
107
127
  self.webhooks = WebhooksResource(self)
108
128
 
109
129
  def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
110
- """Internal — used by resource classes; not part of the public API.
130
+ """Internal: used by the resources; not part of the public API.
111
131
 
112
132
  spec: the call.
113
133
  on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
@@ -128,6 +148,7 @@ class MeterFlow(_BaseClient):
128
148
  raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
129
149
 
130
150
  def close(self) -> None:
151
+ """Release the client's connections."""
131
152
  self._http.close()
132
153
 
133
154
  def __enter__(self) -> MeterFlow:
@@ -138,7 +159,23 @@ class MeterFlow(_BaseClient):
138
159
 
139
160
 
140
161
  class AsyncMeterFlow(_BaseClient):
141
- """Asynchronous client — identical surface, every method awaitable. ``async with`` or ``aclose()``."""
162
+ """The MeterFlow client, asynchronous: the same resources as ``MeterFlow``, every method awaitable.
163
+
164
+ Use it in an ``async with`` block, or call ``aclose()``, to release its connections. Raises ``ValueError`` when the
165
+ key is neither ``mf_live_…`` nor ``mf_test_…``.
166
+
167
+ api_key: your secret API key: ``mf_live_…`` reads and writes live data, ``mf_test_…`` test data. Keep it on your server.
168
+ base_url: the API root, ``https://api.meter-flow.com/api/v1`` by default; change it only for another MeterFlow server.
169
+ timeout: how long one attempt may take, in seconds (30 by default).
170
+ retries: how many times a failed request is tried again (3 by default; 0 turns retries off). Only network errors,
171
+ 429s and 5xx are retried.
172
+ transport: an ``httpx`` async transport to send through, for tests or a proxy.
173
+ entitlements_cache: reuse ``entitlements.check()`` / ``get()`` answers for the ``max-age`` the API sends (15 s today).
174
+ entitlement_defaults: per feature, what ``check()`` answers while MeterFlow is unreachable and nothing is cached;
175
+ ``"*"`` covers every other feature.
176
+ on_error: called before an entitlements call answers from a fallback, with the error that caused it. What it raises
177
+ is ignored.
178
+ """
142
179
 
143
180
  def __init__(
144
181
  self,
@@ -163,7 +200,7 @@ class AsyncMeterFlow(_BaseClient):
163
200
  self.webhooks = AsyncWebhooksResource(self)
164
201
 
165
202
  async def request(self, spec: RequestSpec, on_headers: Callable[[httpx.Headers], None] | None = None) -> Any:
166
- """Internal — used by resource classes; not part of the public API.
203
+ """Internal: used by the resources; not part of the public API.
167
204
 
168
205
  spec: the call.
169
206
  on_headers: given a successful response's headers; how the entitlements cache reads ``Cache-Control``.
@@ -184,6 +221,7 @@ class AsyncMeterFlow(_BaseClient):
184
221
  raise AssertionError("unreachable") # pragma: no cover — `count()` never ends
185
222
 
186
223
  async def aclose(self) -> None:
224
+ """Release the client's connections."""
187
225
  await self._http.aclose()
188
226
 
189
227
  async def __aenter__(self) -> AsyncMeterFlow:
@@ -1,7 +1,7 @@
1
- """Typed error hierarchy — one class per HTTP outcome, mirroring the Node SDK one-to-one.
1
+ """The errors the SDK raises: one class per HTTP outcome.
2
2
 
3
- Every error carries the API's ``X-Request-ID`` (``request_id``) so a support ticket can point at the
4
- exact request, plus ``status_code``, ``error_type`` and ``retryable`` for structured logging.
3
+ Every error carries the API's ``X-Request-ID`` as ``request_id`` (quote it to support), with ``status_code``,
4
+ ``error_type`` and ``retryable``.
5
5
  """
6
6
 
7
7
  from __future__ import annotations
@@ -10,13 +10,26 @@ from typing import TypedDict
10
10
 
11
11
 
12
12
  class ValidationFieldError(TypedDict):
13
- """One field the API rejected, as returned in a 422's ``error.fields``."""
13
+ """One field the API rejected, as returned in a 422's ``error.fields``.
14
+
15
+ field: the field's name in the request.
16
+ message: why it was refused.
17
+ """
14
18
 
15
19
  field: str
16
20
  message: str
17
21
 
18
22
 
19
23
  class MeterFlowError(Exception):
24
+ """Every error the SDK raises for an API answer; each HTTP outcome has its own subclass.
25
+
26
+ message: what went wrong, with the refused fields for a 422.
27
+ error_type: a stable name for the kind of error: ``auth_error``, ``not_found``, ``rate_limit`` and so on.
28
+ retryable: whether the SDK retries this kind of error (it already has, by the time you see it).
29
+ request_id: the API's ``X-Request-ID`` for the request; None when there was no answer.
30
+ status_code: the HTTP status; None when there was no answer.
31
+ """
32
+
20
33
  def __init__(
21
34
  self,
22
35
  message: str,
@@ -38,35 +51,35 @@ class MeterFlowError(Exception):
38
51
 
39
52
 
40
53
  class AuthError(MeterFlowError):
41
- """401 / 403 — the key is missing, invalid, revoked or not allowed to do this."""
54
+ """401 / 403: the key is missing, invalid or revoked, or not allowed to do this."""
42
55
 
43
56
  def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 401) -> None:
44
57
  super().__init__(message, "auth_error", retryable=False, request_id=request_id, status_code=status_code)
45
58
 
46
59
 
47
60
  class NotFoundError(MeterFlowError):
48
- """404 — no such customer, plan, subscription… or an unknown feature key (a typo, not a plan)."""
61
+ """404: no such customer, plan, subscription or hold, or an unknown feature key (a typo, not a plan)."""
49
62
 
50
63
  def __init__(self, message: str, *, request_id: str | None = None) -> None:
51
64
  super().__init__(message, "not_found", retryable=False, request_id=request_id, status_code=404)
52
65
 
53
66
 
54
67
  class InsufficientCreditsError(MeterFlowError):
55
- """402 — a deduction the balance cannot cover. A business outcome, not a failure: prompt a top-up."""
68
+ """402: a deduction or a hold the customer's available credits cannot cover. A business outcome: offer a top-up."""
56
69
 
57
70
  def __init__(self, message: str, *, request_id: str | None = None) -> None:
58
71
  super().__init__(message, "insufficient_credits", retryable=False, request_id=request_id, status_code=402)
59
72
 
60
73
 
61
74
  class ConflictError(MeterFlowError):
62
- """409 — the write clashes with current state (e.g. the project is deactivated)."""
75
+ """409: the write clashes with the current state, for example a deactivated project or a hold already settled."""
63
76
 
64
77
  def __init__(self, message: str, *, request_id: str | None = None) -> None:
65
78
  super().__init__(message, "conflict", retryable=False, request_id=request_id, status_code=409)
66
79
 
67
80
 
68
81
  class ValidationError(MeterFlowError):
69
- """422 — the API rejected the payload. ``fields`` holds the per-field detail (empty when none was sent)."""
82
+ """422: the API refused the request body or query; ``fields`` says which field and why (empty when it named none)."""
70
83
 
71
84
  def __init__(self, message: str, *, request_id: str | None = None, fields: list[ValidationFieldError] | None = None) -> None:
72
85
  super().__init__(message, "validation_error", retryable=False, request_id=request_id, status_code=422)
@@ -74,17 +87,14 @@ class ValidationError(MeterFlowError):
74
87
 
75
88
 
76
89
  class PayloadTooLargeError(MeterFlowError):
77
- """413 — well-formed but too big; today only ``usage.record_batch`` above ``MAX_BATCH_EVENTS``.
78
-
79
- Never retried: the same payload would be refused again. Split it at the call site.
80
- """
90
+ """413: too large; today only ``usage.record_batch`` above ``MAX_BATCH_EVENTS`` events. Never retried: split the batch."""
81
91
 
82
92
  def __init__(self, message: str, *, request_id: str | None = None) -> None:
83
93
  super().__init__(message, "payload_too_large", retryable=False, request_id=request_id, status_code=413)
84
94
 
85
95
 
86
96
  class RateLimitError(MeterFlowError):
87
- """429 — retried automatically after the server's ``Retry-After`` (seconds, exposed as ``retry_after``)."""
97
+ """429: too many requests. Retried automatically after the server's ``Retry-After``, in seconds, kept as ``retry_after``."""
88
98
 
89
99
  def __init__(self, message: str, *, retry_after: int | None = None, request_id: str | None = None) -> None:
90
100
  super().__init__(message, "rate_limit", retryable=True, request_id=request_id, status_code=429)
@@ -92,7 +102,7 @@ class RateLimitError(MeterFlowError):
92
102
 
93
103
 
94
104
  class ServerError(MeterFlowError):
95
- """5xx — MeterFlow's side; retried automatically with backoff."""
105
+ """5xx: a failure on MeterFlow's side. Retried automatically, with backoff."""
96
106
 
97
107
  def __init__(self, message: str, *, request_id: str | None = None, status_code: int = 500) -> None:
98
108
  super().__init__(message, "server_error", retryable=True, request_id=request_id, status_code=status_code)
@@ -0,0 +1 @@
1
+ """The client's seven resources, each in a sync and an async form sharing one set of request specs."""