usethatapp 2.1.0__tar.gz → 2.3.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.
- {usethatapp-2.1.0 → usethatapp-2.3.0}/CHANGELOG.md +118 -0
- {usethatapp-2.1.0/usethatapp.egg-info → usethatapp-2.3.0}/PKG-INFO +51 -12
- {usethatapp-2.1.0 → usethatapp-2.3.0}/README.md +50 -11
- {usethatapp-2.1.0 → usethatapp-2.3.0}/pyproject.toml +1 -1
- {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/conftest.py +7 -2
- {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/test_client.py +214 -0
- usethatapp-2.3.0/tests/test_config.py +177 -0
- usethatapp-2.3.0/tests/test_license_keys.py +197 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/__init__.py +45 -2
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/client.py +342 -32
- usethatapp-2.3.0/usethatapp/config.py +322 -0
- usethatapp-2.3.0/usethatapp/errors.py +110 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/types.py +45 -4
- {usethatapp-2.1.0 → usethatapp-2.3.0/usethatapp.egg-info}/PKG-INFO +51 -12
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/SOURCES.txt +1 -0
- usethatapp-2.1.0/tests/test_config.py +0 -59
- usethatapp-2.1.0/usethatapp/config.py +0 -175
- usethatapp-2.1.0/usethatapp/errors.py +0 -59
- {usethatapp-2.1.0 → usethatapp-2.3.0}/LICENSE +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/MANIFEST.in +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/setup.cfg +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/__init__.py +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/tests/test_purchase.py +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/discovery.py +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp/py.typed +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/dependency_links.txt +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/requires.txt +0 -0
- {usethatapp-2.1.0 → usethatapp-2.3.0}/usethatapp.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,124 @@ 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]
|
|
8
|
+
|
|
9
|
+
## [2.3.0] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Entitlement.is_trial` and `LicenseState.is_trial`: true while the user is in a free
|
|
14
|
+
trial of a paid plan (entitled, nothing charged yet; `period_end` is the conversion
|
|
15
|
+
date). Explicit so integrations never string-match `status == "trialing"`. Both are
|
|
16
|
+
trailing fields with a `False` default, so positional construction and servers that
|
|
17
|
+
predate the field keep working.
|
|
18
|
+
|
|
19
|
+
## [2.2.0] - 2026-08-25
|
|
20
|
+
|
|
21
|
+
### Fixed (pre-release code review)
|
|
22
|
+
|
|
23
|
+
- The nine annotations the pre-release commits left untyped (most
|
|
24
|
+
visibly `UtaServerError.__init__`'s `retry_after`, which a
|
|
25
|
+
downstream strict type checker saw as `Any`) are restored —
|
|
26
|
+
`mypy --strict` is green again, as `py.typed` promises.
|
|
27
|
+
|
|
28
|
+
- `Entitlement.product_id` now carries the same
|
|
29
|
+
fall-back-to-`product_public_id` parsing as `LicenseState.product_id`,
|
|
30
|
+
so the alias contract ("gate on either field") holds in both
|
|
31
|
+
verification modes — previously only the License Key API parser had
|
|
32
|
+
the fallback.
|
|
33
|
+
- `configure(client_secret_path=…)` now outranks an environment
|
|
34
|
+
`UTA_CLIENT_SECRET`: secret-pair precedence is decided per layer
|
|
35
|
+
(overrides → Django settings → environment), not per key.
|
|
36
|
+
- `configure()` rejects wrong-typed values with a `UtaConfigError`
|
|
37
|
+
naming the option (e.g. a scopes *list* gets "join scope names with
|
|
38
|
+
spaces") instead of silently `str()`-coercing them into corrupt
|
|
39
|
+
values that surfaced later as opaque OAuth errors.
|
|
40
|
+
- A malformed `Retry-After` header containing Unicode digit-property
|
|
41
|
+
characters (e.g. `²`) no longer escapes as an uncaught `ValueError`
|
|
42
|
+
from a 429 — it degrades to `retry_after=None`.
|
|
43
|
+
- `userinfo()` and the token endpoint now speak the same 429/5xx
|
|
44
|
+
contract as every other endpoint: 429 raises retriable
|
|
45
|
+
`UtaServerError` with `retry_after` (a throttled token refresh is no
|
|
46
|
+
longer a `UtaTokenError`, which read as "log the user out"), and
|
|
47
|
+
their error messages cap quoted bodies like everywhere else.
|
|
48
|
+
- The ~200-character error-body cap now applies to JSON bodies too —
|
|
49
|
+
a multi-kilobyte JSON error (DRF validation map, debug-mode 500) was
|
|
50
|
+
previously quoted whole — and the truncation suffix reports the
|
|
51
|
+
amount actually cut.
|
|
52
|
+
- `timeout=0.0` (a fail-fast probe, expressible since the `float`
|
|
53
|
+
widening) is respected instead of being replaced by the configured
|
|
54
|
+
default in `get_entitlement`, `get_entitlement_async`, and the
|
|
55
|
+
License Key API calls.
|
|
56
|
+
- The `UtaServiceNotEnabledError` message points at the manage page on
|
|
57
|
+
`https://www.usethatapp.com` — not the resolved `api_url`, which a
|
|
58
|
+
`configure(api_url=…)` override can aim at a host with no manage UI.
|
|
59
|
+
- README: the error table names the add-on by its current public name
|
|
60
|
+
(Hosted sign-in), documents the 429 → `UtaServerError` +
|
|
61
|
+
`retry_after` mapping, and the `product_id` guidance matches the
|
|
62
|
+
post-cutover alias contract everywhere.
|
|
63
|
+
|
|
64
|
+
### Added
|
|
65
|
+
|
|
66
|
+
- `LicenseState.product_id` — an equal-valued alias of
|
|
67
|
+
`product_public_id` (the platform's post-cutover `prod_…` pair, the
|
|
68
|
+
same contract as the entitlement endpoint), with a parser fallback
|
|
69
|
+
against servers that predate the cutover.
|
|
70
|
+
|
|
71
|
+
- **License Key API** (server-side, bring-your-own-auth MoR
|
|
72
|
+
verification): `validate_license_key(key)`, `get_order(ref)` for the
|
|
73
|
+
`uta_order` checkout handback, and `regenerate_license_key(license_id)`
|
|
74
|
+
as the compromise kill switch — all authenticated with your app's
|
|
75
|
+
client credentials (`UTA_CLIENT_SECRET` required; `UTA_REDIRECT_URI`
|
|
76
|
+
is no longer needed unless you use the OIDC login flow). New
|
|
77
|
+
`LicenseState` dataclass and typed errors: `UtaNotFoundError` (with
|
|
78
|
+
`.code`), `UtaOrderProcessingError`, `UtaLicenseCanceledError`.
|
|
79
|
+
- `config.load()` no longer requires `UTA_REDIRECT_URI`; `begin_login()`
|
|
80
|
+
raises `UtaConfigError` if it is missing — keys-mode integrations
|
|
81
|
+
never redirect a browser.
|
|
82
|
+
|
|
83
|
+
- `UtaServiceNotEnabledError` (subclass of `UtaPermissionError`), raised
|
|
84
|
+
when the entitlement endpoint returns `403 service_not_enabled`: the
|
|
85
|
+
app's developer has not enabled the Hosted sign-in add-on. The
|
|
86
|
+
previous behavior mislabeled this case as a missing `entitlements`
|
|
87
|
+
scope; the new error says the actual fix (enable the add-on on the
|
|
88
|
+
app's manage page). Existing `except UtaPermissionError` blocks catch
|
|
89
|
+
it unchanged.
|
|
90
|
+
|
|
91
|
+
- In-code configuration: `configure(api_url=…, issuer=…, …)`,
|
|
92
|
+
`load_config()`, and `reset_config()` are now exported from the
|
|
93
|
+
package root, alongside `UtaConfig` and the `DEFAULT_API_URL` /
|
|
94
|
+
`DEFAULT_ISSUER` / `DEFAULT_SCOPES` constants — parity with the
|
|
95
|
+
JavaScript SDK's `configure()` / `loadConfig()` / `resetConfig()`.
|
|
96
|
+
Overrides win over Django settings and environment variables, and a
|
|
97
|
+
non-production `api_url` can now be corrected at runtime instead of
|
|
98
|
+
only through the process environment.
|
|
99
|
+
|
|
100
|
+
- `UtaServerError.retry_after` — the server's `Retry-After` header in
|
|
101
|
+
seconds on a 429 (`None` otherwise), so backoff loops can use the
|
|
102
|
+
interval the platform actually sent instead of guessing. Matches the
|
|
103
|
+
JavaScript SDK's `retryAfter`.
|
|
104
|
+
|
|
105
|
+
### Fixed
|
|
106
|
+
|
|
107
|
+
- `UtaNotFoundError`, `UtaOrderProcessingError`, and
|
|
108
|
+
`UtaLicenseCanceledError` are now exported from the package root —
|
|
109
|
+
they were defined and documented but not importable as
|
|
110
|
+
`from usethatapp import …`.
|
|
111
|
+
- The `UtaServiceNotEnabledError` message named the add-on by its
|
|
112
|
+
retired public name ("Auth & Entitlement") and hardcoded
|
|
113
|
+
`usethatapp.com`; it now says "Hosted sign-in" and points at the
|
|
114
|
+
configured `UTA_API_URL` host.
|
|
115
|
+
- A `429` from the entitlement endpoint now raises `UtaServerError`
|
|
116
|
+
(the retry-with-backoff class, matching the License Key API and the
|
|
117
|
+
documented error table) instead of the base `UtaError`.
|
|
118
|
+
- Non-JSON error bodies (HTML error pages) are collapsed and capped at
|
|
119
|
+
~200 characters when quoted into exception messages, instead of
|
|
120
|
+
inlining the entire page.
|
|
121
|
+
- `get_entitlement(timeout=)` and `get_entitlement_async(timeout=)` are
|
|
122
|
+
typed `Optional[float]`, matching every other function (they were
|
|
123
|
+
`Optional[int]`, failing strict type checks on e.g. `timeout=2.5`).
|
|
124
|
+
|
|
7
125
|
## [2.1.0] - 2026-08-11
|
|
8
126
|
|
|
9
127
|
Thin, dependency-free support for selling your app from your own website
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: usethatapp
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Python SDK for usethatapp.com — OpenID Connect login and live license entitlement queries.
|
|
5
5
|
Author-email: UseThatApp <support@usethatapp.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -106,13 +106,15 @@ from usethatapp import (
|
|
|
106
106
|
get_prices, # () -> AppPrices (anonymous public API)
|
|
107
107
|
get_prices_async,
|
|
108
108
|
UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
|
|
109
|
-
Entitlement, # entitled, version, product_id, product_public_id, status, ...
|
|
109
|
+
Entitlement, # entitled, version, product_id, product_public_id, status, is_trial, ...
|
|
110
110
|
AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
|
|
111
111
|
Price, # public_id, product_id, product_name, amount, currency, ...
|
|
112
112
|
AppPrices, # client_id, app_name, has_free_tier, prices
|
|
113
113
|
# errors:
|
|
114
114
|
UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
|
|
115
|
-
UtaTokenError, UtaPermissionError,
|
|
115
|
+
UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
|
|
116
|
+
UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
|
|
117
|
+
UtaServerError,
|
|
116
118
|
)
|
|
117
119
|
```
|
|
118
120
|
|
|
@@ -152,14 +154,50 @@ are documentation only — nothing framework-specific ships in the package.
|
|
|
152
154
|
|
|
153
155
|
`get_entitlement` maps status codes to typed exceptions:
|
|
154
156
|
|
|
155
|
-
| Status | Exception
|
|
156
|
-
|
|
157
|
-
| 401 | `UtaTokenError`
|
|
158
|
-
| 403
|
|
159
|
-
|
|
|
160
|
-
|
|
|
157
|
+
| Status | Exception | Meaning |
|
|
158
|
+
|--------|-----------------------------------------------|-----------------------------------------------|
|
|
159
|
+
| 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
|
|
160
|
+
| 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
|
|
161
|
+
| 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
|
|
162
|
+
| 400 | `UtaError` | Client not linked to an app (misconfig). |
|
|
163
|
+
| 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
|
|
164
|
+
| 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
|
|
161
165
|
|
|
162
166
|
All inherit from `UtaError` — catch that for a single `except` clause.
|
|
167
|
+
`UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
|
|
168
|
+
`except UtaPermissionError` blocks keep working unchanged.
|
|
169
|
+
|
|
170
|
+
## License Key API — bring your own auth
|
|
171
|
+
|
|
172
|
+
If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
|
|
173
|
+
purely as merchant of record, skip the OIDC flow entirely and verify
|
|
174
|
+
purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
|
|
175
|
+
(`UTA_REDIRECT_URI` is not needed for this mode).
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from usethatapp import get_order, regenerate_license_key, validate_license_key
|
|
179
|
+
|
|
180
|
+
# 1) The buyer returns from checkout with ?uta_order=... — exchange it
|
|
181
|
+
# for their key and link it to YOUR signed-in user. No email matching.
|
|
182
|
+
order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
|
|
183
|
+
your_db.save(user=current_user, license_key=order.license_key,
|
|
184
|
+
license_id=order.license_id)
|
|
185
|
+
|
|
186
|
+
# 2) Any time you need the truth (app start, feature gate, cron):
|
|
187
|
+
state = validate_license_key(stored_key) # LicenseState
|
|
188
|
+
if state.entitled: ... # cache against state.period_end
|
|
189
|
+
# state.is_trial: in a free trial — entitled, nothing charged yet, and
|
|
190
|
+
# period_end is the conversion date. Show a banner; gate nothing extra.
|
|
191
|
+
# canceled purchases return entitled=False, status="canceled" — an
|
|
192
|
+
# answer, not an exception. UtaNotFoundError means a key that was
|
|
193
|
+
# never issued for your app.
|
|
194
|
+
|
|
195
|
+
# 3) Compromised key? Rotate it — the old key dies instantly:
|
|
196
|
+
state = regenerate_license_key(license_id) # state.license_key is the new key
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Buyers can always re-view their key on their UseThatApp management
|
|
200
|
+
page, so "I lost my key" needs no support ticket and no rotation.
|
|
163
201
|
|
|
164
202
|
## Purchase links & pricing
|
|
165
203
|
|
|
@@ -200,9 +238,10 @@ for price in pricing.prices:
|
|
|
200
238
|
`Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
|
|
201
239
|
`decimal.Decimal` if you need arithmetic. `Price.product_id` is the
|
|
202
240
|
opaque `prod_…` identifier to gate features on after purchase — compare
|
|
203
|
-
it against
|
|
204
|
-
|
|
205
|
-
|
|
241
|
+
it against `Entitlement.product_id` or `Entitlement.product_public_id`:
|
|
242
|
+
since the platform's identifier cutover they are a permanent
|
|
243
|
+
equal-valued alias pair carrying the same `prod_…` value, so gate on
|
|
244
|
+
either.
|
|
206
245
|
|
|
207
246
|
Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
|
|
208
247
|
are handled for you). Purchases are currently **US-only**. External
|
|
@@ -73,13 +73,15 @@ from usethatapp import (
|
|
|
73
73
|
get_prices, # () -> AppPrices (anonymous public API)
|
|
74
74
|
get_prices_async,
|
|
75
75
|
UtaSession, # sub, access_token, refresh_token, id_token, expires_at, ...
|
|
76
|
-
Entitlement, # entitled, version, product_id, product_public_id, status, ...
|
|
76
|
+
Entitlement, # entitled, version, product_id, product_public_id, status, is_trial, ...
|
|
77
77
|
AppInfo, # client_id, name, tagline, listing_mode, url, marketplace_url
|
|
78
78
|
Price, # public_id, product_id, product_name, amount, currency, ...
|
|
79
79
|
AppPrices, # client_id, app_name, has_free_tier, prices
|
|
80
80
|
# errors:
|
|
81
81
|
UtaError, UtaConfigError, UtaDiscoveryError, UtaAuthError,
|
|
82
|
-
UtaTokenError, UtaPermissionError,
|
|
82
|
+
UtaTokenError, UtaPermissionError, UtaServiceNotEnabledError,
|
|
83
|
+
UtaNotFoundError, UtaOrderProcessingError, UtaLicenseCanceledError,
|
|
84
|
+
UtaServerError,
|
|
83
85
|
)
|
|
84
86
|
```
|
|
85
87
|
|
|
@@ -119,14 +121,50 @@ are documentation only — nothing framework-specific ships in the package.
|
|
|
119
121
|
|
|
120
122
|
`get_entitlement` maps status codes to typed exceptions:
|
|
121
123
|
|
|
122
|
-
| Status | Exception
|
|
123
|
-
|
|
124
|
-
| 401 | `UtaTokenError`
|
|
125
|
-
| 403
|
|
126
|
-
|
|
|
127
|
-
|
|
|
124
|
+
| Status | Exception | Meaning |
|
|
125
|
+
|--------|-----------------------------------------------|-----------------------------------------------|
|
|
126
|
+
| 401 | `UtaTokenError` | Access token invalid/expired — re-auth/refresh.|
|
|
127
|
+
| 403 (`insufficient_scope`) | `UtaPermissionError` | Token lacks the `entitlements` scope. |
|
|
128
|
+
| 403 (`service_not_enabled`) | `UtaServiceNotEnabledError` | The Hosted sign-in add-on is switched off for this app. No retry, refresh, or re-consent will help — enable it on the app's manage page at www.usethatapp.com (Integration panel). |
|
|
129
|
+
| 400 | `UtaError` | Client not linked to an app (misconfig). |
|
|
130
|
+
| 429 | `UtaServerError` | Rate limited — back off; `e.retry_after` carries the server's `Retry-After` seconds (or `None`). |
|
|
131
|
+
| 5xx | `UtaServerError` | Retriable with backoff; `e.retry_after` when the server sent one. |
|
|
128
132
|
|
|
129
133
|
All inherit from `UtaError` — catch that for a single `except` clause.
|
|
134
|
+
`UtaServiceNotEnabledError` subclasses `UtaPermissionError`, so existing
|
|
135
|
+
`except UtaPermissionError` blocks keep working unchanged.
|
|
136
|
+
|
|
137
|
+
## License Key API — bring your own auth
|
|
138
|
+
|
|
139
|
+
If you keep your own auth (Auth0, Clerk, homegrown) and use UseThatApp
|
|
140
|
+
purely as merchant of record, skip the OIDC flow entirely and verify
|
|
141
|
+
purchases with license keys. Server-side only; set `UTA_CLIENT_SECRET`
|
|
142
|
+
(`UTA_REDIRECT_URI` is not needed for this mode).
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from usethatapp import get_order, regenerate_license_key, validate_license_key
|
|
146
|
+
|
|
147
|
+
# 1) The buyer returns from checkout with ?uta_order=... — exchange it
|
|
148
|
+
# for their key and link it to YOUR signed-in user. No email matching.
|
|
149
|
+
order = get_order(request.GET["uta_order"]) # raises UtaOrderProcessingError → retry briefly
|
|
150
|
+
your_db.save(user=current_user, license_key=order.license_key,
|
|
151
|
+
license_id=order.license_id)
|
|
152
|
+
|
|
153
|
+
# 2) Any time you need the truth (app start, feature gate, cron):
|
|
154
|
+
state = validate_license_key(stored_key) # LicenseState
|
|
155
|
+
if state.entitled: ... # cache against state.period_end
|
|
156
|
+
# state.is_trial: in a free trial — entitled, nothing charged yet, and
|
|
157
|
+
# period_end is the conversion date. Show a banner; gate nothing extra.
|
|
158
|
+
# canceled purchases return entitled=False, status="canceled" — an
|
|
159
|
+
# answer, not an exception. UtaNotFoundError means a key that was
|
|
160
|
+
# never issued for your app.
|
|
161
|
+
|
|
162
|
+
# 3) Compromised key? Rotate it — the old key dies instantly:
|
|
163
|
+
state = regenerate_license_key(license_id) # state.license_key is the new key
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Buyers can always re-view their key on their UseThatApp management
|
|
167
|
+
page, so "I lost my key" needs no support ticket and no rotation.
|
|
130
168
|
|
|
131
169
|
## Purchase links & pricing
|
|
132
170
|
|
|
@@ -167,9 +205,10 @@ for price in pricing.prices:
|
|
|
167
205
|
`Price.amount` is a decimal **string** (e.g. `"10.00"`) — parse with
|
|
168
206
|
`decimal.Decimal` if you need arithmetic. `Price.product_id` is the
|
|
169
207
|
opaque `prod_…` identifier to gate features on after purchase — compare
|
|
170
|
-
it against
|
|
171
|
-
|
|
172
|
-
|
|
208
|
+
it against `Entitlement.product_id` or `Entitlement.product_public_id`:
|
|
209
|
+
since the platform's identifier cutover they are a permanent
|
|
210
|
+
equal-valued alias pair carrying the same `prod_…` value, so gate on
|
|
211
|
+
either.
|
|
173
212
|
|
|
174
213
|
Notes: UseThatApp is the **merchant of record** (checkout, tax, refunds
|
|
175
214
|
are handled for you). Purchases are currently **US-only**. External
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "usethatapp"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
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"}]
|
|
@@ -71,10 +71,15 @@ def configure_env(monkeypatch):
|
|
|
71
71
|
monkeypatch.setenv("UTA_CLOCK_SKEW_SECONDS", "60")
|
|
72
72
|
# Drop any inherited secret-path var so tests are hermetic.
|
|
73
73
|
monkeypatch.delenv("UTA_CLIENT_SECRET_PATH", raising=False)
|
|
74
|
-
|
|
74
|
+
# reset_config(), not reset_cache(): the process-global _overrides
|
|
75
|
+
# dict must be cleared too, or one test's configure() call leaks
|
|
76
|
+
# into every later test — and since overrides outrank monkeypatched
|
|
77
|
+
# env vars, the leak surfaces as unrelated respx "no route matched"
|
|
78
|
+
# failures (code review finding 7).
|
|
79
|
+
uta_config.reset_config()
|
|
75
80
|
uta_discovery.reset_cache()
|
|
76
81
|
yield
|
|
77
|
-
uta_config.
|
|
82
|
+
uta_config.reset_config()
|
|
78
83
|
uta_discovery.reset_cache()
|
|
79
84
|
|
|
80
85
|
|
|
@@ -6,6 +6,8 @@ from urllib.parse import parse_qs, urlparse
|
|
|
6
6
|
import httpx
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
+
import json
|
|
10
|
+
|
|
9
11
|
from usethatapp import (
|
|
10
12
|
begin_login,
|
|
11
13
|
complete_login,
|
|
@@ -19,6 +21,7 @@ from usethatapp.errors import (
|
|
|
19
21
|
UtaAuthError,
|
|
20
22
|
UtaError,
|
|
21
23
|
UtaPermissionError,
|
|
24
|
+
UtaServiceNotEnabledError,
|
|
22
25
|
UtaServerError,
|
|
23
26
|
UtaTokenError,
|
|
24
27
|
)
|
|
@@ -145,9 +148,23 @@ def test_get_entitlement_licensed(oidc_routes):
|
|
|
145
148
|
assert ent.entitled and ent.version == "Pro" and ent.product_id == "p-1"
|
|
146
149
|
assert ent.product_public_id == "prod_abc123"
|
|
147
150
|
assert ent.status == "active" and ent.is_free is False
|
|
151
|
+
assert ent.is_trial is False
|
|
148
152
|
assert ent.period_end == "2026-07-01"
|
|
149
153
|
|
|
150
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
|
+
|
|
151
168
|
def test_get_entitlement_free(oidc_routes):
|
|
152
169
|
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
153
170
|
return_value=httpx.Response(200, json={
|
|
@@ -172,6 +189,44 @@ def test_get_entitlement_status_mapping(oidc_routes, status, exc):
|
|
|
172
189
|
get_entitlement("at-123")
|
|
173
190
|
|
|
174
191
|
|
|
192
|
+
def test_get_entitlement_service_not_enabled(oidc_routes):
|
|
193
|
+
"""A 403 whose body says service_not_enabled is the developer's to
|
|
194
|
+
fix (enable the add-on) — it must NOT be reported as a scope
|
|
195
|
+
problem, but must stay catchable as UtaPermissionError."""
|
|
196
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
197
|
+
return_value=httpx.Response(403, json={
|
|
198
|
+
"error": "service_not_enabled",
|
|
199
|
+
"error_description": "The developer has not enabled the "
|
|
200
|
+
"entitlement service for this app.",
|
|
201
|
+
})
|
|
202
|
+
)
|
|
203
|
+
with pytest.raises(UtaServiceNotEnabledError, match="Hosted sign-in") as exc_info:
|
|
204
|
+
get_entitlement("at-123")
|
|
205
|
+
assert issubclass(UtaServiceNotEnabledError, UtaPermissionError)
|
|
206
|
+
# Glassbox F-10: the message names the CURRENT public feature name.
|
|
207
|
+
# Code review finding 10 (reversing F-10's host half): the manage
|
|
208
|
+
# hub lives on the production dashboard host — the configured
|
|
209
|
+
# api_url may be a dev stack or gateway with no manage UI, so the
|
|
210
|
+
# message must NOT point there.
|
|
211
|
+
message = str(exc_info.value)
|
|
212
|
+
assert "Auth & Entitlement" not in message
|
|
213
|
+
assert "https://www.usethatapp.com" in message
|
|
214
|
+
assert API_URL not in message
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_get_entitlement_scope_403_still_permission_error(oidc_routes):
|
|
218
|
+
"""The pre-existing 403 (insufficient_scope) keeps its exact class —
|
|
219
|
+
not the new subclass — so callers can tell the two apart."""
|
|
220
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
221
|
+
return_value=httpx.Response(403, json={
|
|
222
|
+
"error": "insufficient_scope", "scope": "entitlements",
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
with pytest.raises(UtaPermissionError) as excinfo:
|
|
226
|
+
get_entitlement("at-123")
|
|
227
|
+
assert type(excinfo.value) is UtaPermissionError
|
|
228
|
+
|
|
229
|
+
|
|
175
230
|
@pytest.mark.asyncio
|
|
176
231
|
async def test_get_entitlement_async(oidc_routes):
|
|
177
232
|
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
@@ -251,3 +306,162 @@ def test_logout_url(oidc_routes):
|
|
|
251
306
|
assert q["id_token_hint"] == ["idt"]
|
|
252
307
|
assert q["post_logout_redirect_uri"] == ["https://app.test.example/bye"]
|
|
253
308
|
assert q["client_id"] == [CLIENT_ID]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_get_entitlement_429_maps_to_server_error(oidc_routes):
|
|
312
|
+
"""Glassbox F-04: a throttle answer is transient — it must land in
|
|
313
|
+
UtaServerError (the retry-with-backoff class), not the base class."""
|
|
314
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
315
|
+
return_value=httpx.Response(
|
|
316
|
+
429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
with pytest.raises(UtaServerError, match="rate limited"):
|
|
320
|
+
get_entitlement("at-123")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_snippet_caps_every_body_shape():
|
|
324
|
+
"""Glassbox F-11 + code review finding 5: ALL bodies are collapsed
|
|
325
|
+
and capped — JSON gets no exemption, because a DRF validation map or
|
|
326
|
+
debug-mode JSON 500 can carry a multi-kilobyte traceback (the
|
|
327
|
+
log-flooding the cap exists to stop). Short bodies pass whole."""
|
|
328
|
+
from usethatapp.client import _snippet
|
|
329
|
+
|
|
330
|
+
json_body = '{"error": "unknown_key"}'
|
|
331
|
+
assert _snippet(json_body) == json_body
|
|
332
|
+
|
|
333
|
+
html = "<!DOCTYPE html>\n<html>\n" + ("<p>filler</p>\n" * 200) + "</html>"
|
|
334
|
+
out = _snippet(html)
|
|
335
|
+
assert len(out) < 260
|
|
336
|
+
assert "\n" not in out
|
|
337
|
+
assert "truncated" in out
|
|
338
|
+
|
|
339
|
+
big_json = json.dumps({"detail": "Traceback (most recent call last)" * 200})
|
|
340
|
+
out = _snippet(big_json)
|
|
341
|
+
assert len(out) < 260
|
|
342
|
+
assert "truncated" in out
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def test_non_json_error_body_is_not_inlined_whole(oidc_routes):
|
|
346
|
+
html_page = "<!DOCTYPE html><html>" + "x" * 5000 + "</html>"
|
|
347
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
348
|
+
return_value=httpx.Response(500, text=html_page)
|
|
349
|
+
)
|
|
350
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
351
|
+
get_entitlement("at-123")
|
|
352
|
+
assert len(str(exc_info.value)) < 400
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def test_429_surfaces_retry_after_on_the_exception(oidc_routes):
|
|
356
|
+
"""Quiver J-05: the server's backoff hint must reach the caller —
|
|
357
|
+
documented, sent, and previously discarded one layer below them."""
|
|
358
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
359
|
+
return_value=httpx.Response(
|
|
360
|
+
429, json={"error": "rate_limited"}, headers={"Retry-After": "60"}
|
|
361
|
+
)
|
|
362
|
+
)
|
|
363
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
364
|
+
get_entitlement("at-123")
|
|
365
|
+
assert exc_info.value.retry_after == 60
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def test_retry_after_is_none_when_absent(oidc_routes):
|
|
369
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
370
|
+
return_value=httpx.Response(503, text="unavailable")
|
|
371
|
+
)
|
|
372
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
373
|
+
get_entitlement("at-123")
|
|
374
|
+
assert exc_info.value.retry_after is None
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_retry_after_http_date_form_is_none(oidc_routes):
|
|
378
|
+
# RFC 9110 allows an HTTP-date; we only surface the seconds form.
|
|
379
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
380
|
+
return_value=httpx.Response(
|
|
381
|
+
429, json={"error": "rate_limited"},
|
|
382
|
+
headers={"Retry-After": "Sat, 23 Aug 2026 02:00:00 GMT"},
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
386
|
+
get_entitlement("at-123")
|
|
387
|
+
assert exc_info.value.retry_after is None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def test_entitlement_product_id_falls_back_to_public_id(oidc_routes):
|
|
391
|
+
"""Code review finding 1: the alias promise in types.py ("gate on
|
|
392
|
+
either field") must hold against a server that predates the
|
|
393
|
+
identifier cutover — same fallback the LicenseState parser has."""
|
|
394
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
395
|
+
return_value=httpx.Response(200, json={
|
|
396
|
+
"entitled": True,
|
|
397
|
+
"version": "Pro",
|
|
398
|
+
"status": "active",
|
|
399
|
+
"product_public_id": "prod_abc123",
|
|
400
|
+
# no product_id field at all
|
|
401
|
+
})
|
|
402
|
+
)
|
|
403
|
+
ent = get_entitlement("at-123")
|
|
404
|
+
assert ent.product_id == "prod_abc123"
|
|
405
|
+
assert ent.product_id == ent.product_public_id
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def test_unicode_digit_retry_after_does_not_crash(oidc_routes):
|
|
409
|
+
"""Code review finding 3 (reproduced): '²'.isdigit() is True but
|
|
410
|
+
int('²') raises — a malformed Retry-After must degrade to None, not
|
|
411
|
+
turn a 429 into an uncaught ValueError."""
|
|
412
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
413
|
+
# As the wire delivers it: the latin-1 byte 0xB2, which httpx
|
|
414
|
+
# decodes to '²' on read.
|
|
415
|
+
return_value=httpx.Response(
|
|
416
|
+
429, text="slow down", headers=[(b"Retry-After", b"\xb2")]
|
|
417
|
+
)
|
|
418
|
+
)
|
|
419
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
420
|
+
get_entitlement("at-123")
|
|
421
|
+
assert exc_info.value.retry_after is None
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def test_userinfo_429_is_retriable_with_retry_after(oidc_routes):
|
|
425
|
+
"""Code review finding 4: the backoff loop this release invites must
|
|
426
|
+
work when the same rate limiter throttles userinfo during login."""
|
|
427
|
+
oidc_routes.get(METADATA["userinfo_endpoint"]).mock(
|
|
428
|
+
return_value=httpx.Response(
|
|
429
|
+
429, text="rate limited", headers={"Retry-After": "30"}
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
433
|
+
userinfo("at-123")
|
|
434
|
+
assert exc_info.value.retry_after == 30
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_token_endpoint_429_is_not_a_credential_failure(oidc_routes):
|
|
438
|
+
"""Code review finding 4: a throttled token refresh is retriable
|
|
439
|
+
server pushback — surfacing it as UtaTokenError makes callers log
|
|
440
|
+
the user out instead of backing off."""
|
|
441
|
+
oidc_routes.post(METADATA["token_endpoint"]).mock(
|
|
442
|
+
return_value=httpx.Response(
|
|
443
|
+
429, text="rate limited", headers={"Retry-After": "15"}
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
with pytest.raises(UtaServerError) as exc_info:
|
|
447
|
+
refresh("rt-123")
|
|
448
|
+
assert exc_info.value.retry_after == 15
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def test_timeout_zero_is_respected(oidc_routes, monkeypatch):
|
|
452
|
+
"""Code review finding 8: timeout=0.0 — expressible since the float
|
|
453
|
+
widening — is a fail-fast probe, not falsy noise to be replaced by
|
|
454
|
+
the configured default."""
|
|
455
|
+
seen = {}
|
|
456
|
+
real_get = httpx.get
|
|
457
|
+
|
|
458
|
+
def spying_get(url, **kwargs):
|
|
459
|
+
seen["timeout"] = kwargs.get("timeout")
|
|
460
|
+
return real_get(url, **kwargs)
|
|
461
|
+
|
|
462
|
+
monkeypatch.setattr(httpx, "get", spying_get)
|
|
463
|
+
oidc_routes.get(API_URL + "/licensing/entitlement/").mock(
|
|
464
|
+
return_value=httpx.Response(200, json={"entitled": False})
|
|
465
|
+
)
|
|
466
|
+
get_entitlement("at-123", timeout=0.0)
|
|
467
|
+
assert seen["timeout"] == 0.0
|