Skip to content

Commit 3186ea0

Browse files
committed
style: apply ruff format
1 parent 83afa6a commit 3186ea0

6 files changed

Lines changed: 115 additions & 17 deletions

File tree

big-plan.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Async Rollout Plan — python-sdk
2+
3+
## Current state
4+
- `future_utils.py` exists with `then`, `wrap`, `resolve` helpers
5+
- `HTTPClient` stores `async_mode_experimental` but doesn't act on it yet
6+
- `DescopeClient.__init__` accepts and forwards the flag
7+
8+
---
9+
10+
## Stage 0 — Foundation: async HTTP transport (1 PR + 1 test PR)
11+
12+
**PR 0a — Implementation:**
13+
- Add `httpx.AsyncClient` (persistent, per-instance) alongside the existing synchronous path
14+
- Add `async def _async_execute_with_retry(request_fn)` mirroring the sync retry loop
15+
- Each public method accepts an explicit `async_mode: bool = False` parameter; passing `True` delegates to the async path and returns a coroutine; the class-level `async_mode_experimental` flag is stored but inert until the final global-rollout stage
16+
- No callers change yet — this PR is purely internal to `HTTPClient`
17+
18+
**PR 0b — Tests:**
19+
- Unit tests asserting async mode methods return coroutines (`asyncio.iscoroutine`)
20+
- Verify sync mode is completely unaffected (all existing tests continue to pass unchanged)
21+
- Test async retry logic (mock 503s, assert delays and retry count)
22+
23+
---
24+
25+
## Stage 1–9 — Auth methods (one file per PR pair)
26+
27+
**Pattern for every auth method file:**
28+
29+
```python
30+
# Before
31+
response = self._http.post(uri, body=body)
32+
return Auth.extract_masked_address(response.json(), method)
33+
34+
# After (using then from future_utils)
35+
from descope.future_utils import then
36+
response = self._http.post(uri, body=body)
37+
return then(response, lambda r: Auth.extract_masked_address(r.json(), method))
38+
```
39+
40+
When the HTTP client returns a plain `httpx.Response` (sync mode), `then` applies the lambda immediately and returns the final value — zero behaviour change. When it returns a coroutine (async mode), `then` returns a new coroutine that awaits it and applies the lambda.
41+
42+
Rollout order (each is one implementation PR + one test PR):
43+
44+
| Stage | File | Methods |
45+
|-------|------|---------|
46+
| 1 | `authmethod/otp.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify\_code, update\_user\_email, update\_user\_phone |
47+
| 2 | `authmethod/magiclink.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify, update\_user\_email, update\_user\_phone |
48+
| 3 | `authmethod/enchantedlink.py` | sign\_in, sign\_up, sign\_up\_or\_in, verify, get\_session, update\_user\_email, update\_user\_phone |
49+
| 4 | `authmethod/oauth.py` | start, exchange\_token, update\_user |
50+
| 5 | `authmethod/password.py` | sign\_in, sign\_up, send\_reset, update, replace, get\_policy |
51+
| 6 | `authmethod/totp.py` | sign\_in, sign\_up, sign\_up\_or\_in, update\_user, verify |
52+
| 7 | `authmethod/webauthn.py` | sign\_in\_start/finish, sign\_up\_start/finish, update\_user\_start/finish |
53+
| 8 | `authmethod/saml.py` + `sso.py` | start methods |
54+
| 9 | `auth.py` | validate\_session, refresh\_session, exchange\_access\_key (I/O-bound JWKS fetch) |
55+
56+
---
57+
58+
## Stage 10–N — Management files (one file per PR pair)
59+
60+
Same `then()` wrapping pattern. Suggested order by impact:
61+
62+
| Stage | File |
63+
|-------|------|
64+
| 10 | `management/user.py` |
65+
| 11 | `management/access_key.py` |
66+
| 12 | `management/tenant.py` |
67+
| 13 | `management/role.py` + `permission.py` |
68+
| 14 | `management/audit.py` |
69+
| 15 | `management/authz.py` + `management/fga.py` |
70+
| 16 | `management/sso_settings.py` + `management/sso_application.py` |
71+
| 17 | `management/flow.py` + `management/jwt.py` |
72+
| 18 | `management/group.py` + `management/project.py` + remaining files |
73+
74+
---
75+
76+
## Final stage — Global setting (future, after all stages done)
77+
78+
Once every file is converted, add a class-level `async_mode` property to `DescopeClient` that applies to all methods at once, and graduate the feature out of experimental. The per-file opt-in PRs make this final step trivial since all callers already use `then()`.
79+
80+
---
81+
82+
## Invariants throughout
83+
- Sync callers are **never broken** at any stage — `then(sync_result, fn)` is identical to `fn(sync_result)`
84+
- No new public API surface until the global-setting stage
85+
- Each implementation PR is independently reviewable and rollback-safe
86+
- Test PRs always cover both sync (regression) and async (new) paths for the converted file

