Skip to content

Commit 3a44504

Browse files
committed
feat: Add async demo application and related tests
- Introduced `async_demo_app.py` to demonstrate async capabilities of the Descope SDK. - Added `asyncer` dependency to `pyproject.toml`. - Updated coverage threshold to 97. - Created comprehensive tests for async methods in `test_async.py`. - Refactored existing tests for better readability and consistency. - Enhanced error handling in async methods.
1 parent 70decfe commit 3a44504

File tree

19 files changed

+2068
-56
lines changed

19 files changed

+2068
-56
lines changed

ASYNC_IMPLEMENTATION.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Descope Python SDK - Async Support
2+
3+
This document provides information about async/await support in the Descope Python SDK.
4+
5+
## Usage
6+
7+
The SDK supports both synchronous and asynchronous patterns with identical functionality and method signatures.
8+
9+
### Basic Usage
10+
11+
```python
12+
# Synchronous (existing)
13+
from descope import DescopeClient, DeliveryMethod
14+
15+
client = DescopeClient(project_id="P123")
16+
masked_email = client.otp.sign_up(DeliveryMethod.EMAIL, "[email protected]")
17+
18+
# Asynchronous (new) - preferred with context manager
19+
from descope import AsyncDescopeClient, DeliveryMethod
20+
21+
async def main():
22+
async with AsyncDescopeClient(project_id="P123") as client:
23+
masked_email = await client.otp.sign_up_async(DeliveryMethod.EMAIL, "[email protected]")
24+
25+
# Or manual resource management
26+
async def main_manual():
27+
client = AsyncDescopeClient(project_id="P123")
28+
try:
29+
masked_email = await client.otp.sign_up_async(DeliveryMethod.EMAIL, "[email protected]")
30+
finally:
31+
await client.close_async()
32+
```
33+
34+
### Key Features
35+
36+
- **Zero breaking changes**: All existing synchronous code continues to work unchanged
37+
- **Async method variants**: Every public method has an async variant with `_async` suffix
38+
- **Context manager support**: Use `async with` for automatic resource cleanup
39+
- **Manual cleanup**: Call `close_async()` for manual resource management
40+
- **Identical error handling**: Same exception types and error codes as sync methods
41+
- **Type safety**: Full type hints and IDE support maintained
42+
43+
For complete examples and usage patterns, see the main [README.md](README.md).

