threecommon 0.5.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.
- {threecommon-0.5.0 → threecommon-0.7.0}/CHANGELOG.md +28 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/PKG-INFO +1 -1
- {threecommon-0.5.0 → threecommon-0.7.0}/pyproject.toml +1 -1
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/client.py +16 -0
- threecommon-0.7.0/src/threecommon/features/__init__.py +42 -0
- threecommon-0.7.0/src/threecommon/features/service.py +251 -0
- threecommon-0.7.0/src/threecommon/features/types.py +175 -0
- threecommon-0.7.0/src/threecommon/prices/__init__.py +46 -0
- threecommon-0.7.0/src/threecommon/prices/service.py +227 -0
- threecommon-0.7.0/src/threecommon/prices/types.py +186 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/.gitignore +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/LICENSE +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/README.md +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/headers.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/http_client.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/parse.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/retry.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/telemetry.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/url.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_generated/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_generated/models.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/api_version.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/config.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/service.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/service.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/base.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/classes.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/service.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/builder.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/helpers.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/service.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/pagination/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/pagination/auto_paginator.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/py.typed +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/__init__.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/service.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/types.py +0 -0
- {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/version.py +0 -0
|
@@ -5,6 +5,34 @@ versions follow [SemVer](https://semver.org/spec/v2.0.0.html).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## 0.7.0
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Features resource. The new `client.features` surface covers the feature
|
|
13
|
+
catalog: `list`, `resolve` (resolve a feature's live value for a customer),
|
|
14
|
+
`retrieve`, `create`, `update`, `archive`, `unarchive`, and a
|
|
15
|
+
`list_auto_paginate` iterator. Both sync and async surfaces.
|
|
16
|
+
- New public types on `threecommon.features`: `Feature`, `FeatureType`,
|
|
17
|
+
`ResolvedFeature`, the `ResolvedFeatureValue` discriminated union and its
|
|
18
|
+
`ResolvedFeatureBoolean`/`ResolvedFeatureQuantity`/`ResolvedFeatureEnum`/
|
|
19
|
+
`ResolvedFeatureDuration` members, `CreateBody`, `UpdateBody`, `ListParams`,
|
|
20
|
+
`RetrieveParams`, `ResolveParams`, and the `ListFeaturesResponse` envelope.
|
|
21
|
+
|
|
22
|
+
## 0.6.0
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Prices resource. The new `client.prices` surface covers the price catalog:
|
|
27
|
+
`list`, `retrieve`, `create`, `update`, `archive`, `unarchive`, and a
|
|
28
|
+
`list_auto_paginate` iterator. Both sync and async surfaces.
|
|
29
|
+
- New public types on `threecommon.prices`: `Price`, `PriceRecurring`,
|
|
30
|
+
`PriceFeature` (the boolean/quantity/enum/duration grant union) and its
|
|
31
|
+
`PriceFeatureBoolean`/`PriceFeatureQuantity`/`PriceFeatureEnum`/
|
|
32
|
+
`PriceFeatureDuration` members, `CreateBody`, `UpdateBody`, `ListParams`,
|
|
33
|
+
`RetrieveParams`, the `ListPricesResponse` envelope, and the
|
|
34
|
+
`PriceType`/`PriceCurrency`/`PriceInterval` literal unions.
|
|
35
|
+
|
|
8
36
|
## 0.5.0
|
|
9
37
|
|
|
10
38
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: threecommon
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: Official Python client for the 3Common Public API.
|
|
5
5
|
Project-URL: Homepage, https://github.com/3-Common/sdk/tree/main/sdk-python
|
|
6
6
|
Project-URL: Issues, https://github.com/3-Common/sdk/issues
|
|
@@ -10,7 +10,7 @@ license = { text = "MIT" }
|
|
|
10
10
|
authors = [{ name = "3Common", email = "support@3common.com" }]
|
|
11
11
|
keywords = ["3common", "sdk", "api-client", "events", "invoices"]
|
|
12
12
|
requires-python = ">=3.10"
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.7.0"
|
|
14
14
|
dependencies = [
|
|
15
15
|
"httpx>=0.27,<1.0",
|
|
16
16
|
"pydantic>=2.7,<3.0",
|
|
@@ -21,7 +21,9 @@ from threecommon.config import RetryDelay, resolve_config
|
|
|
21
21
|
from threecommon.contacts.service import AsyncContactsService, ContactsService
|
|
22
22
|
from threecommon.entitlements.service import AsyncEntitlementsService, EntitlementsService
|
|
23
23
|
from threecommon.events.service import AsyncEventsService, EventsService
|
|
24
|
+
from threecommon.features.service import AsyncFeaturesService, FeaturesService
|
|
24
25
|
from threecommon.invoices.service import AsyncInvoicesService, InvoicesService
|
|
26
|
+
from threecommon.prices.service import AsyncPricesService, PricesService
|
|
25
27
|
from threecommon.subscriptions.service import AsyncSubscriptionsService, SubscriptionsService
|
|
26
28
|
|
|
27
29
|
if TYPE_CHECKING: # pragma: no cover
|
|
@@ -66,6 +68,14 @@ class ThreeCommon:
|
|
|
66
68
|
"""Entitlements resource — ``list``, ``retrieve``, ``lookup``, ``grant``,
|
|
67
69
|
``consume``, plus ``list_auto_paginate``."""
|
|
68
70
|
|
|
71
|
+
prices: PricesService
|
|
72
|
+
"""Prices resource — ``list``, ``retrieve``, ``create``, ``update``,
|
|
73
|
+
``archive``, ``unarchive``, plus ``list_auto_paginate``."""
|
|
74
|
+
|
|
75
|
+
features: FeaturesService
|
|
76
|
+
"""Features resource — ``list``, ``resolve``, ``retrieve``, ``create``,
|
|
77
|
+
``update``, ``archive``, ``unarchive``, plus ``list_auto_paginate``."""
|
|
78
|
+
|
|
69
79
|
_http: HTTPClient
|
|
70
80
|
_telemetry: Telemetry
|
|
71
81
|
|
|
@@ -111,6 +121,8 @@ class ThreeCommon:
|
|
|
111
121
|
self.subscriptions = SubscriptionsService(self._http)
|
|
112
122
|
self.contacts = ContactsService(self._http)
|
|
113
123
|
self.entitlements = EntitlementsService(self._http)
|
|
124
|
+
self.prices = PricesService(self._http)
|
|
125
|
+
self.features = FeaturesService(self._http)
|
|
114
126
|
|
|
115
127
|
def close(self) -> None:
|
|
116
128
|
"""Close the underlying httpx client (no-op if you supplied your own)."""
|
|
@@ -141,6 +153,8 @@ class AsyncThreeCommon:
|
|
|
141
153
|
subscriptions: AsyncSubscriptionsService
|
|
142
154
|
contacts: AsyncContactsService
|
|
143
155
|
entitlements: AsyncEntitlementsService
|
|
156
|
+
prices: AsyncPricesService
|
|
157
|
+
features: AsyncFeaturesService
|
|
144
158
|
|
|
145
159
|
_http: AsyncHTTPClient
|
|
146
160
|
_telemetry: Telemetry
|
|
@@ -187,6 +201,8 @@ class AsyncThreeCommon:
|
|
|
187
201
|
self.subscriptions = AsyncSubscriptionsService(self._http)
|
|
188
202
|
self.contacts = AsyncContactsService(self._http)
|
|
189
203
|
self.entitlements = AsyncEntitlementsService(self._http)
|
|
204
|
+
self.prices = AsyncPricesService(self._http)
|
|
205
|
+
self.features = AsyncFeaturesService(self._http)
|
|
190
206
|
|
|
191
207
|
async def aclose(self) -> None:
|
|
192
208
|
"""Close the underlying async httpx client."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Features resource — sync and async clients plus public types.
|
|
2
|
+
|
|
3
|
+
Most callers reach this module through
|
|
4
|
+
[ThreeCommon.features][threecommon.ThreeCommon] /
|
|
5
|
+
[AsyncThreeCommon.features][threecommon.AsyncThreeCommon]; importing the service
|
|
6
|
+
classes directly is supported for advanced wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threecommon.features.service import AsyncFeaturesService, FeaturesService
|
|
10
|
+
from threecommon.features.types import (
|
|
11
|
+
CreateBody,
|
|
12
|
+
Feature,
|
|
13
|
+
FeatureType,
|
|
14
|
+
ListFeaturesResponse,
|
|
15
|
+
ListParams,
|
|
16
|
+
ResolvedFeature,
|
|
17
|
+
ResolvedFeatureBoolean,
|
|
18
|
+
ResolvedFeatureDuration,
|
|
19
|
+
ResolvedFeatureEnum,
|
|
20
|
+
ResolvedFeatureQuantity,
|
|
21
|
+
ResolveParams,
|
|
22
|
+
RetrieveParams,
|
|
23
|
+
UpdateBody,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = (
|
|
27
|
+
"AsyncFeaturesService",
|
|
28
|
+
"CreateBody",
|
|
29
|
+
"Feature",
|
|
30
|
+
"FeatureType",
|
|
31
|
+
"FeaturesService",
|
|
32
|
+
"ListFeaturesResponse",
|
|
33
|
+
"ListParams",
|
|
34
|
+
"ResolveParams",
|
|
35
|
+
"ResolvedFeature",
|
|
36
|
+
"ResolvedFeatureBoolean",
|
|
37
|
+
"ResolvedFeatureDuration",
|
|
38
|
+
"ResolvedFeatureEnum",
|
|
39
|
+
"ResolvedFeatureQuantity",
|
|
40
|
+
"RetrieveParams",
|
|
41
|
+
"UpdateBody",
|
|
42
|
+
)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Sync and async features services.
|
|
2
|
+
|
|
3
|
+
Both services share the same wire shape and validation logic; the only
|
|
4
|
+
difference is which HTTP client they call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from threecommon._core.http_client import Request
|
|
13
|
+
from threecommon.errors.classes import ValidationError
|
|
14
|
+
from threecommon.features.types import (
|
|
15
|
+
CreateBody,
|
|
16
|
+
Feature,
|
|
17
|
+
ListFeaturesResponse,
|
|
18
|
+
ListParams,
|
|
19
|
+
ResolvedFeature,
|
|
20
|
+
ResolveParams,
|
|
21
|
+
RetrieveParams,
|
|
22
|
+
UpdateBody,
|
|
23
|
+
)
|
|
24
|
+
from threecommon.pagination import AsyncIter, Iter
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _qval(value: object) -> str:
|
|
31
|
+
# The wire (and every other SDK) renders query booleans lowercase.
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return "true" if value else "false"
|
|
34
|
+
return str(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
|
|
38
|
+
if params is None:
|
|
39
|
+
return None
|
|
40
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
41
|
+
if not raw:
|
|
42
|
+
return None
|
|
43
|
+
return {k: _qval(v) for k, v in raw.items()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _encode_retrieve_params(params: RetrieveParams | None) -> dict[str, str] | None:
|
|
47
|
+
if params is None or params.fields is None:
|
|
48
|
+
return None
|
|
49
|
+
return {"fields": params.fields}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _encode_resolve_params(params: ResolveParams) -> dict[str, str]:
|
|
53
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
54
|
+
return {k: str(v) for k, v in raw.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _require_id(method: str, feature_id: str) -> None:
|
|
58
|
+
if not feature_id:
|
|
59
|
+
msg = f"features.{method}: id must be a non-empty string"
|
|
60
|
+
raise ValidationError(code="missing_id", message=msg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _path_for(feature_id: str) -> str:
|
|
64
|
+
return f"/features/{quote(feature_id, safe='')}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
68
|
+
# Sync
|
|
69
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class FeaturesService:
|
|
73
|
+
"""Sync features service — bound as ``client.features`` on [ThreeCommon]."""
|
|
74
|
+
|
|
75
|
+
__slots__ = ("_http",)
|
|
76
|
+
|
|
77
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
78
|
+
self._http = http
|
|
79
|
+
|
|
80
|
+
def list(self, params: ListParams | None = None) -> ListFeaturesResponse:
|
|
81
|
+
"""List the host's feature catalog (one page).
|
|
82
|
+
|
|
83
|
+
For full iteration use [list_auto_paginate][FeaturesService.list_auto_paginate].
|
|
84
|
+
"""
|
|
85
|
+
body = self._http.request(
|
|
86
|
+
Request(method="GET", path="/features", query=_encode_list_params(params))
|
|
87
|
+
)
|
|
88
|
+
return ListFeaturesResponse.model_validate(body)
|
|
89
|
+
|
|
90
|
+
def resolve(self, params: ResolveParams) -> ResolvedFeature:
|
|
91
|
+
"""Resolve a feature's live value for a customer.
|
|
92
|
+
|
|
93
|
+
Walks active subscriptions -> prices -> feature grants. Raises
|
|
94
|
+
[NotFoundError][threecommon.NotFoundError] if the feature key is unknown.
|
|
95
|
+
"""
|
|
96
|
+
body = self._http.request(
|
|
97
|
+
Request(method="GET", path="/features/resolve", query=_encode_resolve_params(params))
|
|
98
|
+
)
|
|
99
|
+
return ResolvedFeature.model_validate(body["data"])
|
|
100
|
+
|
|
101
|
+
def retrieve(self, feature_id: str, params: RetrieveParams | None = None) -> Feature:
|
|
102
|
+
"""Retrieve a single feature by id."""
|
|
103
|
+
_require_id("retrieve", feature_id)
|
|
104
|
+
body = self._http.request(
|
|
105
|
+
Request(method="GET", path=_path_for(feature_id), query=_encode_retrieve_params(params))
|
|
106
|
+
)
|
|
107
|
+
return Feature.model_validate(body["data"])
|
|
108
|
+
|
|
109
|
+
def create(self, body: CreateBody) -> Feature:
|
|
110
|
+
"""Create a feature in the catalog."""
|
|
111
|
+
if body is None:
|
|
112
|
+
raise ValidationError(
|
|
113
|
+
code="missing_body", message="features.create: body must be non-None"
|
|
114
|
+
)
|
|
115
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
116
|
+
response = self._http.request(Request(method="POST", path="/features", body=payload))
|
|
117
|
+
return Feature.model_validate(response["data"])
|
|
118
|
+
|
|
119
|
+
def update(self, feature_id: str, body: UpdateBody) -> Feature:
|
|
120
|
+
"""Apply a partial update to a feature. Set a field to ``None`` to clear it."""
|
|
121
|
+
_require_id("update", feature_id)
|
|
122
|
+
if body is None:
|
|
123
|
+
raise ValidationError(
|
|
124
|
+
code="missing_body", message="features.update: body must be non-None"
|
|
125
|
+
)
|
|
126
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
127
|
+
response = self._http.request(
|
|
128
|
+
Request(method="PATCH", path=_path_for(feature_id), body=payload)
|
|
129
|
+
)
|
|
130
|
+
return Feature.model_validate(response["data"])
|
|
131
|
+
|
|
132
|
+
def archive(self, feature_id: str) -> Feature:
|
|
133
|
+
"""Soft-archive a feature. Idempotent."""
|
|
134
|
+
_require_id("archive", feature_id)
|
|
135
|
+
response = self._http.request(
|
|
136
|
+
Request(method="POST", path=f"{_path_for(feature_id)}/archive", body={})
|
|
137
|
+
)
|
|
138
|
+
return Feature.model_validate(response["data"])
|
|
139
|
+
|
|
140
|
+
def unarchive(self, feature_id: str) -> Feature:
|
|
141
|
+
"""Reactivate a previously archived feature. Idempotent."""
|
|
142
|
+
_require_id("unarchive", feature_id)
|
|
143
|
+
response = self._http.request(
|
|
144
|
+
Request(method="POST", path=f"{_path_for(feature_id)}/unarchive", body={})
|
|
145
|
+
)
|
|
146
|
+
return Feature.model_validate(response["data"])
|
|
147
|
+
|
|
148
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Feature]:
|
|
149
|
+
"""Iterate every feature matching ``params``, paging automatically."""
|
|
150
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
151
|
+
|
|
152
|
+
def fetch(page: int) -> tuple[list[Feature], bool]:
|
|
153
|
+
page_params = (
|
|
154
|
+
params.model_copy(update={"page": page})
|
|
155
|
+
if params is not None
|
|
156
|
+
else ListParams(page=page)
|
|
157
|
+
)
|
|
158
|
+
body = self._http.request(
|
|
159
|
+
Request(method="GET", path="/features", query=_encode_list_params(page_params))
|
|
160
|
+
)
|
|
161
|
+
response = ListFeaturesResponse.model_validate(body)
|
|
162
|
+
return response.data, response.has_more
|
|
163
|
+
|
|
164
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
# Async
|
|
169
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AsyncFeaturesService:
|
|
173
|
+
"""Async features service — bound as ``client.features`` on [AsyncThreeCommon]."""
|
|
174
|
+
|
|
175
|
+
__slots__ = ("_http",)
|
|
176
|
+
|
|
177
|
+
def __init__(self, http: AsyncHTTPClient) -> None:
|
|
178
|
+
self._http = http
|
|
179
|
+
|
|
180
|
+
async def list(self, params: ListParams | None = None) -> ListFeaturesResponse:
|
|
181
|
+
body = await self._http.request(
|
|
182
|
+
Request(method="GET", path="/features", query=_encode_list_params(params))
|
|
183
|
+
)
|
|
184
|
+
return ListFeaturesResponse.model_validate(body)
|
|
185
|
+
|
|
186
|
+
async def resolve(self, params: ResolveParams) -> ResolvedFeature:
|
|
187
|
+
body = await self._http.request(
|
|
188
|
+
Request(method="GET", path="/features/resolve", query=_encode_resolve_params(params))
|
|
189
|
+
)
|
|
190
|
+
return ResolvedFeature.model_validate(body["data"])
|
|
191
|
+
|
|
192
|
+
async def retrieve(self, feature_id: str, params: RetrieveParams | None = None) -> Feature:
|
|
193
|
+
_require_id("retrieve", feature_id)
|
|
194
|
+
body = await self._http.request(
|
|
195
|
+
Request(method="GET", path=_path_for(feature_id), query=_encode_retrieve_params(params))
|
|
196
|
+
)
|
|
197
|
+
return Feature.model_validate(body["data"])
|
|
198
|
+
|
|
199
|
+
async def create(self, body: CreateBody) -> Feature:
|
|
200
|
+
if body is None:
|
|
201
|
+
raise ValidationError(
|
|
202
|
+
code="missing_body", message="features.create: body must be non-None"
|
|
203
|
+
)
|
|
204
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
205
|
+
response = await self._http.request(Request(method="POST", path="/features", body=payload))
|
|
206
|
+
return Feature.model_validate(response["data"])
|
|
207
|
+
|
|
208
|
+
async def update(self, feature_id: str, body: UpdateBody) -> Feature:
|
|
209
|
+
_require_id("update", feature_id)
|
|
210
|
+
if body is None:
|
|
211
|
+
raise ValidationError(
|
|
212
|
+
code="missing_body", message="features.update: body must be non-None"
|
|
213
|
+
)
|
|
214
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
215
|
+
response = await self._http.request(
|
|
216
|
+
Request(method="PATCH", path=_path_for(feature_id), body=payload)
|
|
217
|
+
)
|
|
218
|
+
return Feature.model_validate(response["data"])
|
|
219
|
+
|
|
220
|
+
async def archive(self, feature_id: str) -> Feature:
|
|
221
|
+
_require_id("archive", feature_id)
|
|
222
|
+
response = await self._http.request(
|
|
223
|
+
Request(method="POST", path=f"{_path_for(feature_id)}/archive", body={})
|
|
224
|
+
)
|
|
225
|
+
return Feature.model_validate(response["data"])
|
|
226
|
+
|
|
227
|
+
async def unarchive(self, feature_id: str) -> Feature:
|
|
228
|
+
_require_id("unarchive", feature_id)
|
|
229
|
+
response = await self._http.request(
|
|
230
|
+
Request(method="POST", path=f"{_path_for(feature_id)}/unarchive", body={})
|
|
231
|
+
)
|
|
232
|
+
return Feature.model_validate(response["data"])
|
|
233
|
+
|
|
234
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Feature]:
|
|
235
|
+
"""Async iterate every feature matching ``params``."""
|
|
236
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
237
|
+
http = self._http
|
|
238
|
+
|
|
239
|
+
async def fetch(page: int) -> tuple[list[Feature], bool]:
|
|
240
|
+
page_params = (
|
|
241
|
+
params.model_copy(update={"page": page})
|
|
242
|
+
if params is not None
|
|
243
|
+
else ListParams(page=page)
|
|
244
|
+
)
|
|
245
|
+
body = await http.request(
|
|
246
|
+
Request(method="GET", path="/features", query=_encode_list_params(page_params))
|
|
247
|
+
)
|
|
248
|
+
response = ListFeaturesResponse.model_validate(body)
|
|
249
|
+
return response.data, response.has_more
|
|
250
|
+
|
|
251
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Public types for the features resource.
|
|
2
|
+
|
|
3
|
+
Hand-curated Pydantic models that mirror the wire shape (camelCase aliases
|
|
4
|
+
preserved). All response models use ``extra="ignore"`` so newer server-side
|
|
5
|
+
fields don't break older SDK versions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
#: Feature value shape.
|
|
15
|
+
#:
|
|
16
|
+
#: * ``boolean`` — pure on/off.
|
|
17
|
+
#: * ``quantity`` — countable; drives entitlement balance.
|
|
18
|
+
#: * ``enum`` — one of a fixed ordered set of values.
|
|
19
|
+
#: * ``duration`` — number of days (or unlimited).
|
|
20
|
+
FeatureType = Literal["boolean", "quantity", "enum", "duration"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _BaseModel(BaseModel):
|
|
24
|
+
"""Shared config: accept snake_case or camelCase, ignore unknown fields."""
|
|
25
|
+
|
|
26
|
+
model_config = ConfigDict(
|
|
27
|
+
populate_by_name=True,
|
|
28
|
+
extra="ignore",
|
|
29
|
+
str_strip_whitespace=False,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Feature(_BaseModel):
|
|
34
|
+
"""One feature in the host's catalog.
|
|
35
|
+
|
|
36
|
+
Optional fields are populated only when the server returned them — list
|
|
37
|
+
responses with a ``fields`` filter omit unrequested values.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
host_id: str | None = Field(
|
|
42
|
+
default=None, serialization_alias="hostId", validation_alias="hostId"
|
|
43
|
+
)
|
|
44
|
+
key: str | None = None
|
|
45
|
+
name: str | None = None
|
|
46
|
+
description: str | None = None
|
|
47
|
+
type: FeatureType | None = None
|
|
48
|
+
enum_values: list[str] | None = Field(
|
|
49
|
+
default=None, serialization_alias="enumValues", validation_alias="enumValues"
|
|
50
|
+
)
|
|
51
|
+
active: bool | None = None
|
|
52
|
+
metadata: dict[str, str] | None = None
|
|
53
|
+
created_at: str | None = Field(
|
|
54
|
+
default=None, serialization_alias="createdAt", validation_alias="createdAt"
|
|
55
|
+
)
|
|
56
|
+
updated_at: str | None = Field(
|
|
57
|
+
default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ResolvedFeatureBoolean(_BaseModel):
|
|
62
|
+
"""Resolved value of a boolean feature."""
|
|
63
|
+
|
|
64
|
+
type: Literal["boolean"]
|
|
65
|
+
enabled: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ResolvedFeatureQuantity(_BaseModel):
|
|
69
|
+
"""Resolved value of a quantity feature. ``quantity`` ``None`` = unlimited."""
|
|
70
|
+
|
|
71
|
+
type: Literal["quantity"]
|
|
72
|
+
quantity: int | None
|
|
73
|
+
balance: int | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ResolvedFeatureEnum(_BaseModel):
|
|
77
|
+
"""Resolved value of an enum feature."""
|
|
78
|
+
|
|
79
|
+
type: Literal["enum"]
|
|
80
|
+
enum_value: str | None = Field(serialization_alias="enumValue", validation_alias="enumValue")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ResolvedFeatureDuration(_BaseModel):
|
|
84
|
+
"""Resolved value of a duration feature. ``duration_days`` ``None`` = unlimited."""
|
|
85
|
+
|
|
86
|
+
type: Literal["duration"]
|
|
87
|
+
duration_days: int | None = Field(
|
|
88
|
+
serialization_alias="durationDays", validation_alias="durationDays"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
#: The resolved type-specific value of a feature for a customer. Discriminated
|
|
93
|
+
#: on ``type``.
|
|
94
|
+
ResolvedFeatureValue = Annotated[
|
|
95
|
+
ResolvedFeatureBoolean
|
|
96
|
+
| ResolvedFeatureQuantity
|
|
97
|
+
| ResolvedFeatureEnum
|
|
98
|
+
| ResolvedFeatureDuration,
|
|
99
|
+
Field(discriminator="type"),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ResolvedFeature(_BaseModel):
|
|
104
|
+
"""The resolved state of a feature for a customer, returned by
|
|
105
|
+
``GET /v1/features/resolve``. Combines the catalog feature, the resolved
|
|
106
|
+
value, and the subscriptions that contributed it.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
feature: Feature
|
|
110
|
+
value: ResolvedFeatureValue
|
|
111
|
+
contributing_subscription_ids: list[str] = Field(
|
|
112
|
+
serialization_alias="contributingSubscriptionIds",
|
|
113
|
+
validation_alias="contributingSubscriptionIds",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ListFeaturesResponse(_BaseModel):
|
|
118
|
+
"""Successful response shape from ``GET /v1/features``."""
|
|
119
|
+
|
|
120
|
+
data: list[Feature]
|
|
121
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ListParams(_BaseModel):
|
|
125
|
+
"""Query parameters accepted by ``GET /v1/features``."""
|
|
126
|
+
|
|
127
|
+
page: int | None = None
|
|
128
|
+
page_size: int | None = Field(
|
|
129
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
130
|
+
)
|
|
131
|
+
type: FeatureType | None = None
|
|
132
|
+
active: bool | None = None
|
|
133
|
+
fields: str | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RetrieveParams(_BaseModel):
|
|
137
|
+
"""Query parameters accepted by ``GET /v1/features/{id}``."""
|
|
138
|
+
|
|
139
|
+
fields: str | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ResolveParams(_BaseModel):
|
|
143
|
+
"""Query parameters accepted by ``GET /v1/features/resolve``."""
|
|
144
|
+
|
|
145
|
+
contact_id: str = Field(serialization_alias="contactId", validation_alias="contactId")
|
|
146
|
+
feature_key: str = Field(serialization_alias="featureKey", validation_alias="featureKey")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CreateBody(_BaseModel):
|
|
150
|
+
"""Body accepted by ``POST /v1/features``."""
|
|
151
|
+
|
|
152
|
+
key: str
|
|
153
|
+
name: str
|
|
154
|
+
type: FeatureType
|
|
155
|
+
description: str | None = None
|
|
156
|
+
enum_values: list[str] | None = Field(
|
|
157
|
+
default=None, serialization_alias="enumValues", validation_alias="enumValues"
|
|
158
|
+
)
|
|
159
|
+
metadata: dict[str, str] | None = None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class UpdateBody(_BaseModel):
|
|
163
|
+
"""Body accepted by ``PATCH /v1/features/{id}``.
|
|
164
|
+
|
|
165
|
+
Only fields you set are sent. ``description`` and ``metadata`` accept an
|
|
166
|
+
explicit ``None`` to clear the value server-side. ``key`` and ``type`` are
|
|
167
|
+
immutable.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
name: str | None = None
|
|
171
|
+
description: str | None = None
|
|
172
|
+
enum_values: list[str] | None = Field(
|
|
173
|
+
default=None, serialization_alias="enumValues", validation_alias="enumValues"
|
|
174
|
+
)
|
|
175
|
+
metadata: dict[str, str] | None = None
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Prices resource — sync and async clients plus public types.
|
|
2
|
+
|
|
3
|
+
Most callers reach this module through
|
|
4
|
+
[ThreeCommon.prices][threecommon.ThreeCommon] /
|
|
5
|
+
[AsyncThreeCommon.prices][threecommon.AsyncThreeCommon]; importing the service
|
|
6
|
+
classes directly is supported for advanced wiring.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from threecommon.prices.service import AsyncPricesService, PricesService
|
|
10
|
+
from threecommon.prices.types import (
|
|
11
|
+
CreateBody,
|
|
12
|
+
ListParams,
|
|
13
|
+
ListPricesResponse,
|
|
14
|
+
Price,
|
|
15
|
+
PriceCurrency,
|
|
16
|
+
PriceFeature,
|
|
17
|
+
PriceFeatureBoolean,
|
|
18
|
+
PriceFeatureDuration,
|
|
19
|
+
PriceFeatureEnum,
|
|
20
|
+
PriceFeatureQuantity,
|
|
21
|
+
PriceInterval,
|
|
22
|
+
PriceRecurring,
|
|
23
|
+
PriceType,
|
|
24
|
+
RetrieveParams,
|
|
25
|
+
UpdateBody,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__all__ = (
|
|
29
|
+
"AsyncPricesService",
|
|
30
|
+
"CreateBody",
|
|
31
|
+
"ListParams",
|
|
32
|
+
"ListPricesResponse",
|
|
33
|
+
"Price",
|
|
34
|
+
"PriceCurrency",
|
|
35
|
+
"PriceFeature",
|
|
36
|
+
"PriceFeatureBoolean",
|
|
37
|
+
"PriceFeatureDuration",
|
|
38
|
+
"PriceFeatureEnum",
|
|
39
|
+
"PriceFeatureQuantity",
|
|
40
|
+
"PriceInterval",
|
|
41
|
+
"PriceRecurring",
|
|
42
|
+
"PriceType",
|
|
43
|
+
"PricesService",
|
|
44
|
+
"RetrieveParams",
|
|
45
|
+
"UpdateBody",
|
|
46
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Sync and async prices services.
|
|
2
|
+
|
|
3
|
+
Both services share the same wire shape and validation logic; the only
|
|
4
|
+
difference is which HTTP client they call.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from threecommon._core.http_client import Request
|
|
13
|
+
from threecommon.errors.classes import ValidationError
|
|
14
|
+
from threecommon.pagination import AsyncIter, Iter
|
|
15
|
+
from threecommon.prices.types import (
|
|
16
|
+
CreateBody,
|
|
17
|
+
ListParams,
|
|
18
|
+
ListPricesResponse,
|
|
19
|
+
Price,
|
|
20
|
+
RetrieveParams,
|
|
21
|
+
UpdateBody,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from threecommon._core.http_client import AsyncHTTPClient, HTTPClient
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _qval(value: object) -> str:
|
|
29
|
+
# The wire (and every other SDK) renders query booleans lowercase.
|
|
30
|
+
if isinstance(value, bool):
|
|
31
|
+
return "true" if value else "false"
|
|
32
|
+
return str(value)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _encode_list_params(params: ListParams | None) -> dict[str, str] | None:
|
|
36
|
+
if params is None:
|
|
37
|
+
return None
|
|
38
|
+
raw = params.model_dump(by_alias=True, exclude_none=True)
|
|
39
|
+
if not raw:
|
|
40
|
+
return None
|
|
41
|
+
return {k: _qval(v) for k, v in raw.items()}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _encode_retrieve_params(params: RetrieveParams | None) -> dict[str, str] | None:
|
|
45
|
+
if params is None or params.fields is None:
|
|
46
|
+
return None
|
|
47
|
+
return {"fields": params.fields}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_id(method: str, price_id: str) -> None:
|
|
51
|
+
if not price_id:
|
|
52
|
+
msg = f"prices.{method}: id must be a non-empty string"
|
|
53
|
+
raise ValidationError(code="missing_id", message=msg)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _path_for(price_id: str) -> str:
|
|
57
|
+
return f"/prices/{quote(price_id, safe='')}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
# Sync
|
|
62
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PricesService:
|
|
66
|
+
"""Sync prices service — bound as ``client.prices`` on [ThreeCommon]."""
|
|
67
|
+
|
|
68
|
+
__slots__ = ("_http",)
|
|
69
|
+
|
|
70
|
+
def __init__(self, http: HTTPClient) -> None:
|
|
71
|
+
self._http = http
|
|
72
|
+
|
|
73
|
+
def list(self, params: ListParams | None = None) -> ListPricesResponse:
|
|
74
|
+
"""List the host's prices (one page).
|
|
75
|
+
|
|
76
|
+
For full iteration use [list_auto_paginate][PricesService.list_auto_paginate].
|
|
77
|
+
"""
|
|
78
|
+
body = self._http.request(
|
|
79
|
+
Request(method="GET", path="/prices", query=_encode_list_params(params))
|
|
80
|
+
)
|
|
81
|
+
return ListPricesResponse.model_validate(body)
|
|
82
|
+
|
|
83
|
+
def retrieve(self, price_id: str, params: RetrieveParams | None = None) -> Price:
|
|
84
|
+
"""Retrieve a single price by id."""
|
|
85
|
+
_require_id("retrieve", price_id)
|
|
86
|
+
body = self._http.request(
|
|
87
|
+
Request(method="GET", path=_path_for(price_id), query=_encode_retrieve_params(params))
|
|
88
|
+
)
|
|
89
|
+
return Price.model_validate(body["data"])
|
|
90
|
+
|
|
91
|
+
def create(self, body: CreateBody) -> Price:
|
|
92
|
+
"""Create a price for a product."""
|
|
93
|
+
if body is None:
|
|
94
|
+
raise ValidationError(
|
|
95
|
+
code="missing_body", message="prices.create: body must be non-None"
|
|
96
|
+
)
|
|
97
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
98
|
+
response = self._http.request(Request(method="POST", path="/prices", body=payload))
|
|
99
|
+
return Price.model_validate(response["data"])
|
|
100
|
+
|
|
101
|
+
def update(self, price_id: str, body: UpdateBody) -> Price:
|
|
102
|
+
"""Apply a partial update to a price. Set a field to ``None`` to clear it."""
|
|
103
|
+
_require_id("update", price_id)
|
|
104
|
+
if body is None:
|
|
105
|
+
raise ValidationError(
|
|
106
|
+
code="missing_body", message="prices.update: body must be non-None"
|
|
107
|
+
)
|
|
108
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
109
|
+
response = self._http.request(
|
|
110
|
+
Request(method="PATCH", path=_path_for(price_id), body=payload)
|
|
111
|
+
)
|
|
112
|
+
return Price.model_validate(response["data"])
|
|
113
|
+
|
|
114
|
+
def archive(self, price_id: str) -> Price:
|
|
115
|
+
"""Soft-archive a price. Idempotent."""
|
|
116
|
+
_require_id("archive", price_id)
|
|
117
|
+
response = self._http.request(
|
|
118
|
+
Request(method="POST", path=f"{_path_for(price_id)}/archive", body={})
|
|
119
|
+
)
|
|
120
|
+
return Price.model_validate(response["data"])
|
|
121
|
+
|
|
122
|
+
def unarchive(self, price_id: str) -> Price:
|
|
123
|
+
"""Reactivate a previously archived price. Idempotent."""
|
|
124
|
+
_require_id("unarchive", price_id)
|
|
125
|
+
response = self._http.request(
|
|
126
|
+
Request(method="POST", path=f"{_path_for(price_id)}/unarchive", body={})
|
|
127
|
+
)
|
|
128
|
+
return Price.model_validate(response["data"])
|
|
129
|
+
|
|
130
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> Iter[Price]:
|
|
131
|
+
"""Iterate every price matching ``params``, paging automatically."""
|
|
132
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
133
|
+
|
|
134
|
+
def fetch(page: int) -> tuple[list[Price], bool]:
|
|
135
|
+
page_params = (
|
|
136
|
+
params.model_copy(update={"page": page})
|
|
137
|
+
if params is not None
|
|
138
|
+
else ListParams(page=page)
|
|
139
|
+
)
|
|
140
|
+
body = self._http.request(
|
|
141
|
+
Request(method="GET", path="/prices", query=_encode_list_params(page_params))
|
|
142
|
+
)
|
|
143
|
+
response = ListPricesResponse.model_validate(body)
|
|
144
|
+
return response.data, response.has_more
|
|
145
|
+
|
|
146
|
+
return Iter(fetch_page=fetch, start_page=start_page)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
# Async
|
|
151
|
+
# ────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class AsyncPricesService:
|
|
155
|
+
"""Async prices service — bound as ``client.prices`` on [AsyncThreeCommon]."""
|
|
156
|
+
|
|
157
|
+
__slots__ = ("_http",)
|
|
158
|
+
|
|
159
|
+
def __init__(self, http: AsyncHTTPClient) -> None:
|
|
160
|
+
self._http = http
|
|
161
|
+
|
|
162
|
+
async def list(self, params: ListParams | None = None) -> ListPricesResponse:
|
|
163
|
+
body = await self._http.request(
|
|
164
|
+
Request(method="GET", path="/prices", query=_encode_list_params(params))
|
|
165
|
+
)
|
|
166
|
+
return ListPricesResponse.model_validate(body)
|
|
167
|
+
|
|
168
|
+
async def retrieve(self, price_id: str, params: RetrieveParams | None = None) -> Price:
|
|
169
|
+
_require_id("retrieve", price_id)
|
|
170
|
+
body = await self._http.request(
|
|
171
|
+
Request(method="GET", path=_path_for(price_id), query=_encode_retrieve_params(params))
|
|
172
|
+
)
|
|
173
|
+
return Price.model_validate(body["data"])
|
|
174
|
+
|
|
175
|
+
async def create(self, body: CreateBody) -> Price:
|
|
176
|
+
if body is None:
|
|
177
|
+
raise ValidationError(
|
|
178
|
+
code="missing_body", message="prices.create: body must be non-None"
|
|
179
|
+
)
|
|
180
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
181
|
+
response = await self._http.request(Request(method="POST", path="/prices", body=payload))
|
|
182
|
+
return Price.model_validate(response["data"])
|
|
183
|
+
|
|
184
|
+
async def update(self, price_id: str, body: UpdateBody) -> Price:
|
|
185
|
+
_require_id("update", price_id)
|
|
186
|
+
if body is None:
|
|
187
|
+
raise ValidationError(
|
|
188
|
+
code="missing_body", message="prices.update: body must be non-None"
|
|
189
|
+
)
|
|
190
|
+
payload = body.model_dump(by_alias=True, exclude_unset=True)
|
|
191
|
+
response = await self._http.request(
|
|
192
|
+
Request(method="PATCH", path=_path_for(price_id), body=payload)
|
|
193
|
+
)
|
|
194
|
+
return Price.model_validate(response["data"])
|
|
195
|
+
|
|
196
|
+
async def archive(self, price_id: str) -> Price:
|
|
197
|
+
_require_id("archive", price_id)
|
|
198
|
+
response = await self._http.request(
|
|
199
|
+
Request(method="POST", path=f"{_path_for(price_id)}/archive", body={})
|
|
200
|
+
)
|
|
201
|
+
return Price.model_validate(response["data"])
|
|
202
|
+
|
|
203
|
+
async def unarchive(self, price_id: str) -> Price:
|
|
204
|
+
_require_id("unarchive", price_id)
|
|
205
|
+
response = await self._http.request(
|
|
206
|
+
Request(method="POST", path=f"{_path_for(price_id)}/unarchive", body={})
|
|
207
|
+
)
|
|
208
|
+
return Price.model_validate(response["data"])
|
|
209
|
+
|
|
210
|
+
def list_auto_paginate(self, params: ListParams | None = None) -> AsyncIter[Price]:
|
|
211
|
+
"""Async iterate every price matching ``params``."""
|
|
212
|
+
start_page = params.page if params is not None and params.page is not None else 0
|
|
213
|
+
http = self._http
|
|
214
|
+
|
|
215
|
+
async def fetch(page: int) -> tuple[list[Price], bool]:
|
|
216
|
+
page_params = (
|
|
217
|
+
params.model_copy(update={"page": page})
|
|
218
|
+
if params is not None
|
|
219
|
+
else ListParams(page=page)
|
|
220
|
+
)
|
|
221
|
+
body = await http.request(
|
|
222
|
+
Request(method="GET", path="/prices", query=_encode_list_params(page_params))
|
|
223
|
+
)
|
|
224
|
+
response = ListPricesResponse.model_validate(body)
|
|
225
|
+
return response.data, response.has_more
|
|
226
|
+
|
|
227
|
+
return AsyncIter(fetch_page=fetch, start_page=start_page)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Public types for the prices resource.
|
|
2
|
+
|
|
3
|
+
Hand-curated Pydantic models that mirror the wire shape (camelCase aliases
|
|
4
|
+
preserved). All response models use ``extra="ignore"`` so newer server-side
|
|
5
|
+
fields don't break older SDK versions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Annotated, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
#: Price cadence.
|
|
15
|
+
#:
|
|
16
|
+
#: * ``recurring`` — billed on a fixed cadence (subscription-backed).
|
|
17
|
+
#: * ``one_time`` — single charge, typically an add-on / top-up pack.
|
|
18
|
+
PriceType = Literal["recurring", "one_time"]
|
|
19
|
+
|
|
20
|
+
#: Settlement currency of a price.
|
|
21
|
+
PriceCurrency = Literal["USD", "CAD"]
|
|
22
|
+
|
|
23
|
+
#: Cadence unit of a recurring price.
|
|
24
|
+
PriceInterval = Literal["day", "week", "month", "year"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _BaseModel(BaseModel):
|
|
28
|
+
"""Shared config: accept snake_case or camelCase, ignore unknown fields."""
|
|
29
|
+
|
|
30
|
+
model_config = ConfigDict(
|
|
31
|
+
populate_by_name=True,
|
|
32
|
+
extra="ignore",
|
|
33
|
+
str_strip_whitespace=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PriceRecurring(_BaseModel):
|
|
38
|
+
"""Recurring cadence descriptor, present when ``type`` is ``recurring``."""
|
|
39
|
+
|
|
40
|
+
interval: PriceInterval
|
|
41
|
+
interval_count: int = Field(
|
|
42
|
+
serialization_alias="intervalCount", validation_alias="intervalCount"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PriceFeatureBoolean(_BaseModel):
|
|
47
|
+
"""A boolean feature grant — the feature is on or off."""
|
|
48
|
+
|
|
49
|
+
feature_key: str = Field(serialization_alias="featureKey", validation_alias="featureKey")
|
|
50
|
+
type: Literal["boolean"]
|
|
51
|
+
enabled: bool
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PriceFeatureQuantity(_BaseModel):
|
|
55
|
+
"""A metered feature grant. ``quantity`` ``None`` means unlimited."""
|
|
56
|
+
|
|
57
|
+
feature_key: str = Field(serialization_alias="featureKey", validation_alias="featureKey")
|
|
58
|
+
type: Literal["quantity"]
|
|
59
|
+
quantity: int | None
|
|
60
|
+
rollover_enabled: bool = Field(
|
|
61
|
+
serialization_alias="rolloverEnabled", validation_alias="rolloverEnabled"
|
|
62
|
+
)
|
|
63
|
+
rollover_cap: int | None = Field(
|
|
64
|
+
default=None, serialization_alias="rolloverCap", validation_alias="rolloverCap"
|
|
65
|
+
)
|
|
66
|
+
expire_on_cancel: bool | None = Field(
|
|
67
|
+
default=None, serialization_alias="expireOnCancel", validation_alias="expireOnCancel"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PriceFeatureEnum(_BaseModel):
|
|
72
|
+
"""An enum feature grant — selects one named value."""
|
|
73
|
+
|
|
74
|
+
feature_key: str = Field(serialization_alias="featureKey", validation_alias="featureKey")
|
|
75
|
+
type: Literal["enum"]
|
|
76
|
+
enum_value: str = Field(serialization_alias="enumValue", validation_alias="enumValue")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PriceFeatureDuration(_BaseModel):
|
|
80
|
+
"""A duration feature grant. ``duration_days`` ``None`` means unlimited."""
|
|
81
|
+
|
|
82
|
+
feature_key: str = Field(serialization_alias="featureKey", validation_alias="featureKey")
|
|
83
|
+
type: Literal["duration"]
|
|
84
|
+
duration_days: int | None = Field(
|
|
85
|
+
serialization_alias="durationDays", validation_alias="durationDays"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
#: One typed feature grant on a price. Discriminated on ``type``.
|
|
90
|
+
PriceFeature = Annotated[
|
|
91
|
+
PriceFeatureBoolean | PriceFeatureQuantity | PriceFeatureEnum | PriceFeatureDuration,
|
|
92
|
+
Field(discriminator="type"),
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Price(_BaseModel):
|
|
97
|
+
"""One price as returned by the API.
|
|
98
|
+
|
|
99
|
+
Optional fields are populated only when the server returned them — list
|
|
100
|
+
responses with a ``fields`` filter omit unrequested values.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
id: str
|
|
104
|
+
host_id: str | None = Field(
|
|
105
|
+
default=None, serialization_alias="hostId", validation_alias="hostId"
|
|
106
|
+
)
|
|
107
|
+
product_id: str | None = Field(
|
|
108
|
+
default=None, serialization_alias="productId", validation_alias="productId"
|
|
109
|
+
)
|
|
110
|
+
type: PriceType | None = None
|
|
111
|
+
currency: PriceCurrency | None = None
|
|
112
|
+
unit_amount: int | None = Field(
|
|
113
|
+
default=None, serialization_alias="unitAmount", validation_alias="unitAmount"
|
|
114
|
+
)
|
|
115
|
+
recurring: PriceRecurring | None = None
|
|
116
|
+
features: list[PriceFeature] | None = None
|
|
117
|
+
nickname: str | None = None
|
|
118
|
+
active: bool | None = None
|
|
119
|
+
metadata: dict[str, str] | None = None
|
|
120
|
+
created_at: str | None = Field(
|
|
121
|
+
default=None, serialization_alias="createdAt", validation_alias="createdAt"
|
|
122
|
+
)
|
|
123
|
+
updated_at: str | None = Field(
|
|
124
|
+
default=None, serialization_alias="updatedAt", validation_alias="updatedAt"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class ListPricesResponse(_BaseModel):
|
|
129
|
+
"""Successful response shape from ``GET /v1/prices``."""
|
|
130
|
+
|
|
131
|
+
data: list[Price]
|
|
132
|
+
has_more: bool = Field(serialization_alias="hasMore", validation_alias="hasMore")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ListParams(_BaseModel):
|
|
136
|
+
"""Query parameters accepted by ``GET /v1/prices``."""
|
|
137
|
+
|
|
138
|
+
page: int | None = None
|
|
139
|
+
page_size: int | None = Field(
|
|
140
|
+
default=None, serialization_alias="pageSize", validation_alias="pageSize"
|
|
141
|
+
)
|
|
142
|
+
product_id: str | None = Field(
|
|
143
|
+
default=None, serialization_alias="productId", validation_alias="productId"
|
|
144
|
+
)
|
|
145
|
+
type: PriceType | None = None
|
|
146
|
+
active: bool | None = None
|
|
147
|
+
fields: str | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RetrieveParams(_BaseModel):
|
|
151
|
+
"""Query parameters accepted by ``GET /v1/prices/{id}``."""
|
|
152
|
+
|
|
153
|
+
fields: str | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class CreateBody(_BaseModel):
|
|
157
|
+
"""Body accepted by ``POST /v1/prices``.
|
|
158
|
+
|
|
159
|
+
``recurring`` is required when ``type`` is ``recurring`` and forbidden when
|
|
160
|
+
``type`` is ``one_time``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
product_id: str = Field(serialization_alias="productId", validation_alias="productId")
|
|
164
|
+
type: PriceType
|
|
165
|
+
currency: PriceCurrency
|
|
166
|
+
unit_amount: int = Field(serialization_alias="unitAmount", validation_alias="unitAmount")
|
|
167
|
+
recurring: PriceRecurring | None = None
|
|
168
|
+
features: list[PriceFeature] | None = None
|
|
169
|
+
nickname: str | None = None
|
|
170
|
+
metadata: dict[str, str] | None = None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class UpdateBody(_BaseModel):
|
|
174
|
+
"""Body accepted by ``PATCH /v1/prices/{id}``.
|
|
175
|
+
|
|
176
|
+
Only fields you set are sent. ``features``, ``nickname``, and ``metadata``
|
|
177
|
+
accept an explicit ``None`` to clear the value server-side.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
unit_amount: int | None = Field(
|
|
181
|
+
default=None, serialization_alias="unitAmount", validation_alias="unitAmount"
|
|
182
|
+
)
|
|
183
|
+
recurring: PriceRecurring | None = None
|
|
184
|
+
features: list[PriceFeature] | None = None
|
|
185
|
+
nickname: str | None = None
|
|
186
|
+
metadata: dict[str, str] | None = None
|
|
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
|
|
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
|