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.
Files changed (51) hide show
  1. {threecommon-0.5.0 → threecommon-0.7.0}/CHANGELOG.md +28 -0
  2. {threecommon-0.5.0 → threecommon-0.7.0}/PKG-INFO +1 -1
  3. {threecommon-0.5.0 → threecommon-0.7.0}/pyproject.toml +1 -1
  4. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/client.py +16 -0
  5. threecommon-0.7.0/src/threecommon/features/__init__.py +42 -0
  6. threecommon-0.7.0/src/threecommon/features/service.py +251 -0
  7. threecommon-0.7.0/src/threecommon/features/types.py +175 -0
  8. threecommon-0.7.0/src/threecommon/prices/__init__.py +46 -0
  9. threecommon-0.7.0/src/threecommon/prices/service.py +227 -0
  10. threecommon-0.7.0/src/threecommon/prices/types.py +186 -0
  11. {threecommon-0.5.0 → threecommon-0.7.0}/.gitignore +0 -0
  12. {threecommon-0.5.0 → threecommon-0.7.0}/LICENSE +0 -0
  13. {threecommon-0.5.0 → threecommon-0.7.0}/README.md +0 -0
  14. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/__init__.py +0 -0
  15. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/__init__.py +0 -0
  16. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/headers.py +0 -0
  17. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/http_client.py +0 -0
  18. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/parse.py +0 -0
  19. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/retry.py +0 -0
  20. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/telemetry.py +0 -0
  21. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_core/url.py +0 -0
  22. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_generated/__init__.py +0 -0
  23. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/_generated/models.py +0 -0
  24. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/api_version.py +0 -0
  25. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/config.py +0 -0
  26. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/__init__.py +0 -0
  27. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/service.py +0 -0
  28. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/contacts/types.py +0 -0
  29. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/__init__.py +0 -0
  30. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/service.py +0 -0
  31. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/entitlements/types.py +0 -0
  32. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/__init__.py +0 -0
  33. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/base.py +0 -0
  34. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/errors/classes.py +0 -0
  35. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/__init__.py +0 -0
  36. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/service.py +0 -0
  37. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/events/types.py +0 -0
  38. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/__init__.py +0 -0
  39. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/builder.py +0 -0
  40. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/filters/types.py +0 -0
  41. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/helpers.py +0 -0
  42. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/__init__.py +0 -0
  43. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/service.py +0 -0
  44. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/invoices/types.py +0 -0
  45. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/pagination/__init__.py +0 -0
  46. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/pagination/auto_paginator.py +0 -0
  47. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/py.typed +0 -0
  48. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/__init__.py +0 -0
  49. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/service.py +0 -0
  50. {threecommon-0.5.0 → threecommon-0.7.0}/src/threecommon/subscriptions/types.py +0 -0
  51. {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.5.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.5.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