Skip to content

Commit 57c52c8

Browse files
authored
Merge pull request #30 from dialpad/AddAsyncClient
Adds `AsyncDialpadClient` that uses `httpx`
2 parents 975ccff + a4bcc0c commit 57c52c8

52 files changed

Lines changed: 6041 additions & 661 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ for user in dp_client.users.list():
7373
print(user)
7474
```
7575

76+
### Async Support
77+
78+
`AsyncDialpadClient` is a thing now 🌈
79+
80+
```python
81+
82+
from dialpad import AsyncDialpadClient
83+
84+
dp_client = AsyncDialpadClient(sandbox=True, token='API_TOKEN_HERE')
85+
86+
async for user in dp_client.users.list():
87+
print(user)
88+
```
7689

7790
## Development
7891

cli/client_gen/annotation.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def _get_collection_item_type(schema_dict: dict) -> str:
161161
return None
162162

163163

164-
def spec_piece_to_annotation(spec_piece: SchemaPath) -> ast.Name:
164+
def spec_piece_to_annotation(spec_piece: SchemaPath, use_async: bool = False) -> ast.Name:
165165
"""Converts requestBody, responses, property, or parameter elements to the appropriate ast.Name annotation"""
166166
spec_dict = spec_piece.contents()
167167

@@ -205,6 +205,10 @@ def spec_piece_to_annotation(spec_piece: SchemaPath) -> ast.Name:
205205
item_type = _get_collection_item_type(dereffed_response_schema)
206206
if item_type:
207207
# Return Iterator[ItemType] instead of the Collection type
208+
if use_async:
209+
return create_annotation(
210+
py_type=f'AsyncIterator[{item_type}]', nullable=False, omissible=False
211+
)
208212
return create_annotation(
209213
py_type=f'Iterator[{item_type}]', nullable=False, omissible=False
210214
)

cli/client_gen/resource_classes.py

Lines changed: 4 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414

1515
def resource_class_to_class_def(
16-
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]]
16+
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]], use_async: bool = False
1717
) -> ast.ClassDef:
1818
"""
1919
Converts a list of OpenAPI operations to a Python resource class definition.
@@ -55,80 +55,16 @@ def resource_class_to_class_def(
5555

5656
# Generate function definition for this operation
5757
func_def = http_method_to_func_def(
58-
operation_spec_path, override_func_name=target_method_name, api_path=original_api_path
58+
operation_spec_path, override_func_name=target_method_name, api_path=original_api_path, use_async=use_async
5959
)
6060
class_body_stmts.append(func_def)
6161
except Exception as e:
6262
logger.error(f'Error generating function for {target_method_name}: {e}')
6363

6464
# Base class: DialpadResource
6565
base_class_node = ast.Name(id='DialpadResource', ctx=ast.Load())
66-
67-
return ast.ClassDef(
68-
name=class_name, bases=[base_class_node], keywords=[], body=class_body_stmts, decorator_list=[]
69-
)
70-
71-
72-
# Keep the old function for backward compatibility or testing
73-
def _path_str_to_class_name(path_str: str) -> str:
74-
"""Converts an OpenAPI path string to a Python class name."""
75-
if path_str == '/':
76-
return 'RootResource'
77-
78-
name_parts = []
79-
cleaned_path = path_str.lstrip('/')
80-
for part in cleaned_path.split('/'):
81-
if part.startswith('{') and part.endswith('}'):
82-
param_name = part[1:-1]
83-
# Convert snake_case or kebab-case to CamelCase (e.g., user_id -> UserId)
84-
name_parts.append(''.join(p.capitalize() for p in param_name.replace('-', '_').split('_')))
85-
else:
86-
# Convert static part to CamelCase (e.g., call-queues -> CallQueues)
87-
name_parts.append(''.join(p.capitalize() for p in part.replace('-', '_').split('_')))
88-
89-
return ''.join(name_parts) + 'Resource'
90-
91-
92-
def resource_path_to_class_def(resource_path: SchemaPath) -> ast.ClassDef:
93-
"""
94-
Converts an OpenAPI resource path to a Python resource class definition.
95-
96-
DEPRECATED: Use resource_class_to_class_def instead.
97-
"""
98-
path_item_dict = resource_path.contents()
99-
path_key = resource_path.parts[-1] # The actual path string, e.g., "/users/{id}"
100-
101-
class_name = _path_str_to_class_name(path_key)
102-
103-
class_body_stmts: list[ast.stmt] = []
104-
105-
# Class Docstring
106-
class_docstring_parts = []
107-
summary = path_item_dict.get('summary')
108-
description = path_item_dict.get('description')
109-
110-
if summary:
111-
class_docstring_parts.append(summary)
112-
if description:
113-
if summary: # Add a blank line if summary was also present
114-
class_docstring_parts.append('')
115-
class_docstring_parts.append(description)
116-
117-
if not class_docstring_parts:
118-
class_docstring_parts.append(f'Resource for the path {path_key}')
119-
120-
final_class_docstring = '\n'.join(class_docstring_parts)
121-
class_body_stmts.append(ast.Expr(value=ast.Constant(value=final_class_docstring)))
122-
123-
# Methods for HTTP operations
124-
for http_method_name in path_item_dict.keys():
125-
if http_method_name.lower() in VALID_HTTP_METHODS:
126-
method_spec_path = resource_path / http_method_name
127-
func_def = http_method_to_func_def(method_spec_path)
128-
class_body_stmts.append(func_def)
129-
130-
# Base class: DialpadResource
131-
base_class_node = ast.Name(id='DialpadResource', ctx=ast.Load())
66+
if use_async:
67+
base_class_node = ast.Name(id='AsyncDialpadResource', ctx=ast.Load())
13268

13369
return ast.ClassDef(
13470
name=class_name, bases=[base_class_node], keywords=[], body=class_body_stmts, decorator_list=[]

cli/client_gen/resource_methods.py

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import ast
22
import re
3-
from typing import Optional
3+
from typing import Optional, Union
44

55
from jsonschema_path.paths import SchemaPath
66

@@ -139,14 +139,15 @@ def _build_method_call_args(
139139

140140

141141
def http_method_to_func_body(
142-
method_spec: SchemaPath, api_path: Optional[str] = None
142+
method_spec: SchemaPath, api_path: Optional[str] = None, use_async: bool = False
143143
) -> list[ast.stmt]:
144144
"""
145145
Generates the body of the Python function, including a docstring and request call.
146146
147147
Args:
148148
method_spec: The SchemaPath for the operation
149149
api_path: The original API path string (e.g., '/users/{user_id}')
150+
use_async: Whether to generate an async function
150151
151152
Returns:
152153
A list of ast.stmt nodes representing the function body
@@ -241,16 +242,32 @@ def http_method_to_func_body(
241242
# Create the appropriate request method call
242243
method_name = '_iter_request' if is_collection else '_request'
243244

244-
request_call = ast.Return(
245-
value=ast.Call(
246-
func=ast.Attribute(
247-
value=ast.Name(id='self', ctx=ast.Load()), attr=method_name, ctx=ast.Load()
248-
),
249-
args=[],
250-
keywords=call_args,
251-
)
245+
# Create the request call
246+
request_call_expr = ast.Call(
247+
func=ast.Attribute(
248+
value=ast.Name(id='self', ctx=ast.Load()), attr=method_name, ctx=ast.Load()
249+
),
250+
args=[],
251+
keywords=call_args,
252252
)
253253

254+
if use_async and is_collection:
255+
# For async collection responses, use async for loop with yield
256+
if_async_for = ast.AsyncFor(
257+
target=ast.Name(id='item', ctx=ast.Store()),
258+
iter=request_call_expr,
259+
body=[ast.Expr(value=ast.Yield(value=ast.Name(id='item', ctx=ast.Load())))],
260+
orelse=[],
261+
)
262+
return [docstring_node, if_async_for]
263+
elif use_async:
264+
# For async non-collection responses, use await
265+
request_call_expr = ast.Await(value=request_call_expr)
266+
request_call = ast.Return(value=request_call_expr)
267+
else:
268+
# For sync responses, return directly
269+
request_call = ast.Return(value=request_call_expr)
270+
254271
# Put it all together
255272
return [docstring_node, request_call]
256273

@@ -345,28 +362,41 @@ def http_method_to_func_args(method_spec: SchemaPath) -> ast.arguments:
345362

346363

347364
def http_method_to_func_def(
348-
method_spec: SchemaPath, override_func_name: Optional[str] = None, api_path: Optional[str] = None
349-
) -> ast.FunctionDef:
365+
method_spec: SchemaPath, override_func_name: Optional[str] = None, api_path: Optional[str] = None, use_async: bool = False
366+
) -> Union[ast.FunctionDef, ast.AsyncFunctionDef]:
350367
"""
351368
Converts an OpenAPI method spec to a Python function definition.
352369
353370
Args:
354371
method_spec: The SchemaPath for the operation
355372
override_func_name: An optional name to use for the function instead of the default
356373
api_path: The original API path string (e.g., '/users/{user_id}')
374+
use_async: Whether to generate an async function
357375
358376
Returns:
359-
An ast.FunctionDef node representing the Python method
377+
An ast.FunctionDef or ast.AsyncFunctionDef node representing the Python method
360378
"""
361379
func_name = override_func_name if override_func_name else http_method_to_func_name(method_spec)
362380

363381
# Generate function body with potentially modified path
364-
func_body = http_method_to_func_body(method_spec, api_path=api_path)
365-
366-
return ast.FunctionDef(
367-
name=func_name,
368-
args=http_method_to_func_args(method_spec),
369-
body=func_body,
370-
decorator_list=[],
371-
returns=spec_piece_to_annotation(method_spec / 'responses'),
372-
)
382+
func_body = http_method_to_func_body(method_spec, api_path=api_path, use_async=use_async)
383+
384+
func_args = http_method_to_func_args(method_spec)
385+
returns_annotation = spec_piece_to_annotation(method_spec / 'responses', use_async=use_async)
386+
387+
if use_async:
388+
return ast.AsyncFunctionDef(
389+
name=func_name,
390+
args=func_args,
391+
body=func_body,
392+
decorator_list=[],
393+
returns=returns_annotation,
394+
)
395+
else:
396+
return ast.FunctionDef(
397+
name=func_name,
398+
args=func_args,
399+
body=func_body,
400+
decorator_list=[],
401+
returns=returns_annotation,
402+
)

cli/client_gen/resource_modules.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def scan_for_refs(obj: dict) -> None:
9191

9292

9393
def resource_class_to_module_def(
94-
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]], api_spec: SchemaPath
94+
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]], api_spec: SchemaPath, use_async: bool = False
9595
) -> ast.Module:
9696
"""
9797
Converts a resource class specification to a Python module definition (ast.Module).
@@ -118,14 +118,14 @@ def resource_class_to_module_def(
118118
ast.alias(name='Dict', asname=None),
119119
ast.alias(name='Union', asname=None),
120120
ast.alias(name='Literal', asname=None),
121-
ast.alias(name='Iterator', asname=None),
121+
ast.alias(name='AsyncIterator', asname=None) if use_async else ast.alias(name='Iterator', asname=None),
122122
ast.alias(name='Any', asname=None),
123123
],
124124
level=0, # Absolute import
125125
),
126126
ast.ImportFrom(
127-
module='dialpad.resources.base',
128-
names=[ast.alias(name='DialpadResource', asname=None)],
127+
module='dialpad.async_resources.base' if use_async else 'dialpad.resources.base',
128+
names=[ast.alias(name='AsyncDialpadResource' if use_async else 'DialpadResource', asname=None)],
129129
level=0, # Absolute import
130130
),
131131
]
@@ -141,7 +141,7 @@ def resource_class_to_module_def(
141141
)
142142

143143
# Generate the class definition using resource_class_to_class_def
144-
class_definition = resource_class_to_class_def(class_name, operations_list)
144+
class_definition = resource_class_to_class_def(class_name, operations_list, use_async=use_async)
145145

146146
# Construct the ast.Module with imports and class definition
147147
module_body = import_statements + [class_definition]

cli/client_gen/resource_packages.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def _convert_to_snake_case(name: str) -> str:
4141

4242

4343
def _group_operations_by_class(
44-
api_spec: SchemaPath, module_mapping: Dict[str, Dict[str, ModuleMappingEntry]]
44+
api_spec: SchemaPath, module_mapping: Dict[str, Dict[str, ModuleMappingEntry]], use_async: bool = False,
4545
) -> Dict[str, List[Tuple[SchemaPath, str, str]]]:
4646
"""
4747
Groups API operations by their target resource class.
@@ -87,6 +87,8 @@ def _group_operations_by_class(
8787
continue
8888

8989
resource_class_name = operation_mapping_entry['resource_class']
90+
if use_async:
91+
resource_class_name = f'Async{resource_class_name}'
9092

9193
if resource_class_name not in grouped_operations:
9294
grouped_operations[resource_class_name] = []
@@ -101,6 +103,7 @@ def _group_operations_by_class(
101103
def resources_to_package_directory(
102104
api_spec: SchemaPath,
103105
output_dir: str,
106+
use_async: bool = False,
104107
) -> None:
105108
"""
106109
Converts OpenAPI operations to a Python resource package directory structure,
@@ -118,7 +121,7 @@ def resources_to_package_directory(
118121
print(f'Error loading module mapping: {e}')
119122
return
120123

121-
grouped_operations_by_class_name = _group_operations_by_class(api_spec, mapping_data)
124+
grouped_operations_by_class_name = _group_operations_by_class(api_spec, mapping_data, use_async=use_async)
122125

123126
generated_module_snake_names = []
124127

@@ -129,7 +132,7 @@ def resources_to_package_directory(
129132
operations_with_target_methods.append((op_spec_path, target_method_name, original_api_path))
130133

131134
module_ast = resource_class_to_module_def(
132-
resource_class_name, operations_with_target_methods, api_spec
135+
resource_class_name, operations_with_target_methods, api_spec, use_async=use_async
133136
)
134137

135138
module_file_snake_name = to_snake_case(resource_class_name)
@@ -152,15 +155,18 @@ def resources_to_package_directory(
152155
f.write(f'from .{module_snake_name} import {actual_class_name}\n')
153156

154157
# Add the DialpadResourcesMixin class
155-
f.write('\n\nclass DialpadResourcesMixin:\n')
158+
if use_async:
159+
f.write('\n\nclass AsyncDialpadResourcesMixin:\n')
160+
else:
161+
f.write('\n\nclass DialpadResourcesMixin:\n')
156162
f.write(' """Mixin class that provides resource properties for each API resource.\n\n')
157163
f.write(' This mixin is used by the DialpadClient class to provide easy access\n')
158164
f.write(' to all API resources as properties.\n """\n\n')
159165

160166
# Add a property for each resource class
161167
for class_name in sorted(all_resource_class_names_in_package):
162168
# Convert the class name to property name (removing 'Resource' suffix and converting to snake_case)
163-
property_name = to_snake_case(class_name.removesuffix('Resource'))
169+
property_name = to_snake_case(class_name.removesuffix('Resource').removeprefix('Async'))
164170

165171
f.write(' @property\n')
166172
f.write(f' def {property_name}(self) -> {class_name}:\n')

cli/main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ def generate_client():
6868
# Write the generated resource modules to the client directory
6969
resources_to_package_directory(open_api_spec.spec, os.path.join(CLIENT_DIR, 'resources'))
7070

71+
# Write async version of the resource modules to the async_resources directory
72+
resources_to_package_directory(open_api_spec.spec, os.path.join(CLIENT_DIR, 'async_resources'), use_async=True)
73+
7174
@app.command('interactive-update')
7275
def interactive_update():
7376
"""The one-stop-shop for updating the Dialpad client with the latest dialpad api spec."""

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.9"
77
dependencies = [
88
"cached-property>=2.0.1",
9+
"httpx>=0.28.1",
910
"requests>=2.28.0",
1011
]
1112

@@ -29,7 +30,9 @@ dev-dependencies = [
2930
"faker>=37.3.0",
3031
"inquirer>=3.4.0",
3132
"openapi-core>=0.19.5",
33+
"pytest-asyncio>=1.0.0",
3234
"pytest-cov>=6.2.1",
35+
"pytest-httpx>=0.35.0",
3336
"pytest>=8.4.0",
3437
"requests-mock>=1.12.1",
3538
"ruff>=0.11.12",

src/dialpad/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
from .async_client import AsyncDialpadClient
12
from .client import DialpadClient
23

34
__all__ = [
45
'DialpadClient',
6+
'AsyncDialpadClient',
57
]

0 commit comments

Comments
 (0)