From 71724045519e72e39a5b7c7a974f04bc4239564d Mon Sep 17 00:00:00 2001 From: Christian Assing Date: Fri, 17 Jul 2026 10:41:03 +0200 Subject: [PATCH 1/2] Add Query(alias=...) for query parameters, fix wire name mismatch Lets a query parameter use a Python-friendly (ruff N803-safe) name via Annotated[..., Query(alias="...")] while still calling the API with its real name. The generator now emits this automatically whenever a query parameter's name isn't already valid snake_case, which also fixes a latent bug: such parameters were previously sent to the API under their sanitized Python name instead of the original one. --- clientele/api/__init__.py | 3 +- clientele/api/client.py | 2 + clientele/api/params.py | 37 +++++++++++++ clientele/api/request_context.py | 20 +++++++ clientele/generators/api/clients.py | 35 +++++++------ docs/CHANGELOG.md | 2 + docs/api-examples.md | 24 +++++++++ tests/api/test_api_client.py | 55 +++++++++++++++++++- tests/generators/api/test_clients.py | 38 ++++++++++++++ tests/generators/api/test_fixture_schemas.py | 30 +++++++++++ 10 files changed, 229 insertions(+), 17 deletions(-) create mode 100644 clientele/api/params.py diff --git a/clientele/api/__init__.py b/clientele/api/__init__.py index 56520551..4875bf75 100644 --- a/clientele/api/__init__.py +++ b/clientele/api/__init__.py @@ -1,6 +1,7 @@ from clientele.api.client import APIClient from clientele.api.config import BaseConfig, get_default_config from clientele.api.exceptions import APIException +from clientele.api.params import Query from clientele.http.status_codes import codes -__all__ = ["APIException", "APIClient", "BaseConfig", "codes", "get_default_config"] +__all__ = ["APIException", "APIClient", "BaseConfig", "Query", "codes", "get_default_config"] diff --git a/clientele/api/client.py b/clientele/api/client.py index b603c0da..c72a2b25 100644 --- a/clientele/api/client.py +++ b/clientele/api/client.py @@ -373,6 +373,8 @@ def _prepare_call( else: query_params = {k: v for k, v in request_arguments.items() if k != "data"} query_params.update(extra_kwargs) + if context.query_alias_map: + query_params = {context.query_alias_map.get(k, k): v for k, v in query_params.items()} # Filter out None values from query params to avoid adding empty parameters to the URL if query_params: diff --git a/clientele/api/params.py b/clientele/api/params.py new file mode 100644 index 00000000..2eab90ee --- /dev/null +++ b/clientele/api/params.py @@ -0,0 +1,37 @@ +from __future__ import annotations + + +class Query: + """Marks a decorated function parameter as an HTTP query parameter with a wire-format alias. + + Use via ``typing.Annotated`` when the Python parameter name (e.g. to satisfy naming + conventions such as ruff's N803) differs from the query parameter name expected by the API. + + Example: + ```python + from typing import Annotated + + from clientele import api + + client = api.APIClient(base_url="https://pokeapi.co/api/v2/") + + + @client.get("/pokemon/") + def get_pokemon_page(result: dict, order_by: Annotated[str, api.Query(alias="orderBy")]) -> dict: + return result + ``` + """ + + __slots__ = ("alias",) + + def __init__(self, *, alias: str) -> None: + self.alias = alias + + def __repr__(self) -> str: + return f"Query(alias={self.alias!r})" + + def __eq__(self, other: object) -> bool: + return isinstance(other, Query) and self.alias == other.alias + + def __hash__(self) -> int: + return hash(("clientele.api.Query", self.alias)) diff --git a/clientele/api/request_context.py b/clientele/api/request_context.py index 4d6b2bf6..f13de2fd 100644 --- a/clientele/api/request_context.py +++ b/clientele/api/request_context.py @@ -7,6 +7,7 @@ import pydantic from clientele.api import type_utils +from clientele.api.params import Query from clientele.http import response as http_response from clientele.http import status_codes @@ -52,6 +53,7 @@ class RequestContext(pydantic.BaseModel): typing.Callable[[http_response.Response], typing.Any] | typing.Callable[[str], typing.Any] | None ) = None streaming: bool = False + query_alias_map: dict[str, str] = {} def validate_result_parameter( @@ -124,6 +126,23 @@ def validate_result_parameter( ) +def _extract_query_alias_map(type_hints: dict[str, typing.Any]) -> dict[str, str]: + """ + Scans resolved type hints for `Annotated[X, Query(alias=...)]` metadata. + + Returns a mapping from the Python parameter name to the wire-format query + parameter name declared via `Query(alias=...)`. + """ + alias_map: dict[str, str] = {} + for name, annotation in type_hints.items(): + if typing.get_origin(annotation) is not typing.Annotated: + continue + for meta in getattr(annotation, "__metadata__", ()): + if isinstance(meta, Query): + alias_map[name] = meta.alias + return alias_map + + def build_request_context( method: str, path: str, @@ -175,6 +194,7 @@ def build_request_context( response_map=response_map, response_parser=response_parser, streaming=streaming, + query_alias_map=_extract_query_alias_map(type_hints), ) diff --git a/clientele/generators/api/clients.py b/clientele/generators/api/clients.py index 8bfbb215..5149a4b8 100644 --- a/clientele/generators/api/clients.py +++ b/clientele/generators/api/clients.py @@ -32,23 +32,21 @@ class ParametersResponse(pydantic.BaseModel): headers_args: dict[str, str] # Mapping from sanitized Python name to original API parameter name param_name_map: dict[str, str] = {} + # Keys (from path_args/query_args) that are optional. Tracked explicitly rather than + # sniffed from the type string, since aliased query args wrap their type in + # `typing.Annotated[...]`, which would otherwise hide a `typing.Optional[` prefix. + optional_keys: set[str] = set() def get_required_args_as_string(self) -> str: - """Get only required parameters (those without Optional wrapper).""" + """Get only required parameters (those not in optional_keys).""" args = list(self.path_args.items()) + list(self.query_args.items()) - required_args = [] - for k, v in args: - if not v.startswith("typing.Optional["): - required_args.append(f"{k}: {v}") + required_args = [f"{k}: {v}" for k, v in args if k not in self.optional_keys] return ", ".join(required_args) if required_args else "" def get_optional_args_as_string(self) -> str: - """Get only optional parameters (those with Optional wrapper).""" + """Get only optional parameters (those in optional_keys).""" args = list(self.path_args.items()) + list(self.query_args.items()) - optional_args = [] - for k, v in args: - if v.startswith("typing.Optional["): - optional_args.append(f"{k}: {v} = None") + optional_args = [f"{k}: {v} = None" for k, v in args if k in self.optional_keys] return ", ".join(optional_args) if optional_args else "" @@ -101,6 +99,7 @@ def generate_parameters(self, parameters: list[dict], additional_parameters: lis path_args = {} headers_args = {} param_name_map = {} # Maps sanitized name to original name + optional_keys: set[str] = set() all_parameters = parameters + additional_parameters for param in all_parameters: if param.get("$ref"): @@ -118,11 +117,15 @@ def generate_parameters(self, parameters: list[dict], additional_parameters: lis if in_ == "query": # URL query string values param_type = utils.resolve_forward_refs_for_client(utils.get_type(param["schema"])) - if required: - query_args[clean_key] = param_type - else: - param_type = utils.strip_none_from_type(param_type) - query_args[clean_key] = f"typing.Optional[{param_type}]" + if not required: + param_type = f"typing.Optional[{utils.strip_none_from_type(param_type)}]" + optional_keys.add(clean_key) + if clean_key != original_name: + # The sanitized Python name differs from the name the API expects, so + # record it via Query(alias=...) to keep the request sent with the correct + # wire-format name (see clientele.api.request_context.query_alias_map). + param_type = f'typing.Annotated[{param_type}, clientele_api.Query(alias="{original_name}")]' + query_args[clean_key] = param_type elif in_ == "path": # Function arguments param_type = utils.resolve_forward_refs_for_client(utils.get_type(param["schema"])) @@ -131,6 +134,7 @@ def generate_parameters(self, parameters: list[dict], additional_parameters: lis else: param_type = utils.strip_none_from_type(param_type) path_args[clean_key] = f"typing.Optional[{param_type}]" + optional_keys.add(clean_key) elif in_ == "header": # Header object arguments headers_args[param["name"]] = utils.get_type(param["schema"]) @@ -140,6 +144,7 @@ def generate_parameters(self, parameters: list[dict], additional_parameters: lis path_args=path_args, headers_args=headers_args, param_name_map=param_name_map, + optional_keys=optional_keys, ) def get_response_class_names(self, responses: dict, func_name: str) -> list[str]: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8057e29f..42bdf591 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.3.0 UNRELEASED +- Add `clientele.api.Query(alias=...)` for annotating a query parameter with its wire-format name via `typing.Annotated`, so a Python-friendly (snake_case) parameter name can be used without breaking the actual HTTP request. Clients generated from an OpenAPI spec now apply this automatically whenever a query parameter's name isn't already valid snake_case (e.g. `orderBy` → `order_by`). +- Fix generated/decorated functions sending query parameters under their sanitized Python name instead of the original API name whenever that name required sanitization (e.g. a spec's `yourInput` query parameter was sent as `?your_input=...` instead of `?yourInput=...`). - Major refactoring of the project structure including removing of redundant code paths (mostly left over from the old generator logic). - Reorganisation of the tests and their files to mirror the codebase more. diff --git a/docs/api-examples.md b/docs/api-examples.md index d1e3c588..7ebb1eb3 100644 --- a/docs/api-examples.md +++ b/docs/api-examples.md @@ -98,6 +98,30 @@ get_pokemon_page(query={"limit": 10, "offset": 30}) - You can pass a dict `query` to achieve the same results. - This does not need to be declared in your decorated function. +### Using aliases + +Some APIs use query parameter names that aren't valid, idiomatic Python identifiers (e.g. camelCase), which would trip up linters like ruff's `N803`. Use `Annotated` with `api.Query(alias=...)` to give the parameter a Python-friendly name while still sending the API's expected name over the wire: + +```python +from typing import Annotated + +from clientele import api + +client = api.APIClient(base_url="https://pokeapi.co/api/v2") + + +@client.get("/pokemon/") +def get_pokemon_page(result: dict, order_by: Annotated[str, api.Query(alias="orderBy")]) -> dict: + return result +``` + +```python +get_pokemon_page(order_by="name") +``` + +- The function is called with `order_by`, but the request is sent as `?orderBy=name`. +- Clients generated from an OpenAPI spec add this automatically whenever a query parameter's name isn't already valid snake_case. + ## Simple POST request ```python diff --git a/tests/api/test_api_client.py b/tests/api/test_api_client.py index 3478ecf0..12825b10 100644 --- a/tests/api/test_api_client.py +++ b/tests/api/test_api_client.py @@ -1,9 +1,11 @@ from __future__ import annotations +import typing + import pytest from pydantic import BaseModel -from clientele.api import APIClient, APIException, BaseConfig +from clientele.api import APIClient, APIException, BaseConfig, Query from clientele.testing import ResponseFactory, configure_client_for_testing BASE_URL = "https://api.example.com" @@ -938,6 +940,57 @@ def get_user(result: User, user_id: int, include_details: bool | None = None) -> client.close() +def test_query_param_alias_uses_wire_name() -> None: + """A snake_case Python parameter aliased via Query(alias=...) must send the alias over the wire. + + This satisfies ruff's N803 (non-lowercase argument name) for camelCase API query parameters + while still calling the API with the name it actually expects. + """ + client = APIClient(base_url=BASE_URL) + + fake_backend = configure_client_for_testing(client) + fake_backend.queue_response( + path="/pokemon/", + response_obj=ResponseFactory.ok(data={"count": 1}), + ) + + @client.get("/pokemon/") + def get_pokemon_page(result: dict, order_by: typing.Annotated[str, Query(alias="orderBy")]) -> dict: + return result + + get_pokemon_page(order_by="name") + + params = fake_backend.requests[0]["kwargs"].get("params") or {} + assert params == {"orderBy": "name"} + assert "order_by" not in params + + client.close() + + +def test_optional_query_param_alias_none_is_omitted() -> None: + """An optional aliased query parameter left as None must not appear in the request at all.""" + client = APIClient(base_url=BASE_URL) + + fake_backend = configure_client_for_testing(client) + fake_backend.queue_response( + path="/pokemon/", + response_obj=ResponseFactory.ok(data={"count": 1}), + ) + + @client.get("/pokemon/") + def get_pokemon_page( + result: dict, order_by: typing.Annotated[typing.Optional[str], Query(alias="orderBy")] = None + ) -> dict: + return result + + get_pokemon_page() + + params = fake_backend.requests[0]["kwargs"].get("params") or {} + assert params == {} + + client.close() + + def test_multiple_optional_query_params_some_none() -> None: """Test that when some optional query params are None and others have values, only non-None ones are included.""" client = APIClient(base_url=BASE_URL) diff --git a/tests/generators/api/test_clients.py b/tests/generators/api/test_clients.py index 1524fc41..37c48483 100644 --- a/tests/generators/api/test_clients.py +++ b/tests/generators/api/test_clients.py @@ -63,6 +63,44 @@ def test_clients_generator_resolves_schema_refs_in_parameters(tmp_path): assert param_type.count("None") <= 1 +def test_clients_generator_adds_query_alias_for_camel_case_names(tmp_path): + """A camelCase query parameter must become a snake_case arg annotated with Query(alias=...).""" + generator = _make_clients_generator(load_spec("simple.json"), tmp_path) + + parameters = [ + { + "name": "orderBy", + "in": "query", + "required": True, + "schema": {"type": "string"}, + } + ] + + result = generator.generate_parameters(parameters, []) + + assert "order_by" in result.query_args + assert "order_by" not in result.optional_keys + assert result.query_args["order_by"] == 'typing.Annotated[str, clientele_api.Query(alias="orderBy")]' + + +def test_clients_generator_skips_query_alias_for_already_snake_case_names(tmp_path): + """A query parameter that already matches its sanitized name needs no alias annotation.""" + generator = _make_clients_generator(load_spec("simple.json"), tmp_path) + + parameters = [ + { + "name": "limit", + "in": "query", + "required": True, + "schema": {"type": "integer"}, + } + ] + + result = generator.generate_parameters(parameters, []) + + assert result.query_args["limit"] == "int" + + def test_clients_generator_handles_multiple_input_classes(tmp_path): """Test that clients generator handles multiple input classes.""" generator = _make_clients_generator(load_spec("simple.json"), tmp_path) diff --git a/tests/generators/api/test_fixture_schemas.py b/tests/generators/api/test_fixture_schemas.py index cf6e3995..26a4ff81 100644 --- a/tests/generators/api/test_fixture_schemas.py +++ b/tests/generators/api/test_fixture_schemas.py @@ -124,3 +124,33 @@ def test_fixture_schema_generates_client(fixture_path, client_generator) -> None # Verify the generated files are valid Python validate_generated_python_file(output_dir / "client.py", client_content, fixture_path) validate_generated_python_file(output_dir / "schemas.py", schemas_content, fixture_path) + + +def test_camel_case_query_parameter_gets_query_alias() -> None: + """A camelCase query parameter must be sanitized to snake_case *and* keep its wire-format name. + + Regression fixture: tests/fixtures/regression/dep_query_alias.json declares an optional + query parameter named `aliasName`. The generated Python parameter must be `alias_name` + (so it satisfies ruff's N803), annotated with `clientele_api.Query(alias="aliasName")` so + the request is still sent with the API's expected name. + """ + spec_path = REPO_ROOT / "tests/fixtures/regression/dep_query_alias.json" + spec = load_fixture_spec(spec_path) + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "generated_client" + generator = APIGenerator( + spec=spec, + asyncio=False, + regen=True, + output_dir=str(output_dir), + url=None, + file=str(spec_path), + ) + generator.generate() + client_content = (output_dir / "client.py").read_text() + + assert ( + 'alias_name: typing.Annotated[typing.Optional[str], clientele_api.Query(alias="aliasName")] = None' + in client_content + ) From 93c2d3fc44fb2039b81917931f3b0ddca37c7580 Mon Sep 17 00:00:00 2001 From: Christian Assing Date: Mon, 20 Jul 2026 11:49:32 +0200 Subject: [PATCH 2/2] Add pageSize fixture param and regenerate test clients to demonstrate Query(alias=...) --- example_openapi_specs/best.json | 9 +++++++++ tests/api_clients/async_test_client/client.py | 6 ++++-- tests/api_clients/test_client/client.py | 6 ++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/example_openapi_specs/best.json b/example_openapi_specs/best.json index 5d6013de..f45b0854 100644 --- a/example_openapi_specs/best.json +++ b/example_openapi_specs/best.json @@ -653,6 +653,15 @@ "title": "Your Input", "type": "string" } + }, + { + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "title": "Page Size", + "type": "integer" + } } ], "responses": { diff --git a/tests/api_clients/async_test_client/client.py b/tests/api_clients/async_test_client/client.py index 2bbfc561..c9a03ce7 100644 --- a/tests/api_clients/async_test_client/client.py +++ b/tests/api_clients/async_test_client/client.py @@ -94,7 +94,9 @@ async def security_required_request_security_required_get( "/simple-query", response_map={200: schemas.SimpleQueryParametersResponse, 422: schemas.HTTPValidationError} ) async def query_request_simple_query_get( - result: schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse, your_input: str + result: schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse, + your_input: typing.Annotated[str, clientele_api.Query(alias="yourInput")], + page_size: typing.Annotated[typing.Optional[int], clientele_api.Query(alias="pageSize")] = None, ) -> schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse: """Query Request @@ -108,7 +110,7 @@ async def query_request_simple_query_get( ) async def query_request_optional_query_get( result: schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse, - your_input: typing.Optional[str] = None, + your_input: typing.Annotated[typing.Optional[str], clientele_api.Query(alias="yourInput")] = None, ) -> schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse: """Optional Query Request diff --git a/tests/api_clients/test_client/client.py b/tests/api_clients/test_client/client.py index 6dd7064d..3ad0fb95 100644 --- a/tests/api_clients/test_client/client.py +++ b/tests/api_clients/test_client/client.py @@ -94,7 +94,9 @@ def security_required_request_security_required_get( "/simple-query", response_map={200: schemas.SimpleQueryParametersResponse, 422: schemas.HTTPValidationError} ) def query_request_simple_query_get( - result: schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse, your_input: str + result: schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse, + your_input: typing.Annotated[str, clientele_api.Query(alias="yourInput")], + page_size: typing.Annotated[typing.Optional[int], clientele_api.Query(alias="pageSize")] = None, ) -> schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse: """Query Request @@ -108,7 +110,7 @@ def query_request_simple_query_get( ) def query_request_optional_query_get( result: schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse, - your_input: typing.Optional[str] = None, + your_input: typing.Annotated[typing.Optional[str], clientele_api.Query(alias="yourInput")] = None, ) -> schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse: """Optional Query Request