From a0b4ac10b72669e47b8f3684b84a07f8d916d8d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:12 +0000 Subject: [PATCH 01/11] Use sys.version_info instead of parsing the Python version string split_ver() parsed platform.python_version() with int(), which raises ValueError on pre-release interpreters (e.g. 3.14.0rc2 or the 3.15-dev build in the CI matrix) and made the package unimportable there. sys.version_info already provides the integer components we need. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/settings.py | 9 ++------- tests/test_settings.py | 18 +++++------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/clientele/settings.py b/clientele/settings.py index 0269011f..d32a7df5 100644 --- a/clientele/settings.py +++ b/clientele/settings.py @@ -1,10 +1,5 @@ -import platform +import sys VERSION = "2.2.2" - -def split_ver(): - return [int(v) for v in platform.python_version().split(".")] - - -PY_VERSION = split_ver() +PY_VERSION = sys.version_info diff --git a/tests/test_settings.py b/tests/test_settings.py index 23446f95..8fd46f66 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -17,16 +17,8 @@ def test_version_format(): assert parts[1].isdigit() -def test_split_ver_returns_list_of_ints(): - """Test that split_ver returns a list of integers.""" - result = settings.split_ver() - assert isinstance(result, list) - assert len(result) >= 2 - assert all(isinstance(v, int) for v in result) - - -def test_py_version_is_list(): - """Test that PY_VERSION is defined and is a list.""" - assert isinstance(settings.PY_VERSION, list) - assert len(settings.PY_VERSION) >= 2 - assert all(isinstance(v, int) for v in settings.PY_VERSION) +def test_py_version_exposes_major_and_minor(): + """Test that PY_VERSION provides integer major/minor components.""" + assert isinstance(settings.PY_VERSION[0], int) + assert isinstance(settings.PY_VERSION[1], int) + assert settings.PY_VERSION.major >= 3 From e43ee403e2950fb22bb503c2973a42cd27a3e560 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:12 +0000 Subject: [PATCH 02/11] Collapse the dead client-generator base class into ClientsGenerator BaseClientsGenerator had exactly one subclass, and the only method that justified the split - its generate_function - rendered templates (get_method.jinja2, post_method.jinja2) that do not exist anywhere in the repository, so it could never run. The base/subclass split also forced the FrameworkHTTPPlaceholder shim, whose only job was to carry a dict between methods of what is logically one class. Everything now lives in a single ClientsGenerator, with the status-code bundle as a plain attribute. Also removed along the way, all verified unused by code or templates: - ParametersResponse.get_path_args_as_string / get_required_path_args_as_string / has_query_args - shared/utils create_query_args and create_query_args_with_mapping (only callers were the dead generate_function) - the new_unions template variable (no template references it) Regenerating the test clients produces byte-identical output. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/generators/api/generator.py | 2 - .../generators/api/generators/clients.py | 249 +++++++++++-- clientele/generators/base_clients.py | 339 ------------------ clientele/generators/shared/utils.py | 23 -- .../test_base_and_basic_coverage.py | 2 - tests/test_additional_coverage.py | 12 - 6 files changed, 222 insertions(+), 405 deletions(-) delete mode 100644 clientele/generators/base_clients.py diff --git a/clientele/generators/api/generator.py b/clientele/generators/api/generator.py index 34d197b6..5d05dfc5 100644 --- a/clientele/generators/api/generator.py +++ b/clientele/generators/api/generator.py @@ -60,7 +60,6 @@ def __init__( ) 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 @@ -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) diff --git a/clientele/generators/api/generators/clients.py b/clientele/generators/api/generators/clients.py index 413cbf41..bd4498e4 100644 --- a/clientele/generators/api/generators/clients.py +++ b/clientele/generators/api/generators/clients.py @@ -1,35 +1,62 @@ +"""Generates the client.py file for the api generator.""" + +import collections import typing +import pydantic from cicerone.spec import openapi_spec as cicerone_openapi_spec +from rich import console as rich_console -from clientele.generators import base_clients +from clientele.generators import cicerone_compat from clientele.generators.api import writer from clientele.generators.api.generators import schemas +from clientele.generators.shared import utils +console = rich_console.Console() -class FrameworkHTTPPlaceholder: - """ - Placeholder for http_generator to store status codes. - Framework generator doesn't generate http.py but needs to track status codes - for response_map generation. - """ - def __init__(self) -> None: - self.function_and_status_codes_bundle: dict[str, dict[str, str]] = {} +class ParametersResponse(pydantic.BaseModel): + # Parameters that need to be passed in the URL query + query_args: dict[str, str] + # Parameters that need to be passed as variables in the function + path_args: dict[str, str] + # Parameters that are needed in the headers object + headers_args: dict[str, str] + # Mapping from sanitized Python name to original API parameter name + param_name_map: dict[str, str] = {} - def add_status_codes_to_bundle(self, func_name: str, status_code_map: dict[str, str]) -> None: - """Store status codes for generating response_map.""" - self.function_and_status_codes_bundle[func_name] = status_code_map + 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()) + required_args = [] + for k, v in args: + if not v.startswith("typing.Optional["): + required_args.append(f"{k}: {v}") + return ", ".join(required_args) if required_args else "" + def get_optional_args_as_string(self) -> str: + """Get only optional parameters (those with Optional wrapper).""" + 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") + return ", ".join(optional_args) if optional_args else "" -class ClientsGenerator(base_clients.BaseClientsGenerator): + +class ClientsGenerator: """ 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: schemas.SchemasGenerator - http_placeholder: FrameworkHTTPPlaceholder + # Status codes per generated function, used to build response_map arguments + function_and_status_codes_bundle: dict[str, dict[str, str]] def __init__( self, @@ -38,12 +65,13 @@ def __init__( schemas_generator: schemas.SchemasGenerator, asyncio: bool, ) -> None: - # Create placeholder to track status codes - self.http_placeholder = FrameworkHTTPPlaceholder() - # Pass placeholder as http_generator to base class - super().__init__(spec, output_dir, schemas_generator, self.http_placeholder, asyncio) + self.spec = spec + self.output_dir = output_dir + self.results = collections.defaultdict(int) + self.schemas_generator = schemas_generator + self.asyncio = asyncio + self.function_and_status_codes_bundle = {} self.writer = writer - # Override template map for clientele-specific templates self.method_template_map = dict( get="api_get_method.jinja2", delete="api_get_method.jinja2", @@ -52,12 +80,174 @@ def __init__( patch="api_post_method.jinja2", ) + def generate_paths(self) -> None: + # Check if the spec has paths + if not self.spec.paths or not self.spec.paths.items: + console.log("No paths found in spec, skipping client generation...") + return + + for path, path_item in self.spec.paths.items.items(): + # Convert path_item to operations dict using centralized compat layer + operations_dict = cicerone_compat.path_item_to_operations_dict(path_item) + self.write_path_to_client((path, operations_dict)) + console.log(f"Generated {self.results['get']} GET methods...") + console.log(f"Generated {self.results['post']} POST methods...") + console.log(f"Generated {self.results['put']} PUT methods...") + console.log(f"Generated {self.results['patch']} PATCH methods...") + console.log(f"Generated {self.results['delete']} DELETE methods...") + + def generate_parameters(self, parameters: list[dict], additional_parameters: list[dict]) -> ParametersResponse: + param_keys = [] + query_args = {} + path_args = {} + headers_args = {} + param_name_map = {} # Maps sanitized name to original name + all_parameters = parameters + additional_parameters + for param in all_parameters: + if param.get("$ref"): + # Get the actual parameter it is referencing + param = utils.get_param_from_ref(spec=self.spec, param=param) + # Sanitize the parameter name to be a valid Python identifier + original_name = param["name"] + clean_key = utils.snake_case_prop(original_name) + if clean_key in param_keys: + continue + # Store mapping from sanitized to original name + param_name_map[clean_key] = original_name + in_ = param.get("in") + required = param.get("required", False) or in_ != "query" + 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}]" + elif in_ == "path": + # Function arguments + param_type = utils.resolve_forward_refs_for_client(utils.get_type(param["schema"])) + if required: + path_args[clean_key] = param_type + else: + param_type = utils.strip_none_from_type(param_type) + path_args[clean_key] = f"typing.Optional[{param_type}]" + elif in_ == "header": + # Header object arguments + headers_args[param["name"]] = utils.get_type(param["schema"]) + param_keys.append(clean_key) + return ParametersResponse( + query_args=query_args, + path_args=path_args, + headers_args=headers_args, + param_name_map=param_name_map, + ) + + def get_response_class_names(self, responses: dict, func_name: str) -> list[str]: + """ + Generates a list of response class for this operation. + For each response found, also generate the schema by calling + the schema generator. + Returns a list of names of the classes generated. + """ + status_code_map: dict[str, str] = {} + response_classes = [] + for status_code, details in responses.items(): + # Handle responses without content (e.g., 204 No Content) + if "content" not in details or not details.get("content"): + # For no-content responses, use None as the response type + status_code_map[status_code] = "None" + # Don't add None to response_classes list as it's not a schema class + continue + + for _, content in details.get("content", {}).items(): + # Skip if no schema is defined (e.g., only examples) + if "schema" not in content: + console.log(f"[yellow]Warning: Response {status_code} has no schema, using typing.Any") + class_name = utils.class_name_titled(func_name + status_code + "Response") + # Generate a minimal schema with Any type + self.schemas_generator.make_schema_class( + func_name + status_code + "Response", + schema={"type": "object", "properties": {"data": {"type": "object"}}}, + ) + status_code_map[status_code] = class_name + response_classes.append(class_name) + continue + + class_name = "" + if ref := content["schema"].get("$ref", False): + # An object reference, so should be generated + # by the schema generator later. + class_name = utils.class_name_titled(utils.schema_ref(ref)) + elif title := content["schema"].get("title", False): + # This usually means we have an object that isn't + # $ref so we need to create the schema class here + class_name = utils.class_name_titled(title) + self.schemas_generator.make_schema_class(class_name, schema=content["schema"]) + else: + # At this point we're just making things up! + # It is likely it isn't an object it is just a simple response. + class_name = utils.class_name_titled(func_name + status_code + "Response") + # We need to generate the class at this point because it does not exist + # Pass the schema directly - make_schema_class knows how to handle arrays + self.schemas_generator.make_schema_class( + func_name + status_code + "Response", + schema=content["schema"], + ) + status_code_map[status_code] = class_name + response_classes.append(class_name) + 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)) + + def get_input_class_names(self, inputs: dict, func_name: str) -> list[str]: + """ + Generates a list of input class for this operation. + """ + input_classes = [] + for _, details in inputs.items(): + for encoding, content in details.get("content", {}).items(): + class_name = "" + if ref := content["schema"].get("$ref", False): + class_name = utils.class_name_titled(utils.schema_ref(ref)) + elif title := content["schema"].get("title", False): + class_name = title + else: + # Use function name + encoding to create unique class name + class_name = f"{func_name}_{encoding}" + class_name = utils.class_name_titled(class_name) + input_classes.append(class_name) + # Return deduplicated list - order doesn't matter for this use case + return list(set(input_classes)) + + def generate_response_types(self, responses: dict, func_name: str) -> str: + response_class_names = self.get_response_class_names(responses=responses, func_name=func_name) + if len(response_class_names) > 1: + return utils.union_for_py_ver([f"schemas.{r}" for r in response_class_names]) + elif len(response_class_names) == 0: + return "None" + else: + return f"schemas.{response_class_names[0]}" + + def generate_input_types(self, request_body: dict, func_name: str) -> str: + input_class_names = self.get_input_class_names(inputs={"": request_body}, func_name=func_name) + for input_class in input_class_names: + if input_class not in self.schemas_generator.schemas.keys(): + # It doesn't exist! Generate the schema for it + self.schemas_generator.generate_input_class(schema=request_body, func_name=func_name) + if len(input_class_names) > 1: + return utils.union_for_py_ver([f"schemas.{r}" for r in input_class_names]) + elif len(input_class_names) == 0: + return "None" + 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.http_placeholder.function_and_status_codes_bundle.get(func_name, {}) + 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: @@ -85,13 +275,6 @@ def generate_function( additional_parameters: list[dict], summary: typing.Optional[str], ) -> None: - """Override to add response_map to template context and fix URL handling for clientele api.""" - from rich import console as rich_console - - from clientele.generators.shared import utils - - console = rich_console.Console() - func_name = utils.get_func_name(operation, url) # Handle missing responses (OpenAPI spec violation, but handle gracefully) @@ -144,3 +327,15 @@ def generate_function( response_map=response_map, ) self.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(): + self.generate_function( + operation=operation, + method=method, + url=url, + additional_parameters=operations.get("parameters", []), + summary=operations.get("summary", None), + ) diff --git a/clientele/generators/base_clients.py b/clientele/generators/base_clients.py deleted file mode 100644 index d4dda2a9..00000000 --- a/clientele/generators/base_clients.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Base class for client generators shared by api and basic generators.""" - -import collections -import typing - -import pydantic -from cicerone.spec import openapi_spec as cicerone_openapi_spec -from rich import console as rich_console - -from clientele.generators import cicerone_compat -from clientele.generators.shared import utils - -console = rich_console.Console() - - -class ParametersResponse(pydantic.BaseModel): - # Parameters that need to be passed in the URL query - query_args: dict[str, str] - # Parameters that need to be passed as variables in the function - path_args: dict[str, str] - # Parameters that are needed in the headers object - headers_args: dict[str, str] - # 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()) - required_args = [] - for k, v in args: - if not v.startswith("typing.Optional["): - required_args.append(f"{k}: {v}") - return ", ".join(required_args) if required_args else "" - - def get_optional_args_as_string(self) -> str: - """Get only optional parameters (those with Optional wrapper).""" - 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") - 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: - """ - Base class for generating client code from OpenAPI specifications. - Provides common functionality for client generators. - """ - - 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 - - def __init__( - self, - spec: cicerone_openapi_spec.OpenAPISpec, - output_dir: str, - schemas_generator: typing.Any, - http_generator: typing.Any, - 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", - ) - - def generate_paths(self) -> None: - # Check if the spec has paths - if not self.spec.paths or not self.spec.paths.items: - console.log("No paths found in spec, skipping client generation...") - return - - for path, path_item in self.spec.paths.items.items(): - # Convert path_item to operations dict using centralized compat layer - operations_dict = cicerone_compat.path_item_to_operations_dict(path_item) - self.write_path_to_client((path, operations_dict)) - console.log(f"Generated {self.results['get']} GET methods...") - console.log(f"Generated {self.results['post']} POST methods...") - console.log(f"Generated {self.results['put']} PUT methods...") - console.log(f"Generated {self.results['patch']} PATCH methods...") - console.log(f"Generated {self.results['delete']} DELETE methods...") - - def generate_parameters(self, parameters: list[dict], additional_parameters: list[dict]) -> ParametersResponse: - param_keys = [] - query_args = {} - path_args = {} - headers_args = {} - param_name_map = {} # Maps sanitized name to original name - all_parameters = parameters + additional_parameters - for param in all_parameters: - if param.get("$ref"): - # Get the actual parameter it is referencing - param = utils.get_param_from_ref(spec=self.spec, param=param) - # Sanitize the parameter name to be a valid Python identifier - original_name = param["name"] - clean_key = utils.snake_case_prop(original_name) - if clean_key in param_keys: - continue - # Store mapping from sanitized to original name - param_name_map[clean_key] = original_name - in_ = param.get("in") - required = param.get("required", False) or in_ != "query" - 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}]" - elif in_ == "path": - # Function arguments - param_type = utils.resolve_forward_refs_for_client(utils.get_type(param["schema"])) - if required: - path_args[clean_key] = param_type - else: - param_type = utils.strip_none_from_type(param_type) - path_args[clean_key] = f"typing.Optional[{param_type}]" - elif in_ == "header": - # Header object arguments - headers_args[param["name"]] = utils.get_type(param["schema"]) - param_keys.append(clean_key) - return ParametersResponse( - query_args=query_args, - path_args=path_args, - headers_args=headers_args, - param_name_map=param_name_map, - ) - - def get_response_class_names(self, responses: dict, func_name: str) -> list[str]: - """ - Generates a list of response class for this operation. - For each response found, also generate the schema by calling - the schema generator. - Returns a list of names of the classes generated. - """ - status_code_map: dict[str, str] = {} - response_classes = [] - for status_code, details in responses.items(): - # Handle responses without content (e.g., 204 No Content) - if "content" not in details or not details.get("content"): - # For no-content responses, use None as the response type - status_code_map[status_code] = "None" - # Don't add None to response_classes list as it's not a schema class - continue - - for _, content in details.get("content", {}).items(): - # Skip if no schema is defined (e.g., only examples) - if "schema" not in content: - console.log(f"[yellow]Warning: Response {status_code} has no schema, using typing.Any") - class_name = utils.class_name_titled(func_name + status_code + "Response") - # Generate a minimal schema with Any type - self.schemas_generator.make_schema_class( - func_name + status_code + "Response", - schema={"type": "object", "properties": {"data": {"type": "object"}}}, - ) - status_code_map[status_code] = class_name - response_classes.append(class_name) - continue - - class_name = "" - if ref := content["schema"].get("$ref", False): - # An object reference, so should be generated - # by the schema generator later. - class_name = utils.class_name_titled(utils.schema_ref(ref)) - elif title := content["schema"].get("title", False): - # This usually means we have an object that isn't - # $ref so we need to create the schema class here - class_name = utils.class_name_titled(title) - self.schemas_generator.make_schema_class(class_name, schema=content["schema"]) - else: - # At this point we're just making things up! - # It is likely it isn't an object it is just a simple response. - class_name = utils.class_name_titled(func_name + status_code + "Response") - # We need to generate the class at this point because it does not exist - # Pass the schema directly - make_schema_class knows how to handle arrays - self.schemas_generator.make_schema_class( - func_name + status_code + "Response", - schema=content["schema"], - ) - 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) - # Use set to deduplicate, then sorted for consistent ordering - return sorted(set(response_classes)) - - def get_input_class_names(self, inputs: dict, func_name: str) -> list[str]: - """ - Generates a list of input class for this operation. - """ - input_classes = [] - for _, details in inputs.items(): - for encoding, content in details.get("content", {}).items(): - class_name = "" - if ref := content["schema"].get("$ref", False): - class_name = utils.class_name_titled(utils.schema_ref(ref)) - elif title := content["schema"].get("title", False): - class_name = title - else: - # Use function name + encoding to create unique class name - class_name = f"{func_name}_{encoding}" - class_name = utils.class_name_titled(class_name) - input_classes.append(class_name) - # Return deduplicated list - order doesn't matter for this use case - return list(set(input_classes)) - - def generate_response_types(self, responses: dict, func_name: str) -> str: - response_class_names = self.get_response_class_names(responses=responses, func_name=func_name) - if len(response_class_names) > 1: - return utils.union_for_py_ver([f"schemas.{r}" for r in response_class_names]) - elif len(response_class_names) == 0: - return "None" - else: - return f"schemas.{response_class_names[0]}" - - def generate_input_types(self, request_body: dict, func_name: str) -> str: - input_class_names = self.get_input_class_names(inputs={"": request_body}, func_name=func_name) - for input_class in input_class_names: - if input_class not in self.schemas_generator.schemas.keys(): - # It doesn't exist! Generate the schema for it - self.schemas_generator.generate_input_class(schema=request_body, func_name=func_name) - if len(input_class_names) > 1: - return utils.union_for_py_ver([f"schemas.{r}" for r in input_class_names]) - elif len(input_class_names) == 0: - return "None" - else: - return f"schemas.{input_class_names[0]}" - - def generate_function( - self, - operation: dict, - method: str, - url: str, - additional_parameters: list[dict], - summary: typing.Optional[str], - ) -> None: - func_name = utils.get_func_name(operation, url) - - # Handle missing responses (OpenAPI spec violation, but handle gracefully) - if "responses" not in operation: - console.log(f"[yellow]Warning: Operation {func_name} has no responses defined, using default 200 response") - responses = {"200": {"description": "Success"}} - else: - responses = operation["responses"] - - response_types = self.generate_response_types(responses=responses, func_name=func_name) - function_arguments = self.generate_parameters( - parameters=operation.get("parameters", []), - additional_parameters=additional_parameters, - ) - # Replace path parameters in URL with sanitized names - 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]) - 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 - content = template.render( - asyncio=self.asyncio, - func_name=func_name, - function_arguments=function_arguments, # Pass the object, not the string - response_types=response_types, - data_class_name=data_class_name, - header_class_name=header_class_name, - api_url=api_url, - method=method, - summary=operation.get("summary", summary), - description=operation.get("description"), - deprecated=operation.get("deprecated", False), - ) - self.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(): - self.generate_function( - operation=operation, - method=method, - url=url, - additional_parameters=operations.get("parameters", []), - summary=operations.get("summary", None), - ) diff --git a/clientele/generators/shared/utils.py b/clientele/generators/shared/utils.py index ca2abe76..eeb4f3e5 100644 --- a/clientele/generators/shared/utils.py +++ b/clientele/generators/shared/utils.py @@ -175,29 +175,6 @@ def get_type(t): return base_type if base_type else "typing.Any" -def create_query_args(query_args: list[str]) -> str: - return "?" + "&".join([f"{p}=" + "{" + p + "}" for p in query_args]) - - -def create_query_args_with_mapping(sanitized_names: list[str], param_name_map: dict[str, str]) -> str: - """ - Create query string using original API parameter names in the URL, - but sanitized Python variable names in the f-string interpolation. - - Args: - sanitized_names: List of sanitized Python parameter names - param_name_map: Mapping from sanitized names to original API names - - Returns: - Query string like "?originalName={sanitized_name}&other={other_var}" - """ - parts = [] - for sanitized in sanitized_names: - original = param_name_map.get(sanitized, sanitized) - parts.append(f"{original}=" + "{" + sanitized + "}") - return "?" + "&".join(parts) - - def replace_path_parameters(url: str, param_name_map: dict[str, str]) -> str: """ Replace path parameters in a URL with their sanitized Python variable names. diff --git a/tests/generators/test_base_and_basic_coverage.py b/tests/generators/test_base_and_basic_coverage.py index c945e22f..05d07e01 100644 --- a/tests/generators/test_base_and_basic_coverage.py +++ b/tests/generators/test_base_and_basic_coverage.py @@ -58,8 +58,6 @@ def test_clients_generator_handles_optional_path_parameters(): schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) - api_clients.FrameworkHTTPPlaceholder() - generator = api_clients.ClientsGenerator( spec=spec, output_dir=str(output_dir), diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py index 3dc8f18e..a04d8b74 100644 --- a/tests/test_additional_coverage.py +++ b/tests/test_additional_coverage.py @@ -9,18 +9,6 @@ from tests.generators.integration_utils import load_spec -def test_utils_create_query_args(): - """Test create_query_args function.""" - query_args = ["param1", "param2", "param3"] - result = utils.create_query_args(query_args) - - assert result.startswith("?") - assert "param1=" in result - assert "param2=" in result - assert "param3=" in result - assert "&" in result - - def test_utils_get_type_with_allof_single_schema(): """Test get_type with allOf containing a single schema.""" type_spec = {"allOf": [{"type": "string"}]} From fa379ce0d84116dabb94e13358fd97088ff03d7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:13 +0000 Subject: [PATCH 03/11] Flatten the generators package layout The package nested a second generators/ directory inside both the api and shared packages (clientele/generators/api/generators/, clientele/generators/shared/generators/), and api/generators/schemas.py was a four-line re-export of the shared SchemasGenerator. The extra level added indirection without grouping anything - each inner directory held a single real module. Now: - clientele/generators/api/clients.py (was api/generators/clients.py) - clientele/generators/shared/schemas.py (was shared/generators/schemas.py) - the re-export module is gone; callers import the shared SchemasGenerator directly https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/generators/api/{generators => }/clients.py | 3 +-- clientele/generators/api/generator.py | 4 ++-- clientele/generators/api/generators/__init__.py | 1 - clientele/generators/api/generators/schemas.py | 3 --- clientele/generators/shared/generators/__init__.py | 0 clientele/generators/shared/{generators => }/schemas.py | 0 tests/generators/test_base_and_basic_coverage.py | 8 ++++---- tests/generators/test_schemas_coverage.py | 2 +- tests/test_additional_coverage.py | 2 +- tests/test_edge_case_coverage.py | 2 +- tests/test_generator_coverage.py | 2 +- 11 files changed, 11 insertions(+), 16 deletions(-) rename clientele/generators/api/{generators => }/clients.py (99%) delete mode 100644 clientele/generators/api/generators/__init__.py delete mode 100644 clientele/generators/api/generators/schemas.py delete mode 100644 clientele/generators/shared/generators/__init__.py rename clientele/generators/shared/{generators => }/schemas.py (100%) diff --git a/clientele/generators/api/generators/clients.py b/clientele/generators/api/clients.py similarity index 99% rename from clientele/generators/api/generators/clients.py rename to clientele/generators/api/clients.py index bd4498e4..c450b853 100644 --- a/clientele/generators/api/generators/clients.py +++ b/clientele/generators/api/clients.py @@ -9,8 +9,7 @@ from clientele.generators import cicerone_compat from clientele.generators.api import writer -from clientele.generators.api.generators import schemas -from clientele.generators.shared import utils +from clientele.generators.shared import schemas, utils console = rich_console.Console() diff --git a/clientele/generators/api/generator.py b/clientele/generators/api/generator.py index 5d05dfc5..f90c620d 100644 --- a/clientele/generators/api/generator.py +++ b/clientele/generators/api/generator.py @@ -8,8 +8,8 @@ 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.generators.api import clients, writer +from clientele.generators.shared import schemas console = rich_console.Console() diff --git a/clientele/generators/api/generators/__init__.py b/clientele/generators/api/generators/__init__.py deleted file mode 100644 index 29401b71..00000000 --- a/clientele/generators/api/generators/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Framework generator components diff --git a/clientele/generators/api/generators/schemas.py b/clientele/generators/api/generators/schemas.py deleted file mode 100644 index 1d95adcc..00000000 --- a/clientele/generators/api/generators/schemas.py +++ /dev/null @@ -1,3 +0,0 @@ -from clientele.generators.shared.generators.schemas import SchemasGenerator - -__all__ = ["SchemasGenerator"] diff --git a/clientele/generators/shared/generators/__init__.py b/clientele/generators/shared/generators/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/clientele/generators/shared/generators/schemas.py b/clientele/generators/shared/schemas.py similarity index 100% rename from clientele/generators/shared/generators/schemas.py rename to clientele/generators/shared/schemas.py diff --git a/tests/generators/test_base_and_basic_coverage.py b/tests/generators/test_base_and_basic_coverage.py index 05d07e01..fe68dbfc 100644 --- a/tests/generators/test_base_and_basic_coverage.py +++ b/tests/generators/test_base_and_basic_coverage.py @@ -4,7 +4,7 @@ from pathlib import Path from unittest.mock import patch -from clientele.generators.api.generators import clients as api_clients +from clientele.generators.api import clients as api_clients from clientele.generators.basic.generator import BasicGenerator from tests.generators.integration_utils import load_spec @@ -54,7 +54,7 @@ def test_clients_generator_handles_optional_path_parameters(): output_dir.mkdir(parents=True) from clientele.generators.api import writer as api_writer - from clientele.generators.shared.generators.schemas import SchemasGenerator + from clientele.generators.shared.schemas import SchemasGenerator schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) @@ -89,7 +89,7 @@ def test_clients_generator_resolves_schema_refs_in_parameters(): output_dir.mkdir(parents=True) from clientele.generators.api import writer as api_writer - from clientele.generators.shared.generators.schemas import SchemasGenerator + from clientele.generators.shared.schemas import SchemasGenerator schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) @@ -131,7 +131,7 @@ def test_clients_generator_handles_multiple_input_classes(): output_dir.mkdir(parents=True) from clientele.generators.api import writer as api_writer - from clientele.generators.shared.generators.schemas import SchemasGenerator + from clientele.generators.shared.schemas import SchemasGenerator schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) diff --git a/tests/generators/test_schemas_coverage.py b/tests/generators/test_schemas_coverage.py index 39636f20..1148c651 100644 --- a/tests/generators/test_schemas_coverage.py +++ b/tests/generators/test_schemas_coverage.py @@ -4,7 +4,7 @@ from pathlib import Path from clientele.generators.api import writer as api_writer -from clientele.generators.shared.generators.schemas import SchemasGenerator +from clientele.generators.shared.schemas import SchemasGenerator from tests.generators.integration_utils import load_spec diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py index a04d8b74..6affa85f 100644 --- a/tests/test_additional_coverage.py +++ b/tests/test_additional_coverage.py @@ -5,7 +5,7 @@ from clientele.generators.api import writer as api_writer from clientele.generators.shared import utils -from clientele.generators.shared.generators.schemas import SchemasGenerator +from clientele.generators.shared.schemas import SchemasGenerator from tests.generators.integration_utils import load_spec diff --git a/tests/test_edge_case_coverage.py b/tests/test_edge_case_coverage.py index 16e4d3df..1989a8db 100644 --- a/tests/test_edge_case_coverage.py +++ b/tests/test_edge_case_coverage.py @@ -44,7 +44,7 @@ def test_load_openapi_spec_no_normalization_needed(openapi_30_spec): def test_schemas_generator_no_schemas_branch(): """Test schemas generator when components has no schemas attribute.""" from clientele.generators.api import writer as api_writer - from clientele.generators.shared.generators.schemas import SchemasGenerator + from clientele.generators.shared.schemas import SchemasGenerator spec_dict = { "openapi": "3.0.0", diff --git a/tests/test_generator_coverage.py b/tests/test_generator_coverage.py index 8ced8dd7..60911234 100644 --- a/tests/test_generator_coverage.py +++ b/tests/test_generator_coverage.py @@ -9,7 +9,7 @@ from clientele.generators.api import writer as api_writer from clientele.generators.api.generator import APIGenerator from clientele.generators.cicerone_compat import normalize_openapi_31_schema, normalize_openapi_31_spec -from clientele.generators.shared.generators.schemas import SchemasGenerator +from clientele.generators.shared.schemas import SchemasGenerator def test_normalize_openapi_31_schema_only_null_type(): From c89778e3d40ffa174eabf53eed3315e97219b612 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:13 +0000 Subject: [PATCH 04/11] Fold single-function modules into the shared generator utilities Two modules each held one function with a single caller: - clientele/utils.py only contained get_client_project_directory_path, which is used exclusively by the generators at code-generation time. It now lives in clientele/generators/shared/utils.py with the rest of the generator helpers, so the top-level package no longer has a utils.py whose contents are unrelated to the runtime library. - clientele/generators/schema_utils.py only contained build_union_type_string, called once from the shared SchemasGenerator. It is now defined in clientele/generators/shared/schemas.py next to its caller. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/generators/api/generator.py | 4 +-- clientele/generators/basic/generator.py | 3 +- clientele/generators/schema_utils.py | 42 ------------------------- clientele/generators/shared/schemas.py | 41 ++++++++++++++++++++++-- clientele/generators/shared/utils.py | 38 ++++++++++++++++++++++ clientele/utils.py | 38 ---------------------- tests/test_utils.py | 2 +- 7 files changed, 82 insertions(+), 86 deletions(-) delete mode 100644 clientele/generators/schema_utils.py delete mode 100644 clientele/utils.py diff --git a/clientele/generators/api/generator.py b/clientele/generators/api/generator.py index f90c620d..dd8253fb 100644 --- a/clientele/generators/api/generator.py +++ b/clientele/generators/api/generator.py @@ -7,9 +7,9 @@ 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 import generators, settings from clientele.generators.api import clients, writer -from clientele.generators.shared import schemas +from clientele.generators.shared import schemas, utils console = rich_console.Console() diff --git a/clientele/generators/basic/generator.py b/clientele/generators/basic/generator.py index fe37fe6d..ba2247b8 100644 --- a/clientele/generators/basic/generator.py +++ b/clientele/generators/basic/generator.py @@ -1,8 +1,9 @@ import os import pathlib -from clientele import generators, settings, utils +from clientele import generators, settings from clientele.generators.basic import writer +from clientele.generators.shared import utils class BasicGenerator(generators.Generator): diff --git a/clientele/generators/schema_utils.py b/clientele/generators/schema_utils.py deleted file mode 100644 index 450b1536..00000000 --- a/clientele/generators/schema_utils.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shared utilities for schema generation across different generators.""" - -import typing - -from clientele.generators.shared import utils - - -def build_union_type_string( - schema_options: list[typing.Dict[str, typing.Any]], - discriminator: typing.Optional[str] = None, -) -> str: - """ - Build a union type string from a list of schema options (for oneOf or anyOf). - - Args: - schema_options: List of schema option dictionaries from oneOf or anyOf - discriminator: Optional discriminator property name for discriminated unions - - Returns: - A union type string (e.g., "Cat | Dog" or "typing.Union[Cat, Dog]") - When discriminator is provided, returns: - "typing.Annotated[Cat | Dog, pydantic.Field(discriminator='type')]" - """ - union_types = [] - for schema_option in schema_options: - if ref := schema_option.get("$ref"): - ref_name = utils.class_name_titled(utils.schema_ref(ref)) - # Use direct reference without quotes for type aliases - union_types.append(ref_name) - else: - # Inline schema - convert to type and remove forward ref quotes - type_str = utils.get_type(schema_option) - type_str = utils.remove_forward_ref_quotes(type_str) - union_types.append(type_str) - - union_str = utils.union_for_py_ver(union_types) - - # Wrap with Annotated + Field(discriminator=...) if discriminator is present - if discriminator: - return f'typing.Annotated[{union_str}, pydantic.Field(discriminator="{discriminator}")]' - - return union_str diff --git a/clientele/generators/shared/schemas.py b/clientele/generators/shared/schemas.py index d8dcc389..7438f0f3 100644 --- a/clientele/generators/shared/schemas.py +++ b/clientele/generators/shared/schemas.py @@ -3,12 +3,49 @@ from cicerone.spec import openapi_spec as cicerone_openapi_spec from rich import console as rich_console -from clientele.generators import cicerone_compat, schema_utils +from clientele.generators import cicerone_compat from clientele.generators.shared import utils console = rich_console.Console() +def build_union_type_string( + schema_options: list[typing.Dict[str, typing.Any]], + discriminator: typing.Optional[str] = None, +) -> str: + """ + Build a union type string from a list of schema options (for oneOf or anyOf). + + Args: + schema_options: List of schema option dictionaries from oneOf or anyOf + discriminator: Optional discriminator property name for discriminated unions + + Returns: + A union type string (e.g., "Cat | Dog" or "typing.Union[Cat, Dog]") + When discriminator is provided, returns: + "typing.Annotated[Cat | Dog, pydantic.Field(discriminator='type')]" + """ + union_types = [] + for schema_option in schema_options: + if ref := schema_option.get("$ref"): + ref_name = utils.class_name_titled(utils.schema_ref(ref)) + # Use direct reference without quotes for type aliases + union_types.append(ref_name) + else: + # Inline schema - convert to type and remove forward ref quotes + type_str = utils.get_type(schema_option) + type_str = utils.remove_forward_ref_quotes(type_str) + union_types.append(type_str) + + union_str = utils.union_for_py_ver(union_types) + + # Wrap with Annotated + Field(discriminator=...) if discriminator is present + if discriminator: + return f'typing.Annotated[{union_str}, pydantic.Field(discriminator="{discriminator}")]' + + return union_str + + class SchemasGenerator: """ Handles all the content generated in the schemas.py file. @@ -112,7 +149,7 @@ def _create_union_type_alias( schema_options: list[dict], discriminator: typing.Optional[str] = None, ) -> None: - union_type = schema_utils.build_union_type_string(schema_options, discriminator=discriminator) + union_type = build_union_type_string(schema_options, discriminator=discriminator) template = self.writer.templates.get_template("schema_type_alias.jinja2") content = template.render(class_name=schema_key, union_type=union_type) self.writer.write_to_schemas(content, output_dir=self.output_dir) diff --git a/clientele/generators/shared/utils.py b/clientele/generators/shared/utils.py index eeb4f3e5..a9c9fb44 100644 --- a/clientele/generators/shared/utils.py +++ b/clientele/generators/shared/utils.py @@ -1,5 +1,6 @@ import functools import keyword +import os import re from cicerone.spec import openapi_spec as cicerone_openapi_spec @@ -11,6 +12,43 @@ RESERVED_WORDS = frozenset(list(keyword.kwlist) + list(keyword.softkwlist)) +def get_client_project_directory_path(output_dir: str) -> str: + """ + Returns a dot-notation path for the client directory. + Assumes that the `clientele` command is being run in the + project root directory. + + For absolute paths, returns an empty string (which will result in + relative imports being used). + """ + # If it's an absolute path, return empty string for relative imports + if os.path.isabs(output_dir): + return "" + + # Check if path has trailing slash + has_trailing_slash = output_dir.endswith(os.sep) + + # Split the path by separator + parts = output_dir.split(os.sep) + + # Filter out empty parts + parts = [p for p in parts if p] + + # If no trailing slash, remove the last component (the directory name) + # If trailing slash, include all components + if not has_trailing_slash and len(parts) > 1: + parts = parts[:-1] + elif not has_trailing_slash and len(parts) == 1: + # Single directory with no trailing slash means no parent + return "" + + # Return the path as a Python module path + if parts: + return ".".join(parts) + + return "" + + class DataType: INTEGER = "integer" NUMBER = "number" diff --git a/clientele/utils.py b/clientele/utils.py deleted file mode 100644 index 9b6bfa43..00000000 --- a/clientele/utils.py +++ /dev/null @@ -1,38 +0,0 @@ -import os - - -def get_client_project_directory_path(output_dir: str) -> str: - """ - Returns a dot-notation path for the client directory. - Assumes that the `clientele` command is being run in the - project root directory. - - For absolute paths, returns an empty string (which will result in - relative imports being used). - """ - # If it's an absolute path, return empty string for relative imports - if os.path.isabs(output_dir): - return "" - - # Check if path has trailing slash - has_trailing_slash = output_dir.endswith(os.sep) - - # Split the path by separator - parts = output_dir.split(os.sep) - - # Filter out empty parts - parts = [p for p in parts if p] - - # If no trailing slash, remove the last component (the directory name) - # If trailing slash, include all components - if not has_trailing_slash and len(parts) > 1: - parts = parts[:-1] - elif not has_trailing_slash and len(parts) == 1: - # Single directory with no trailing slash means no parent - return "" - - # Return the path as a Python module path - if parts: - return ".".join(parts) - - return "" diff --git a/tests/test_utils.py b/tests/test_utils.py index f35558d9..51474927 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ """Tests for utility functions.""" -from clientele.utils import get_client_project_directory_path +from clientele.generators.shared.utils import get_client_project_directory_path def test_get_client_project_directory_path(): From 4fa712fc8f287e2c46bf295ae8e948b126545990 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:13 +0000 Subject: [PATCH 05/11] Rename clientele.api.requests to clientele.api.request_context A module named requests inside an HTTP client library reads as if it wraps the requests package (which the project also supports as a backend via http/requests_backend.py). It actually defines RequestContext, PreparedCall and the validation that builds them, and it is only imported internally, so the rename cannot break users. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/api/client.py | 20 +++--- .../api/{requests.py => request_context.py} | 0 clientele/graphql/client.py | 10 +-- ...st_requests.py => test_request_context.py} | 68 ++++++++++--------- tests/api/test_stream.py | 6 +- 5 files changed, 54 insertions(+), 50 deletions(-) rename clientele/api/{requests.py => request_context.py} (100%) rename tests/api/{test_requests.py => test_request_context.py} (82%) diff --git a/clientele/api/client.py b/clientele/api/client.py index b1823735..b603c0da 100644 --- a/clientele/api/client.py +++ b/clientele/api/client.py @@ -11,7 +11,7 @@ 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 @@ -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, @@ -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. @@ -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, @@ -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( @@ -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( @@ -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. @@ -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. @@ -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( diff --git a/clientele/api/requests.py b/clientele/api/request_context.py similarity index 100% rename from clientele/api/requests.py rename to clientele/api/request_context.py diff --git a/clientele/graphql/client.py b/clientele/graphql/client.py index a37f9ad9..c3ae5a4d 100644 --- a/clientele/graphql/client.py +++ b/clientele/graphql/client.py @@ -5,7 +5,7 @@ import inspect import typing -from clientele.api import APIClient, requests +from clientele.api import APIClient, request_context _P = typing.ParamSpec("_P") _R = typing.TypeVar("_R") @@ -85,7 +85,7 @@ def _create_graphql_decorator( """ def decorator(func: typing.Any) -> typing.Any: - context = requests.build_request_context( + context = request_context.build_request_context( method=method, path="", # GraphQL uses a single endpoint func=func, @@ -112,7 +112,7 @@ def wrapper(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: def _execute_graphql_sync( self, - context: requests.RequestContext, + context: request_context.RequestContext, graphql_string: str, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], @@ -153,7 +153,7 @@ def _execute_graphql_sync( async def _execute_graphql_async( self, - context: requests.RequestContext, + context: request_context.RequestContext, graphql_string: str, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], @@ -194,7 +194,7 @@ async def _execute_graphql_async( def _extract_variables( self, - context: requests.RequestContext, + context: request_context.RequestContext, args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any], ) -> dict[str, typing.Any]: diff --git a/tests/api/test_requests.py b/tests/api/test_request_context.py similarity index 82% rename from tests/api/test_requests.py rename to tests/api/test_request_context.py index 8191143e..b3fc0dcc 100644 --- a/tests/api/test_requests.py +++ b/tests/api/test_request_context.py @@ -1,4 +1,4 @@ -"""Tests for clientele.api.requests module.""" +"""Tests for clientele.api.request_context module.""" import inspect import typing @@ -6,7 +6,7 @@ import pytest from pydantic import BaseModel -from clientele.api import requests +from clientele.api import request_context from clientele.http import response as http_response @@ -36,7 +36,7 @@ def func_without_result(): type_hints = typing.get_type_hints(func_without_result) with pytest.raises(TypeError, match="must have a 'result' parameter"): - requests.validate_result_parameter(func_without_result, sig, type_hints) + request_context.validate_result_parameter(func_without_result, sig, type_hints) def test_validate_result_parameter_no_annotation(self): """Test that result parameter without annotation raises TypeError.""" @@ -48,7 +48,7 @@ def func_no_annotation(result): type_hints = {} with pytest.raises(TypeError, match="lacks a type annotation"): - requests.validate_result_parameter(func_no_annotation, sig, type_hints) + request_context.validate_result_parameter(func_no_annotation, sig, type_hints) def test_validate_result_parameter_valid(self): """Test that valid result parameter passes validation.""" @@ -60,7 +60,7 @@ def func_valid(result: dict) -> dict: type_hints = typing.get_type_hints(func_valid) # Should not raise - requests.validate_result_parameter(func_valid, sig, type_hints) + request_context.validate_result_parameter(func_valid, sig, type_hints) def test_validate_result_parameter_streaming_valid(self): """Test streaming validation with AsyncIterator.""" @@ -72,7 +72,7 @@ async def func_stream(result: typing.AsyncIterator[str]) -> typing.AsyncIterator type_hints = typing.get_type_hints(func_stream, include_extras=True) # Should not raise - requests.validate_result_parameter(func_stream, sig, type_hints, expect_streaming=True) + request_context.validate_result_parameter(func_stream, sig, type_hints, expect_streaming=True) def test_validate_result_parameter_streaming_invalid_type(self): """Test that non-streaming type raises error when expect_streaming=True.""" @@ -84,7 +84,7 @@ async def func_not_stream(result: dict) -> dict: type_hints = typing.get_type_hints(func_not_stream) with pytest.raises(TypeError, match="must have a streaming result type"): - requests.validate_result_parameter(func_not_stream, sig, type_hints, expect_streaming=True) + request_context.validate_result_parameter(func_not_stream, sig, type_hints, expect_streaming=True) def test_validate_result_parameter_streaming_no_inner_type(self): """Test that AsyncIterator without inner type raises error.""" @@ -96,7 +96,7 @@ async def func_no_inner(result: typing.AsyncIterator) -> typing.AsyncIterator: type_hints = typing.get_type_hints(func_no_inner, include_extras=True) with pytest.raises(TypeError, match="no inner type specified"): - requests.validate_result_parameter(func_no_inner, sig, type_hints, expect_streaming=True) + request_context.validate_result_parameter(func_no_inner, sig, type_hints, expect_streaming=True) def test_validate_result_parameter_async_func_with_iterator(self): """Test async function with Iterator (should be AsyncIterator) raises error.""" @@ -108,7 +108,7 @@ async def func_wrong_type(result: typing.Iterator[str]) -> typing.Iterator[str]: type_hints = typing.get_type_hints(func_wrong_type, include_extras=True) with pytest.raises(TypeError, match="must use AsyncIterator, not Iterator"): - requests.validate_result_parameter(func_wrong_type, sig, type_hints, expect_streaming=True) + request_context.validate_result_parameter(func_wrong_type, sig, type_hints, expect_streaming=True) def test_validate_result_parameter_sync_func_with_async_iterator(self): """Test sync function with AsyncIterator (should be Iterator) raises error.""" @@ -120,7 +120,7 @@ def func_wrong_type(result: typing.AsyncIterator[str]) -> typing.AsyncIterator[s type_hints = typing.get_type_hints(func_wrong_type, include_extras=True) with pytest.raises(TypeError, match="must use Iterator, not AsyncIterator"): - requests.validate_result_parameter(func_wrong_type, sig, type_hints, expect_streaming=True) + request_context.validate_result_parameter(func_wrong_type, sig, type_hints, expect_streaming=True) class TestBuildRequestContext: @@ -130,7 +130,7 @@ def test_build_request_context_basic(self): def func(result: dict) -> dict: return result - context = requests.build_request_context("GET", "/test", func) + context = request_context.build_request_context("GET", "/test", func) assert context.method == "GET" assert context.path_template == "/test" @@ -146,7 +146,7 @@ def func(result: SampleModel | ErrorModel) -> SampleModel | ErrorModel: return result response_map = {200: SampleModel, 404: ErrorModel} - context = requests.build_request_context("GET", "/test", func, response_map=response_map) + context = request_context.build_request_context("GET", "/test", func, response_map=response_map) assert context.response_map == response_map @@ -156,7 +156,7 @@ def test_build_request_context_streaming(self): async def func(result: typing.AsyncIterator[str]) -> typing.AsyncIterator[str]: return result - context = requests.build_request_context("GET", "/stream", func, streaming=True) + context = request_context.build_request_context("GET", "/stream", func, streaming=True) assert context.streaming is True @@ -167,7 +167,9 @@ async def func(result: typing.AsyncIterator[str]) -> typing.AsyncIterator[str]: return result with pytest.raises(TypeError, match="cannot use response_map"): - requests.build_request_context("GET", "/stream", func, response_map={200: SampleModel}, streaming=True) + request_context.build_request_context( + "GET", "/stream", func, response_map={200: SampleModel}, streaming=True + ) def test_build_request_context_both_response_map_and_parser_raises(self): """Test that having both response_map and response_parser raises error.""" @@ -179,7 +181,7 @@ def parser(response: http_response.Response) -> SampleModel: return SampleModel(name="test", value=1) with pytest.raises(TypeError, match="cannot have both"): - requests.build_request_context( + request_context.build_request_context( "GET", "/test", func, response_map={200: SampleModel}, response_parser=parser ) @@ -194,7 +196,7 @@ def func(result: dict) -> dict: func.__annotations__ = {"result": "SomeForwardRef", "return": "SomeForwardRef"} # This should fall back to using __annotations__ when get_type_hints raises NameError - context = requests.build_request_context("GET", "/test", func) + context = request_context.build_request_context("GET", "/test", func) assert context.type_hints is not None assert "result" in context.type_hints @@ -211,7 +213,7 @@ def func(result: SampleModel | ErrorModel) -> SampleModel | ErrorModel: response_map = {200: SampleModel, 404: ErrorModel} # Should not raise - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) def test_validate_response_map_invalid_status_code(self): """Test that invalid status code raises ValueError.""" @@ -223,7 +225,7 @@ def func(result: SampleModel) -> SampleModel: response_map = {999: SampleModel} # Invalid status code with pytest.raises(ValueError, match="Invalid status code 999"): - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) def test_validate_response_map_non_pydantic_model(self): """Test that non-Pydantic model in response_map raises ValueError.""" @@ -235,7 +237,7 @@ def func(result: str) -> str: response_map = {200: str} # Not a Pydantic model or TypedDict with pytest.raises(ValueError, match="must be a Pydantic BaseModel subclass or TypedDict"): - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) def test_validate_response_map_model_not_in_result_type(self): """Test that model not in result type raises ValueError.""" @@ -247,7 +249,7 @@ def func(result: SampleModel) -> SampleModel: response_map = {200: SampleModel, 404: ErrorModel} # ErrorModel not in result type with pytest.raises(ValueError, match="is not in the 'result' parameter's type annotation"): - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) def test_validate_response_map_with_typeddict(self): """Test response_map with TypedDict models.""" @@ -259,7 +261,7 @@ def func(result: SampleTypedDict) -> SampleTypedDict: response_map = {200: SampleTypedDict} # Should not raise - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) def test_validate_response_map_with_list_type_alias_issue_240(self): """Confirm correct behaviour for https://github.com/phalt/clientele/issues/240. @@ -283,7 +285,7 @@ def func(result: list[SampleModel]) -> list[SampleModel]: response_map = {200: list_type} # Must not raise — importing the generated client should succeed - requests._validate_response_map(response_map, func, type_hints) + request_context._validate_response_map(response_map, func, type_hints) class TestValidateResponseParser: @@ -299,7 +301,7 @@ def parser(response: http_response.Response) -> SampleModel: type_hints = typing.get_type_hints(func) # Should not raise - requests._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) + request_context._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) def test_validate_response_parser_no_return_annotation(self): """Test that parser without return annotation raises TypeError.""" @@ -313,7 +315,7 @@ def parser(response: http_response.Response): # No return annotation type_hints = typing.get_type_hints(func) with pytest.raises(TypeError, match="must have a return type annotation"): - requests._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) + request_context._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) def test_validate_response_parser_mismatched_types(self): """Test that mismatched parser and result types raise TypeError.""" @@ -327,7 +329,7 @@ def parser(response: http_response.Response) -> ErrorModel: # Different type type_hints = typing.get_type_hints(func) with pytest.raises(TypeError, match="does not match the type"): - requests._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) + request_context._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) def test_validate_response_parser_union_types(self): """Test response_parser with Union types.""" @@ -343,7 +345,7 @@ def parser(response: http_response.Response) -> SampleModel | ErrorModel: type_hints = typing.get_type_hints(func) # Should not raise - requests._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) + request_context._validate_response_parser_return_type_matches_result_return_type(parser, func, type_hints) class TestGetResultTypesFromTypeHints: @@ -354,7 +356,7 @@ def func(result: SampleModel) -> SampleModel: return result type_hints = typing.get_type_hints(func) - result_types = requests._get_result_types_from_type_hints(type_hints) + result_types = request_context._get_result_types_from_type_hints(type_hints) assert result_types == [SampleModel] @@ -365,7 +367,7 @@ def func(result: SampleModel | ErrorModel) -> SampleModel | ErrorModel: return result type_hints = typing.get_type_hints(func) - result_types = requests._get_result_types_from_type_hints(type_hints) + result_types = request_context._get_result_types_from_type_hints(type_hints) assert len(result_types) == 2 assert SampleModel in result_types @@ -376,7 +378,7 @@ def test_get_result_types_no_result_annotation(self): type_hints = {} with pytest.raises(ValueError, match="must have a 'result' parameter with a type annotation"): - requests._get_result_types_from_type_hints(type_hints) + request_context._get_result_types_from_type_hints(type_hints) def test_get_result_types_old_style_union(self): """Test Union with typing.Union (not | syntax).""" @@ -385,7 +387,7 @@ def func(result: typing.Union[SampleModel, ErrorModel]) -> typing.Union[SampleMo return result type_hints = typing.get_type_hints(func) - result_types = requests._get_result_types_from_type_hints(type_hints) + result_types = request_context._get_result_types_from_type_hints(type_hints) assert len(result_types) == 2 assert SampleModel in result_types @@ -399,11 +401,11 @@ def test_prepared_call_creation(self): def func(result: dict) -> dict: return result - context = requests.build_request_context("GET", "/test", func) + context = request_context.build_request_context("GET", "/test", func) sig = inspect.signature(func) bound_args = sig.bind(result={}) - prepared = requests.PreparedCall( + prepared = request_context.PreparedCall( context=context, bound_arguments=bound_args, call_arguments={"result": {}}, @@ -429,7 +431,7 @@ def func(result: dict) -> dict: sig = inspect.signature(func) type_hints = typing.get_type_hints(func) - context = requests.RequestContext( + context = request_context.RequestContext( method="POST", path_template="/api/test", func=func, diff --git a/tests/api/test_stream.py b/tests/api/test_stream.py index fa2c457e..f00a6ee5 100644 --- a/tests/api/test_stream.py +++ b/tests/api/test_stream.py @@ -5,7 +5,7 @@ import pytest from pydantic import BaseModel -from clientele.api import requests +from clientele.api import request_context from clientele.api.client import APIClient from clientele.api.exceptions import HTTPStatusError from clientele.testing import ResponseFactory, configure_client_for_testing @@ -242,7 +242,9 @@ async def dummy_func(result: AsyncIterator[Token]) -> AsyncIterator[Token]: return result with pytest.raises(TypeError, match="cannot use response_map"): - requests.build_request_context("GET", "/events", dummy_func, response_map={200: Token}, streaming=True) + request_context.build_request_context( + "GET", "/events", dummy_func, response_map={200: Token}, streaming=True + ) @pytest.mark.asyncio async def test_stream_with_response_parser(self): From f6a0165dc135483f2f891d9f0b7e170c81fd8f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:13 +0000 Subject: [PATCH 06/11] Consolidate the three overlapping CLI test files into one test_cli.py, test_cli_coverage.py and test_cli_full_coverage.py all tested clientele/cli.py, each redefining the same runner and OpenAPI spec fixtures, and several tests covered the same branch (the url-or-file assertion was tested three times, plain 3.0 file loading twice). The unique cases - Swagger 2.0 auto-conversion, the pre-3.0 version rejection in _prepare_spec, and OpenAPI 3.1 normalization - now live in tests/test_cli.py alongside the existing tests, sharing one set of fixtures. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- tests/test_cli.py | 104 +++++++++++++++++++++----- tests/test_cli_coverage.py | 86 --------------------- tests/test_cli_full_coverage.py | 124 ------------------------------- tests/test_edge_case_coverage.py | 38 ---------- 4 files changed, 85 insertions(+), 267 deletions(-) delete mode 100644 tests/test_cli_coverage.py delete mode 100644 tests/test_cli_full_coverage.py diff --git a/tests/test_cli.py b/tests/test_cli.py index 7bc3bbc9..6b5c3da9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -101,17 +101,37 @@ def test_load_openapi_spec_requires_url_or_file(): cli._load_openapi_spec() -def test_load_openapi_spec_raises_value_error_when_no_params(): - """Test that _load_openapi_spec raises ValueError when neither url nor file provided.""" - # The function has an assert, but if that's removed it should raise ValueError - # Test the else branch that raises ValueError - try: - # This will hit the assert first, but we're testing the logic - cli._load_openapi_spec(url=None, file=None) - assert False, "Should have raised an error" - except (AssertionError, ValueError): - # Either assertion or ValueError is acceptable - pass +def test_load_openapi_spec_normalizes_openapi_31(write_spec_file): + """Test that _load_openapi_spec normalizes OpenAPI 3.1 specs.""" + openapi_31_spec = { + "openapi": "3.1.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": { + "/test": { + "get": { + "operationId": "test_get", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": ["string", "null"], # OpenAPI 3.1 nullable syntax + } + } + }, + } + }, + } + } + }, + } + + spec_file = write_spec_file(".json", spec=openapi_31_spec) + spec = cli._load_openapi_spec(file=str(spec_file)) + # Should successfully load and normalize the spec + assert spec is not None + assert spec.info.title == "Test API" @pytest.mark.parametrize( @@ -135,6 +155,29 @@ def test_load_openapi_spec_from_url(simple_openapi_spec, httpserver, content_typ assert spec.info.title == "Test API" +def test_prepare_spec_returns_none_for_old_version(write_spec_file): + """Test that _prepare_spec returns None for OpenAPI version < 3.0.""" + from rich.console import Console + + openapi_v2_spec = { + "openapi": "2.9.0", + "info": {"title": "Old API", "version": "1.0.0"}, + "paths": { + "/test": { + "get": { + "operationId": "test_get", + "responses": {"200": {"description": "Success"}}, + } + } + }, + } + + spec_file = write_spec_file(".json", spec=openapi_v2_spec) + spec = cli._prepare_spec(console=Console(), file=str(spec_file)) + # Should return None for version < 3.0 + assert spec is None + + @pytest.mark.parametrize( "command,regen_arg,expected_output", [ @@ -143,9 +186,6 @@ def test_load_openapi_spec_from_url(simple_openapi_spec, httpserver, content_typ ) def test_generate_commands_with_valid_spec(runner, command, regen_arg, expected_output): """Test that all generate commands create client successfully with valid spec.""" - import tempfile - from pathlib import Path - # Use the simple spec from example_openapi_specs spec_path = Path(__file__).parent.parent / "example_openapi_specs" / "simple.json" @@ -162,9 +202,35 @@ def test_generate_commands_with_valid_spec(runner, command, regen_arg, expected_ assert expected_output.lower() in result.output.lower() -def test_cli_main_block(): - """Test the CLI main block can be imported without running.""" - # Just importing the module should work without executing main - import clientele.cli as cli_module +def test_start_api_command_with_swagger_spec(runner, write_spec_file): + """Test start-api command with Swagger 2.0 spec (gets auto-converted to 3.0). - assert hasattr(cli_module, "cli_group") + Note: Cicerone automatically converts Swagger 2.0 to OpenAPI 3.0, so the + pre-3.0 version check would only trigger for malformed specs, not valid + Swagger 2.0. + """ + swagger_v2_spec = { + "swagger": "2.0", + "info": {"title": "Old API", "version": "1.0.0"}, + "paths": { + "/test": { + "get": { + "operationId": "test_get", + "responses": {"200": {"description": "Success"}}, + } + } + }, + } + + spec_file = write_spec_file(".json", spec=swagger_v2_spec) + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + result = runner.invoke( + cli.cli_group, + ["start-api", "--file", str(spec_file), "--output", str(output_dir), "--regen"], + ) + + # Should succeed (Swagger 2.0 is auto-converted to 3.0) + assert result.exit_code == 0 diff --git a/tests/test_cli_coverage.py b/tests/test_cli_coverage.py deleted file mode 100644 index 103ba941..00000000 --- a/tests/test_cli_coverage.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Tests for additional CLI coverage.""" - -import json -import tempfile -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from clientele import cli - - -@pytest.fixture -def runner(): - """Fixture providing a Click CLI test runner.""" - return CliRunner() - - -@pytest.fixture -def openapi_v2_spec(): - """Fixture providing a Swagger/OpenAPI v2 spec (old version).""" - return { - "swagger": "2.0", - "info": {"title": "Old API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -@pytest.fixture -def openapi_v3_spec(): - """Fixture providing a valid OpenAPI v3 spec.""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -def test_load_openapi_spec_else_branch(): - """Test assertion in _load_openapi_spec when neither url nor file provided. - - Note: The else branch on line 35 is unreachable defensive code because - the assertion on line 28 catches this case first. We test the assertion here. - """ - # Test that assertion fires when neither url nor file is provided - with pytest.raises(AssertionError): - cli._load_openapi_spec(url=None, file=None) - - -def test_start_api_command_with_swagger_spec(runner, openapi_v2_spec): - """Test start-api command with Swagger 2.0 spec (gets auto-converted to 3.0). - - Note: Cicerone automatically converts Swagger 2.0 to OpenAPI 3.0, so the version - check on lines 96-97 would only trigger for malformed specs, not valid Swagger 2.0. - """ - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_v2_spec, f) - spec_file = f.name - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - try: - result = runner.invoke( - cli.cli_group, - ["start-api", "--file", spec_file, "--output", str(output_dir), "--regen"], - ) - - # Should succeed (Swagger 2.0 is auto-converted to 3.0) - assert result.exit_code == 0 - finally: - Path(spec_file).unlink() diff --git a/tests/test_cli_full_coverage.py b/tests/test_cli_full_coverage.py deleted file mode 100644 index 120c5c75..00000000 --- a/tests/test_cli_full_coverage.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Tests for CLI module to achieve full coverage.""" - -import json -import tempfile -from pathlib import Path - -import pytest -from click.testing import CliRunner - -from clientele import cli - - -@pytest.fixture -def runner(): - """Fixture providing a Click CLI test runner.""" - return CliRunner() - - -@pytest.fixture -def openapi_v1_spec(): - """Fixture providing an OpenAPI v1 spec (old version that should fail).""" - return { - "openapi": "1.0.0", - "info": {"title": "Old API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -@pytest.fixture -def openapi_v2_spec(): - """Fixture providing an OpenAPI v2 spec.""" - return { - "openapi": "2.9.0", - "info": {"title": "Old API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -@pytest.fixture -def openapi_v3_spec(): - """Fixture providing a valid OpenAPI v3 spec.""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -def test_prepare_spec_returns_none_for_old_version(openapi_v2_spec): - """Test that _prepare_spec returns None for OpenAPI version < 3.0.""" - from rich.console import Console - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_v2_spec, f) - spec_file = f.name - - try: - console = Console() - spec = cli._prepare_spec(console=console, file=spec_file) - # Should return None for version < 3.0 - assert spec is None - finally: - Path(spec_file).unlink() - - -def test_load_openapi_spec_normalizes_openapi_31(openapi_v3_spec): - """Test that _load_openapi_spec normalizes OpenAPI 3.1 specs.""" - # Create an OpenAPI 3.1 spec with nullable array syntax - openapi_31_spec = { - "openapi": "3.1.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": ["string", "null"], # OpenAPI 3.1 nullable syntax - } - } - }, - } - }, - } - } - }, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_31_spec, f) - spec_file = f.name - - try: - spec = cli._load_openapi_spec(file=spec_file) - # Should successfully load and normalize the spec - assert spec is not None - assert spec.info.title == "Test API" - finally: - Path(spec_file).unlink() diff --git a/tests/test_edge_case_coverage.py b/tests/test_edge_case_coverage.py index 1989a8db..ce21fbc7 100644 --- a/tests/test_edge_case_coverage.py +++ b/tests/test_edge_case_coverage.py @@ -1,44 +1,6 @@ """Additional tests for edge cases to reach 100% coverage.""" -import json import tempfile -from pathlib import Path - -import pytest - -from clientele import cli - - -@pytest.fixture -def openapi_30_spec(): - """Fixture providing a pure OpenAPI 3.0 spec (no 3.1 features).""" - return { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - -def test_load_openapi_spec_no_normalization_needed(openapi_30_spec): - """Test that _load_openapi_spec returns original spec when no normalization needed.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_30_spec, f) - spec_file = f.name - - try: - spec = cli._load_openapi_spec(file=spec_file) - # Should successfully load without normalization (line 46 coverage) - assert spec is not None - assert spec.info.title == "Test API" - finally: - Path(spec_file).unlink() def test_schemas_generator_no_schemas_branch(): From 2cf7a6ad80b43b6f2b530f1a0515bd3a60d332c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:14 +0000 Subject: [PATCH 07/11] Merge the duplicated shared-utils test files into one tests/generators/shared/test_utils.py and test_utils_coverage.py both tested clientele/generators/shared/utils.py, re-testing snake_case_prop and get_type with overlapping inputs. The unique inputs from the coverage file are now parametrize cases or named tests in test_utils.py. Also absorbed here, since their subject moved into shared/utils.py in an earlier commit: the get_client_project_directory_path tests from tests/test_utils.py, and the get_param_from_ref/get_schema_from_ref edge cases from tests/test_additional_coverage.py. The rest of test_additional_coverage.py duplicated existing tests (the allOf cases are covered by test_get_type_with_allof and test_schemas_generator_handles_allof_with_object_properties), so the file is gone. Tests of private helpers with vacuous assertions (_split_upper) were dropped. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- tests/generators/shared/test_utils.py | 187 +++++++++++++++--- .../generators/shared/test_utils_coverage.py | 163 --------------- tests/test_additional_coverage.py | 60 ------ tests/test_utils.py | 29 --- 4 files changed, 164 insertions(+), 275 deletions(-) delete mode 100644 tests/generators/shared/test_utils_coverage.py delete mode 100644 tests/test_additional_coverage.py delete mode 100644 tests/test_utils.py diff --git a/tests/generators/shared/test_utils.py b/tests/generators/shared/test_utils.py index ab475ddf..26dac76c 100644 --- a/tests/generators/shared/test_utils.py +++ b/tests/generators/shared/test_utils.py @@ -1,6 +1,7 @@ import pytest from clientele.generators.shared import utils +from tests.generators.integration_utils import load_spec @pytest.mark.parametrize( @@ -12,6 +13,16 @@ ("FooBarBazz", "foo_bar_bazz"), ("RETAIN_ALL_UPPER", "RETAIN_ALL_UPPER"), ("RETAINALLUPPER", "RETAINALLUPPER"), + ("some.property.name", "some_property_name"), + # Reserved words get an underscore suffix + ("from", "from_"), + # Empty (or empty after sanitization) inputs get a placeholder + ("", "EMPTY"), + ("!@#$%^&*()", "EMPTY"), + # Digit-starting identifiers get an underscore prefix to stay valid Python + ("123test", "_123test"), + ("1_property", "_1_property"), + ("123456", "_123456"), ], ) def test_snake_case_prop(input, expected_output): @@ -30,6 +41,7 @@ def test_snake_case_prop(input, expected_output): ({"type": "array", "items": {"type": "string"}}, "list[str]"), ({"type": "array", "items": {"type": "integer"}}, "list[int]"), ({"type": "array", "items": {"type": "number"}}, "list[float]"), + ({"type": "array"}, "list[typing.Any]"), ({}, "typing.Any"), ], ) @@ -54,9 +66,13 @@ def test_class_name_titled(input_str, expected_output): assert utils.class_name_titled(input_str) == expected_output -def test_snake_case_prop_reserved_words(): - """Test that reserved words get an underscore suffix.""" - assert utils.snake_case_prop("from") == "from_" +def test_class_name_titled_removes_special_chars(): + """Test class_name_titled removes various special characters.""" + result = utils.class_name_titled("test-name_with.special>chars") + assert "-" not in result + assert "_" not in result + assert "." not in result + assert ">" not in result def test_get_type_with_array_of_objects(): @@ -82,31 +98,156 @@ def test_get_type_with_enum(): assert result == "str" -def test_snake_case_prop_with_dots(): - """Test snake_case_prop converts dots to underscores.""" - assert utils.snake_case_prop("some.property.name") == "some_property_name" +def test_get_type_with_oneof(): + """Test get_type handles oneOf schemas.""" + type_spec = { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + ] + } + result = utils.get_type(type_spec) + # Should create a union type + assert "str" in result + assert "int" in result + assert "|" in result or "Union" in result + + +def test_get_type_with_oneof_and_nullable(): + """Test get_type handles oneOf with nullable.""" + type_spec = { + "oneOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + "nullable": True, + } + result = utils.get_type(type_spec) + # Should wrap union in Optional + assert "Optional" in result or "None" in result + + +def test_get_type_with_anyof(): + """Test get_type handles anyOf schemas.""" + type_spec = { + "anyOf": [ + {"type": "string"}, + {"type": "boolean"}, + ] + } + result = utils.get_type(type_spec) + # Should create a union type + assert "str" in result + assert "bool" in result + assert "|" in result or "Union" in result + + +def test_get_type_with_anyof_and_nullable(): + """Test get_type handles anyOf with nullable.""" + type_spec = { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + "nullable": True, + } + result = utils.get_type(type_spec) + # Should wrap union in Optional + assert "Optional" in result or "None" in result -def test_class_name_titled_removes_special_chars(): - """Test class_name_titled removes various special characters.""" - result = utils.class_name_titled("test-name_with.special>chars") - assert "-" not in result - assert "_" not in result - assert "." not in result - assert ">" not in result +def test_get_type_with_allof(): + """Test get_type handles allOf schemas.""" + type_spec = { + "allOf": [ + {"type": "string"}, + ] + } + result = utils.get_type(type_spec) + # allOf should be handled, returning a type + assert result is not None + + +def test_get_schema_from_ref_basic(): + """Test get_schema_from_ref retrieves schema from components/schemas.""" + spec = load_spec("best.json") + + # This spec should have some schemas we can reference + if spec.components and spec.components.schemas: + # Get first available schema key + schema_keys = list(spec.components.schemas.keys()) + if schema_keys: + first_key = schema_keys[0] + ref = f"#/components/schemas/{first_key}" + result = utils.get_schema_from_ref(spec=spec, ref=ref) + + # Should return a dict + assert result is not None + assert isinstance(result, dict) + + +def test_get_schema_from_ref_missing(): + """Test get_schema_from_ref with non-existent schema.""" + spec = load_spec("simple.json") + + result = utils.get_schema_from_ref(spec, "#/components/schemas/NonExistentSchema") + + assert result == {} + + +def test_get_param_from_ref_missing(): + """Test get_param_from_ref with non-existent parameter.""" + spec = load_spec("simple.json") + + param = {"$ref": "#/components/parameters/NonExistentParam"} + result = utils.get_param_from_ref(spec, param) + + assert result == {} + + +def test_resolve_forward_refs_for_client(): + """Test that forward references are replaced with schemas module references.""" + assert utils.resolve_forward_refs_for_client('"ActionStatus"') == "schemas.ActionStatus" + assert ( + utils.resolve_forward_refs_for_client('typing.Union["ActionStatus", None]') + == "typing.Union[schemas.ActionStatus, None]" + ) + assert utils.resolve_forward_refs_for_client('list["MyModel"]') == "list[schemas.MyModel]" + assert utils.resolve_forward_refs_for_client("str") == "str" + assert utils.resolve_forward_refs_for_client("int") == "int" + + +def test_strip_none_from_type(): + """Test stripping None/Optional wrapping from type strings.""" + assert utils.strip_none_from_type("typing.Optional[str]") == "str" + assert utils.strip_none_from_type("typing.Union[schemas.ActionStatus, None]") == "schemas.ActionStatus" + assert utils.strip_none_from_type("schemas.ActionStatus | None") == "schemas.ActionStatus" + assert utils.strip_none_from_type("str") == "str" + assert utils.strip_none_from_type("typing.Optional[list[str]]") == "list[str]" + assert utils.strip_none_from_type("typing.Union[dict[str, int], None]") == "dict[str, int]" + +def test_get_client_project_directory_path(): + """Test converting output directory to dot-notation path.""" + # Test basic conversion + result = utils.get_client_project_directory_path("path/to/client") + assert result == "path.to" -def test_split_upper_single_char(): - """Test _split_upper with single character.""" - from clientele.generators.shared.utils import _split_upper + # Test single level + result = utils.get_client_project_directory_path("client") + assert result == "" - result = _split_upper("A") - assert result == "A" + # Test with multiple levels + result = utils.get_client_project_directory_path("a/b/c/d") + assert result == "a.b.c" -def test_split_upper_multiple_uppercase(): - """Test _split_upper with multiple uppercase letters.""" - from clientele.generators.shared.utils import _split_upper +def test_get_client_project_directory_path_edge_cases(): + """Test edge cases for path conversion.""" + # Empty string + result = utils.get_client_project_directory_path("") + assert result == "" - result = _split_upper("HelloWorld") - assert "_" in result or result == "HelloWorld" + # Path with trailing slash + result = utils.get_client_project_directory_path("path/to/client/") + assert result == "path.to.client" diff --git a/tests/generators/shared/test_utils_coverage.py b/tests/generators/shared/test_utils_coverage.py deleted file mode 100644 index 711d75de..00000000 --- a/tests/generators/shared/test_utils_coverage.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Additional tests for standard utils to increase coverage.""" - -from clientele.generators.shared import utils - - -def test_snake_case_prop_with_empty_string(): - """Test snake_case_prop handles empty strings (line 55).""" - result = utils.snake_case_prop("") - assert result == "EMPTY" - - -def test_snake_case_prop_with_only_special_chars(): - """Test snake_case_prop handles strings that become empty after sanitization (line 55).""" - result = utils.snake_case_prop("!@#$%^&*()") - assert result == "EMPTY" - - -def test_snake_case_prop_with_digit_start(): - """Test snake_case_prop handles identifiers starting with digit. - - The function correctly adds underscore prefix for digit-starting identifiers - and preserves it to ensure valid Python identifier syntax. - """ - result = utils.snake_case_prop("123test") - # Underscore prefix is preserved for valid Python identifier - assert result == "_123test" - - # Also test with underscores and other characters - result2 = utils.snake_case_prop("1_property") - assert result2 == "_1_property" - - -def test_snake_case_prop_with_only_digits(): - """Test snake_case_prop handles strings with only digits.""" - result = utils.snake_case_prop("123456") - assert result == "_123456" - - -def test_get_type_with_oneof(): - """Test get_type handles oneOf schemas (lines 99-104).""" - type_spec = { - "oneOf": [ - {"type": "string"}, - {"type": "integer"}, - ] - } - result = utils.get_type(type_spec) - # Should create a union type - assert "str" in result - assert "int" in result - assert "|" in result or "Union" in result - - -def test_get_type_with_oneof_and_nullable(): - """Test get_type handles oneOf with nullable (lines 102-104).""" - type_spec = { - "oneOf": [ - {"type": "string"}, - {"type": "integer"}, - ], - "nullable": True, - } - result = utils.get_type(type_spec) - # Should wrap union in Optional - assert "Optional" in result or "None" in result - - -def test_get_type_with_anyof(): - """Test get_type handles anyOf schemas (lines 107-112).""" - type_spec = { - "anyOf": [ - {"type": "string"}, - {"type": "boolean"}, - ] - } - result = utils.get_type(type_spec) - # Should create a union type - assert "str" in result - assert "bool" in result - assert "|" in result or "Union" in result - - -def test_get_type_with_anyof_and_nullable(): - """Test get_type handles anyOf with nullable (line 112).""" - type_spec = { - "anyOf": [ - {"type": "string"}, - {"type": "integer"}, - ], - "nullable": True, - } - result = utils.get_type(type_spec) - # Should wrap union in Optional - assert "Optional" in result or "None" in result - - -def test_get_type_with_allof(): - """Test get_type handles allOf schemas (line 157).""" - type_spec = { - "allOf": [ - {"type": "string"}, - ] - } - result = utils.get_type(type_spec) - # allOf should be handled, returning a type - assert result is not None - - -def test_get_type_with_array_with_items() -> None: - type_spec = {"type": "array", "items": {"type": "string"}} - result = utils.get_type(type_spec) - # Should create a list type with the inner type - assert result == "list[str]" - - -def test_get_type_with_array_without_items() -> None: - type_spec = {"type": "array"} - result = utils.get_type(type_spec) - # Should create a list type with typing.Any as the inner type - assert result == "list[typing.Any]" - - -def test_get_schema_from_ref_basic(): - """Test get_schema_from_ref retrieves schema from components/schemas.""" - # Use existing test spec that has components - from tests.generators.integration_utils import load_spec - - spec = load_spec("best.json") - - # This spec should have some schemas we can reference - if spec.components and spec.components.schemas: - # Get first available schema key - schema_keys = list(spec.components.schemas.keys()) - if schema_keys: - first_key = schema_keys[0] - ref = f"#/components/schemas/{first_key}" - result = utils.get_schema_from_ref(spec=spec, ref=ref) - - # Should return a dict - assert result is not None - assert isinstance(result, dict) - - -def test_resolve_forward_refs_for_client(): - """Test that forward references are replaced with schemas module references.""" - assert utils.resolve_forward_refs_for_client('"ActionStatus"') == "schemas.ActionStatus" - assert ( - utils.resolve_forward_refs_for_client('typing.Union["ActionStatus", None]') - == "typing.Union[schemas.ActionStatus, None]" - ) - assert utils.resolve_forward_refs_for_client('list["MyModel"]') == "list[schemas.MyModel]" - assert utils.resolve_forward_refs_for_client("str") == "str" - assert utils.resolve_forward_refs_for_client("int") == "int" - - -def test_strip_none_from_type(): - """Test stripping None/Optional wrapping from type strings.""" - assert utils.strip_none_from_type("typing.Optional[str]") == "str" - assert utils.strip_none_from_type("typing.Union[schemas.ActionStatus, None]") == "schemas.ActionStatus" - assert utils.strip_none_from_type("schemas.ActionStatus | None") == "schemas.ActionStatus" - assert utils.strip_none_from_type("str") == "str" - assert utils.strip_none_from_type("typing.Optional[list[str]]") == "list[str]" - assert utils.strip_none_from_type("typing.Union[dict[str, int], None]") == "dict[str, int]" diff --git a/tests/test_additional_coverage.py b/tests/test_additional_coverage.py deleted file mode 100644 index 6affa85f..00000000 --- a/tests/test_additional_coverage.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Additional tests to reach even higher coverage.""" - -import tempfile -from pathlib import Path - -from clientele.generators.api import writer as api_writer -from clientele.generators.shared import utils -from clientele.generators.shared.schemas import SchemasGenerator -from tests.generators.integration_utils import load_spec - - -def test_utils_get_type_with_allof_single_schema(): - """Test get_type with allOf containing a single schema.""" - type_spec = {"allOf": [{"type": "string"}]} - result = utils.get_type(type_spec) - assert result is not None - - -def test_utils_get_param_from_ref_missing(): - """Test get_param_from_ref with non-existent parameter.""" - spec = load_spec("simple.json") - - param = {"$ref": "#/components/parameters/NonExistentParam"} - result = utils.get_param_from_ref(spec, param) - - assert result == {} - - -def test_utils_get_schema_from_ref_missing(): - """Test get_schema_from_ref with non-existent schema.""" - spec = load_spec("simple.json") - - result = utils.get_schema_from_ref(spec, "#/components/schemas/NonExistentSchema") - - assert result == {} - - -def test_schemas_generator_with_allof_object(): - """Test schemas generator handles allOf with object properties.""" - spec = load_spec("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_schemas" - output_dir.mkdir(parents=True) - - generator = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) - - schema_with_allof_props = { - "allOf": [ - { - "type": "object", - "properties": {"id": {"type": "integer"}, "name": {"type": "string"}}, - "required": ["id"], - } - ] - } - - generator.make_schema_class("TestAllOfProps", schema_with_allof_props) - - assert "TestAllOfProps" in generator.schemas diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 51474927..00000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for utility functions.""" - -from clientele.generators.shared.utils import get_client_project_directory_path - - -def test_get_client_project_directory_path(): - """Test converting output directory to dot-notation path.""" - # Test basic conversion - result = get_client_project_directory_path("path/to/client") - assert result == "path.to" - - # Test single level - result = get_client_project_directory_path("client") - assert result == "" - - # Test with multiple levels - result = get_client_project_directory_path("a/b/c/d") - assert result == "a.b.c" - - -def test_get_client_project_directory_path_edge_cases(): - """Test edge cases for path conversion.""" - # Empty string - result = get_client_project_directory_path("") - assert result == "" - - # Path with trailing slash - result = get_client_project_directory_path("path/to/client/") - assert result == "path.to.client" From 8844a5512cce4b10e3ad06ac81195736045f64c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:14 +0000 Subject: [PATCH 08/11] Dissolve the remaining coverage grab-bag test files tests/test_generator_coverage.py mixed tests for two unrelated modules and tests/test_edge_case_coverage.py was a leftover catch-all. Their tests now live with the other tests for the module they exercise: - the normalize_openapi_31_* tests moved to tests/generators/test_cicerone_compat.py, which already tested that module - the SchemasGenerator no-components/no-schemas tests moved to tests/generators/test_schemas_coverage.py (dropping the duplicate copy from test_edge_case_coverage.py) - the APIGenerator regeneration and server-URL tests moved to tests/generators/framework/test_framework_generator_coverage.py, rewritten to build their specs with parse_spec_from_dict instead of tempfile round-trips https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- .../test_framework_generator_coverage.py | 58 +++ tests/generators/test_cicerone_compat.py | 161 ++++++++- tests/generators/test_schemas_coverage.py | 39 ++ tests/test_edge_case_coverage.py | 27 -- tests/test_generator_coverage.py | 333 ------------------ 5 files changed, 257 insertions(+), 361 deletions(-) delete mode 100644 tests/test_edge_case_coverage.py delete mode 100644 tests/test_generator_coverage.py diff --git a/tests/generators/framework/test_framework_generator_coverage.py b/tests/generators/framework/test_framework_generator_coverage.py index cb38cf7b..cf6ec639 100644 --- a/tests/generators/framework/test_framework_generator_coverage.py +++ b/tests/generators/framework/test_framework_generator_coverage.py @@ -109,3 +109,61 @@ def test_framework_generator_handles_ruff_not_found(): # Verify client was still generated despite missing ruff assert (Path(output_dir) / "client.py").exists() + + +def test_framework_generator_removes_existing_files(): + """Test that the generator removes existing files before regenerating.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + generator.generate() + + assert (output_dir / "client.py").exists() + + test_file = output_dir / "schemas.py" + test_file.write_text("# Old content") + + generator2 = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + generator2.generate() + + assert test_file.exists() + assert "# Old content" not in test_file.read_text() + + +def test_framework_generator_uses_server_url_from_spec(): + """Test that the base URL from the spec servers ends up in config.py.""" + from cicerone import parse as cicerone_parse + + spec = cicerone_parse.parse_spec_from_dict( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/test": { + "get": { + "operationId": "test_get", + "responses": {"200": {"description": "Success"}}, + } + } + }, + } + ) + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator(spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=None) + generator.generate() + + assert (output_dir / "config.py").exists() + config_content = (output_dir / "config.py").read_text() + assert "api.example.com" in config_content diff --git a/tests/generators/test_cicerone_compat.py b/tests/generators/test_cicerone_compat.py index 200d6011..cdf98067 100644 --- a/tests/generators/test_cicerone_compat.py +++ b/tests/generators/test_cicerone_compat.py @@ -1,8 +1,9 @@ -"""Tests for cicerone_compat module to increase coverage.""" +"""Tests for the cicerone_compat module.""" from unittest.mock import Mock from clientele.generators import cicerone_compat +from clientele.generators.cicerone_compat import normalize_openapi_31_schema, normalize_openapi_31_spec def test_schema_to_dict_with_dict_input(): @@ -215,3 +216,161 @@ def test_get_pydantic_extra_with_key_present(): result = cicerone_compat.get_pydantic_extra(obj, "test_key") assert result == "test_value" + + +def test_normalize_openapi_31_schema_only_null_type(): + """Test schema with only null type becomes nullable string.""" + schema = {"type": ["null"]} + result = normalize_openapi_31_schema(schema) + assert result["type"] == "string" + assert result["nullable"] is True + + +def test_normalize_openapi_31_schema_preserves_existing_nullable(): + """Test that existing nullable: true is preserved.""" + schema = {"type": "string", "nullable": True} + result = normalize_openapi_31_schema(schema) + assert result["nullable"] is True + + +def test_normalize_openapi_31_schema_items(): + """Test normalization of array items.""" + schema = {"type": "array", "items": {"type": ["string", "null"]}} + result = normalize_openapi_31_schema(schema) + assert result["items"]["type"] == "string" + assert result["items"]["nullable"] is True + + +def test_normalize_openapi_31_schema_allof(): + """Test normalization of allOf schemas.""" + schema = {"allOf": [{"type": ["string", "null"]}, {"minLength": 1}]} + result = normalize_openapi_31_schema(schema) + assert result["allOf"][0]["type"] == "string" + assert result["allOf"][0]["nullable"] is True + + +def test_normalize_openapi_31_schema_oneof(): + """Test normalization of oneOf schemas.""" + schema = {"oneOf": [{"type": ["integer", "null"]}, {"type": "string"}]} + result = normalize_openapi_31_schema(schema) + assert result["oneOf"][0]["type"] == "integer" + assert result["oneOf"][0]["nullable"] is True + + +def test_normalize_openapi_31_schema_anyof(): + """Test normalization of anyOf schemas.""" + schema = {"anyOf": [{"type": ["boolean", "null"]}, {"type": "number"}]} + result = normalize_openapi_31_schema(schema) + assert result["anyOf"][0]["type"] == "boolean" + assert result["anyOf"][0]["nullable"] is True + + +def test_normalize_openapi_31_spec_components_schemas(): + """Test normalization of schemas in components.""" + spec = { + "openapi": "3.1.0", + "components": {"schemas": {"NullableString": {"type": ["string", "null"]}}}, + } + result = normalize_openapi_31_spec(spec) + assert result["components"]["schemas"]["NullableString"]["type"] == "string" + assert result["components"]["schemas"]["NullableString"]["nullable"] is True + + +def test_normalize_openapi_31_spec_request_body(): + """Test normalization of request body schemas.""" + spec = { + "openapi": "3.1.0", + "paths": { + "/test": { + "post": {"requestBody": {"content": {"application/json": {"schema": {"type": ["string", "null"]}}}}} + } + }, + } + result = normalize_openapi_31_spec(spec) + schema = result["paths"]["/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert schema["type"] == "string" + assert schema["nullable"] is True + + +def test_normalize_openapi_31_spec_response_schemas(): + """Test normalization of response schemas.""" + spec = { + "openapi": "3.1.0", + "paths": { + "/test": { + "get": { + "responses": {"200": {"content": {"application/json": {"schema": {"type": ["integer", "null"]}}}}} + } + } + }, + } + result = normalize_openapi_31_spec(spec) + schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert schema["type"] == "integer" + assert schema["nullable"] is True + + +def test_normalize_openapi_31_spec_parameter_schemas(): + """Test normalization of parameter schemas.""" + spec = { + "openapi": "3.1.0", + "paths": { + "/test": { + "get": { + "parameters": [ + { + "name": "test_param", + "in": "query", + "schema": {"type": ["boolean", "null"]}, + } + ] + } + } + }, + } + result = normalize_openapi_31_spec(spec) + param_schema = result["paths"]["/test"]["get"]["parameters"][0]["schema"] + assert param_schema["type"] == "boolean" + assert param_schema["nullable"] is True + + +def test_normalize_openapi_31_spec_non_dict_path_item(): + """Test that non-dict path items are skipped.""" + spec = { + "openapi": "3.1.0", + "paths": { + "/test": "not a dict", + }, + } + result = normalize_openapi_31_spec(spec) + assert result["paths"]["/test"] == "not a dict" + + +def test_normalize_openapi_31_spec_dollar_prefixed_keys(): + """Test that $-prefixed keys (like $ref) are skipped.""" + spec = { + "openapi": "3.1.0", + "paths": {"/test": {"$ref": "#/components/paths/TestPath"}}, + } + result = normalize_openapi_31_spec(spec) + assert result["paths"]["/test"]["$ref"] == "#/components/paths/TestPath" + + +def test_normalize_openapi_31_spec_non_dict_response(): + """Test that non-dict responses are skipped.""" + spec = { + "openapi": "3.1.0", + "paths": {"/test": {"get": {"responses": {"200": "not a dict"}}}}, + } + result = normalize_openapi_31_spec(spec) + assert result["paths"]["/test"]["get"]["responses"]["200"] == "not a dict" + + +def test_normalize_openapi_31_spec_non_dict_parameter(): + """Test that non-dict parameters are handled.""" + spec = { + "openapi": "3.1.0", + "paths": {"/test": {"get": {"parameters": ["not a dict"]}}}, + } + result = normalize_openapi_31_spec(spec) + assert result["paths"]["/test"]["get"]["parameters"][0] == "not a dict" diff --git a/tests/generators/test_schemas_coverage.py b/tests/generators/test_schemas_coverage.py index 1148c651..4bb92113 100644 --- a/tests/generators/test_schemas_coverage.py +++ b/tests/generators/test_schemas_coverage.py @@ -231,3 +231,42 @@ def test_generate_class_properties_const_with_alias(): "action_type: typing.Literal['delete'] = pydantic.Field(default='delete', alias=\"action-type\")" in result ) assert "model_config = pydantic.ConfigDict(populate_by_name=True)" in result + + +def test_schemas_generator_no_components(): + """Test schemas generator with no components in spec.""" + from cicerone import parse as cicerone_parse + + spec = cicerone_parse.parse_spec_from_dict( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "paths": {}, + } + ) + + with tempfile.TemporaryDirectory() as tmpdir: + generator = _make_generator(spec, tmpdir) + + # Should complete without error + generator.generate_schema_classes() + + +def test_schemas_generator_no_schemas_in_components(): + """Test schemas generator with components but no schemas.""" + from cicerone import parse as cicerone_parse + + spec = cicerone_parse.parse_spec_from_dict( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "components": {}, + "paths": {}, + } + ) + + with tempfile.TemporaryDirectory() as tmpdir: + generator = _make_generator(spec, tmpdir) + + # Should complete without error + generator.generate_schema_classes() diff --git a/tests/test_edge_case_coverage.py b/tests/test_edge_case_coverage.py deleted file mode 100644 index ce21fbc7..00000000 --- a/tests/test_edge_case_coverage.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Additional tests for edge cases to reach 100% coverage.""" - -import tempfile - - -def test_schemas_generator_no_schemas_branch(): - """Test schemas generator when components has no schemas attribute.""" - from clientele.generators.api import writer as api_writer - from clientele.generators.shared.schemas import SchemasGenerator - - spec_dict = { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "components": {}, - "paths": {}, - } - - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_dict(spec_dict) - - with tempfile.TemporaryDirectory() as tmpdir: - generator = SchemasGenerator(spec=spec, output_dir=str(tmpdir), writer=api_writer) - - generator.generate_schema_classes() - - # Should complete without error diff --git a/tests/test_generator_coverage.py b/tests/test_generator_coverage.py deleted file mode 100644 index 60911234..00000000 --- a/tests/test_generator_coverage.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Tests for generator modules to achieve full coverage.""" - -import json -import tempfile -from pathlib import Path - -import pytest - -from clientele.generators.api import writer as api_writer -from clientele.generators.api.generator import APIGenerator -from clientele.generators.cicerone_compat import normalize_openapi_31_schema, normalize_openapi_31_spec -from clientele.generators.shared.schemas import SchemasGenerator - - -def test_normalize_openapi_31_schema_only_null_type(): - """Test schema with only null type becomes nullable string.""" - schema = {"type": ["null"]} - result = normalize_openapi_31_schema(schema) - assert result["type"] == "string" - assert result["nullable"] is True - - -def test_normalize_openapi_31_schema_preserves_existing_nullable(): - """Test that existing nullable: true is preserved.""" - schema = {"type": "string", "nullable": True} - result = normalize_openapi_31_schema(schema) - assert result["nullable"] is True - - -def test_normalize_openapi_31_schema_items(): - """Test normalization of array items.""" - schema = {"type": "array", "items": {"type": ["string", "null"]}} - result = normalize_openapi_31_schema(schema) - assert result["items"]["type"] == "string" - assert result["items"]["nullable"] is True - - -def test_normalize_openapi_31_schema_allof(): - """Test normalization of allOf schemas.""" - schema = {"allOf": [{"type": ["string", "null"]}, {"minLength": 1}]} - result = normalize_openapi_31_schema(schema) - assert result["allOf"][0]["type"] == "string" - assert result["allOf"][0]["nullable"] is True - - -def test_normalize_openapi_31_schema_oneof(): - """Test normalization of oneOf schemas.""" - schema = {"oneOf": [{"type": ["integer", "null"]}, {"type": "string"}]} - result = normalize_openapi_31_schema(schema) - assert result["oneOf"][0]["type"] == "integer" - assert result["oneOf"][0]["nullable"] is True - - -def test_normalize_openapi_31_schema_anyof(): - """Test normalization of anyOf schemas.""" - schema = {"anyOf": [{"type": ["boolean", "null"]}, {"type": "number"}]} - result = normalize_openapi_31_schema(schema) - assert result["anyOf"][0]["type"] == "boolean" - assert result["anyOf"][0]["nullable"] is True - - -def test_normalize_openapi_31_spec_components_schemas(): - """Test normalization of schemas in components.""" - spec = { - "openapi": "3.1.0", - "components": {"schemas": {"NullableString": {"type": ["string", "null"]}}}, - } - result = normalize_openapi_31_spec(spec) - assert result["components"]["schemas"]["NullableString"]["type"] == "string" - assert result["components"]["schemas"]["NullableString"]["nullable"] is True - - -def test_normalize_openapi_31_spec_request_body(): - """Test normalization of request body schemas.""" - spec = { - "openapi": "3.1.0", - "paths": { - "/test": { - "post": {"requestBody": {"content": {"application/json": {"schema": {"type": ["string", "null"]}}}}} - } - }, - } - result = normalize_openapi_31_spec(spec) - schema = result["paths"]["/test"]["post"]["requestBody"]["content"]["application/json"]["schema"] - assert schema["type"] == "string" - assert schema["nullable"] is True - - -def test_normalize_openapi_31_spec_response_schemas(): - """Test normalization of response schemas.""" - spec = { - "openapi": "3.1.0", - "paths": { - "/test": { - "get": { - "responses": {"200": {"content": {"application/json": {"schema": {"type": ["integer", "null"]}}}}} - } - } - }, - } - result = normalize_openapi_31_spec(spec) - schema = result["paths"]["/test"]["get"]["responses"]["200"]["content"]["application/json"]["schema"] - assert schema["type"] == "integer" - assert schema["nullable"] is True - - -def test_normalize_openapi_31_spec_parameter_schemas(): - """Test normalization of parameter schemas.""" - spec = { - "openapi": "3.1.0", - "paths": { - "/test": { - "get": { - "parameters": [ - { - "name": "test_param", - "in": "query", - "schema": {"type": ["boolean", "null"]}, - } - ] - } - } - }, - } - result = normalize_openapi_31_spec(spec) - param_schema = result["paths"]["/test"]["get"]["parameters"][0]["schema"] - assert param_schema["type"] == "boolean" - assert param_schema["nullable"] is True - - -def test_normalize_openapi_31_spec_non_dict_path_item(): - """Test that non-dict path items are skipped.""" - spec = { - "openapi": "3.1.0", - "paths": { - "/test": "not a dict", - }, - } - result = normalize_openapi_31_spec(spec) - assert result["paths"]["/test"] == "not a dict" - - -def test_normalize_openapi_31_spec_dollar_prefixed_keys(): - """Test that $-prefixed keys (like $ref) are skipped.""" - spec = { - "openapi": "3.1.0", - "paths": {"/test": {"$ref": "#/components/paths/TestPath"}}, - } - result = normalize_openapi_31_spec(spec) - assert result["paths"]["/test"]["$ref"] == "#/components/paths/TestPath" - - -def test_normalize_openapi_31_spec_non_dict_response(): - """Test that non-dict responses are skipped.""" - spec = { - "openapi": "3.1.0", - "paths": {"/test": {"get": {"responses": {"200": "not a dict"}}}}, - } - result = normalize_openapi_31_spec(spec) - assert result["paths"]["/test"]["get"]["responses"]["200"] == "not a dict" - - -def test_normalize_openapi_31_spec_non_dict_parameter(): - """Test that non-dict parameters are handled.""" - spec = { - "openapi": "3.1.0", - "paths": {"/test": {"get": {"parameters": ["not a dict"]}}}, - } - result = normalize_openapi_31_spec(spec) - assert result["paths"]["/test"]["get"]["parameters"][0] == "not a dict" - - -class TestGeneratorCoverage: - """Tests for generator module coverage.""" - - @pytest.mark.parametrize("generator_class", [APIGenerator]) - def test_generator_removes_existing_file(self, generator_class): - """Test that generators removes existing files before regenerating.""" - - openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_spec, f) - spec_file = f.name - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - try: - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_file(spec_file) - - generator = generator_class( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=spec_file - ) - generator.generate() - - assert (output_dir / "client.py").exists() - - test_file = output_dir / "schemas.py" - test_file.write_text("# Old content") - - generator2 = generator_class( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=spec_file - ) - generator2.generate() - - assert test_file.exists() - assert "# Old content" not in test_file.read_text() - - finally: - Path(spec_file).unlink() - - @pytest.mark.parametrize("generator_class", [APIGenerator]) - def test_generator_with_servers(self, generator_class): - """Test generator with servers in spec.""" - - openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_spec, f) - spec_file = f.name - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - try: - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_file(spec_file) - - generator = generator_class( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=spec_file - ) - generator.generate() - - assert (output_dir / "config.py").exists() - client_content = (output_dir / "config.py").read_text() - assert "api.example.com" in client_content # nosec: B113 - - finally: - Path(spec_file).unlink() - - def test_schemas_generator_no_components(self): - """Test schemas generator with no components in spec.""" - - openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_spec, f) - spec_file = f.name - - with tempfile.TemporaryDirectory() as tmpdir: - try: - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_file(spec_file) - - generator = SchemasGenerator(spec=spec, output_dir=str(tmpdir), writer=api_writer) - - generator.generate_schema_classes() - - finally: - Path(spec_file).unlink() - - def test_schemas_generator_no_schemas_in_components(self): - """Test schemas generator with components but no schemas.""" - - openapi_spec = { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "components": {}, - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(openapi_spec, f) - spec_file = f.name - - with tempfile.TemporaryDirectory() as tmpdir: - try: - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_file(spec_file) - - generator = SchemasGenerator(spec=spec, output_dir=str(tmpdir), writer=api_writer) - - generator.generate_schema_classes() - - finally: - Path(spec_file).unlink() From bdd1286f7ae6c0ec11dc46c1a78b73776f5f5a4b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 08:14:14 +0000 Subject: [PATCH 09/11] Reorganize the test suite to mirror the package layout Tests for the generators were spread between the tests/ root (seven files all exercising clientele/generators/api), a tests/generators/ root with misplaced files, and a tests/generators/framework/ directory whose name matched nothing in the package. The tree now follows the source tree, so the tests for a module are where you expect them: - tests/generators/api/ APIGenerator and ClientsGenerator tests, including the feature/regression suites that lived at the tests/ root (complex schemas, $ref handling, nullable fields, OpenAPI 3.1 null types, issue 248, real-world fixture specs) and the former framework/ tests, renamed test_framework_*/test_standard_* -> test_api_generator_* - tests/generators/basic/ BasicGenerator tests (split out of test_base_and_basic_coverage.py; its ClientsGenerator half went to tests/generators/api/test_clients.py) - tests/generators/shared/ test_schemas.py (was tests/generators/test_schemas_coverage.py) next to test_utils.py - tests/test_testing.py renamed from test_testing_utilities.py to mirror clientele/testing.py __init__.py files added to the new directories so the two test_generator.py modules get unique import names. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- tests/generators/__init__.py | 0 tests/generators/api/__init__.py | 0 .../test_clients.py} | 39 +- .../api}/test_complex_schemas.py | 0 .../api}/test_fixture_schemas.py | 10 +- tests/generators/api/test_generator.py | 332 ++++++++++++++++++ tests/{ => generators/api}/test_issue_248.py | 0 .../api}/test_nullable_fields.py | 0 .../api}/test_openapi_31_null_type.py | 0 .../{ => generators/api}/test_ref_handling.py | 0 tests/generators/basic/__init__.py | 0 tests/generators/basic/test_generator.py | 42 +++ .../test_framework_generator_coverage.py | 169 --------- .../test_framework_generator_integration.py | 170 --------- .../test_schemas.py} | 2 +- ...t_testing_utilities.py => test_testing.py} | 0 16 files changed, 382 insertions(+), 382 deletions(-) create mode 100644 tests/generators/__init__.py create mode 100644 tests/generators/api/__init__.py rename tests/generators/{test_base_and_basic_coverage.py => api/test_clients.py} (71%) rename tests/{ => generators/api}/test_complex_schemas.py (100%) rename tests/{ => generators/api}/test_fixture_schemas.py (94%) create mode 100644 tests/generators/api/test_generator.py rename tests/{ => generators/api}/test_issue_248.py (100%) rename tests/{ => generators/api}/test_nullable_fields.py (100%) rename tests/{ => generators/api}/test_openapi_31_null_type.py (100%) rename tests/{ => generators/api}/test_ref_handling.py (100%) create mode 100644 tests/generators/basic/__init__.py create mode 100644 tests/generators/basic/test_generator.py delete mode 100644 tests/generators/framework/test_framework_generator_coverage.py delete mode 100644 tests/generators/framework/test_framework_generator_integration.py rename tests/generators/{test_schemas_coverage.py => shared/test_schemas.py} (99%) rename tests/{test_testing_utilities.py => test_testing.py} (100%) diff --git a/tests/generators/__init__.py b/tests/generators/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/generators/api/__init__.py b/tests/generators/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/generators/test_base_and_basic_coverage.py b/tests/generators/api/test_clients.py similarity index 71% rename from tests/generators/test_base_and_basic_coverage.py rename to tests/generators/api/test_clients.py index fe68dbfc..3df31e05 100644 --- a/tests/generators/test_base_and_basic_coverage.py +++ b/tests/generators/api/test_clients.py @@ -1,50 +1,13 @@ -"""Additional tests for base clients and basic generator coverage.""" +"""Tests for the ClientsGenerator (clientele/generators/api/clients.py).""" import tempfile from pathlib import Path from unittest.mock import patch from clientele.generators.api import clients as api_clients -from clientele.generators.basic.generator import BasicGenerator from tests.generators.integration_utils import load_spec -def test_basic_generator_removes_existing_manifest(): - """Test that basic generator removes existing MANIFEST.md (line 32).""" - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "basic_client" - output_dir.mkdir(parents=True) - - existing_manifest = output_dir / "MANIFEST.md" - existing_manifest.write_text("# Old manifest\n") - assert existing_manifest.exists() - - generator = BasicGenerator(output_dir=str(output_dir)) - generator.generate() - - assert existing_manifest.exists() - new_content = existing_manifest.read_text() - assert "Old manifest" not in new_content - - -def test_basic_generator_removes_existing_files(): - """Test that basic generator removes existing files (line 43).""" - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "basic_client" - output_dir.mkdir(parents=True) - - (output_dir / "client.py").write_text("# Old client\n") - (output_dir / "schemas.py").write_text("# Old schemas\n") - (output_dir / "http.py").write_text("# Old http\n") - (output_dir / "config.py").write_text("# Old config\n") - - generator = BasicGenerator(output_dir=str(output_dir)) - generator.generate() - - assert "Old client" not in (output_dir / "client.py").read_text() - assert "Old schemas" not in (output_dir / "schemas.py").read_text() - - def test_clients_generator_handles_optional_path_parameters(): """Test that clients generator handles optional query parameters.""" spec = load_spec("simple.json") diff --git a/tests/test_complex_schemas.py b/tests/generators/api/test_complex_schemas.py similarity index 100% rename from tests/test_complex_schemas.py rename to tests/generators/api/test_complex_schemas.py diff --git a/tests/test_fixture_schemas.py b/tests/generators/api/test_fixture_schemas.py similarity index 94% rename from tests/test_fixture_schemas.py rename to tests/generators/api/test_fixture_schemas.py index c78377f4..cf6e3995 100644 --- a/tests/test_fixture_schemas.py +++ b/tests/generators/api/test_fixture_schemas.py @@ -8,6 +8,9 @@ from clientele.generators.api.generator import APIGenerator +# Repository root, used to resolve the fixture paths below +REPO_ROOT = Path(__file__).parents[3] + def load_fixture_spec(spec_path: Path): """Load an OpenAPI spec from a fixture file.""" @@ -16,13 +19,13 @@ def load_fixture_spec(spec_path: Path): def get_regression_schemas() -> list[str]: """Discover all schema files in the regressions fixture directory.""" - regressions_dir = Path(__file__).parent / "fixtures" / "regression" + regressions_dir = REPO_ROOT / "tests" / "fixtures" / "regression" if not regressions_dir.exists(): return [] schemas = [] for ext in ["*.yaml", "*.yml", "*.json"]: schemas.extend(regressions_dir.glob(ext)) - return [str(p.relative_to(Path(__file__).parent.parent)) for p in sorted(schemas)] + return [str(p.relative_to(REPO_ROOT)) for p in sorted(schemas)] # Define all fixture schemas to test @@ -74,8 +77,7 @@ def validate_generated_python_file(file_path: Path, file_content: str, fixture_p @pytest.mark.parametrize("client_generator", [APIGenerator]) def test_fixture_schema_generates_client(fixture_path, client_generator) -> None: """Test that each fixture schema can generate a working client.""" - base_path = Path(__file__).parent.parent - spec_path = base_path / fixture_path + spec_path = REPO_ROOT / fixture_path # Skip if file doesn't exist or is empty if not spec_path.exists() or spec_path.stat().st_size == 0: diff --git a/tests/generators/api/test_generator.py b/tests/generators/api/test_generator.py new file mode 100644 index 00000000..b96a6fe3 --- /dev/null +++ b/tests/generators/api/test_generator.py @@ -0,0 +1,332 @@ +"""Tests for the APIGenerator (clientele/generators/api/generator.py).""" + +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import patch + +from clientele.generators.api.generator import APIGenerator +from tests.generators.integration_utils import get_spec_path, load_spec + + +def test_api_generator_with_simple_spec(): + """Test APIGenerator can generate a complete client from simple spec.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "generated_client" + + # Create generator + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + # Generate client + generator.generate() + + # Verify expected files were created + assert (output_dir / "client.py").exists() + assert (output_dir / "schemas.py").exists() + assert (output_dir / "config.py").exists() + assert (output_dir / "__init__.py").exists() + assert (output_dir / "MANIFEST.md").exists() + + # Verify files have content + client_content = (output_dir / "client.py").read_text() + assert len(client_content) > 100 + assert "def " in client_content + + schemas_content = (output_dir / "schemas.py").read_text() + assert len(schemas_content) > 50 + + +def test_api_generator_with_best_spec(): + """Test APIGenerator with the comprehensive 'best' spec.""" + spec = load_spec("best.json") + spec_path = get_spec_path("best.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "best_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + # Verify generation succeeded + assert (output_dir / "client.py").exists() + assert (output_dir / "schemas.py").exists() + + # Check for specific expected operations + client_content = (output_dir / "client.py").read_text() + assert "def simple_request_simple_request_get" in client_content + assert "def parameter_request_simple_request" in client_content + + +def test_api_generator_async_mode(): + """Test APIGenerator generates async client.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "async_client" + + generator = APIGenerator( + spec=spec, asyncio=True, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + # Verify async client was generated + client_content = (output_dir / "client.py").read_text() + assert "async def " in client_content + + +def test_api_generator_prevents_accidental_regen(): + """Test that generator prevents accidental regeneration.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "protected_client" + + # First generation with regen=True + generator1 = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + generator1.generate() + + # Second generation without regen should be prevented + generator2 = APIGenerator( + spec=spec, asyncio=False, regen=False, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + # prevent_accidental_regens should return False (blocked) + assert generator2.prevent_accidental_regens() is False + + +def test_api_generator_with_yaml_spec(): + """Test APIGenerator works with YAML spec.""" + spec = load_spec("test_303.yaml") + spec_path = get_spec_path("test_303.yaml") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "yaml_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + # Verify generation succeeded + assert (output_dir / "client.py").exists() + assert (output_dir / "schemas.py").exists() + + +def test_post_without_request_body_omits_data_parameter(): + """POST/PUT/PATCH endpoints without a request body must not generate a `data` parameter.""" + spec = load_spec("best.json") + spec_path = get_spec_path("best.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "no_body_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 "def post_without_body(" in client_content + # Extract the full function signature (may span multiple lines until ->) + sig_start = client_content.index("def post_without_body(") + sig_end = client_content.index("->", sig_start) + signature = client_content[sig_start:sig_end] + assert "data:" not in signature, f"Unexpected 'data' parameter in: {signature}" + assert "item_id: str" in signature + assert "force:" in signature + + +def test_api_generator_creates_manifest(): + """Test that generator creates proper MANIFEST.md.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "manifest_test" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + manifest_path = output_dir / "MANIFEST.md" + assert manifest_path.exists() + + manifest_content = manifest_path.read_text() + assert "clientele" in manifest_content + assert "Generated" in manifest_content or "generated" in manifest_content + + +def test_api_generator_preserves_existing_config_py(): + """Test that generator preserves existing config.py.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + output_dir.mkdir(parents=True) + + # Create an existing config.py with custom content + existing_config = output_dir / "config.py" + custom_content = "base_url: str = 'https://custom.example.com'\n" + existing_config.write_text(custom_content) + assert existing_config.exists() + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + # Verify existing config.py was NOT replaced + preserved_content = existing_config.read_text() + # Check for the key part - custom URL should still be there + assert "custom.example.com" in preserved_content + + +def test_api_generator_removes_existing_manifest(): + """Test that generator removes existing MANIFEST.md.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + output_dir.mkdir(parents=True) + + # Create an existing MANIFEST.md file + existing_manifest = output_dir / "MANIFEST.md" + existing_manifest.write_text("# Old manifest content\n") + assert existing_manifest.exists() + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + generator.generate() + + # Verify file was replaced + assert existing_manifest.exists() + new_content = existing_manifest.read_text() + assert "Old manifest content" not in new_content + assert "Generated" in new_content or "generated" in new_content + + +def test_api_generator_handles_ruff_formatting_error(): + """Test that generator handles Ruff formatting errors gracefully.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + # Mock subprocess.run to raise CalledProcessError on first call (format) + with patch("subprocess.run") as mock_run: + mock_run.side_effect = subprocess.CalledProcessError( + returncode=1, cmd=["ruff", "format"], stderr="Formatting error occurred" + ) + + # Should not raise exception, just log warning + generator.generate() + + # Verify client was still generated despite formatting error + assert (Path(output_dir) / "client.py").exists() + + +def test_api_generator_handles_ruff_not_found(): + """Test that generator handles missing Ruff gracefully.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + + # Mock subprocess.run to raise FileNotFoundError + with patch("subprocess.run") as mock_run: + mock_run.side_effect = FileNotFoundError("ruff: command not found") + + # Should not raise exception, just log warning + generator.generate() + + # Verify client was still generated despite missing ruff + assert (Path(output_dir) / "client.py").exists() + + +def test_api_generator_removes_existing_files(): + """Test that the generator removes existing files before regenerating.""" + spec = load_spec("simple.json") + spec_path = get_spec_path("simple.json") + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + generator.generate() + + assert (output_dir / "client.py").exists() + + test_file = output_dir / "schemas.py" + test_file.write_text("# Old content") + + generator2 = APIGenerator( + spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) + ) + generator2.generate() + + assert test_file.exists() + assert "# Old content" not in test_file.read_text() + + +def test_api_generator_uses_server_url_from_spec(): + """Test that the base URL from the spec servers ends up in config.py.""" + from cicerone import parse as cicerone_parse + + spec = cicerone_parse.parse_spec_from_dict( + { + "openapi": "3.0.0", + "info": {"title": "Test API", "version": "1.0.0"}, + "servers": [{"url": "https://api.example.com"}], + "paths": { + "/test": { + "get": { + "operationId": "test_get", + "responses": {"200": {"description": "Success"}}, + } + } + }, + } + ) + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "test_client" + + generator = APIGenerator(spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=None) + generator.generate() + + assert (output_dir / "config.py").exists() + config_content = (output_dir / "config.py").read_text() + assert "api.example.com" in config_content diff --git a/tests/test_issue_248.py b/tests/generators/api/test_issue_248.py similarity index 100% rename from tests/test_issue_248.py rename to tests/generators/api/test_issue_248.py diff --git a/tests/test_nullable_fields.py b/tests/generators/api/test_nullable_fields.py similarity index 100% rename from tests/test_nullable_fields.py rename to tests/generators/api/test_nullable_fields.py diff --git a/tests/test_openapi_31_null_type.py b/tests/generators/api/test_openapi_31_null_type.py similarity index 100% rename from tests/test_openapi_31_null_type.py rename to tests/generators/api/test_openapi_31_null_type.py diff --git a/tests/test_ref_handling.py b/tests/generators/api/test_ref_handling.py similarity index 100% rename from tests/test_ref_handling.py rename to tests/generators/api/test_ref_handling.py diff --git a/tests/generators/basic/__init__.py b/tests/generators/basic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/generators/basic/test_generator.py b/tests/generators/basic/test_generator.py new file mode 100644 index 00000000..1b033d28 --- /dev/null +++ b/tests/generators/basic/test_generator.py @@ -0,0 +1,42 @@ +"""Tests for the BasicGenerator (clientele/generators/basic/generator.py).""" + +import tempfile +from pathlib import Path + +from clientele.generators.basic.generator import BasicGenerator + + +def test_basic_generator_removes_existing_manifest(): + """Test that basic generator removes existing MANIFEST.md (line 32).""" + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "basic_client" + output_dir.mkdir(parents=True) + + existing_manifest = output_dir / "MANIFEST.md" + existing_manifest.write_text("# Old manifest\n") + assert existing_manifest.exists() + + generator = BasicGenerator(output_dir=str(output_dir)) + generator.generate() + + assert existing_manifest.exists() + new_content = existing_manifest.read_text() + assert "Old manifest" not in new_content + + +def test_basic_generator_removes_existing_files(): + """Test that basic generator removes existing files (line 43).""" + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir) / "basic_client" + output_dir.mkdir(parents=True) + + (output_dir / "client.py").write_text("# Old client\n") + (output_dir / "schemas.py").write_text("# Old schemas\n") + (output_dir / "http.py").write_text("# Old http\n") + (output_dir / "config.py").write_text("# Old config\n") + + generator = BasicGenerator(output_dir=str(output_dir)) + generator.generate() + + assert "Old client" not in (output_dir / "client.py").read_text() + assert "Old schemas" not in (output_dir / "schemas.py").read_text() diff --git a/tests/generators/framework/test_framework_generator_coverage.py b/tests/generators/framework/test_framework_generator_coverage.py deleted file mode 100644 index cf6ec639..00000000 --- a/tests/generators/framework/test_framework_generator_coverage.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Additional tests for framework generator to increase coverage.""" - -import subprocess -import tempfile -from pathlib import Path -from unittest.mock import patch - -from clientele.generators.api.generator import APIGenerator -from tests.generators.integration_utils import get_spec_path, load_spec - - -def test_framework_generator_preserves_existing_config_py(): - """Test that generator preserves existing config.py.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - output_dir.mkdir(parents=True) - - # Create an existing config.py with custom content - existing_config = output_dir / "config.py" - custom_content = "base_url: str = 'https://custom.example.com'\n" - existing_config.write_text(custom_content) - assert existing_config.exists() - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - # Verify existing config.py was NOT replaced - preserved_content = existing_config.read_text() - # Check for the key part - custom URL should still be there - assert "custom.example.com" in preserved_content - - -def test_framework_generator_removes_existing_manifest(): - """Test that generator removes existing MANIFEST.md.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - output_dir.mkdir(parents=True) - - # Create an existing MANIFEST.md file - existing_manifest = output_dir / "MANIFEST.md" - existing_manifest.write_text("# Old manifest content\n") - assert existing_manifest.exists() - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - # Verify file was replaced - assert existing_manifest.exists() - new_content = existing_manifest.read_text() - assert "Old manifest content" not in new_content - assert "Generated" in new_content or "generated" in new_content - - -def test_framework_generator_handles_ruff_formatting_error(): - """Test that generator handles Ruff formatting errors gracefully.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - # Mock subprocess.run to raise CalledProcessError on first call (format) - with patch("subprocess.run") as mock_run: - mock_run.side_effect = subprocess.CalledProcessError( - returncode=1, cmd=["ruff", "format"], stderr="Formatting error occurred" - ) - - # Should not raise exception, just log warning - generator.generate() - - # Verify client was still generated despite formatting error - assert (Path(output_dir) / "client.py").exists() - - -def test_framework_generator_handles_ruff_not_found(): - """Test that generator handles missing Ruff gracefully.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - # Mock subprocess.run to raise FileNotFoundError - with patch("subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError("ruff: command not found") - - # Should not raise exception, just log warning - generator.generate() - - # Verify client was still generated despite missing ruff - assert (Path(output_dir) / "client.py").exists() - - -def test_framework_generator_removes_existing_files(): - """Test that the generator removes existing files before regenerating.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - generator.generate() - - assert (output_dir / "client.py").exists() - - test_file = output_dir / "schemas.py" - test_file.write_text("# Old content") - - generator2 = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - generator2.generate() - - assert test_file.exists() - assert "# Old content" not in test_file.read_text() - - -def test_framework_generator_uses_server_url_from_spec(): - """Test that the base URL from the spec servers ends up in config.py.""" - from cicerone import parse as cicerone_parse - - spec = cicerone_parse.parse_spec_from_dict( - { - "openapi": "3.0.0", - "info": {"title": "Test API", "version": "1.0.0"}, - "servers": [{"url": "https://api.example.com"}], - "paths": { - "/test": { - "get": { - "operationId": "test_get", - "responses": {"200": {"description": "Success"}}, - } - } - }, - } - ) - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - - generator = APIGenerator(spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=None) - generator.generate() - - assert (output_dir / "config.py").exists() - config_content = (output_dir / "config.py").read_text() - assert "api.example.com" in config_content diff --git a/tests/generators/framework/test_framework_generator_integration.py b/tests/generators/framework/test_framework_generator_integration.py deleted file mode 100644 index 29dafbf0..00000000 --- a/tests/generators/framework/test_framework_generator_integration.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Integration tests for standard generator.""" - -import tempfile -from pathlib import Path - -from clientele.generators.api.generator import APIGenerator -from tests.generators.integration_utils import get_spec_path, load_spec - - -def test_framework_generator_with_simple_spec(): - """Test APIGenerator can generate a complete client from simple spec.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "generated_client" - - # Create generator - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - # Generate client - generator.generate() - - # Verify expected files were created - assert (output_dir / "client.py").exists() - assert (output_dir / "schemas.py").exists() - assert (output_dir / "config.py").exists() - assert (output_dir / "__init__.py").exists() - assert (output_dir / "MANIFEST.md").exists() - - # Verify files have content - client_content = (output_dir / "client.py").read_text() - assert len(client_content) > 100 - assert "def " in client_content - - schemas_content = (output_dir / "schemas.py").read_text() - assert len(schemas_content) > 50 - - -def test_standard_generator_with_best_spec(): - """Test APIGenerator with the comprehensive 'best' spec.""" - spec = load_spec("best.json") - spec_path = get_spec_path("best.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "best_client" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - # Verify generation succeeded - assert (output_dir / "client.py").exists() - assert (output_dir / "schemas.py").exists() - - # Check for specific expected operations - client_content = (output_dir / "client.py").read_text() - assert "def simple_request_simple_request_get" in client_content - assert "def parameter_request_simple_request" in client_content - - -def test_standard_generator_async_mode(): - """Test APIGenerator generates async client.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "async_client" - - generator = APIGenerator( - spec=spec, asyncio=True, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - # Verify async client was generated - client_content = (output_dir / "client.py").read_text() - assert "async def " in client_content - - -def test_standard_generator_prevents_accidental_regen(): - """Test that generator prevents accidental regeneration.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "protected_client" - - # First generation with regen=True - generator1 = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - generator1.generate() - - # Second generation without regen should be prevented - generator2 = APIGenerator( - spec=spec, asyncio=False, regen=False, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - # prevent_accidental_regens should return False (blocked) - assert generator2.prevent_accidental_regens() is False - - -def test_standard_generator_with_yaml_spec(): - """Test APIGenerator works with YAML spec.""" - spec = load_spec("test_303.yaml") - spec_path = get_spec_path("test_303.yaml") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "yaml_client" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - # Verify generation succeeded - assert (output_dir / "client.py").exists() - assert (output_dir / "schemas.py").exists() - - -def test_post_without_request_body_omits_data_parameter(): - """POST/PUT/PATCH endpoints without a request body must not generate a `data` parameter.""" - spec = load_spec("best.json") - spec_path = get_spec_path("best.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "no_body_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 "def post_without_body(" in client_content - # Extract the full function signature (may span multiple lines until ->) - sig_start = client_content.index("def post_without_body(") - sig_end = client_content.index("->", sig_start) - signature = client_content[sig_start:sig_end] - assert "data:" not in signature, f"Unexpected 'data' parameter in: {signature}" - assert "item_id: str" in signature - assert "force:" in signature - - -def test_standard_generator_creates_manifest(): - """Test that generator creates proper MANIFEST.md.""" - spec = load_spec("simple.json") - spec_path = get_spec_path("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "manifest_test" - - generator = APIGenerator( - spec=spec, asyncio=False, regen=True, output_dir=str(output_dir), url=None, file=str(spec_path) - ) - - generator.generate() - - manifest_path = output_dir / "MANIFEST.md" - assert manifest_path.exists() - - manifest_content = manifest_path.read_text() - assert "clientele" in manifest_content - assert "Generated" in manifest_content or "generated" in manifest_content diff --git a/tests/generators/test_schemas_coverage.py b/tests/generators/shared/test_schemas.py similarity index 99% rename from tests/generators/test_schemas_coverage.py rename to tests/generators/shared/test_schemas.py index 4bb92113..f95532b8 100644 --- a/tests/generators/test_schemas_coverage.py +++ b/tests/generators/shared/test_schemas.py @@ -1,4 +1,4 @@ -"""Additional tests for schema generators to increase coverage.""" +"""Tests for the shared SchemasGenerator (clientele/generators/shared/schemas.py).""" import tempfile from pathlib import Path diff --git a/tests/test_testing_utilities.py b/tests/test_testing.py similarity index 100% rename from tests/test_testing_utilities.py rename to tests/test_testing.py From 0fb27cb18a0d0fe23741febdc675a838386e116b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:11:19 +0000 Subject: [PATCH 10/11] Remove vestigial extension seams and stale 'framework' naming Follow-ups from reviewing the refactor: - ClientsGenerator kept two seams that only existed so the deleted base class could be specialized: writer stored as an instance attribute aliasing the module, and the constant method template map rebuilt per instance. Both suggested override points that no longer exist; the template map is now a module-level constant and writer is used directly. - SchemasGenerator.generated_response_class_names was a mutable class-level list that nothing reads or writes - removed. - The api generator still described itself as 'the framework generator' in docstrings and comments, terminology this branch otherwise removed. Docstrings, the package comment, and the matching test name now say api. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- clientele/generators/api/__init__.py | 2 +- clientele/generators/api/clients.py | 24 ++++++++++++------------ clientele/generators/api/generator.py | 6 +++--- clientele/generators/shared/schemas.py | 2 -- tests/generators/test_base.py | 2 +- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/clientele/generators/api/__init__.py b/clientele/generators/api/__init__.py index 4aa2dce1..a9d5ecc3 100644 --- a/clientele/generators/api/__init__.py +++ b/clientele/generators/api/__init__.py @@ -1 +1 @@ -# Framework generator for decorator-based clients +# API generator for decorator-based clients diff --git a/clientele/generators/api/clients.py b/clientele/generators/api/clients.py index c450b853..8bfbb215 100644 --- a/clientele/generators/api/clients.py +++ b/clientele/generators/api/clients.py @@ -13,6 +13,15 @@ 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 @@ -49,7 +58,6 @@ class ClientsGenerator: 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 @@ -70,14 +78,6 @@ def __init__( self.schemas_generator = schemas_generator self.asyncio = asyncio self.function_and_status_codes_bundle = {} - self.writer = writer - self.method_template_map = dict( - 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", - ) def generate_paths(self) -> None: # Check if the spec has paths @@ -299,7 +299,7 @@ def generate_function( 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, @@ -325,12 +325,12 @@ def generate_function( 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, diff --git a/clientele/generators/api/generator.py b/clientele/generators/api/generator.py index dd8253fb..1b603293 100644 --- a/clientele/generators/api/generator.py +++ b/clientele/generators/api/generator.py @@ -16,9 +16,9 @@ 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 @@ -52,7 +52,7 @@ 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), diff --git a/clientele/generators/shared/schemas.py b/clientele/generators/shared/schemas.py index 7438f0f3..c89a3125 100644 --- a/clientele/generators/shared/schemas.py +++ b/clientele/generators/shared/schemas.py @@ -63,8 +63,6 @@ def __init__(self, spec: cicerone_openapi_spec.OpenAPISpec, output_dir: str, wri self.output_dir = output_dir self.writer = writer - generated_response_class_names: list[str] = [] - def generate_enum_properties(self, properties: dict) -> str: lines = [ f" {utils.snake_case_prop(arg.upper())} = {utils.get_type(arg_details)}\n" diff --git a/tests/generators/test_base.py b/tests/generators/test_base.py index df0adf4e..057a4230 100644 --- a/tests/generators/test_base.py +++ b/tests/generators/test_base.py @@ -16,7 +16,7 @@ def test_basic_generator_inherits_from_generator(): assert issubclass(BasicGenerator, Generator) -def test_framework_generator_inherits_from_generator(): +def test_api_generator_inherits_from_generator(): """Test that APIGenerator inherits from Generator ABC.""" assert issubclass(APIGenerator, Generator) From a8afd349a2e367ffe7dada827fee3a26bc0a506c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 12:11:19 +0000 Subject: [PATCH 11/11] Tidy test suite details found in self-review - Make every tests/ directory a package: tests/generators/* gained __init__.py during the reorganization while tests/api, tests/cache, tests/http, tests/graphql and tests/mypy had none, leaving two import conventions and a collision trap for same-named test files in non-package directories. - tests/generators/api/test_clients.py: replace the identical 10-line generator construction repeated in all three tests with one helper, and use tmp_path instead of tempfile boilerplate. - tests/test_cli.py: use the existing get_spec_path() helper instead of re-deriving the example_openapi_specs path from __file__. - tests/generators/basic/test_generator.py: drop docstring references to physical line numbers in the module under test. https://claude.ai/code/session_012AGyWgJj3Mp5aNFJ5qAB4s --- tests/api/__init__.py | 0 tests/api/retries/__init__.py | 0 tests/api/stream/__init__.py | 0 tests/cache/__init__.py | 0 tests/generators/api/test_clients.py | 152 +++++++++-------------- tests/generators/basic/test_generator.py | 4 +- tests/graphql/__init__.py | 0 tests/http/__init__.py | 0 tests/http/backends/__init__.py | 0 tests/mypy/__init__.py | 0 tests/test_cli.py | 3 +- 11 files changed, 61 insertions(+), 98 deletions(-) create mode 100644 tests/api/__init__.py create mode 100644 tests/api/retries/__init__.py create mode 100644 tests/api/stream/__init__.py create mode 100644 tests/cache/__init__.py create mode 100644 tests/graphql/__init__.py create mode 100644 tests/http/__init__.py create mode 100644 tests/http/backends/__init__.py create mode 100644 tests/mypy/__init__.py diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/retries/__init__.py b/tests/api/retries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/stream/__init__.py b/tests/api/stream/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/generators/api/test_clients.py b/tests/generators/api/test_clients.py index 3df31e05..1524fc41 100644 --- a/tests/generators/api/test_clients.py +++ b/tests/generators/api/test_clients.py @@ -1,118 +1,80 @@ """Tests for the ClientsGenerator (clientele/generators/api/clients.py).""" -import tempfile -from pathlib import Path from unittest.mock import patch from clientele.generators.api import clients as api_clients +from clientele.generators.api import writer as api_writer +from clientele.generators.shared.schemas import SchemasGenerator from tests.generators.integration_utils import load_spec -def test_clients_generator_handles_optional_path_parameters(): - """Test that clients generator handles optional query parameters.""" - spec = load_spec("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - output_dir.mkdir(parents=True) - - from clientele.generators.api import writer as api_writer - from clientele.generators.shared.schemas import SchemasGenerator +def _make_clients_generator(spec, output_dir) -> api_clients.ClientsGenerator: + schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) + return api_clients.ClientsGenerator( + spec=spec, + output_dir=str(output_dir), + schemas_generator=schemas_gen, + asyncio=False, + ) - schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) - generator = api_clients.ClientsGenerator( - spec=spec, - output_dir=str(output_dir), - schemas_generator=schemas_gen, - asyncio=False, - ) - - parameters = [ - { - "name": "filter", - "in": "query", - "required": False, - "schema": {"type": "string"}, - } - ] +def test_clients_generator_handles_optional_path_parameters(tmp_path): + """Test that clients generator handles optional query parameters.""" + generator = _make_clients_generator(load_spec("simple.json"), tmp_path) + + parameters = [ + { + "name": "filter", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] - result = generator.generate_parameters(parameters, []) + result = generator.generate_parameters(parameters, []) - assert "filter" in result.query_args - assert "Optional" in result.query_args["filter"] + assert "filter" in result.query_args + assert "Optional" in result.query_args["filter"] -def test_clients_generator_resolves_schema_refs_in_parameters(): +def test_clients_generator_resolves_schema_refs_in_parameters(tmp_path): """Test that $ref schema types in parameters use schemas.X prefix.""" - spec = load_spec("simple.json") - - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - output_dir.mkdir(parents=True) - - from clientele.generators.api import writer as api_writer - from clientele.generators.shared.schemas import SchemasGenerator - - schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) - - generator = api_clients.ClientsGenerator( - spec=spec, - output_dir=str(output_dir), - schemas_generator=schemas_gen, - asyncio=False, - ) - - parameters = [ - { - "name": "status", - "in": "query", - "required": False, - "schema": { - "anyOf": [ - {"$ref": "#/components/schemas/ActionStatus"}, - {"type": "null"}, - ], - }, - } - ] - - result = generator.generate_parameters(parameters, []) - - assert "status" in result.query_args - param_type = result.query_args["status"] - assert "schemas.ActionStatus" in param_type - assert param_type.count("None") <= 1 - - -def test_clients_generator_handles_multiple_input_classes(): - """Test that clients generator handles multiple input classes.""" - spec = load_spec("simple.json") + generator = _make_clients_generator(load_spec("simple.json"), tmp_path) + + parameters = [ + { + "name": "status", + "in": "query", + "required": False, + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/ActionStatus"}, + {"type": "null"}, + ], + }, + } + ] - with tempfile.TemporaryDirectory() as tmpdir: - output_dir = Path(tmpdir) / "test_client" - output_dir.mkdir(parents=True) + result = generator.generate_parameters(parameters, []) - from clientele.generators.api import writer as api_writer - from clientele.generators.shared.schemas import SchemasGenerator + assert "status" in result.query_args + param_type = result.query_args["status"] + assert "schemas.ActionStatus" in param_type + assert param_type.count("None") <= 1 - schemas_gen = SchemasGenerator(spec=spec, output_dir=str(output_dir), writer=api_writer) - generator = api_clients.ClientsGenerator( - spec=spec, - output_dir=str(output_dir), - schemas_generator=schemas_gen, - asyncio=False, - ) +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) - request_body = { - "content": { - "application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, - "application/xml": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, - } + request_body = { + "content": { + "application/json": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, + "application/xml": {"schema": {"type": "object", "properties": {"name": {"type": "string"}}}}, } + } - with patch.object(generator, "get_input_class_names", return_value=["InputClass1", "InputClass2"]): - result = generator.generate_input_types(request_body, "test_func") + with patch.object(generator, "get_input_class_names", return_value=["InputClass1", "InputClass2"]): + result = generator.generate_input_types(request_body, "test_func") - assert "Union" in result or "|" in result + assert "Union" in result or "|" in result diff --git a/tests/generators/basic/test_generator.py b/tests/generators/basic/test_generator.py index 1b033d28..cfa9b363 100644 --- a/tests/generators/basic/test_generator.py +++ b/tests/generators/basic/test_generator.py @@ -7,7 +7,7 @@ def test_basic_generator_removes_existing_manifest(): - """Test that basic generator removes existing MANIFEST.md (line 32).""" + """Test that basic generator replaces an existing MANIFEST.md.""" with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) / "basic_client" output_dir.mkdir(parents=True) @@ -25,7 +25,7 @@ def test_basic_generator_removes_existing_manifest(): def test_basic_generator_removes_existing_files(): - """Test that basic generator removes existing files (line 43).""" + """Test that basic generator removes existing files before regenerating.""" with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) / "basic_client" output_dir.mkdir(parents=True) diff --git a/tests/graphql/__init__.py b/tests/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/http/__init__.py b/tests/http/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/http/backends/__init__.py b/tests/http/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mypy/__init__.py b/tests/mypy/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_cli.py b/tests/test_cli.py index 6b5c3da9..3ff195fc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,6 +9,7 @@ from click.testing import CliRunner from clientele import cli +from tests.generators.integration_utils import get_spec_path @pytest.fixture @@ -187,7 +188,7 @@ def test_prepare_spec_returns_none_for_old_version(write_spec_file): def test_generate_commands_with_valid_spec(runner, command, regen_arg, expected_output): """Test that all generate commands create client successfully with valid spec.""" # Use the simple spec from example_openapi_specs - spec_path = Path(__file__).parent.parent / "example_openapi_specs" / "simple.json" + spec_path = get_spec_path("simple.json") with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) / "test_client"