usethatapp 2.2.0__tar.gz → 2.3.1__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 (28) hide show
  1. {usethatapp-2.2.0 → usethatapp-2.3.1}/CHANGELOG.md +33 -1
  2. usethatapp-2.3.1/PKG-INFO +194 -0
  3. usethatapp-2.3.1/README.md +161 -0
  4. {usethatapp-2.2.0 → usethatapp-2.3.1}/pyproject.toml +1 -1
  5. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_client.py +14 -0
  6. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_license_keys.py +1 -0
  7. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/__init__.py +1 -1
  8. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/client.py +2 -0
  9. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/types.py +13 -0
  10. usethatapp-2.3.1/usethatapp.egg-info/PKG-INFO +194 -0
  11. usethatapp-2.2.0/PKG-INFO +0 -278
  12. usethatapp-2.2.0/README.md +0 -245
  13. usethatapp-2.2.0/usethatapp.egg-info/PKG-INFO +0 -278
  14. {usethatapp-2.2.0 → usethatapp-2.3.1}/LICENSE +0 -0
  15. {usethatapp-2.2.0 → usethatapp-2.3.1}/MANIFEST.in +0 -0
  16. {usethatapp-2.2.0 → usethatapp-2.3.1}/setup.cfg +0 -0
  17. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/__init__.py +0 -0
  18. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/conftest.py +0 -0
  19. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_config.py +0 -0
  20. {usethatapp-2.2.0 → usethatapp-2.3.1}/tests/test_purchase.py +0 -0
  21. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/config.py +0 -0
  22. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/discovery.py +0 -0
  23. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/errors.py +0 -0
  24. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp/py.typed +0 -0
  25. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/SOURCES.txt +0 -0
  26. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/dependency_links.txt +0 -0
  27. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/requires.txt +0 -0
  28. {usethatapp-2.2.0 → usethatapp-2.3.1}/usethatapp.egg-info/top_level.txt +0 -0