README.md

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,33 @@
33
The Descope SDK for python provides convenient access to the Descope user management and authentication API
44
for a backend written in python. You can read more on the [Descope Website](https://descope.com).
55

6+
## ✨ New: Async Support
7+
8+
The SDK now supports async/await patterns alongside the existing synchronous API. Both APIs provide identical functionality with the same method signatures and error handling.
9+
10+
```python
11+
# Synchronous usage (existing)
12+
from descope import DescopeClient, DeliveryMethod
13+
14+
client = DescopeClient(project_id="P123")
15+
masked_email = client.otp.sign_up(DeliveryMethod.EMAIL, "[email protected]")
16+
17+
# Asynchronous usage (new) - preferred with context manager
18+
from descope import AsyncDescopeClient, DeliveryMethod
19+
20+
async def main():
21+
async with AsyncDescopeClient(project_id="P123") as client:
22+
masked_email = await client.otp.sign_up_async(DeliveryMethod.EMAIL, "[email protected]")
23+
24+
# Or manual resource management
25+
async def main_manual():
26+
client = AsyncDescopeClient(project_id="P123")
27+
try:
28+
masked_email = await client.otp.sign_up_async(DeliveryMethod.EMAIL, "[email protected]")
29+
finally:
30+
await client.close_async()
31+
```
32+
633
## Requirements
734

835
The SDK supports Python 3.8.1 and above.
@@ -15,7 +42,7 @@ Install the package with:
1542
pip install descope
1643
```
1744

18-
#### If you would like to use the Flask decorators, make sure to install the Flask extras:
45+
### If you would like to use the Flask decorators, make sure to install the Flask extras:
1946

2047
```bash
2148
pip install descope[Flask]
@@ -86,8 +113,12 @@ Send a user a one-time password (OTP) using your preferred delivery method (_ema
86113

87114
The user can either `sign up`, `sign in` or `sign up or in`
88115

116+
#### Synchronous usage
117+
89118
```python
90-
from descope import DeliveryMethod
119+
from descope import DescopeClient, DeliveryMethod
120+
121+
descope_client = DescopeClient(project_id="<Project ID>")
91122

92123
# Every user must have a login ID. All other user information is optional
93124
@@ -105,6 +136,26 @@ session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
105136
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
106137
```
107138

139+
#### Asynchronous usage
140+
141+
```python
142+
from descope import AsyncDescopeClient, DeliveryMethod
143+
144+
async def otp_example():
145+
async with AsyncDescopeClient(project_id="<Project ID>") as client:
146+
# Every user must have a login ID. All other user information is optional
147+
148+
user = {"name": "Desmond Copeland", "phone": "212-555-1234", "email": email}
149+
masked_address = await client.otp.sign_up_async(method=DeliveryMethod.EMAIL, login_id=email, user=user)
150+
151+
# Verify the code
152+
jwt_response = await client.otp.verify_code_async(
153+
method=DeliveryMethod.EMAIL, login_id=email, code=value
154+
)
155+
session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
156+
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
157+
```
158+
108159
The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation)
109160

110161
### Magic Link
@@ -115,8 +166,12 @@ This redirection can be configured in code, or generally in the [Descope Console
115166

116167
The user can either `sign up`, `sign in` or `sign up or in`
117168

169+
#### Synchronous usage
170+
118171
```python
119-
from descope import DeliveryMethod
172+
from descope import DescopeClient, DeliveryMethod
173+
174+
descope_client = DescopeClient(project_id="<Project ID>")
120175

121176
masked_address = descope_client.magiclink.sign_up_or_in(
122177
method=DeliveryMethod.EMAIL,
@@ -133,6 +188,25 @@ session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
133188
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
134189
```
135190

191+
#### Asynchronous usage
192+
193+
```python
194+
from descope import AsyncDescopeClient, DeliveryMethod
195+
196+
async def magiclink_example():
197+
async with AsyncDescopeClient(project_id="<Project ID>") as client:
198+
masked_address = await client.magiclink.sign_up_or_in_async(
199+
method=DeliveryMethod.EMAIL,
200+
login_id="[email protected]",
201+
uri="http://myapp.com/verify-magic-link", # Set redirect URI here or via console
202+
)
203+
204+
# Verify the magic link token
205+
jwt_response = await client.magiclink.verify_async(token=token)
206+
session_token = jwt_response[SESSION_TOKEN_NAME].get("jwt")
207+
refresh_token = jwt_response[REFRESH_SESSION_TOKEN_NAME].get("jwt")
208+
```
209+
136210
The session and refresh JWTs should be returned to the caller, and passed with every request in the session. Read more on [session validation](#session-validation)
137211

138212
### Enchanted Link

descope/__init__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
SignUpOptions,
1111
)
1212
from descope.descope_client import DescopeClient
13+
from descope.descope_client_async import AsyncDescopeClient
1314
from descope.exceptions import (
1415
API_RATE_LIMIT_RETRY_AFTER_HEADER,
1516
ERROR_TYPE_API_RATE_LIMIT,
@@ -39,3 +40,35 @@
3940
UserPasswordFirebase,
4041
UserPasswordPbkdf2,
4142
)
43+
44+
__all__ = [
45+
"COOKIE_DATA_NAME",
46+
"REFRESH_SESSION_COOKIE_NAME",
47+
"REFRESH_SESSION_TOKEN_NAME",
48+
"SESSION_COOKIE_NAME",
49+
"SESSION_TOKEN_NAME",
50+
"AccessKeyLoginOptions",
51+
"DeliveryMethod",
52+
"LoginOptions",
53+
"SignUpOptions",
54+
"DescopeClient",
55+
"AsyncDescopeClient",
56+
"AuthException",
57+
"RateLimitException",
58+
"AssociatedTenant",
59+
"SAMLIDPAttributeMappingInfo",
60+
"SAMLIDPGroupsMappingInfo",
61+
"SAMLIDPRoleGroupMappingInfo",
62+
"AttributeMapping",
63+
"OIDCAttributeMapping",
64+
"RoleMapping",
65+
"SSOOIDCSettings",
66+
"SSOSAMLSettings",
67+
"SSOSAMLSettingsByMetadata",
68+
"UserObj",
69+
"UserPassword",
70+
"UserPasswordBcrypt",
71+
"UserPasswordDjango",
72+
"UserPasswordFirebase",
73+
"UserPasswordPbkdf2",
74+
]

descope/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
try:
1515
from importlib.metadata import version
1616
except ImportError:
17-
from pkg_resources import get_distribution
17+
from pkg_resources import get_distribution # type: ignore
1818

1919
import requests
2020
from email_validator import EmailNotValidError, validate_email
@@ -120,7 +120,7 @@ def _raise_rate_limit_exception(self, response):
120120
)
121121
except RateLimitException:
122122
raise
123-
except Exception as e:
123+
except Exception:
124124
raise RateLimitException(
125125
status_code=HTTPStatus.TOO_MANY_REQUESTS,
126126
error_type=ERROR_TYPE_API_RATE_LIMIT,

descope/authmethod/enchantedlink.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,13 @@ def update_user_email(
118118
Auth.validate_email(email)
119119

120120
body = EnchantedLink._compose_update_user_email_body(
121-
login_id, email, add_to_login_ids, on_merge_use_existing,
122-
template_options, template_id, provider_id
121+
login_id,
122+
email,
123+
add_to_login_ids,
124+
on_merge_use_existing,
125+
template_options,
126+
template_id,
127+
provider_id,
123128
)
124129
uri = EndpointsV1.update_user_email_enchantedlink_path
125130
response = self._auth.do_post(uri, body, None, refresh_token)

descope/authmethod/magiclink.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,13 @@ def update_user_email(
117117
Auth.validate_email(email)
118118

119119
body = MagicLink._compose_update_user_email_body(
120-
login_id, email, add_to_login_ids, on_merge_use_existing,
121-
template_options, template_id, provider_id
120+
login_id,
121+
email,
122+
add_to_login_ids,
123+
on_merge_use_existing,
124+
template_options,
125+
template_id,
126+
provider_id,
122127
)
123128
uri = EndpointsV1.update_user_email_magiclink_path
124129
response = self._auth.do_post(uri, body, None, refresh_token)
@@ -144,8 +149,13 @@ def update_user_phone(
144149
Auth.validate_phone(method, phone)
145150

146151
body = MagicLink._compose_update_user_phone_body(
147-
login_id, phone, add_to_login_ids, on_merge_use_existing,
148-
template_options, template_id, provider_id
152+
login_id,
153+
phone,
154+
add_to_login_ids,
155+
on_merge_use_existing,
156+
template_options,
157+
template_id,
158+
provider_id,
149159
)
150160
uri = EndpointsV1.update_user_phone_magiclink_path
151161
response = self._auth.do_post(uri, body, None, refresh_token)

descope/authmethod/otp.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -198,8 +198,13 @@ def update_user_email(
198198

199199
uri = EndpointsV1.update_user_email_otp_path
200200
body = OTP._compose_update_user_email_body(
201-
login_id, email, add_to_login_ids, on_merge_use_existing,
202-
template_options, template_id, provider_id
201+
login_id,
202+
email,
203+
add_to_login_ids,
204+
on_merge_use_existing,
205+
template_options,
206+
template_id,
207+
provider_id,
203208
)
204209
response = self._auth.do_post(uri, body, None, refresh_token)
205210
return Auth.extract_masked_address(response.json(), DeliveryMethod.EMAIL)
@@ -241,8 +246,13 @@ def update_user_phone(
241246

242247
uri = OTP._compose_update_phone_url(method)
243248
body = OTP._compose_update_user_phone_body(
244-
login_id, phone, add_to_login_ids, on_merge_use_existing,
245-
template_options, template_id, provider_id
249+
login_id,
250+
phone,
251+
add_to_login_ids,
252+
on_merge_use_existing,
253+
template_options,
254+
template_id,
255+
provider_id,
246256
)
247257
response = self._auth.do_post(uri, body, None, refresh_token)
248258
return Auth.extract_masked_address(response.json(), method)

0 commit comments

Comments
 (0)