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/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/base_clients.py b/clientele/generators/api/clients.py similarity index 81% rename from clientele/generators/base_clients.py rename to clientele/generators/api/clients.py index d4dda2a9..8bfbb215 100644 --- a/clientele/generators/base_clients.py +++ b/clientele/generators/api/clients.py @@ -1,4 +1,4 @@ -"""Base class for client generators shared by api and basic generators.""" +"""Generates the client.py file for the api generator.""" import collections import typing @@ -8,10 +8,20 @@ from rich import console as rich_console from clientele.generators import cicerone_compat -from clientele.generators.shared import utils +from clientele.generators.api import writer +from clientele.generators.shared import schemas, utils console = rich_console.Console() +# Template used for each HTTP method when rendering client functions +METHOD_TEMPLATE_MAP = { + "get": "api_get_method.jinja2", + "delete": "api_get_method.jinja2", + "post": "api_post_method.jinja2", + "put": "api_post_method.jinja2", + "patch": "api_post_method.jinja2", +} + class ParametersResponse(pydantic.BaseModel): # Parameters that need to be passed in the URL query @@ -23,21 +33,6 @@ class ParametersResponse(pydantic.BaseModel): # Mapping from sanitized Python name to original API parameter name param_name_map: dict[str, str] = {} - def get_path_args_as_string(self) -> str: - # Get all the path arguments, and the query arguments and make a big string out of them. - args = list(self.path_args.items()) + list(self.query_args.items()) - # Separate required and optional parameters - # Python requires all required parameters (no default) before optional ones (with default) - required_args = [] - optional_args = [] - for k, v in args: - if v.startswith("typing.Optional["): - optional_args.append(f"{k}: {v} = None") - else: - required_args.append(f"{k}: {v}") - # Return required parameters first, then optional ones - return ", ".join(required_args + optional_args) - def get_required_args_as_string(self) -> str: """Get only required parameters (those without Optional wrapper).""" args = list(self.path_args.items()) + list(self.query_args.items()) @@ -56,54 +51,33 @@ def get_optional_args_as_string(self) -> str: optional_args.append(f"{k}: {v} = None") return ", ".join(optional_args) if optional_args else "" - def get_required_path_args_as_string(self) -> str: - """Get only required path parameters (for clientele-api generator).""" - required_args = [] - for k, v in self.path_args.items(): - if not v.startswith("typing.Optional["): - required_args.append(f"{k}: {v}") - return ", ".join(required_args) if required_args else "" - - def has_query_args(self) -> bool: - """Check if there are any query parameters.""" - return len(self.query_args) > 0 - -class BaseClientsGenerator: +class ClientsGenerator: """ - Base class for generating client code from OpenAPI specifications. - Provides common functionality for client generators. + Handles all the content generated in the client.py file for the api generator. + This generates decorator-based client functions instead of traditional functions. """ - method_template_map: dict[str, str] results: dict[str, int] spec: cicerone_openapi_spec.OpenAPISpec output_dir: str - schemas_generator: typing.Any # Type will be specific in subclasses - http_generator: typing.Any # Type will be specific in subclasses - writer: typing.Any # Must be set by subclass + schemas_generator: schemas.SchemasGenerator + # Status codes per generated function, used to build response_map arguments + function_and_status_codes_bundle: dict[str, dict[str, str]] def __init__( self, spec: cicerone_openapi_spec.OpenAPISpec, output_dir: str, - schemas_generator: typing.Any, - http_generator: typing.Any, + schemas_generator: schemas.SchemasGenerator, asyncio: bool, ) -> None: self.spec = spec self.output_dir = output_dir self.results = collections.defaultdict(int) self.schemas_generator = schemas_generator - self.http_generator = http_generator self.asyncio = asyncio - self.method_template_map = dict( - get="get_method.jinja2", - delete="get_method.jinja2", - post="post_method.jinja2", - put="post_method.jinja2", - patch="post_method.jinja2", - ) + self.function_and_status_codes_bundle = {} def generate_paths(self) -> None: # Check if the spec has paths @@ -221,7 +195,7 @@ def get_response_class_names(self, responses: dict, func_name: str) -> list[str] ) status_code_map[status_code] = class_name response_classes.append(class_name) - self.http_generator.add_status_codes_to_bundle(func_name=func_name, status_code_map=status_code_map) + self.function_and_status_codes_bundle[func_name] = status_code_map # Use set to deduplicate, then sorted for consistent ordering return sorted(set(response_classes)) @@ -267,6 +241,31 @@ def generate_input_types(self, request_body: dict, func_name: str) -> str: else: return f"schemas.{input_class_names[0]}" + def get_response_map(self, func_name: str) -> typing.Optional[str]: + """ + Generate response_map for decorator if there are multiple response types. + Returns a string representation of the response_map dict or None. + """ + status_codes = self.function_and_status_codes_bundle.get(func_name, {}) + + # If there's only one response code, we don't need response_map + if len(status_codes) <= 1: + return None + + # Filter out None responses (no content responses) + valid_responses = {code: schema for code, schema in status_codes.items() if schema != "None"} + + if len(valid_responses) <= 1: + return None + + # Build response_map dict with schemas. prefix + response_map_parts = [] + for status_code, schema_name in sorted(valid_responses.items()): + # Add schemas. prefix to match the response types + response_map_parts.append(f"{status_code}: schemas.{schema_name}") + + return "{" + ", ".join(response_map_parts) + "}" + def generate_function( self, operation: dict, @@ -289,13 +288,10 @@ def generate_function( parameters=operation.get("parameters", []), additional_parameters=additional_parameters, ) - # Replace path parameters in URL with sanitized names + # Replace path parameters in URL with sanitized names but DON'T add query args + # In the clientele api, query args are passed as function parameters, not in the URL api_url = utils.replace_path_parameters(url, function_arguments.param_name_map) - if query_args := function_arguments.query_args: - # Use original parameter names in URL, but sanitized names for Python variables - api_url = api_url + utils.create_query_args_with_mapping( - list(query_args.keys()), function_arguments.param_name_map - ) + if method in ["post", "put", "patch"] and not operation.get("requestBody"): data_class_name = None elif method in ["post", "put", "patch"]: @@ -303,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, @@ -311,10 +307,14 @@ def generate_function( ) else: header_class_name = None + + # Get response_map for clientele api decorator + response_map = self.get_response_map(func_name) + content = template.render( asyncio=self.asyncio, func_name=func_name, - function_arguments=function_arguments, # Pass the object, not the string + function_arguments=function_arguments, response_types=response_types, data_class_name=data_class_name, header_class_name=header_class_name, @@ -323,13 +323,14 @@ def generate_function( summary=operation.get("summary", summary), description=operation.get("description"), deprecated=operation.get("deprecated", False), + response_map=response_map, ) - self.writer.write_to_client(content=content, output_dir=self.output_dir) + writer.write_to_client(content=content, output_dir=self.output_dir) def write_path_to_client(self, path: tuple[str, dict]) -> None: url, operations = path for method, operation in operations.items(): - if method.lower() in self.method_template_map.keys(): + if method.lower() in METHOD_TEMPLATE_MAP: self.generate_function( operation=operation, method=method, diff --git a/clientele/generators/api/generator.py b/clientele/generators/api/generator.py index 34d197b6..1b603293 100644 --- a/clientele/generators/api/generator.py +++ b/clientele/generators/api/generator.py @@ -7,18 +7,18 @@ from cicerone.spec import openapi_spec as cicerone_openapi_spec from rich import console as rich_console -from clientele import generators, settings, utils -from clientele.generators.api import writer -from clientele.generators.api.generators import clients, schemas +from clientele import generators, settings +from clientele.generators.api import clients, writer +from clientele.generators.shared import schemas, utils console = rich_console.Console() class APIGenerator(generators.Generator): """ - The framework Clientele generator. + The API Clientele generator. - Produces a decorator-based Python HTTP Client library using the clientele framework. + Produces a decorator-based Python HTTP Client library using the clientele runtime. """ spec: cicerone_openapi_spec.OpenAPISpec @@ -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), @@ -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/__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/clients.py b/clientele/generators/api/generators/clients.py deleted file mode 100644 index 413cbf41..00000000 --- a/clientele/generators/api/generators/clients.py +++ /dev/null @@ -1,146 +0,0 @@ -import typing - -from cicerone.spec import openapi_spec as cicerone_openapi_spec - -from clientele.generators import base_clients -from clientele.generators.api import writer -from clientele.generators.api.generators import schemas - - -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]] = {} - - 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 - - -class ClientsGenerator(base_clients.BaseClientsGenerator): - """ - Handles all the content generated in the client.py file for the api generator. - This generates decorator-based client functions instead of traditional functions. - """ - - schemas_generator: schemas.SchemasGenerator - http_placeholder: FrameworkHTTPPlaceholder - - def __init__( - self, - spec: cicerone_openapi_spec.OpenAPISpec, - output_dir: str, - 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.writer = writer - # Override template map for clientele-specific templates - 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 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, {}) - - # If there's only one response code, we don't need response_map - if len(status_codes) <= 1: - return None - - # Filter out None responses (no content responses) - valid_responses = {code: schema for code, schema in status_codes.items() if schema != "None"} - - if len(valid_responses) <= 1: - return None - - # Build response_map dict with schemas. prefix - response_map_parts = [] - for status_code, schema_name in sorted(valid_responses.items()): - # Add schemas. prefix to match the response types - response_map_parts.append(f"{status_code}: schemas.{schema_name}") - - return "{" + ", ".join(response_map_parts) + "}" - - def generate_function( - self, - operation: dict, - method: str, - url: str, - 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) - 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 but DON'T add query args - # In the clientele api, query args are passed as function parameters, not in the URL - api_url = utils.replace_path_parameters(url, function_arguments.param_name_map) - - if 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 - - # Get response_map for clientele api decorator - response_map = self.get_response_map(func_name) - - content = template.render( - asyncio=self.asyncio, - func_name=func_name, - function_arguments=function_arguments, - 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), - response_map=response_map, - ) - self.writer.write_to_client(content=content, output_dir=self.output_dir) 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/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/generators/schemas.py b/clientele/generators/shared/schemas.py similarity index 85% rename from clientele/generators/shared/generators/schemas.py rename to clientele/generators/shared/schemas.py index d8dcc389..c89a3125 100644 --- a/clientele/generators/shared/generators/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. @@ -26,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" @@ -112,7 +147,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 ca2abe76..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" @@ -175,29 +213,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/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/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/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/clientele/generators/shared/generators/__init__.py b/tests/api/__init__.py similarity index 100% rename from clientele/generators/shared/generators/__init__.py rename to tests/api/__init__.py 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/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): diff --git a/tests/cache/__init__.py b/tests/cache/__init__.py new file mode 100644 index 00000000..e69de29b 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/api/test_clients.py b/tests/generators/api/test_clients.py new file mode 100644 index 00000000..1524fc41 --- /dev/null +++ b/tests/generators/api/test_clients.py @@ -0,0 +1,80 @@ +"""Tests for the ClientsGenerator (clientele/generators/api/clients.py).""" + +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 _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, + ) + + +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, []) + + assert "filter" in result.query_args + assert "Optional" in result.query_args["filter"] + + +def test_clients_generator_resolves_schema_refs_in_parameters(tmp_path): + """Test that $ref schema types in parameters use schemas.X prefix.""" + 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"}, + ], + }, + } + ] + + 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(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"}}}}, + } + } + + 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 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..cfa9b363 --- /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 replaces an existing MANIFEST.md.""" + 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 before regenerating.""" + 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 cb38cf7b..00000000 --- a/tests/generators/framework/test_framework_generator_coverage.py +++ /dev/null @@ -1,111 +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() 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 86% rename from tests/generators/test_schemas_coverage.py rename to tests/generators/shared/test_schemas.py index 39636f20..f95532b8 100644 --- a/tests/generators/test_schemas_coverage.py +++ b/tests/generators/shared/test_schemas.py @@ -1,10 +1,10 @@ -"""Additional tests for schema generators to increase coverage.""" +"""Tests for the shared SchemasGenerator (clientele/generators/shared/schemas.py).""" import tempfile 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 @@ -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/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/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) diff --git a/tests/generators/test_base_and_basic_coverage.py b/tests/generators/test_base_and_basic_coverage.py deleted file mode 100644 index c945e22f..00000000 --- a/tests/generators/test_base_and_basic_coverage.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Additional tests for base clients and basic generator coverage.""" - -import tempfile -from pathlib import Path -from unittest.mock import patch - -from clientele.generators.api.generators 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") - - 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.generators.schemas import SchemasGenerator - - 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), - schemas_generator=schemas_gen, - asyncio=False, - ) - - parameters = [ - { - "name": "filter", - "in": "query", - "required": False, - "schema": {"type": "string"}, - } - ] - - result = generator.generate_parameters(parameters, []) - - assert "filter" in result.query_args - assert "Optional" in result.query_args["filter"] - - -def test_clients_generator_resolves_schema_refs_in_parameters(): - """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.generators.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") - - 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.generators.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, - ) - - 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") - - assert "Union" in result or "|" in result 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/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_additional_coverage.py b/tests/test_additional_coverage.py deleted file mode 100644 index 3dc8f18e..00000000 --- a/tests/test_additional_coverage.py +++ /dev/null @@ -1,72 +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.generators.schemas import SchemasGenerator -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"}]} - 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_cli.py b/tests/test_cli.py index 7bc3bbc9..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 @@ -101,17 +102,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 +156,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,11 +187,8 @@ 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" + spec_path = get_spec_path("simple.json") with tempfile.TemporaryDirectory() as tmpdir: output_dir = Path(tmpdir) / "test_client" @@ -162,9 +203,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 deleted file mode 100644 index 16e4d3df..00000000 --- a/tests/test_edge_case_coverage.py +++ /dev/null @@ -1,65 +0,0 @@ -"""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(): - """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 - - 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 8ced8dd7..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.generators.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() 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 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 diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index f35558d9..00000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for utility functions.""" - -from clientele.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"