@@ -4,7 +4,28 @@ All notable changes to this project are documented in this file. This project ad
4
4
  [Semantic Versioning](https://semver.org/) and follows a clear, machine- and human-readable
5
5
  format inspired by "Keep a Changelog".
6
6
 
7
- ## [Unreleased]
7
+ ## [2.3.1] - 2026-11-02
8
+
9
+ ### Documentation
10
+
11
+ - README rewritten around the default integration: license keys first,
12
+ then prices and buy links, with Hosted sign-in as the optional add-on.
13
+ Corrected stale claims (purchases are open to international buyers;
14
+ external sales is on by default; a cancellation keeps access until
15
+ `period_end`; seller apps always use a confidential client, so the
16
+ secret is never optional). The v1 migration table moved here, under
17
+ 2.0.0.
18
+ - New `examples/license_keys` Flask app for the license-key flow.
19
+
20
+ ## [2.3.0] - 2026-10-29
21
+
22
+ ### Added
23
+
24
+ - `Entitlement.is_trial` and `LicenseState.is_trial`: true while the user is in a free
25
+ trial of a paid plan (entitled, nothing charged yet; `period_end` is the conversion
26
+ date). Explicit so integrations never string-match `status == "trialing"`. Both are
27
+ trailing fields with a `False` default, so positional construction and servers that
28
+ predate the field keep working.
8
29
 
9
30
  ## [2.2.0] - 2026-08-25
10
31
 
@@ -189,6 +210,17 @@ Breaking rewrite onto standard OAuth2 / OpenID Connect. usethatapp.com is
189
210
  now an OpenID Provider; the SDK is a framework-agnostic OIDC client. The
190
211
  v1 launch-envelope push + signed `get_version` pull are gone.
191
212
 
213
+ ### Migrating from 1.x
214
+
215
+ | 1.x | 2.x |
216
+ |---|---|
217
+ | `get_user(payload)` (decrypt envelope) | `begin_login()` + `complete_login()` (OIDC) |
218
+ | `UtaUser.user_key` | `UtaSession.sub` (pairwise, stable per app) |
219
+ | `get_version(user_key) -> str | None` | `get_entitlement(access_token) -> Entitlement` |
220
+ | RSA keys (`UTA_PRIVATE_KEY`, market key) | OAuth client (`UTA_CLIENT_ID` / `UTA_CLIENT_SECRET`) |
221
+ | `UTA_APP_ID` | Gone: the client id identifies your app |
222
+ | `uta_launch_view` Django decorator | Gone: wire your own callback view |
223
+
192
224
  ### Removed
193
225
 
194
226
  - `get_user` / `get_user_from_request` / `get_user_from_request_async` and
@@ -0,0 +1,194 @@
1
+ Metadata-Version: 2.4
2
+ Name: usethatapp
3
+ Version: 2.3.1
4
+ Summary: Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries.
5
+ Author-email: UseThatApp <support@usethatapp.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/UseThatApp/python
8
+ Project-URL: Changelog, https://github.com/UseThatApp/python/blob/main/CHANGELOG.md
9
+ Project-URL: Documentation, https://docs.usethatapp.com
10
+ Project-URL: Repository, https://github.com/UseThatApp/python
11
+ Keywords: UseThatApp,Python,Web Apps,licensing,SDK
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.24
24
+ Requires-Dist: joserfc>=1.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
28
+ Requires-Dist: pytest-cov>=4; extra == "dev"
29
+ Requires-Dist: respx>=0.20; extra == "dev"
30
+ Requires-Dist: mypy>=1.5; extra == "dev"
31
+ Requires-Dist: Django>=3.2; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # usethatapp
35
+
36
+ Python SDK for [UseThatApp](https://www.usethatapp.com), the merchant of
37
+ record for web apps. You put Buy buttons on your own website; UseThatApp
38
+ runs checkout, charges the card, collects and remits tax, handles refunds,
39
+ and pays you through Stripe. This SDK lets your server show live prices,
40
+ build checkout links, and check what each customer bought.
41
+
42
+ - Docs: <https://docs.usethatapp.com>
43
+ - AI agents: read <https://docs.usethatapp.com/llms-full.txt> (the whole
44
+ site as one Markdown file) and follow its "Rules for AI agents".
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install usethatapp
50
+ ```
51
+
52
+ Python 3.9+. Runtime dependencies: `httpx`, `joserfc`. The SDK is
53
+ **server-side only**: it holds your client secret, so never run it in a
54
+ browser or ship it inside a desktop app. It never touches your web
55
+ framework — it takes and returns plain values, and you wire it into your
56
+ routes.
57
+
58
+ ## Configure
59
+
60
+ Settings come from `django.conf.settings` when Django is configured,
61
+ otherwise from environment variables. `configure(...)` overrides either in
62
+ code.
63
+
64
+ | Variable | Needed for |
65
+ |---|---|
66
+ | `UTA_CLIENT_ID` | Everything except `purchase_url` |
67
+ | `UTA_CLIENT_SECRET` (or `UTA_CLIENT_SECRET_PATH` for a mounted file) | License keys, Hosted sign-in |
68
+ | `UTA_REDIRECT_URI` | Hosted sign-in only |
69
+ | `UTA_SCOPES`, `UTA_API_URL`, `UTA_ISSUER`, `UTA_REQUEST_TIMEOUT_SECONDS`, `UTA_CLOCK_SKEW_SECONDS` | Optional; defaults point at production |
70
+
71
+ The client id and secret are on your app's *Manage → Integration* page.
72
+ The secret is shown once; keep it in your host's secret store.
73
+
74
+ ## Verify purchases with license keys (the default)
75
+
76
+ Each purchase creates one license key. When checkout finishes, the buyer
77
+ returns to your buy link's `next` URL with `?uta_order=…` appended. Your
78
+ server exchanges that once for the key, stores it on the user signed in to
79
+ *your* app, and validates it wherever you gate a paid feature. Your login
80
+ stays yours.
81
+
82
+ ```python
83
+ from usethatapp import UtaOrderProcessingError, get_order, validate_license_key
84
+
85
+ # Return route, e.g. /thanks?uta_order=...
86
+ try:
87
+ order = get_order(request.args["uta_order"])
88
+ except UtaOrderProcessingError:
89
+ ... # paid, key not stored yet: show "finishing up" and retry in a second or two
90
+ current_user.license_key = order.license_key
91
+ current_user.license_id = order.license_id
92
+
93
+ # Any gate:
94
+ state = validate_license_key(current_user.license_key)
95
+ if state.entitled and state.product_public_id == "prod_<your-pro-id>":
96
+ ... # paid feature
97
+ ```
98
+
99
+ - Gate on `entitled` and `product_public_id` (the plan's `prod_…` id;
100
+ `product_id` holds the same value). Never on the plan's display name.
101
+ - A cancellation keeps `entitled=True` until `period_end`; the buyer paid
102
+ for that period. Cache the answer for a session or up to a day, never past
103
+ `period_end`.
104
+ - A buyer who already holds a plan is sent back to `next` with no
105
+ `uta_order`; treat its absence as nothing to link.
106
+ - A canceled key returns `entitled=False`; it does not raise.
107
+ `UtaNotFoundError` means the string was never a key for your app.
108
+ - A lost key is always visible on the buyer's manage page. Rotate only a
109
+ leaked one: `regenerate_license_key(license_id)` returns the new key once
110
+ and kills the old one.
111
+
112
+ Walkthrough, statuses and edge cases: <https://docs.usethatapp.com/license-keys>.
113
+
114
+ ## Show prices and link to checkout
115
+
116
+ ```python
117
+ from usethatapp import get_prices, manage_url, purchase_url
118
+
119
+ for price in get_prices().prices: # anonymous; needs only UTA_CLIENT_ID
120
+ show_plan(price.product_name, price.amount, price.currency,
121
+ price.frequency, price.buy_url)
122
+
123
+ purchase_url("prc_...", next="https://yourapp.example/thanks") # no network call
124
+ manage_url(next="https://yourapp.example/account") # buyer's subscription page
125
+ ```
126
+
127
+ Never hardcode prices: render them from `get_prices()` and use each
128
+ price's `buy_url`. `amount` is a decimal string (`"10.00"`). `next` must be
129
+ HTTPS on your app's registered domain (loopback hosts such as `localhost`
130
+ are accepted for local testing). Until the seller is verified, the app is
131
+ published and the product is active, a buy link shows the buyer a "not
132
+ available" page instead of checkout; it answers 404 only for an unknown
133
+ price id or an app whose sales UseThatApp has suspended. Details:
134
+ <https://docs.usethatapp.com/sell>.
135
+
136
+ ## Hosted sign-in (optional add-on)
137
+
138
+ Only if your app has no accounts of its own: UseThatApp runs login over
139
+ OpenID Connect, and `get_entitlement` reports what the signed-in user
140
+ bought. The entitlement call needs the Hosted sign-in add-on, switched on
141
+ in the dashboard.
142
+
143
+ ```python
144
+ from usethatapp import begin_login, complete_login, get_entitlement
145
+
146
+ auth_url, flow_state = begin_login() # /login: keep flow_state in the session
147
+ s = complete_login(code=..., state=..., flow_state=flow_state) # /callback
148
+ ent = get_entitlement(s.access_token) # gate on ent.entitled + ent.product_public_id
149
+ ```
150
+
151
+ Users are identified by `s.sub`, a pairwise pseudonymous id with no email or
152
+ name. Setup, sign-out and stack notes: <https://docs.usethatapp.com/hosted-sign-in>.
153
+
154
+ ## Errors
155
+
156
+ Every exception subclasses `UtaError`.
157
+
158
+ | Exception | When |
159
+ |---|---|
160
+ | `UtaConfigError` | Missing settings, or the client id/secret was rejected |
161
+ | `UtaNotFoundError` (`.code`) | A key, order or license id never issued for your app |
162
+ | `UtaOrderProcessingError` | Order paid, key not stored yet: retry shortly |
163
+ | `UtaLicenseCanceledError` | Rotating a key whose license has ended |
164
+ | `UtaServerError` (`.retry_after`) | Rate limited (429) or a server error: back off and retry |
165
+ | `UtaTokenError`, `UtaPermissionError`, `UtaServiceNotEnabledError`, `UtaAuthError`, `UtaDiscoveryError` | Hosted sign-in only |
166
+
167
+ Full table: <https://docs.usethatapp.com/rest-api#errors>.
168
+
169
+ ## API
170
+
171
+ | Function | Returns |
172
+ |---|---|
173
+ | `get_order(ref)` / `validate_license_key(key)` / `regenerate_license_key(license_id)` | `LicenseState` |
174
+ | `get_prices()` / `get_prices_async()` | `AppPrices` (with `Price` entries) |
175
+ | `get_app_info()` / `get_app_info_async()` | `AppInfo` |
176
+ | `purchase_url(price_id, *, next=, ref=, email=)` / `manage_url(*, next=)` | `str` (no network) |
177
+ | `begin_login()` / `complete_login(code=, state=, flow_state=)` / `refresh(refresh_token)` | `(url, flow_state)` / `UtaSession` |
178
+ | `get_entitlement(access_token)` / `get_entitlement_async(...)` | `Entitlement` |
179
+ | `userinfo(access_token)` / `logout_url(id_token=, post_logout_redirect_uri=)` | `{"sub": …}` / `str` |
180
+ | `configure(**settings)` / `load_config()` / `reset_config()` | Configuration |
181
+
182
+ Every call is plain HTTPS underneath, documented at
183
+ <https://docs.usethatapp.com/rest-api>.
184
+
185
+ ## Examples
186
+
187
+ [`examples/`](./examples/) holds runnable apps: `license_keys` (Flask,
188
+ the default integration) and Hosted sign-in apps for Flask, Django and
189
+ FastAPI.
190
+
191
+ ## Changelog and license
192
+
193
+ [CHANGELOG.md](./CHANGELOG.md), including how to migrate from 1.x. MIT —
194
+ see [LICENSE](./LICENSE).
@@ -0,0 +1,161 @@
1
+ # usethatapp
2
+
3
+ Python SDK for [UseThatApp](https://www.usethatapp.com), the merchant of
4
+ record for web apps. You put Buy buttons on your own website; UseThatApp
5
+ runs checkout, charges the card, collects and remits tax, handles refunds,
6
+ and pays you through Stripe. This SDK lets your server show live prices,
7
+ build checkout links, and check what each customer bought.
8
+
9
+ - Docs: <https://docs.usethatapp.com>
10
+ - AI agents: read <https://docs.usethatapp.com/llms-full.txt> (the whole
11
+ site as one Markdown file) and follow its "Rules for AI agents".
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install usethatapp
17
+ ```
18
+
19
+ Python 3.9+. Runtime dependencies: `httpx`, `joserfc`. The SDK is
20
+ **server-side only**: it holds your client secret, so never run it in a
21
+ browser or ship it inside a desktop app. It never touches your web
22
+ framework — it takes and returns plain values, and you wire it into your
23
+ routes.
24
+
25
+ ## Configure
26
+
27
+ Settings come from `django.conf.settings` when Django is configured,
28
+ otherwise from environment variables. `configure(...)` overrides either in
29
+ code.
30
+
31
+ | Variable | Needed for |
32
+ |---|---|
33
+ | `UTA_CLIENT_ID` | Everything except `purchase_url` |
34
+ | `UTA_CLIENT_SECRET` (or `UTA_CLIENT_SECRET_PATH` for a mounted file) | License keys, Hosted sign-in |
35
+ | `UTA_REDIRECT_URI` | Hosted sign-in only |
36
+ | `UTA_SCOPES`, `UTA_API_URL`, `UTA_ISSUER`, `UTA_REQUEST_TIMEOUT_SECONDS`, `UTA_CLOCK_SKEW_SECONDS` | Optional; defaults point at production |
37
+
38
+ The client id and secret are on your app's *Manage → Integration* page.
39
+ The secret is shown once; keep it in your host's secret store.
40
+
41
+ ## Verify purchases with license keys (the default)
42
+
43
+ Each purchase creates one license key. When checkout finishes, the buyer
44
+ returns to your buy link's `next` URL with `?uta_order=…` appended. Your
45
+ server exchanges that once for the key, stores it on the user signed in to
46
+ *your* app, and validates it wherever you gate a paid feature. Your login
47
+ stays yours.
48
+
49
+ ```python
50
+ from usethatapp import UtaOrderProcessingError, get_order, validate_license_key
51
+
52
+ # Return route, e.g. /thanks?uta_order=...
53
+ try:
54
+ order = get_order(request.args["uta_order"])
55
+ except UtaOrderProcessingError:
56
+ ... # paid, key not stored yet: show "finishing up" and retry in a second or two
57
+ current_user.license_key = order.license_key
58
+ current_user.license_id = order.license_id
59
+
60
+ # Any gate:
61
+ state = validate_license_key(current_user.license_key)
62
+ if state.entitled and state.product_public_id == "prod_<your-pro-id>":
63
+ ... # paid feature
64
+ ```
65
+
66
+ - Gate on `entitled` and `product_public_id` (the plan's `prod_…` id;
67
+ `product_id` holds the same value). Never on the plan's display name.
68
+ - A cancellation keeps `entitled=True` until `period_end`; the buyer paid
69
+ for that period. Cache the answer for a session or up to a day, never past
70
+ `period_end`.
71
+ - A buyer who already holds a plan is sent back to `next` with no
72
+ `uta_order`; treat its absence as nothing to link.
73
+ - A canceled key returns `entitled=False`; it does not raise.
74
+ `UtaNotFoundError` means the string was never a key for your app.
75
+ - A lost key is always visible on the buyer's manage page. Rotate only a
76
+ leaked one: `regenerate_license_key(license_id)` returns the new key once
77
+ and kills the old one.
78
+
79
+ Walkthrough, statuses and edge cases: <https://docs.usethatapp.com/license-keys>.
80
+
81
+ ## Show prices and link to checkout
82
+
83
+ ```python
84
+ from usethatapp import get_prices, manage_url, purchase_url
85
+
86
+ for price in get_prices().prices: # anonymous; needs only UTA_CLIENT_ID
87
+ show_plan(price.product_name, price.amount, price.currency,
88
+ price.frequency, price.buy_url)
89
+
90
+ purchase_url("prc_...", next="https://yourapp.example/thanks") # no network call
91
+ manage_url(next="https://yourapp.example/account") # buyer's subscription page
92
+ ```
93
+
94
+ Never hardcode prices: render them from `get_prices()` and use each
95
+ price's `buy_url`. `amount` is a decimal string (`"10.00"`). `next` must be
96
+ HTTPS on your app's registered domain (loopback hosts such as `localhost`
97
+ are accepted for local testing). Until the seller is verified, the app is
98
+ published and the product is active, a buy link shows the buyer a "not
99
+ available" page instead of checkout; it answers 404 only for an unknown
100
+ price id or an app whose sales UseThatApp has suspended. Details:
101
+ <https://docs.usethatapp.com/sell>.
102
+
103
+ ## Hosted sign-in (optional add-on)
104
+
105
+ Only if your app has no accounts of its own: UseThatApp runs login over
106
+ OpenID Connect, and `get_entitlement` reports what the signed-in user
107
+ bought. The entitlement call needs the Hosted sign-in add-on, switched on
108
+ in the dashboard.
109
+
110
+ ```python
111
+ from usethatapp import begin_login, complete_login, get_entitlement
112
+
113
+ auth_url, flow_state = begin_login() # /login: keep flow_state in the session
114
+ s = complete_login(code=..., state=..., flow_state=flow_state) # /callback
115
+ ent = get_entitlement(s.access_token) # gate on ent.entitled + ent.product_public_id
116
+ ```
117
+
118
+ Users are identified by `s.sub`, a pairwise pseudonymous id with no email or
119
+ name. Setup, sign-out and stack notes: <https://docs.usethatapp.com/hosted-sign-in>.
120
+
121
+ ## Errors
122
+
123
+ Every exception subclasses `UtaError`.
124
+
125
+ | Exception | When |
126
+ |---|---|
127
+ | `UtaConfigError` | Missing settings, or the client id/secret was rejected |
128
+ | `UtaNotFoundError` (`.code`) | A key, order or license id never issued for your app |
129
+ | `UtaOrderProcessingError` | Order paid, key not stored yet: retry shortly |
130
+ | `UtaLicenseCanceledError` | Rotating a key whose license has ended |
131
+ | `UtaServerError` (`.retry_after`) | Rate limited (429) or a server error: back off and retry |
132
+ | `UtaTokenError`, `UtaPermissionError`, `UtaServiceNotEnabledError`, `UtaAuthError`, `UtaDiscoveryError` | Hosted sign-in only |
133
+
134
+ Full table: <https://docs.usethatapp.com/rest-api#errors>.
135
+
136
+ ## API
137
+
138
+ | Function | Returns |
139
+ |---|---|
140
+ | `get_order(ref)` / `validate_license_key(key)` / `regenerate_license_key(license_id)` | `LicenseState` |
141
+ | `get_prices()` / `get_prices_async()` | `AppPrices` (with `Price` entries) |
142
+ | `get_app_info()` / `get_app_info_async()` | `AppInfo` |
143
+ | `purchase_url(price_id, *, next=, ref=, email=)` / `manage_url(*, next=)` | `str` (no network) |
144
+ | `begin_login()` / `complete_login(code=, state=, flow_state=)` / `refresh(refresh_token)` | `(url, flow_state)` / `UtaSession` |
145
+ | `get_entitlement(access_token)` / `get_entitlement_async(...)` | `Entitlement` |
146
+ | `userinfo(access_token)` / `logout_url(id_token=, post_logout_redirect_uri=)` | `{"sub": …}` / `str` |
147
+ | `configure(**settings)` / `load_config()` / `reset_config()` | Configuration |
148
+
149
+ Every call is plain HTTPS underneath, documented at
150
+ <https://docs.usethatapp.com/rest-api>.
151
+
152
+ ## Examples
153
+
154
+ [`examples/`](./examples/) holds runnable apps: `license_keys` (Flask,
155
+ the default integration) and Hosted sign-in apps for Flask, Django and
156
+ FastAPI.
157
+
158
+ ## Changelog and license
159
+
160
+ [CHANGELOG.md](./CHANGELOG.md), including how to migrate from 1.x. MIT —
161
+ see [LICENSE](./LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "usethatapp"
7
- version = "2.2.0"
7
+ version = "2.3.1"
8
8
  description = "Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries."
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  authors = [{ name = "UseThatApp", email = "support@usethatapp.com"}]
@@ -148,9 +148,23 @@ def test_get_entitlement_licensed(oidc_routes):
148
148
  assert ent.entitled and ent.version == "Pro" and ent.product_id == "p-1"
149
149
  assert ent.product_public_id == "prod_abc123"
150
150
  assert ent.status == "active" and ent.is_free is False
151
+ assert ent.is_trial is False
151
152
  assert ent.period_end == "2026-07-01"
152
153
 
153
154
 
155
+ def test_get_entitlement_trialing(oidc_routes):
156
+ oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
157
+ return_value=httpx.Response(200, json={
158
+ "entitled": True, "version": "Pro", "product_id": "prod_abc",
159
+ "product_public_id": "prod_abc", "status": "trialing",
160
+ "is_free": False, "is_trial": True, "period_end": "2026-11-11",
161
+ })
162
+ )
163
+ ent = get_entitlement("at-123")
164
+ assert ent.entitled and ent.is_trial and ent.status == "trialing"
165
+ assert ent.period_end == "2026-11-11"
166
+
167
+
154
168
  def test_get_entitlement_free(oidc_routes):