descope/descope_client.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,7 @@ def __init__(
5454

5555
async_mode_experimental = bool(kwargs.pop("async_mode_experimental", False))
5656
if kwargs:
57-
raise TypeError(
58-
f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}"
59-
)
57+
raise TypeError(f"DescopeClient.__init__() got unexpected keyword arguments: {list(kwargs)}")
6058

6159
# Warn about TLS verification bypass
6260
if skip_verify:

descope/future_utils.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
T = TypeVar("T")
77

88

9-
def then(
10-
result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any]
11-
) -> Union[Any, Awaitable[Any]]:
9+
def then(result_or_coro: Union[T, Awaitable[T]], modifier: Callable[[T], Any]) -> Union[Any, Awaitable[Any]]:
1210
if inspect.isawaitable(result_or_coro):
1311

1412
async def process_async():

descope/http_client.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,11 @@ def get(
213213
) -> httpx.Response | Awaitable[httpx.Response]:
214214
if async_mode:
215215
if self._async_client is None:
216-
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction")
216+
raise AuthException(
217+
400,
218+
ERROR_TYPE_INVALID_ARGUMENT,
219+
"async_mode requires async_mode_experimental=True at client construction",
220+
)
217221
return self._async_get(uri, params=params, allow_redirects=allow_redirects, pswd=pswd)
218222
response = self._execute_with_retry(
219223
lambda: httpx.get(
@@ -242,7 +246,11 @@ def post(
242246
) -> httpx.Response | Awaitable[httpx.Response]:
243247
if async_mode:
244248
if self._async_client is None:
245-
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction")
249+
raise AuthException(
250+
400,
251+
ERROR_TYPE_INVALID_ARGUMENT,
252+
"async_mode requires async_mode_experimental=True at client construction",
253+
)
246254
return self._async_post(uri, body=body, params=params, pswd=pswd, base_url=base_url)
247255
response = self._execute_with_retry(
248256
lambda: httpx.post(
@@ -271,7 +279,11 @@ def put(
271279
) -> httpx.Response | Awaitable[httpx.Response]:
272280
if async_mode:
273281
if self._async_client is None:
274-
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction")
282+
raise AuthException(
283+
400,
284+
ERROR_TYPE_INVALID_ARGUMENT,
285+
"async_mode requires async_mode_experimental=True at client construction",
286+
)
275287
return self._async_put(uri, body=body, params=params, pswd=pswd)
276288
response = self._execute_with_retry(
277289
lambda: httpx.put(
@@ -298,7 +310,11 @@ def patch(
298310
) -> httpx.Response | Awaitable[httpx.Response]:
299311
if async_mode:
300312
if self._async_client is None:
301-
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction")
313+
raise AuthException(
314+
400,
315+
ERROR_TYPE_INVALID_ARGUMENT,
316+
"async_mode requires async_mode_experimental=True at client construction",
317+
)
302318
return self._async_patch(uri, body=body, params=params, pswd=pswd)
303319
response = self._execute_with_retry(
304320
lambda: httpx.patch(
@@ -326,7 +342,11 @@ def delete(
326342
) -> httpx.Response | Awaitable[httpx.Response]:
327343
if async_mode:
328344
if self._async_client is None:
329-
raise AuthException(400, ERROR_TYPE_INVALID_ARGUMENT, "async_mode requires async_mode_experimental=True at client construction")
345+
raise AuthException(
346+
400,
347+
ERROR_TYPE_INVALID_ARGUMENT,
348+
"async_mode requires async_mode_experimental=True at client construction",
349+
)
330350
return self._async_delete(uri, params=params, pswd=pswd)
331351
response = self._execute_with_retry(
332352
lambda: httpx.delete(

tests/management/test_descoper.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -532,8 +532,6 @@ def test_sync_behavior_with_async_mode_experimental(self, _mock_async):
532532
'{"descopers": [{"id": "U2111111111111111111111111", "status": "invited"}], "total": 1}'
533533
)
534534
mock_put.return_value = network_resp
535-
result = client.mgmt.descoper.create(
536-
descopers=[DescoperCreate(login_id="user1@example.com")]
537-
)
535+
result = client.mgmt.descoper.create(descopers=[DescoperCreate(login_id="user1@example.com")])
538536
self.assertFalse(asyncio.iscoroutine(result))
539537
self.assertEqual(result["total"], 1)

tests/test_descope_client.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,9 +1062,7 @@ def test_unknown_kwargs_raise_type_error(self):
10621062

10631063
@patch("httpx.AsyncClient")
10641064
@patch("httpx.post")
1065-
def test_async_mode_experimental_flag_does_not_return_coroutine(
1066-
self, mock_post, mock_async_client
1067-
):
1065+
def test_async_mode_experimental_flag_does_not_return_coroutine(self, mock_post, mock_async_client):
10681066
"""DescopeClient with async_mode_experimental=True still returns sync results from auth methods."""
10691067
import asyncio
10701068

0 commit comments

Comments
 (0)