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
20 changes: 10 additions & 10 deletions clientele/api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A genuinely sensible change.

from clientele.api import config as api_config
from clientele.api import exceptions as api_exceptions
from clientele.api import requests, type_utils
from clientele.api import request_context, type_utils
from clientele.http import httpx_backend
from clientele.http import response as http_response

Expand Down Expand Up @@ -281,7 +281,7 @@ def _create_decorator(
streaming_response: bool = False,
) -> typing.Callable[[typing.Any], typing.Any]:
def decorator(func: typing.Any) -> typing.Any:
context = requests.build_request_context(
context = request_context.build_request_context(
method,
path,
func,
Expand Down Expand Up @@ -319,8 +319,8 @@ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any:
return decorator

def _prepare_call(
self, context: requests.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> requests.PreparedCall:
self, context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> request_context.PreparedCall:
"""
Parse function arguments into an HTTP request specification.

Expand Down Expand Up @@ -400,7 +400,7 @@ def _prepare_call(
url_path = self._substitute_path(context.path_template, path_params)
result_annotation = context.type_hints.get("result", inspect._empty)

return requests.PreparedCall(
return request_context.PreparedCall(
context=context,
bound_arguments=bound_arguments,
call_arguments=call_arguments,
Expand All @@ -412,7 +412,7 @@ def _prepare_call(
)

def _execute_sync(
self, context: requests.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
self, context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> typing.Any:
prepared = self._prepare_call(context, args, kwargs)
response = self._send_request(
Expand All @@ -427,7 +427,7 @@ def _execute_sync(
return result

async def _execute_async(
self, context: requests.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
self, context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> typing.Any:
prepared = self._prepare_call(context, args, kwargs)
response = await self._send_request_async(
Expand All @@ -442,7 +442,7 @@ async def _execute_async(
return await result

async def _execute_async_stream(
self, context: requests.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
self, context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> typing.Any:
"""
Execute an async streaming request.
Expand Down Expand Up @@ -495,7 +495,7 @@ async def _execute_async_stream(
return result

def _execute_sync_stream(
self, context: requests.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
self, context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]
) -> typing.Any:
"""
Execute a sync streaming request.
Expand Down Expand Up @@ -658,7 +658,7 @@ async def _send_request_async(

def _finalise_call(
self,
prepared: requests.PreparedCall,
prepared: request_context.PreparedCall,
response: http_response.Response,
) -> typing.Any:
parsed_result = self._parse_response(
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion clientele/generators/api/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
# Framework generator for decorator-based clients
# API generator for decorator-based clients
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Base class for client generators shared by api and basic generators."""
"""Generates the client.py file for the api generator."""

import collections
import typing
Expand All @@ -8,10 +8,20 @@
from rich import console as rich_console

from clientele.generators import cicerone_compat
from clientele.generators.shared import utils
from clientele.generators.api import writer
from clientele.generators.shared import schemas, utils

console = rich_console.Console()

# Template used for each HTTP method when rendering client functions
METHOD_TEMPLATE_MAP = {
"get": "api_get_method.jinja2",
"delete": "api_get_method.jinja2",
"post": "api_post_method.jinja2",
"put": "api_post_method.jinja2",
"patch": "api_post_method.jinja2",
}


class ParametersResponse(pydantic.BaseModel):
# Parameters that need to be passed in the URL query
Expand All @@ -23,21 +33,6 @@ class ParametersResponse(pydantic.BaseModel):
# Mapping from sanitized Python name to original API parameter name
param_name_map: dict[str, str] = {}

def get_path_args_as_string(self) -> str:
# Get all the path arguments, and the query arguments and make a big string out of them.
args = list(self.path_args.items()) + list(self.query_args.items())
# Separate required and optional parameters
# Python requires all required parameters (no default) before optional ones (with default)
required_args = []
optional_args = []
for k, v in args:
if v.startswith("typing.Optional["):
optional_args.append(f"{k}: {v} = None")
else:
required_args.append(f"{k}: {v}")
# Return required parameters first, then optional ones
return ", ".join(required_args + optional_args)

def get_required_args_as_string(self) -> str:
"""Get only required parameters (those without Optional wrapper)."""
args = list(self.path_args.items()) + list(self.query_args.items())
Expand All @@ -56,54 +51,33 @@ def get_optional_args_as_string(self) -> str:
optional_args.append(f"{k}: {v} = None")
return ", ".join(optional_args) if optional_args else ""

def get_required_path_args_as_string(self) -> str:
"""Get only required path parameters (for clientele-api generator)."""
required_args = []
for k, v in self.path_args.items():
if not v.startswith("typing.Optional["):
required_args.append(f"{k}: {v}")
return ", ".join(required_args) if required_args else ""

def has_query_args(self) -> bool:
"""Check if there are any query parameters."""
return len(self.query_args) > 0


class BaseClientsGenerator:
class ClientsGenerator:
"""
Base class for generating client code from OpenAPI specifications.
Provides common functionality for client generators.
Handles all the content generated in the client.py file for the api generator.
This generates decorator-based client functions instead of traditional functions.
"""

method_template_map: dict[str, str]
results: dict[str, int]
spec: cicerone_openapi_spec.OpenAPISpec
output_dir: str
schemas_generator: typing.Any # Type will be specific in subclasses
http_generator: typing.Any # Type will be specific in subclasses
writer: typing.Any # Must be set by subclass
schemas_generator: schemas.SchemasGenerator
# Status codes per generated function, used to build response_map arguments
function_and_status_codes_bundle: dict[str, dict[str, str]]

def __init__(
self,
spec: cicerone_openapi_spec.OpenAPISpec,
output_dir: str,
schemas_generator: typing.Any,
http_generator: typing.Any,
schemas_generator: schemas.SchemasGenerator,
asyncio: bool,
) -> None:
self.spec = spec
self.output_dir = output_dir
self.results = collections.defaultdict(int)
self.schemas_generator = schemas_generator
self.http_generator = http_generator
self.asyncio = asyncio
self.method_template_map = dict(
get="get_method.jinja2",
delete="get_method.jinja2",
post="post_method.jinja2",
put="post_method.jinja2",
patch="post_method.jinja2",
)
self.function_and_status_codes_bundle = {}

def generate_paths(self) -> None:
# Check if the spec has paths
Expand Down Expand Up @@ -221,7 +195,7 @@ def get_response_class_names(self, responses: dict, func_name: str) -> list[str]
)
status_code_map[status_code] = class_name
response_classes.append(class_name)
self.http_generator.add_status_codes_to_bundle(func_name=func_name, status_code_map=status_code_map)
self.function_and_status_codes_bundle[func_name] = status_code_map
# Use set to deduplicate, then sorted for consistent ordering
return sorted(set(response_classes))

Expand Down Expand Up @@ -267,6 +241,31 @@ def generate_input_types(self, request_body: dict, func_name: str) -> str:
else:
return f"schemas.{input_class_names[0]}"

def get_response_map(self, func_name: str) -> typing.Optional[str]:
"""
Generate response_map for decorator if there are multiple response types.
Returns a string representation of the response_map dict or None.
"""
status_codes = self.function_and_status_codes_bundle.get(func_name, {})

# If there's only one response code, we don't need response_map
if len(status_codes) <= 1:
return None

# Filter out None responses (no content responses)
valid_responses = {code: schema for code, schema in status_codes.items() if schema != "None"}

if len(valid_responses) <= 1:
return None

# Build response_map dict with schemas. prefix
response_map_parts = []
for status_code, schema_name in sorted(valid_responses.items()):
# Add schemas. prefix to match the response types
response_map_parts.append(f"{status_code}: schemas.{schema_name}")

return "{" + ", ".join(response_map_parts) + "}"

def generate_function(
self,
operation: dict,
Expand All @@ -289,32 +288,33 @@ def generate_function(
parameters=operation.get("parameters", []),
additional_parameters=additional_parameters,
)
# Replace path parameters in URL with sanitized names
# Replace path parameters in URL with sanitized names but DON'T add query args
# In the clientele api, query args are passed as function parameters, not in the URL
api_url = utils.replace_path_parameters(url, function_arguments.param_name_map)
if query_args := function_arguments.query_args:
# Use original parameter names in URL, but sanitized names for Python variables
api_url = api_url + utils.create_query_args_with_mapping(
list(query_args.keys()), function_arguments.param_name_map
)

if method in ["post", "put", "patch"] and not operation.get("requestBody"):
data_class_name = None
elif method in ["post", "put", "patch"]:
data_class_name = self.generate_input_types(operation.get("requestBody", {}), func_name=func_name)
else:
data_class_name = None
self.results[method] += 1
template = self.writer.templates.get_template(self.method_template_map[method])
template = writer.templates.get_template(METHOD_TEMPLATE_MAP[method])
if headers := function_arguments.headers_args:
header_class_name = self.schemas_generator.generate_headers_class(
properties=headers,
func_name=func_name,
)
else:
header_class_name = None

# Get response_map for clientele api decorator
response_map = self.get_response_map(func_name)

content = template.render(
asyncio=self.asyncio,
func_name=func_name,
function_arguments=function_arguments, # Pass the object, not the string
function_arguments=function_arguments,
response_types=response_types,
data_class_name=data_class_name,
header_class_name=header_class_name,
Expand All @@ -323,13 +323,14 @@ def generate_function(
summary=operation.get("summary", summary),
description=operation.get("description"),
deprecated=operation.get("deprecated", False),
response_map=response_map,
)
self.writer.write_to_client(content=content, output_dir=self.output_dir)
writer.write_to_client(content=content, output_dir=self.output_dir)

def write_path_to_client(self, path: tuple[str, dict]) -> None:
url, operations = path
for method, operation in operations.items():
if method.lower() in self.method_template_map.keys():
if method.lower() in METHOD_TEMPLATE_MAP:
self.generate_function(
operation=operation,
method=method,
Expand Down
14 changes: 6 additions & 8 deletions clientele/generators/api/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@
from cicerone.spec import openapi_spec as cicerone_openapi_spec
from rich import console as rich_console

from clientele import generators, settings, utils
from clientele.generators.api import writer
from clientele.generators.api.generators import clients, schemas
from clientele import generators, settings
from clientele.generators.api import clients, writer
from clientele.generators.shared import schemas, utils

console = rich_console.Console()


class APIGenerator(generators.Generator):
"""
The framework Clientele generator.
The API Clientele generator.

Produces a decorator-based Python HTTP Client library using the clientele framework.
Produces a decorator-based Python HTTP Client library using the clientele runtime.
"""

spec: cicerone_openapi_spec.OpenAPISpec
Expand Down Expand Up @@ -52,15 +52,14 @@ def __init__(
self.output_dir = output_dir
self.file = file
self.url = url
# Framework generator only needs client, config, and schemas (no http.py)
# The api generator only needs client, config, and schemas (no http.py)
self.file_name_writer_tuple = (
("config.py", "config_py.jinja2", writer.write_to_config),
("client.py", "client_py.jinja2", writer.write_to_client),
("schemas.py", "schemas_py.jinja2", writer.write_to_schemas),
)

def generate_templates_files(self) -> None:
new_unions = settings.PY_VERSION[1] > 10
client_project_directory_path = utils.get_client_project_directory_path(output_dir=self.output_dir)

# Extract base_url from OpenAPI spec servers
Expand All @@ -83,7 +82,6 @@ def generate_templates_files(self) -> None:
template = writer.templates.get_template(client_template_file)
content = template.render(
client_project_directory_path=client_project_directory_path,
new_unions=new_unions,
base_url=base_url,
)
write_func(content, output_dir=self.output_dir)
Expand Down
1 change: 0 additions & 1 deletion clientele/generators/api/generators/__init__.py

This file was deleted.

Loading
Loading