155
169
  oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
156
170
  return_value=httpx.Response(200, json={
@@ -44,6 +44,7 @@ def test_validate_sends_client_credentials(respx_mock):
44
44
  )
45
45
  state = validate_license_key("key_abc")
46
46
  assert state.entitled and state.status == "active"
47
+ assert state.is_trial is False
47
48
  assert state.license_id == STATE["license_id"]
48
49
  assert state.license_key is None
49
50
  request = route.calls.last.request
@@ -67,7 +67,7 @@ from .types import (
67
67
  UtaSession,
68
68
  )
69
69
 
70
- __version__ = "2.2.0"
70
+ __version__ = "2.3.1"
71
71
 
72
72
  __all__ = [
73
73
  "__version__",
@@ -694,6 +694,7 @@ def _parse_entitlement(data: Mapping[str, Any]) -> Entitlement:
694
694
  period_end=data.get("period_end"),
695
695
  product_public_id=data.get("product_public_id"),
696
696
  raw=dict(data),
697
+ is_trial=bool(data.get("is_trial", False)),
697
698
  )
698
699
 
699
700
 
@@ -870,6 +871,7 @@ def _parse_license_state(data: Mapping[str, Any]) -> LicenseState:
870
871
  canceled_at=data.get("canceled_at"),
871
872
  license_key=data.get("license_key"),
872
873
  rotated_at=data.get("rotated_at"),
874
+ is_trial=bool(data.get("is_trial", False)),
873
875
  # Alias pair: fall back to product_public_id against a server
874
876
  # that predates the identifier cutover.
875
877
  product_id=str(
@@ -87,6 +87,15 @@ class Entitlement:
87
87
  :attr:`Price.product_id` from the pricing API. New integrations should
88
88
  gate on this field."""
89
89
 
90
+ is_trial: bool = False
91
+ """True while the user is in a free trial of a paid plan: entitled to
92
+ the plan's real functionality, nothing charged yet. While true,
93
+ :attr:`period_end` is the date the trial converts (the first charge).
94
+ A canceled trial keeps ``entitled`` until that date and then drops it
95
+ — never cache past ``period_end``. Explicit so you never string-match
96
+ :attr:`status`. Trailing with a default (see the note above) so
97
+ positional construction predating it still works."""
98
+
90
99
 
91
100
  @dataclass(frozen=True)
92
101
  class AppInfo:
@@ -211,3 +220,7 @@ class LicenseState:
211
220
  gating code works identically in both verification modes. Trailing
212
221
  with a default so positional construction predating it still
213
222
  works."""
223
+
224
+ is_trial: bool = False
225
+ """True while the license is in its free trial — see
226
+ :attr:`Entitlement.is_trial`."""