Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ for user in dp_client.users.list():
print(user)
```

### Async Support

`AsyncDialpadClient` is a thing now 🌈

```python

from dialpad import AsyncDialpadClient

dp_client = AsyncDialpadClient(sandbox=True, token='API_TOKEN_HERE')

async for user in dp_client.users.list():
print(user)
```

## Development

Expand Down
6 changes: 5 additions & 1 deletion cli/client_gen/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ def _get_collection_item_type(schema_dict: dict) -> str:
return None


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

Expand Down Expand Up @@ -205,6 +205,10 @@ def spec_piece_to_annotation(spec_piece: SchemaPath) -> ast.Name:
item_type = _get_collection_item_type(dereffed_response_schema)
if item_type:
# Return Iterator[ItemType] instead of the Collection type
if use_async:
return create_annotation(
py_type=f'AsyncIterator[{item_type}]', nullable=False, omissible=False
)
return create_annotation(
py_type=f'Iterator[{item_type}]', nullable=False, omissible=False
)
Expand Down
72 changes: 4 additions & 68 deletions cli/client_gen/resource_classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


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

# Generate function definition for this operation
func_def = http_method_to_func_def(
operation_spec_path, override_func_name=target_method_name, api_path=original_api_path
operation_spec_path, override_func_name=target_method_name, api_path=original_api_path, use_async=use_async
)
class_body_stmts.append(func_def)
except Exception as e:
logger.error(f'Error generating function for {target_method_name}: {e}')

# Base class: DialpadResource
base_class_node = ast.Name(id='DialpadResource', ctx=ast.Load())

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


# Keep the old function for backward compatibility or testing
def _path_str_to_class_name(path_str: str) -> str:
"""Converts an OpenAPI path string to a Python class name."""
if path_str == '/':
return 'RootResource'

name_parts = []
cleaned_path = path_str.lstrip('/')
for part in cleaned_path.split('/'):
if part.startswith('{') and part.endswith('}'):
param_name = part[1:-1]
# Convert snake_case or kebab-case to CamelCase (e.g., user_id -> UserId)
name_parts.append(''.join(p.capitalize() for p in param_name.replace('-', '_').split('_')))
else:
# Convert static part to CamelCase (e.g., call-queues -> CallQueues)
name_parts.append(''.join(p.capitalize() for p in part.replace('-', '_').split('_')))

return ''.join(name_parts) + 'Resource'


def resource_path_to_class_def(resource_path: SchemaPath) -> ast.ClassDef:
"""
Converts an OpenAPI resource path to a Python resource class definition.

DEPRECATED: Use resource_class_to_class_def instead.
"""
path_item_dict = resource_path.contents()
path_key = resource_path.parts[-1] # The actual path string, e.g., "/users/{id}"

class_name = _path_str_to_class_name(path_key)

class_body_stmts: list[ast.stmt] = []

# Class Docstring
class_docstring_parts = []
summary = path_item_dict.get('summary')
description = path_item_dict.get('description')

if summary:
class_docstring_parts.append(summary)
if description:
if summary: # Add a blank line if summary was also present
class_docstring_parts.append('')
class_docstring_parts.append(description)

if not class_docstring_parts:
class_docstring_parts.append(f'Resource for the path {path_key}')

final_class_docstring = '\n'.join(class_docstring_parts)
class_body_stmts.append(ast.Expr(value=ast.Constant(value=final_class_docstring)))

# Methods for HTTP operations
for http_method_name in path_item_dict.keys():
if http_method_name.lower() in VALID_HTTP_METHODS:
method_spec_path = resource_path / http_method_name
func_def = http_method_to_func_def(method_spec_path)
class_body_stmts.append(func_def)

# Base class: DialpadResource
base_class_node = ast.Name(id='DialpadResource', ctx=ast.Load())
if use_async:
base_class_node = ast.Name(id='AsyncDialpadResource', ctx=ast.Load())

return ast.ClassDef(
name=class_name, bases=[base_class_node], keywords=[], body=class_body_stmts, decorator_list=[]
Expand Down
74 changes: 52 additions & 22 deletions cli/client_gen/resource_methods.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ast
import re
from typing import Optional
from typing import Optional, Union

from jsonschema_path.paths import SchemaPath

Expand Down Expand Up @@ -139,14 +139,15 @@ def _build_method_call_args(


def http_method_to_func_body(
method_spec: SchemaPath, api_path: Optional[str] = None
method_spec: SchemaPath, api_path: Optional[str] = None, use_async: bool = False
) -> list[ast.stmt]:
"""
Generates the body of the Python function, including a docstring and request call.

Args:
method_spec: The SchemaPath for the operation
api_path: The original API path string (e.g., '/users/{user_id}')
use_async: Whether to generate an async function

Returns:
A list of ast.stmt nodes representing the function body
Expand Down Expand Up @@ -241,16 +242,32 @@ def http_method_to_func_body(
# Create the appropriate request method call
method_name = '_iter_request' if is_collection else '_request'

request_call = ast.Return(
value=ast.Call(
func=ast.Attribute(
value=ast.Name(id='self', ctx=ast.Load()), attr=method_name, ctx=ast.Load()
),
args=[],
keywords=call_args,
)
# Create the request call
request_call_expr = ast.Call(
func=ast.Attribute(
value=ast.Name(id='self', ctx=ast.Load()), attr=method_name, ctx=ast.Load()
),
args=[],
keywords=call_args,
)

if use_async and is_collection:
# For async collection responses, use async for loop with yield
if_async_for = ast.AsyncFor(
target=ast.Name(id='item', ctx=ast.Store()),
iter=request_call_expr,
body=[ast.Expr(value=ast.Yield(value=ast.Name(id='item', ctx=ast.Load())))],
orelse=[],
)
return [docstring_node, if_async_for]
elif use_async:
# For async non-collection responses, use await
request_call_expr = ast.Await(value=request_call_expr)
request_call = ast.Return(value=request_call_expr)
else:
# For sync responses, return directly
request_call = ast.Return(value=request_call_expr)

# Put it all together
return [docstring_node, request_call]

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


def http_method_to_func_def(
method_spec: SchemaPath, override_func_name: Optional[str] = None, api_path: Optional[str] = None
) -> ast.FunctionDef:
method_spec: SchemaPath, override_func_name: Optional[str] = None, api_path: Optional[str] = None, use_async: bool = False
) -> Union[ast.FunctionDef, ast.AsyncFunctionDef]:
"""
Converts an OpenAPI method spec to a Python function definition.

Args:
method_spec: The SchemaPath for the operation
override_func_name: An optional name to use for the function instead of the default
api_path: The original API path string (e.g., '/users/{user_id}')
use_async: Whether to generate an async function

Returns:
An ast.FunctionDef node representing the Python method
An ast.FunctionDef or ast.AsyncFunctionDef node representing the Python method
"""
func_name = override_func_name if override_func_name else http_method_to_func_name(method_spec)

# Generate function body with potentially modified path
func_body = http_method_to_func_body(method_spec, api_path=api_path)

return ast.FunctionDef(
name=func_name,
args=http_method_to_func_args(method_spec),
body=func_body,
decorator_list=[],
returns=spec_piece_to_annotation(method_spec / 'responses'),
)
func_body = http_method_to_func_body(method_spec, api_path=api_path, use_async=use_async)

func_args = http_method_to_func_args(method_spec)
returns_annotation = spec_piece_to_annotation(method_spec / 'responses', use_async=use_async)

if use_async:
return ast.AsyncFunctionDef(
name=func_name,
args=func_args,
body=func_body,
decorator_list=[],
returns=returns_annotation,
)
else:
return ast.FunctionDef(
name=func_name,
args=func_args,
body=func_body,
decorator_list=[],
returns=returns_annotation,
)
10 changes: 5 additions & 5 deletions cli/client_gen/resource_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def scan_for_refs(obj: dict) -> None:


def resource_class_to_module_def(
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]], api_spec: SchemaPath
class_name: str, operations_list: List[Tuple[SchemaPath, str, str]], api_spec: SchemaPath, use_async: bool = False
) -> ast.Module:
"""
Converts a resource class specification to a Python module definition (ast.Module).
Expand All @@ -118,14 +118,14 @@ def resource_class_to_module_def(
ast.alias(name='Dict', asname=None),
ast.alias(name='Union', asname=None),
ast.alias(name='Literal', asname=None),
ast.alias(name='Iterator', asname=None),
ast.alias(name='AsyncIterator', asname=None) if use_async else ast.alias(name='Iterator', asname=None),
ast.alias(name='Any', asname=None),
],
level=0, # Absolute import
),
ast.ImportFrom(
module='dialpad.resources.base',
names=[ast.alias(name='DialpadResource', asname=None)],
module='dialpad.async_resources.base' if use_async else 'dialpad.resources.base',
names=[ast.alias(name='AsyncDialpadResource' if use_async else 'DialpadResource', asname=None)],
level=0, # Absolute import
),
]
Expand All @@ -141,7 +141,7 @@ def resource_class_to_module_def(
)

# Generate the class definition using resource_class_to_class_def
class_definition = resource_class_to_class_def(class_name, operations_list)
class_definition = resource_class_to_class_def(class_name, operations_list, use_async=use_async)

# Construct the ast.Module with imports and class definition
module_body = import_statements + [class_definition]
Expand Down
16 changes: 11 additions & 5 deletions cli/client_gen/resource_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _convert_to_snake_case(name: str) -> str:


def _group_operations_by_class(
api_spec: SchemaPath, module_mapping: Dict[str, Dict[str, ModuleMappingEntry]]
api_spec: SchemaPath, module_mapping: Dict[str, Dict[str, ModuleMappingEntry]], use_async: bool = False,
) -> Dict[str, List[Tuple[SchemaPath, str, str]]]:
"""
Groups API operations by their target resource class.
Expand Down Expand Up @@ -87,6 +87,8 @@ def _group_operations_by_class(
continue

resource_class_name = operation_mapping_entry['resource_class']
if use_async:
resource_class_name = f'Async{resource_class_name}'

if resource_class_name not in grouped_operations:
grouped_operations[resource_class_name] = []
Expand All @@ -101,6 +103,7 @@ def _group_operations_by_class(
def resources_to_package_directory(
api_spec: SchemaPath,
output_dir: str,
use_async: bool = False,
) -> None:
"""
Converts OpenAPI operations to a Python resource package directory structure,
Expand All @@ -118,7 +121,7 @@ def resources_to_package_directory(
print(f'Error loading module mapping: {e}')
return

grouped_operations_by_class_name = _group_operations_by_class(api_spec, mapping_data)
grouped_operations_by_class_name = _group_operations_by_class(api_spec, mapping_data, use_async=use_async)

generated_module_snake_names = []

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

module_ast = resource_class_to_module_def(
resource_class_name, operations_with_target_methods, api_spec
resource_class_name, operations_with_target_methods, api_spec, use_async=use_async
)

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

# Add the DialpadResourcesMixin class
f.write('\n\nclass DialpadResourcesMixin:\n')
if use_async:
f.write('\n\nclass AsyncDialpadResourcesMixin:\n')
else:
f.write('\n\nclass DialpadResourcesMixin:\n')
f.write(' """Mixin class that provides resource properties for each API resource.\n\n')
f.write(' This mixin is used by the DialpadClient class to provide easy access\n')
f.write(' to all API resources as properties.\n """\n\n')

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

f.write(' @property\n')
f.write(f' def {property_name}(self) -> {class_name}:\n')
Expand Down
3 changes: 3 additions & 0 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ def generate_client():
# Write the generated resource modules to the client directory
resources_to_package_directory(open_api_spec.spec, os.path.join(CLIENT_DIR, 'resources'))

# Write async version of the resource modules to the async_resources directory
resources_to_package_directory(open_api_spec.spec, os.path.join(CLIENT_DIR, 'async_resources'), use_async=True)

@app.command('interactive-update')
def interactive_update():
"""The one-stop-shop for updating the Dialpad client with the latest dialpad api spec."""
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"cached-property>=2.0.1",
"httpx>=0.28.1",
"requests>=2.28.0",
]

Expand All @@ -29,7 +30,9 @@ dev-dependencies = [
"faker>=37.3.0",
"inquirer>=3.4.0",
"openapi-core>=0.19.5",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.2.1",
"pytest-httpx>=0.35.0",
"pytest>=8.4.0",
"requests-mock>=1.12.1",
"ruff>=0.11.12",
Expand Down
2 changes: 2 additions & 0 deletions src/dialpad/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from .async_client import AsyncDialpadClient
from .client import DialpadClient

__all__ = [
'DialpadClient',
'AsyncDialpadClient',
]
Loading