meterflow 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.
@@ -0,0 +1,49 @@
1
+ """Plans — read-only through the SDK: pricing is managed by humans in the dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._http import RequestSpec, encode_path_segment
8
+ from ..types import PlanResponse
9
+
10
+ if TYPE_CHECKING:
11
+ from ..client import AsyncMeterFlow, MeterFlow
12
+
13
+
14
+ def list_spec(project_id: str | None) -> RequestSpec:
15
+ return RequestSpec("GET", "plans", query={"project_id": project_id})
16
+
17
+
18
+ def get_spec(plan_id: str, project_id: str | None) -> RequestSpec:
19
+ return RequestSpec("GET", f"plans/{encode_path_segment(plan_id)}", query={"project_id": project_id})
20
+
21
+
22
+ class PlansResource:
23
+ def __init__(self, client: MeterFlow) -> None:
24
+ self._client = client
25
+
26
+ def list(self, *, project_id: str | None = None) -> list[PlanResponse]:
27
+ """The project's plans — render your pricing page from this so it can never drift from billing."""
28
+ result: list[PlanResponse] = self._client.request(list_spec(project_id))
29
+ return result
30
+
31
+ def get(self, plan_id: str, *, project_id: str | None = None) -> PlanResponse:
32
+ """One plan, including its per-meter limits and feature keys."""
33
+ result: PlanResponse = self._client.request(get_spec(plan_id, project_id))
34
+ return result
35
+
36
+
37
+ class AsyncPlansResource:
38
+ def __init__(self, client: AsyncMeterFlow) -> None:
39
+ self._client = client
40
+
41
+ async def list(self, *, project_id: str | None = None) -> list[PlanResponse]:
42
+ """The project's plans — render your pricing page from this so it can never drift from billing."""
43
+ result: list[PlanResponse] = await self._client.request(list_spec(project_id))
44
+ return result
45
+
46
+ async def get(self, plan_id: str, *, project_id: str | None = None) -> PlanResponse:
47
+ """One plan, including its per-meter limits and feature keys."""
48
+ result: PlanResponse = await self._client.request(get_spec(plan_id, project_id))
49
+ return result
@@ -0,0 +1,89 @@
1
+ """Subscriptions — one customer on one plan from a date; renews itself."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._http import RequestSpec, encode_path_segment
8
+ from ..types import SubscriptionCreateRequest, SubscriptionResponse, SubscriptionUpdateRequest
9
+
10
+ if TYPE_CHECKING:
11
+ from ..client import AsyncMeterFlow, MeterFlow
12
+
13
+
14
+ def create_spec(body: SubscriptionCreateRequest, idempotency_key: str | None) -> RequestSpec:
15
+ return RequestSpec("POST", "subscriptions", body=body, idempotency_key=idempotency_key)
16
+
17
+
18
+ def list_spec(customer_id: str | None) -> RequestSpec:
19
+ return RequestSpec("GET", "subscriptions", query={"customer_id": customer_id})
20
+
21
+
22
+ def get_spec(subscription_id: str) -> RequestSpec:
23
+ return RequestSpec("GET", f"subscriptions/{encode_path_segment(subscription_id)}")
24
+
25
+
26
+ def update_spec(subscription_id: str, body: SubscriptionUpdateRequest, idempotency_key: str | None) -> RequestSpec:
27
+ return RequestSpec("PATCH", f"subscriptions/{encode_path_segment(subscription_id)}", body=body, idempotency_key=idempotency_key)
28
+
29
+
30
+ def delete_spec(subscription_id: str) -> RequestSpec:
31
+ return RequestSpec("DELETE", f"subscriptions/{encode_path_segment(subscription_id)}")
32
+
33
+
34
+ class SubscriptionsResource:
35
+ def __init__(self, client: MeterFlow) -> None:
36
+ self._client = client
37
+
38
+ def create(self, body: SubscriptionCreateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
39
+ """Put a customer on a plan. ``status`` is ``trialing`` if the plan has trial days, else ``active``."""
40
+ result: SubscriptionResponse = self._client.request(create_spec(body, idempotency_key))
41
+ return result
42
+
43
+ def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
44
+ """The project's subscriptions in this key's environment, optionally one customer's."""
45
+ result: list[SubscriptionResponse] = self._client.request(list_spec(customer_id))
46
+ return result
47
+
48
+ def get(self, subscription_id: str) -> SubscriptionResponse:
49
+ result: SubscriptionResponse = self._client.request(get_spec(subscription_id))
50
+ return result
51
+
52
+ def update(self, subscription_id: str, body: SubscriptionUpdateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
53
+ """Change status or metadata. Cancel with ``{"status": "canceled"}`` — the record stays for history."""
54
+ result: SubscriptionResponse = self._client.request(update_spec(subscription_id, body, idempotency_key))
55
+ return result
56
+
57
+ def delete(self, subscription_id: str) -> None:
58
+ """Remove the record entirely — for test data; prefer cancelling in production."""
59
+ self._client.request(delete_spec(subscription_id))
60
+
61
+
62
+ class AsyncSubscriptionsResource:
63
+ def __init__(self, client: AsyncMeterFlow) -> None:
64
+ self._client = client
65
+
66
+ async def create(self, body: SubscriptionCreateRequest, *, idempotency_key: str | None = None) -> SubscriptionResponse:
67
+ """Put a customer on a plan. ``status`` is ``trialing`` if the plan has trial days, else ``active``."""
68
+ result: SubscriptionResponse = await self._client.request(create_spec(body, idempotency_key))
69
+ return result
70
+
71
+ async def list(self, *, customer_id: str | None = None) -> list[SubscriptionResponse]:
72
+ """The project's subscriptions in this key's environment, optionally one customer's."""
73
+ result: list[SubscriptionResponse] = await self._client.request(list_spec(customer_id))
74
+ return result
75
+
76
+ async def get(self, subscription_id: str) -> SubscriptionResponse:
77
+ result: SubscriptionResponse = await self._client.request(get_spec(subscription_id))
78
+ return result
79
+
80
+ async def update(
81
+ self, subscription_id: str, body: SubscriptionUpdateRequest, *, idempotency_key: str | None = None
82
+ ) -> SubscriptionResponse:
83
+ """Change status or metadata. Cancel with ``{"status": "canceled"}`` — the record stays for history."""
84
+ result: SubscriptionResponse = await self._client.request(update_spec(subscription_id, body, idempotency_key))
85
+ return result
86
+
87
+ async def delete(self, subscription_id: str) -> None:
88
+ """Remove the record entirely — for test data; prefer cancelling in production."""
89
+ await self._client.request(delete_spec(subscription_id))
@@ -0,0 +1,77 @@
1
+ """Usage events — "this customer just did this thing, this much", matched to a meter by ``event_name``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .._http import RequestSpec, encode_path_segment
8
+ from ..errors import PayloadTooLargeError
9
+ from ..types import UsageEventRequest, UsageEventResponse, UsageSummaryResponse
10
+
11
+ if TYPE_CHECKING:
12
+ from ..client import AsyncMeterFlow, MeterFlow
13
+
14
+ # The API refuses a batch above this with 413. The SDK checks it before sending so an oversized
15
+ # batch fails instantly, offline, with the same ``PayloadTooLargeError`` — and it does NOT split
16
+ # the batch for you: one call is one request with one idempotency key, and silently turning it
17
+ # into N requests would leave partial success indistinguishable from failure. Chunk at the call
18
+ # site, where you can pick a key per chunk.
19
+ MAX_BATCH_EVENTS = 500
20
+
21
+
22
+ def record_spec(body: UsageEventRequest, idempotency_key: str | None) -> RequestSpec:
23
+ return RequestSpec("POST", "usage/events", body=body, idempotency_key=idempotency_key)
24
+
25
+
26
+ def record_batch_spec(events: list[UsageEventRequest], idempotency_key: str | None) -> RequestSpec:
27
+ if len(events) > MAX_BATCH_EVENTS:
28
+ raise PayloadTooLargeError(f"A batch may contain at most {MAX_BATCH_EVENTS} events; this one has {len(events)}. Split it.")
29
+ return RequestSpec("POST", "usage/events/batch", body={"events": events}, idempotency_key=idempotency_key)
30
+
31
+
32
+ def summary_spec(customer_id: str, meter_id: str | None, from_: str | None, to: str | None) -> RequestSpec:
33
+ return RequestSpec("GET", f"usage/{encode_path_segment(customer_id)}", query={"meter_id": meter_id, "from_": from_, "to": to})
34
+
35
+
36
+ class UsageResource:
37
+ def __init__(self, client: MeterFlow) -> None:
38
+ self._client = client
39
+
40
+ def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
41
+ """Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
42
+ result: UsageEventResponse = self._client.request(record_spec(body, idempotency_key))
43
+ return result
44
+
45
+ def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
46
+ """Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
47
+ result: list[UsageEventResponse] = self._client.request(record_batch_spec(events, idempotency_key))
48
+ return result
49
+
50
+ def summary(
51
+ self, customer_id: str, *, meter_id: str | None = None, from_: str | None = None, to: str | None = None
52
+ ) -> UsageSummaryResponse:
53
+ """A customer's usage broken down by meter. ``from_`` keeps the API's trailing underscore."""
54
+ result: UsageSummaryResponse = self._client.request(summary_spec(customer_id, meter_id, from_, to))
55
+ return result
56
+
57
+
58
+ class AsyncUsageResource:
59
+ def __init__(self, client: AsyncMeterFlow) -> None:
60
+ self._client = client
61
+
62
+ async def record(self, body: UsageEventRequest, *, idempotency_key: str | None = None) -> UsageEventResponse:
63
+ """Record one event. Returns immediately (``processed: False``); totals materialise moments later."""
64
+ result: UsageEventResponse = await self._client.request(record_spec(body, idempotency_key))
65
+ return result
66
+
67
+ async def record_batch(self, events: list[UsageEventRequest], *, idempotency_key: str | None = None) -> list[UsageEventResponse]:
68
+ """Record up to ``MAX_BATCH_EVENTS`` events in one request. A larger list is refused locally, never sent."""
69
+ result: list[UsageEventResponse] = await self._client.request(record_batch_spec(events, idempotency_key))
70
+ return result
71
+
72
+ async def summary(
73
+ self, customer_id: str, *, meter_id: str | None = None, from_: str | None = None, to: str | None = None
74
+ ) -> UsageSummaryResponse:
75
+ """A customer's usage broken down by meter. ``from_`` keeps the API's trailing underscore."""
76
+ result: UsageSummaryResponse = await self._client.request(summary_spec(customer_id, meter_id, from_, to))
77
+ return result
meterflow/types.py ADDED
@@ -0,0 +1,214 @@
1
+ # GENERATED by scripts/generate_types.py from docs/openapi.json — DO NOT EDIT.
2
+ # OpenAPI version: 0.1.0. Re-run the script after the contract changes; CI fails on drift.
3
+ """Request and response shapes of the SDK-facing API, as TypedDicts (plain dicts at runtime)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Literal, TypedDict
8
+
9
+ EntitlementReason = Literal["no_subscription", "not_in_plan", "hard_limit_reached", "soft_limit_exceeded", "insufficient_credits"]
10
+
11
+ FeatureType = Literal["boolean", "metered"]
12
+
13
+ SubscriptionStatus = Literal["active", "trialing", "past_due", "paused", "canceled", "expired"]
14
+
15
+
16
+ class CreditBalanceResponse(TypedDict):
17
+ id: str
18
+ project_id: str
19
+ customer_external_id: str
20
+ balance: str
21
+ total_granted: str
22
+ total_consumed: str
23
+ version: int
24
+ created_at: str
25
+ updated_at: str
26
+
27
+
28
+ class _CreditDeductRequestRequired(TypedDict):
29
+ customer_external_id: str
30
+ amount: float | str
31
+
32
+
33
+ class CreditDeductRequest(_CreditDeductRequestRequired, total=False):
34
+ description: str | None
35
+ idempotency_key: str | None
36
+ metadata: dict[str, Any]
37
+
38
+
39
+ class _CreditGrantRequestRequired(TypedDict):
40
+ customer_external_id: str
41
+ amount: float | str
42
+
43
+
44
+ class CreditGrantRequest(_CreditGrantRequestRequired, total=False):
45
+ description: str | None
46
+ idempotency_key: str | None
47
+ metadata: dict[str, Any]
48
+
49
+
50
+ class CreditTransactionResponse(TypedDict):
51
+ id: str
52
+ project_id: str
53
+ customer_external_id: str
54
+ transaction_type: str
55
+ amount: str
56
+ balance_before: str
57
+ balance_after: str
58
+ description: str | None
59
+ idempotency_key: str | None
60
+ metadata_: dict[str, Any]
61
+ created_at: str
62
+
63
+
64
+ class _EntitlementResponseRequired(TypedDict):
65
+ feature: str
66
+ feature_type: FeatureType
67
+ allowed: bool
68
+
69
+
70
+ class EntitlementResponse(_EntitlementResponseRequired, total=False):
71
+ reason: EntitlementReason | None
72
+ limit_type: str | None
73
+ included: int | None
74
+ used: str | None
75
+ remaining: str | None
76
+ period_start: str | None
77
+ period_end: str | None
78
+
79
+
80
+ class EntitlementsResponse(TypedDict):
81
+ customer_id: str
82
+ environment: str
83
+ subscription_id: str | None
84
+ plan_id: str | None
85
+ period_end: str | None
86
+ entitlements: list[EntitlementResponse]
87
+
88
+
89
+ class PlanMeterLimitResponse(TypedDict):
90
+ id: str
91
+ plan_id: str
92
+ meter_id: str
93
+ included_units: int | None
94
+ overage_rate: str | None
95
+ limit_type: str
96
+
97
+
98
+ class PlanResponse(TypedDict):
99
+ id: str
100
+ project_id: str
101
+ name: str
102
+ slug: str
103
+ description: str | None
104
+ billing_period: str
105
+ price: str
106
+ currency: str
107
+ trial_days: int
108
+ is_active: bool
109
+ is_public: bool
110
+ metadata_: dict[str, Any]
111
+ meter_limits: list[PlanMeterLimitResponse]
112
+ features: list[str]
113
+ created_at: str
114
+ updated_at: str
115
+
116
+
117
+ class _SubscriptionCreateRequestRequired(TypedDict):
118
+ plan_id: str
119
+ customer_external_id: str
120
+
121
+
122
+ class SubscriptionCreateRequest(_SubscriptionCreateRequestRequired, total=False):
123
+ metadata: dict[str, Any]
124
+ idempotency_key: str | None
125
+
126
+
127
+ class SubscriptionResponse(TypedDict):
128
+ id: str
129
+ project_id: str
130
+ plan_id: str
131
+ customer_external_id: str
132
+ status: str
133
+ current_period_start: str
134
+ current_period_end: str | None
135
+ trial_end_date: str | None
136
+ canceled_at: str | None
137
+ metadata_: dict[str, Any]
138
+ created_at: str
139
+ updated_at: str
140
+
141
+
142
+ class SubscriptionUpdateRequest(TypedDict, total=False):
143
+ status: SubscriptionStatus | None
144
+ metadata: dict[str, Any] | None
145
+
146
+
147
+ class UsageEventBatchRequest(TypedDict):
148
+ events: list[UsageEventRequest]
149
+
150
+
151
+ class _UsageEventRequestRequired(TypedDict):
152
+ event_name: str
153
+ customer_external_id: str
154
+
155
+
156
+ class UsageEventRequest(_UsageEventRequestRequired, total=False):
157
+ value: float | str
158
+ properties: dict[str, Any]
159
+ idempotency_key: str | None
160
+ timestamp: str | None
161
+
162
+
163
+ class UsageEventResponse(TypedDict):
164
+ id: str
165
+ project_id: str
166
+ meter_id: str
167
+ customer_external_id: str
168
+ event_name: str
169
+ value: str
170
+ properties: dict[str, Any]
171
+ idempotency_key: str | None
172
+ processed: bool
173
+ timestamp: str
174
+ created_at: str
175
+
176
+
177
+ class UsageSummaryItem(TypedDict):
178
+ meter_id: str
179
+ meter_name: str
180
+ event_name: str
181
+ aggregation_type: str
182
+ value: str
183
+ event_count: int
184
+
185
+
186
+ class UsageSummaryResponse(TypedDict):
187
+ project_id: str
188
+ customer_external_id: str
189
+ from_: str | None
190
+ to: str | None
191
+ meters: list[UsageSummaryItem]
192
+
193
+
194
+ __all__ = [
195
+ "CreditBalanceResponse",
196
+ "CreditDeductRequest",
197
+ "CreditGrantRequest",
198
+ "CreditTransactionResponse",
199
+ "EntitlementReason",
200
+ "EntitlementResponse",
201
+ "EntitlementsResponse",
202
+ "FeatureType",
203
+ "PlanMeterLimitResponse",
204
+ "PlanResponse",
205
+ "SubscriptionCreateRequest",
206
+ "SubscriptionResponse",
207
+ "SubscriptionStatus",
208
+ "SubscriptionUpdateRequest",
209
+ "UsageEventBatchRequest",
210
+ "UsageEventRequest",
211
+ "UsageEventResponse",
212
+ "UsageSummaryItem",
213
+ "UsageSummaryResponse",
214
+ ]
meterflow/webhook.py ADDED
@@ -0,0 +1,21 @@
1
+ """Webhook signature verification — HMAC-SHA256 over the raw body, hex-encoded, timing-safe.
2
+
3
+ Byte-for-byte the same scheme as the Node SDK's ``verifyWebhook`` and the API's signer: a delivery
4
+ signed by MeterFlow verifies here with the webhook's secret, and nothing else does.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+
12
+
13
+ def verify_webhook(body: bytes | str, signature: str, secret: str) -> bool:
14
+ """Return ``True`` when ``signature`` (the ``X-MeterFlow-Signature`` header) matches ``body``.
15
+
16
+ Pass the **raw** request body — re-serialised JSON changes whitespace and key order and will
17
+ not match. Never raises on bad input: an empty, short or malformed signature is simply ``False``.
18
+ """
19
+ raw = body.encode("utf-8") if isinstance(body, str) else body
20
+ expected = hmac.new(secret.encode("utf-8"), raw, hashlib.sha256).hexdigest()
21
+ return hmac.compare_digest(expected, signature)