otterapi 0.0.5__py3-none-any.whl → 0.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. README.md +581 -8
  2. otterapi/__init__.py +73 -0
  3. otterapi/cli.py +327 -29
  4. otterapi/codegen/__init__.py +115 -0
  5. otterapi/codegen/ast_utils.py +134 -5
  6. otterapi/codegen/client.py +1271 -0
  7. otterapi/codegen/codegen.py +1736 -0
  8. otterapi/codegen/dataframes.py +392 -0
  9. otterapi/codegen/emitter.py +473 -0
  10. otterapi/codegen/endpoints.py +2597 -343
  11. otterapi/codegen/pagination.py +1026 -0
  12. otterapi/codegen/schema.py +593 -0
  13. otterapi/codegen/splitting.py +1397 -0
  14. otterapi/codegen/types.py +1345 -0
  15. otterapi/codegen/utils.py +180 -1
  16. otterapi/config.py +1017 -24
  17. otterapi/exceptions.py +231 -0
  18. otterapi/openapi/__init__.py +46 -0
  19. otterapi/openapi/v2/__init__.py +86 -0
  20. otterapi/openapi/v2/spec.json +1607 -0
  21. otterapi/openapi/v2/v2.py +1776 -0
  22. otterapi/openapi/v3/__init__.py +131 -0
  23. otterapi/openapi/v3/spec.json +1651 -0
  24. otterapi/openapi/v3/v3.py +1557 -0
  25. otterapi/openapi/v3_1/__init__.py +133 -0
  26. otterapi/openapi/v3_1/spec.json +1411 -0
  27. otterapi/openapi/v3_1/v3_1.py +798 -0
  28. otterapi/openapi/v3_2/__init__.py +133 -0
  29. otterapi/openapi/v3_2/spec.json +1666 -0
  30. otterapi/openapi/v3_2/v3_2.py +777 -0
  31. otterapi/tests/__init__.py +3 -0
  32. otterapi/tests/fixtures/__init__.py +455 -0
  33. otterapi/tests/test_ast_utils.py +680 -0
  34. otterapi/tests/test_codegen.py +610 -0
  35. otterapi/tests/test_dataframe.py +1038 -0
  36. otterapi/tests/test_exceptions.py +493 -0
  37. otterapi/tests/test_openapi_support.py +616 -0
  38. otterapi/tests/test_openapi_upgrade.py +215 -0
  39. otterapi/tests/test_pagination.py +1101 -0
  40. otterapi/tests/test_splitting_config.py +319 -0
  41. otterapi/tests/test_splitting_integration.py +427 -0
  42. otterapi/tests/test_splitting_resolver.py +512 -0
  43. otterapi/tests/test_splitting_tree.py +525 -0
  44. otterapi-0.0.6.dist-info/METADATA +627 -0
  45. otterapi-0.0.6.dist-info/RECORD +48 -0
  46. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
  47. otterapi/codegen/generator.py +0 -358
  48. otterapi/codegen/openapi_processor.py +0 -27
  49. otterapi/codegen/type_generator.py +0 -559
  50. otterapi-0.0.5.dist-info/METADATA +0 -54
  51. otterapi-0.0.5.dist-info/RECORD +0 -16
  52. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
@@ -1,25 +1,1708 @@
1
+ """
2
+ Endpoint generation module for creating sync and async HTTP request functions.
3
+
4
+ This module provides utilities for generating Python AST nodes that represent
5
+ HTTP endpoint functions. It supports both synchronous and asynchronous request
6
+ patterns with automatic parameter handling, response validation, and type hints.
7
+
8
+ Key Features:
9
+ - Generates sync and async base request functions with response validation
10
+ - Creates endpoint-specific functions with proper parameter handling
11
+ - Supports query, path, header, and body parameters
12
+ - Automatic TypeAdapter validation and RootModel unwrapping
13
+ - Configurable BASE_URL handling
14
+ - File upload support (multipart/form-data)
15
+ - Form data support (application/x-www-form-urlencoded)
16
+ - Binary response handling with streaming
17
+ - Request timeout configuration
18
+ - Unified EndpointFunctionFactory for consistent endpoint generation
19
+ """
20
+
1
21
  import ast
22
+ import re
2
23
  import textwrap
24
+ from dataclasses import dataclass, field
25
+ from enum import Enum
26
+ from typing import TYPE_CHECKING, Literal, Self
27
+
28
+ from otterapi.codegen.ast_utils import (
29
+ _argument,
30
+ _assign,
31
+ _async_func,
32
+ _attr,
33
+ _call,
34
+ _func,
35
+ _name,
36
+ _subscript,
37
+ _union_expr,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from otterapi.codegen.types import Parameter, RequestBodyInfo, ResponseInfo, Type
42
+
43
+ # Type alias for import dictionaries used throughout this module
44
+ ImportDict = dict[str, set[str]]
45
+
46
+ __all__ = [
47
+ # Enums and dataclasses
48
+ 'EndpointMode',
49
+ 'DataFrameLibrary',
50
+ 'PaginationStyle',
51
+ 'EndpointFunctionConfig',
52
+ 'FunctionSignature',
53
+ # Factory class
54
+ 'EndpointFunctionFactory',
55
+ # Builder classes
56
+ 'ParameterASTBuilder',
57
+ 'FunctionSignatureBuilder',
58
+ # Base request functions
59
+ 'base_request_fn',
60
+ 'base_async_request_fn',
61
+ 'request_fn',
62
+ 'async_request_fn',
63
+ # Helper functions
64
+ 'clean_docstring',
65
+ 'get_parameters',
66
+ 'get_base_call_keywords',
67
+ 'build_header_params',
68
+ 'build_query_params',
69
+ 'build_path_params',
70
+ 'build_body_params',
71
+ 'prepare_call_from_parameters',
72
+ 'build_default_client_code',
73
+ # Convenience functions
74
+ 'build_standalone_endpoint_fn',
75
+ 'build_delegating_endpoint_fn',
76
+ 'build_standalone_dataframe_fn',
77
+ 'build_delegating_dataframe_fn',
78
+ 'build_standalone_paginated_fn',
79
+ 'build_standalone_paginated_iter_fn',
80
+ 'build_standalone_paginated_dataframe_fn',
81
+ ]
82
+
83
+
84
+ # =============================================================================
85
+ # Enums and Configuration
86
+ # =============================================================================
87
+
88
+
89
+ class EndpointMode(Enum):
90
+ """Mode of endpoint function generation."""
91
+
92
+ STANDALONE = 'standalone'
93
+ """Standalone function that uses a Client instance internally."""
94
+
95
+ DELEGATING = 'delegating'
96
+ """Delegating function that calls _get_client().method_name()."""
97
+
98
+
99
+ class DataFrameLibrary(Enum):
100
+ """DataFrame library for DataFrame endpoint functions."""
101
+
102
+ PANDAS = 'pandas'
103
+ POLARS = 'polars'
104
+
105
+
106
+ class PaginationStyle(Enum):
107
+ """Pagination style for paginated endpoint functions."""
108
+
109
+ OFFSET = 'offset'
110
+ CURSOR = 'cursor'
111
+ PAGE = 'page'
112
+
113
+
114
+ def clean_docstring(docstring: str) -> str:
115
+ """Clean and normalize a docstring by removing excess indentation.
116
+
117
+ Args:
118
+ docstring: The raw docstring to clean.
119
+
120
+ Returns:
121
+ A cleaned docstring with normalized indentation.
122
+ """
123
+ return textwrap.dedent(f'\n{docstring}\n').strip()
124
+
125
+
126
+ # =============================================================================
127
+ # Function Signature Building
128
+ # =============================================================================
129
+
130
+
131
+ @dataclass
132
+ class FunctionSignature:
133
+ """Represents a function signature with args, kwargs, and annotations.
134
+
135
+ This dataclass holds all the components needed to build the arguments
136
+ portion of a function definition AST node.
137
+
138
+ Attributes:
139
+ args: Required positional arguments.
140
+ kwonlyargs: Optional keyword-only arguments.
141
+ kw_defaults: Default values for keyword-only arguments.
142
+ imports: Required imports for type annotations.
143
+ """
144
+
145
+ args: list[ast.arg] = field(default_factory=list)
146
+ kwonlyargs: list[ast.arg] = field(default_factory=list)
147
+ kw_defaults: list[ast.expr] = field(default_factory=list)
148
+ imports: ImportDict = field(default_factory=dict)
149
+
150
+ def to_ast_arguments(
151
+ self,
152
+ kwargs: ast.arg | None = None,
153
+ ) -> ast.arguments:
154
+ """Convert to an ast.arguments node.
155
+
156
+ Args:
157
+ kwargs: Optional **kwargs argument to include.
158
+
159
+ Returns:
160
+ An ast.arguments node ready for use in a FunctionDef.
161
+ """
162
+ return ast.arguments(
163
+ posonlyargs=[],
164
+ args=self.args,
165
+ kwonlyargs=self.kwonlyargs,
166
+ kw_defaults=self.kw_defaults,
167
+ kwarg=kwargs,
168
+ defaults=[],
169
+ )
170
+
171
+
172
+ class FunctionSignatureBuilder:
173
+ """Builder for function signatures from endpoint parameters.
174
+
175
+ This builder provides a fluent interface for constructing function
176
+ signatures, handling the common patterns of:
177
+ - Converting parameters to function arguments
178
+ - Separating required and optional parameters
179
+ - Adding body parameters
180
+ - Adding optional client/path parameters
181
+ - Collecting type annotation imports
182
+
183
+ Example:
184
+ >>> builder = FunctionSignatureBuilder()
185
+ >>> signature = (
186
+ ... builder
187
+ ... .add_parameters(endpoint.parameters)
188
+ ... .add_request_body(endpoint.request_body)
189
+ ... .add_client_parameter()
190
+ ... .build()
191
+ ... )
192
+ """
193
+
194
+ def __init__(self):
195
+ """Initialize an empty signature builder."""
196
+ self._args: list[ast.arg] = []
197
+ self._kwonlyargs: list[ast.arg] = []
198
+ self._kw_defaults: list[ast.expr] = []
199
+ self._imports: ImportDict = {}
200
+
201
+ def _add_import(self, module: str, name: str) -> None:
202
+ """Add a single import to the collection."""
203
+ if module not in self._imports:
204
+ self._imports[module] = set()
205
+ self._imports[module].add(name)
206
+
207
+ def _merge_imports(self, imports: ImportDict) -> None:
208
+ """Merge imports from another ImportDict."""
209
+ for module, names in imports.items():
210
+ if module not in self._imports:
211
+ self._imports[module] = set()
212
+ self._imports[module].update(names)
213
+
214
+ def add_parameters(self, parameters: list['Parameter'] | None) -> Self:
215
+ """Add endpoint parameters to the signature.
216
+
217
+ Separates required parameters into positional args and optional
218
+ parameters into keyword-only args with None defaults.
219
+
220
+ Args:
221
+ parameters: List of Parameter objects, or None.
222
+
223
+ Returns:
224
+ Self for method chaining.
225
+ """
226
+ if not parameters:
227
+ return self
228
+
229
+ for param in parameters:
230
+ if param.type:
231
+ annotation = param.type.annotation_ast
232
+ self._merge_imports(param.type.annotation_imports)
233
+ else:
234
+ annotation = _name('Any')
235
+ self._add_import('typing', 'Any')
236
+
237
+ arg = _argument(param.name_sanitized, annotation)
238
+
239
+ if param.required:
240
+ self._args.append(arg)
241
+ else:
242
+ self._kwonlyargs.append(arg)
243
+ self._kw_defaults.append(ast.Constant(value=None))
244
+
245
+ return self
246
+
247
+ def add_request_body(
248
+ self,
249
+ body: 'RequestBodyInfo | None',
250
+ ) -> Self:
251
+ """Add request body parameter to the signature.
252
+
253
+ Adds a 'body' parameter with the appropriate type annotation.
254
+ Required bodies become positional args, optional become keyword-only.
255
+
256
+ Args:
257
+ body: The RequestBodyInfo object, or None.
258
+
259
+ Returns:
260
+ Self for method chaining.
261
+ """
262
+ if not body:
263
+ return self
264
+
265
+ if body.type:
266
+ body_annotation = body.type.annotation_ast
267
+ self._merge_imports(body.type.annotation_imports)
268
+ else:
269
+ body_annotation = _name('Any')
270
+ self._add_import('typing', 'Any')
271
+
272
+ body_arg = _argument('body', body_annotation)
273
+
274
+ if body.required:
275
+ self._args.append(body_arg)
276
+ else:
277
+ self._kwonlyargs.append(body_arg)
278
+ self._kw_defaults.append(ast.Constant(value=None))
279
+
280
+ return self
281
+
282
+ def add_client_parameter(self, client_type: str = 'Client') -> Self:
283
+ """Add an optional client parameter to the signature.
284
+
285
+ Adds a keyword-only 'client' parameter with type `Client | None`
286
+ and default value None.
287
+
288
+ Args:
289
+ client_type: The name of the client class (default: 'Client').
290
+
291
+ Returns:
292
+ Self for method chaining.
293
+ """
294
+ self._kwonlyargs.append(
295
+ _argument(
296
+ 'client',
297
+ _union_expr([_name(client_type), ast.Constant(value=None)]),
298
+ )
299
+ )
300
+ self._kw_defaults.append(ast.Constant(value=None))
301
+
302
+ return self
303
+
304
+ def add_path_parameter(self, default: str | None = None) -> Self:
305
+ """Add an optional path parameter for DataFrame methods.
306
+
307
+ Adds a keyword-only 'path' parameter with type `str | None`
308
+ and the specified default value.
309
+
310
+ Args:
311
+ default: Default value for the path parameter.
312
+
313
+ Returns:
314
+ Self for method chaining.
315
+ """
316
+ self._kwonlyargs.append(
317
+ _argument(
318
+ 'path',
319
+ _union_expr([_name('str'), ast.Constant(value=None)]),
320
+ )
321
+ )
322
+ self._kw_defaults.append(ast.Constant(value=default))
323
+
324
+ return self
325
+
326
+ def add_self_parameter(self) -> Self:
327
+ """Add 'self' as the first positional argument.
328
+
329
+ Used when building method signatures for classes.
330
+
331
+ Returns:
332
+ Self for method chaining.
333
+ """
334
+ self._args.insert(0, _argument('self'))
335
+ return self
336
+
337
+ def add_custom_kwarg(
338
+ self,
339
+ name: str,
340
+ annotation: ast.expr,
341
+ default: ast.expr,
342
+ imports: ImportDict | None = None,
343
+ ) -> Self:
344
+ """Add a custom keyword-only argument.
345
+
346
+ Args:
347
+ name: The argument name.
348
+ annotation: The type annotation AST node.
349
+ default: The default value AST node.
350
+ imports: Optional imports required for the annotation.
351
+
352
+ Returns:
353
+ Self for method chaining.
354
+ """
355
+ self._kwonlyargs.append(_argument(name, annotation))
356
+ self._kw_defaults.append(default)
357
+
358
+ if imports:
359
+ self._merge_imports(imports)
360
+
361
+ return self
362
+
363
+ def build(self) -> FunctionSignature:
364
+ """Build and return the function signature.
365
+
366
+ Returns:
367
+ A FunctionSignature containing all accumulated arguments and imports.
368
+ """
369
+ return FunctionSignature(
370
+ args=self._args.copy(),
371
+ kwonlyargs=self._kwonlyargs.copy(),
372
+ kw_defaults=self._kw_defaults.copy(),
373
+ imports=self._imports.copy(),
374
+ )
375
+
376
+ def reset(self) -> Self:
377
+ """Reset the builder to empty state.
378
+
379
+ Returns:
380
+ Self for method chaining.
381
+ """
382
+ self._args = []
383
+ self._kwonlyargs = []
384
+ self._kw_defaults = []
385
+ self._imports = {}
386
+ return self
387
+
388
+
389
+ # =============================================================================
390
+ # Parameter AST Building
391
+ # =============================================================================
392
+
393
+
394
+ class ParameterASTBuilder:
395
+ """Unified builder for parameter-related AST nodes.
396
+
397
+ This class provides static methods for building AST nodes that represent
398
+ various types of request parameters.
399
+
400
+ Example:
401
+ >>> query_dict = ParameterASTBuilder.build_query_params(parameters)
402
+ >>> path_expr = ParameterASTBuilder.build_path_expr('/pet/{petId}', parameters)
403
+ """
404
+
405
+ @staticmethod
406
+ def build_query_params(parameters: list['Parameter']) -> ast.Dict | None:
407
+ """Build a dictionary AST node for query parameters.
408
+
409
+ Creates an AST Dict node mapping query parameter names to their values.
410
+ Only includes parameters where location == 'query'.
411
+
412
+ Args:
413
+ parameters: List of Parameter objects to filter for query params.
414
+
415
+ Returns:
416
+ An AST Dict node for query parameters, or None if no query params.
417
+ """
418
+ query_params = [p for p in parameters if p.location == 'query']
419
+ if not query_params:
420
+ return None
421
+
422
+ return ast.Dict(
423
+ keys=[ast.Constant(value=param.name) for param in query_params],
424
+ values=[_name(param.name_sanitized) for param in query_params],
425
+ )
426
+
427
+ @staticmethod
428
+ def build_header_params(parameters: list['Parameter']) -> ast.Dict | None:
429
+ """Build a dictionary AST node for header parameters.
430
+
431
+ Creates an AST Dict node mapping header parameter names to their values.
432
+ Only includes parameters where location == 'header'.
433
+
434
+ Args:
435
+ parameters: List of Parameter objects to filter for headers.
436
+
437
+ Returns:
438
+ An AST Dict node for header parameters, or None if no header params.
439
+ """
440
+ header_params = [p for p in parameters if p.location == 'header']
441
+ if not header_params:
442
+ return None
443
+
444
+ return ast.Dict(
445
+ keys=[ast.Constant(value=param.name) for param in header_params],
446
+ values=[_name(param.name_sanitized) for param in header_params],
447
+ )
448
+
449
+ @staticmethod
450
+ def build_path_expr(path: str, parameters: list['Parameter']) -> ast.expr:
451
+ """Build an f-string or constant for the request path.
452
+
453
+ Replaces OpenAPI path parameters like {petId} with Python f-string
454
+ expressions using the sanitized parameter names.
455
+
456
+ Args:
457
+ path: The OpenAPI path string with placeholders (e.g., '/pet/{petId}').
458
+ parameters: List of Parameter objects to map placeholders to variables.
459
+
460
+ Returns:
461
+ An AST expression:
462
+ - ast.Constant for static paths without parameters
463
+ - ast.JoinedStr (f-string) for paths with parameter substitution
464
+ """
465
+ path_params = {
466
+ p.name: p.name_sanitized for p in parameters if p.location == 'path'
467
+ }
468
+
469
+ if not path_params:
470
+ return ast.Constant(value=path)
471
+
472
+ pattern = r'\{([^}]+)\}'
473
+ parts = re.split(pattern, path)
474
+ values: list[ast.expr] = []
475
+
476
+ for i, part in enumerate(parts):
477
+ if i % 2 == 0:
478
+ if part:
479
+ values.append(ast.Constant(value=part))
480
+ else:
481
+ sanitized = path_params.get(part, part)
482
+ values.append(ast.FormattedValue(value=_name(sanitized), conversion=-1))
483
+
484
+ if len(values) == 1 and isinstance(values[0], ast.Constant):
485
+ return values[0]
486
+
487
+ return ast.JoinedStr(values=values)
488
+
489
+ @staticmethod
490
+ def build_body_expr(
491
+ body: 'RequestBodyInfo | None',
492
+ ) -> tuple[ast.expr | None, str | None]:
493
+ """Build an AST expression for the request body parameter.
494
+
495
+ Handles different content types and determines the appropriate
496
+ httpx parameter name to use.
497
+
498
+ Args:
499
+ body: The RequestBodyInfo object, or None if no body.
500
+
501
+ Returns:
502
+ A tuple of (body_expr, httpx_param_name).
503
+ """
504
+ if not body:
505
+ return None, None
506
+
507
+ body_name = 'body'
508
+
509
+ if body.is_json and body.type and body.type.type in ('model', 'root'):
510
+ body_expr = _call(
511
+ func=_attr(_name(body_name), 'model_dump'),
512
+ args=[],
513
+ )
514
+ elif body.is_multipart:
515
+ body_expr = _name(body_name)
516
+ elif body.is_form:
517
+ if body.type and body.type.type in ('model', 'root'):
518
+ body_expr = _call(
519
+ func=_attr(_name(body_name), 'model_dump'),
520
+ args=[],
521
+ )
522
+ else:
523
+ body_expr = _name(body_name)
524
+ else:
525
+ body_expr = _name(body_name)
526
+
527
+ return body_expr, body.httpx_param_name
528
+
529
+ @staticmethod
530
+ def prepare_all_params(
531
+ parameters: list['Parameter'] | None,
532
+ path: str,
533
+ request_body_info: 'RequestBodyInfo | None' = None,
534
+ ) -> tuple[ast.expr | None, ast.expr | None, ast.expr | None, str | None, ast.expr]:
535
+ """Prepare all parameter AST nodes for a request function call.
536
+
537
+ Args:
538
+ parameters: List of Parameter objects, or None.
539
+ path: The API path with optional placeholders.
540
+ request_body_info: Request body info, or None.
541
+
542
+ Returns:
543
+ A tuple of (query_params, header_params, body_expr, body_param_name, path_expr).
544
+ """
545
+ if not parameters:
546
+ parameters = []
547
+
548
+ query_params = ParameterASTBuilder.build_query_params(parameters)
549
+ header_params = ParameterASTBuilder.build_header_params(parameters)
550
+ body_expr, body_param_name = ParameterASTBuilder.build_body_expr(
551
+ request_body_info
552
+ )
553
+ path_expr = ParameterASTBuilder.build_path_expr(path, parameters)
554
+
555
+ return query_params, header_params, body_expr, body_param_name, path_expr
556
+
557
+
558
+ # =============================================================================
559
+ # Endpoint Function Configuration
560
+ # =============================================================================
561
+
562
+
563
+ @dataclass
564
+ class EndpointFunctionConfig:
565
+ """Configuration for endpoint function generation.
566
+
567
+ This dataclass holds all the parameters needed to generate an endpoint
568
+ function. By using a configuration object, we can easily create different
569
+ types of endpoint functions through a single factory.
570
+
571
+ Attributes:
572
+ fn_name: The name of the function to generate.
573
+ method: HTTP method (GET, POST, etc.).
574
+ path: The API path with optional placeholders.
575
+ parameters: List of Parameter objects for the endpoint.
576
+ request_body_info: RequestBodyInfo object for the request body, or None.
577
+ response_type: The response Type for the return annotation.
578
+ response_infos: List of ResponseInfo objects for status code handling.
579
+ docs: Optional docstring for the generated function.
580
+ is_async: Whether to generate an async function.
581
+ mode: The generation mode (standalone or delegating).
582
+ client_method_name: For delegating mode, the client method to call.
583
+ dataframe_library: For DataFrame functions, which library to use.
584
+ dataframe_path: For DataFrame functions, the default path parameter.
585
+ """
586
+
587
+ fn_name: str
588
+ method: str
589
+ path: str
590
+ parameters: list['Parameter'] | None = None
591
+ request_body_info: 'RequestBodyInfo | None' = None
592
+ response_type: 'Type | None' = None
593
+ response_infos: list['ResponseInfo'] | None = None
594
+ docs: str | None = None
595
+ is_async: bool = False
596
+ mode: EndpointMode = EndpointMode.STANDALONE
597
+ client_method_name: str | None = None
598
+ dataframe_library: DataFrameLibrary | None = None
599
+ dataframe_path: str | None = None
600
+ # Pagination config
601
+ pagination_style: PaginationStyle | None = None
602
+ pagination_config: dict | None = None # Contains offset_param, limit_param, etc.
603
+ is_iterator: bool = False # If True, generates _iter function
604
+ is_paginated_dataframe: bool = (
605
+ False # If True, generates DataFrame from paginated results
606
+ )
607
+ item_type_ast: ast.expr | None = None # The type of items in the list
608
+
609
+
610
+ # =============================================================================
611
+ # Endpoint Function Factory
612
+ # =============================================================================
613
+
614
+
615
+ class EndpointFunctionFactory:
616
+ """Factory for generating endpoint functions.
617
+
618
+ This factory consolidates the logic for generating various types of
619
+ endpoint functions, reducing code duplication and improving maintainability.
620
+
621
+ Example:
622
+ >>> config = EndpointFunctionConfig(
623
+ ... fn_name='get_pet_by_id',
624
+ ... method='get',
625
+ ... path='/pet/{petId}',
626
+ ... parameters=[...],
627
+ ... mode=EndpointMode.STANDALONE,
628
+ ... )
629
+ >>> factory = EndpointFunctionFactory(config)
630
+ >>> func_ast, imports = factory.build()
631
+ """
632
+
633
+ def __init__(self, config: EndpointFunctionConfig):
634
+ """Initialize the factory with configuration.
635
+
636
+ Args:
637
+ config: The endpoint function configuration.
638
+ """
639
+ self.config = config
640
+ self._imports: ImportDict = {}
641
+
642
+ def _add_import(self, module: str, name: str) -> None:
643
+ """Add a single import to the collection."""
644
+ if module not in self._imports:
645
+ self._imports[module] = set()
646
+ self._imports[module].add(name)
647
+
648
+ def _merge_imports(self, imports: ImportDict) -> None:
649
+ """Merge imports from another ImportDict."""
650
+ for module, names in imports.items():
651
+ if module not in self._imports:
652
+ self._imports[module] = set()
653
+ self._imports[module].update(names)
654
+
655
+ def build(self) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
656
+ """Build the endpoint function based on configuration.
657
+
658
+ Returns:
659
+ A tuple of (function_ast, imports).
660
+ """
661
+ self._imports = {}
662
+
663
+ signature = self._build_signature()
664
+
665
+ if self.config.pagination_style:
666
+ if self.config.is_iterator:
667
+ body = self._build_paginated_iter_body()
668
+ elif self.config.is_paginated_dataframe:
669
+ body = self._build_paginated_dataframe_body()
670
+ else:
671
+ body = self._build_paginated_body()
672
+ elif self.config.dataframe_library:
673
+ body = self._build_dataframe_body()
674
+ elif self.config.mode == EndpointMode.STANDALONE:
675
+ body = self._build_standalone_body()
676
+ else:
677
+ body = self._build_delegating_body()
678
+
679
+ returns = self._build_return_type()
680
+
681
+ func_builder = _async_func if self.config.is_async else _func
682
+
683
+ func_ast = func_builder(
684
+ name=self.config.fn_name,
685
+ args=signature.args,
686
+ body=body,
687
+ kwargs=_argument('kwargs', _name('dict')),
688
+ kwonlyargs=signature.kwonlyargs,
689
+ kw_defaults=signature.kw_defaults,
690
+ returns=returns,
691
+ )
692
+
693
+ self._merge_imports(signature.imports)
694
+
695
+ return func_ast, self._imports
696
+
697
+ def _build_signature(self) -> FunctionSignature:
698
+ """Build the function signature using FunctionSignatureBuilder."""
699
+ builder = FunctionSignatureBuilder()
700
+
701
+ # For pagination, we need to filter out the pagination parameters
702
+ # since we'll add our own pagination-specific parameters
703
+ if self.config.pagination_style and self.config.pagination_config:
704
+ pag_config = self.config.pagination_config
705
+ skip_params = set()
706
+ if self.config.pagination_style == PaginationStyle.OFFSET:
707
+ skip_params = {
708
+ pag_config.get('offset_param'),
709
+ pag_config.get('limit_param'),
710
+ }
711
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
712
+ skip_params = {
713
+ pag_config.get('cursor_param'),
714
+ pag_config.get('limit_param'),
715
+ }
716
+ elif self.config.pagination_style == PaginationStyle.PAGE:
717
+ skip_params = {
718
+ pag_config.get('page_param'),
719
+ pag_config.get('per_page_param'),
720
+ }
721
+
722
+ filtered_params = [
723
+ p for p in (self.config.parameters or []) if p.name not in skip_params
724
+ ]
725
+ builder.add_parameters(filtered_params)
726
+
727
+ # Add pagination-specific parameters
728
+ self._add_pagination_parameters(builder)
729
+ else:
730
+ builder.add_parameters(self.config.parameters)
731
+
732
+ builder.add_request_body(self.config.request_body_info)
733
+
734
+ if self.config.dataframe_library and not self.config.is_paginated_dataframe:
735
+ # Only add path parameter for non-paginated DataFrame functions
736
+ # Paginated DataFrame functions don't need it - data is already extracted
737
+ builder.add_path_parameter(default=self.config.dataframe_path)
738
+
739
+ if self.config.mode == EndpointMode.STANDALONE:
740
+ builder.add_client_parameter()
741
+
742
+ return builder.build()
743
+
744
+ def _add_pagination_parameters(self, builder: FunctionSignatureBuilder) -> None:
745
+ """Add pagination-specific parameters to the signature builder."""
746
+ pag_config = self.config.pagination_config or {}
747
+ default_page_size = pag_config.get('default_page_size', 100)
748
+
749
+ if self.config.pagination_style == PaginationStyle.OFFSET:
750
+ # offset: int | None = None
751
+ builder.add_custom_kwarg(
752
+ name='offset',
753
+ annotation=_union_expr([_name('int'), ast.Constant(value=None)]),
754
+ default=ast.Constant(value=None),
755
+ )
756
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
757
+ # cursor: str | None = None
758
+ builder.add_custom_kwarg(
759
+ name='cursor',
760
+ annotation=_union_expr([_name('str'), ast.Constant(value=None)]),
761
+ default=ast.Constant(value=None),
762
+ )
763
+ elif self.config.pagination_style == PaginationStyle.PAGE:
764
+ # page: int | None = None
765
+ builder.add_custom_kwarg(
766
+ name='page',
767
+ annotation=_union_expr([_name('int'), ast.Constant(value=None)]),
768
+ default=ast.Constant(value=None),
769
+ )
770
+
771
+ # page_size: int = default_page_size
772
+ builder.add_custom_kwarg(
773
+ name='page_size',
774
+ annotation=_name('int'),
775
+ default=ast.Constant(value=default_page_size),
776
+ )
777
+
778
+ # max_items: int | None = None
779
+ builder.add_custom_kwarg(
780
+ name='max_items',
781
+ annotation=_union_expr([_name('int'), ast.Constant(value=None)]),
782
+ default=ast.Constant(value=None),
783
+ )
784
+
785
+ def _build_return_type(self) -> ast.expr:
786
+ """Build the return type annotation."""
787
+ if self.config.dataframe_library:
788
+ if self.config.dataframe_library == DataFrameLibrary.PANDAS:
789
+ return ast.Constant(value='pd.DataFrame')
790
+ else:
791
+ return ast.Constant(value='pl.DataFrame')
792
+
793
+ # Pagination return types
794
+ if self.config.pagination_style:
795
+ if self.config.item_type_ast:
796
+ if self.config.is_iterator:
797
+ # Iterator[ItemType] or AsyncIterator[ItemType]
798
+ self._add_import('collections.abc', 'Iterator')
799
+ self._add_import('collections.abc', 'AsyncIterator')
800
+ iter_type = 'AsyncIterator' if self.config.is_async else 'Iterator'
801
+ return _subscript(iter_type, self.config.item_type_ast)
802
+ else:
803
+ # list[ItemType]
804
+ return _subscript('list', self.config.item_type_ast)
805
+ else:
806
+ # Fallback to list[Any]
807
+ self._add_import('typing', 'Any')
808
+ if self.config.is_iterator:
809
+ self._add_import('collections.abc', 'Iterator')
810
+ self._add_import('collections.abc', 'AsyncIterator')
811
+ iter_type = 'AsyncIterator' if self.config.is_async else 'Iterator'
812
+ return _subscript(iter_type, _name('Any'))
813
+ else:
814
+ return _subscript('list', _name('Any'))
815
+
816
+ if self.config.response_type:
817
+ self._merge_imports(self.config.response_type.annotation_imports)
818
+ return self.config.response_type.annotation_ast
819
+
820
+ self._add_import('httpx', 'Response')
821
+ return _name('Response')
822
+
823
+ def _build_standalone_body(self) -> list[ast.stmt]:
824
+ """Build the body for a standalone endpoint function."""
825
+ body: list[ast.stmt] = []
826
+
827
+ if self.config.docs:
828
+ body.append(
829
+ ast.Expr(value=ast.Constant(value=clean_docstring(self.config.docs)))
830
+ )
831
+
832
+ # c = client or Client()
833
+ body.append(
834
+ _assign(
835
+ _name('c'),
836
+ ast.BoolOp(
837
+ op=ast.Or(),
838
+ values=[_name('client'), _call(_name('Client'))],
839
+ ),
840
+ )
841
+ )
842
+
843
+ request_keywords = self._build_request_keywords()
844
+
845
+ request_method = '_request_async' if self.config.is_async else '_request'
846
+ request_call = _call(
847
+ func=_attr('c', request_method),
848
+ keywords=request_keywords,
849
+ )
850
+
851
+ if self.config.is_async:
852
+ request_call = ast.Await(value=request_call)
853
+
854
+ if self.config.response_type:
855
+ self._merge_imports(self.config.response_type.annotation_imports)
856
+
857
+ # Check if response type is a raw type (Response, bytes, str) that doesn't need parsing
858
+ is_raw_response = self._is_raw_response_type(self.config.response_type)
859
+
860
+ if is_raw_response:
861
+ # For non-JSON responses, just return the response directly
862
+ body.append(ast.Return(value=request_call))
863
+ else:
864
+ # For JSON responses, parse and validate with Pydantic
865
+ body.append(_assign(_name('response'), request_call))
866
+
867
+ parse_method = (
868
+ '_parse_response_async'
869
+ if self.config.is_async
870
+ else '_parse_response'
871
+ )
872
+ parse_call = _call(
873
+ func=_attr('c', parse_method),
874
+ args=[_name('response'), self.config.response_type.annotation_ast],
875
+ )
876
+ if self.config.is_async:
877
+ parse_call = ast.Await(value=parse_call)
878
+ body.append(ast.Return(value=parse_call))
879
+ else:
880
+ body.append(ast.Return(value=request_call))
881
+
882
+ return body
883
+
884
+ def _is_raw_response_type(self, response_type: 'Type') -> bool:
885
+ """Check if the response type is a raw type that doesn't need JSON parsing.
886
+
887
+ Raw types include: Response, bytes, str (for non-JSON content types).
888
+ """
889
+ if response_type is None:
890
+ return True
891
+
892
+ ann = response_type.annotation_ast
893
+ if isinstance(ann, ast.Name):
894
+ # Simple type like Response, bytes, str
895
+ return ann.id in ('Response', 'bytes', 'str')
896
+
897
+ # For Union types, check if ALL types are raw types
898
+ if isinstance(ann, ast.Subscript):
899
+ if isinstance(ann.value, ast.Name) and ann.value.id == 'Union':
900
+ if isinstance(ann.slice, ast.Tuple):
901
+ for elt in ann.slice.elts:
902
+ if isinstance(elt, ast.Name):
903
+ if elt.id not in ('Response', 'bytes', 'str', 'None'):
904
+ return False
905
+ elif isinstance(elt, ast.Constant) and elt.value is None:
906
+ continue # None is OK
907
+ else:
908
+ return False # Complex type, not raw
909
+ return True
910
+
911
+ return False
912
+
913
+ def _build_delegating_body(self) -> list[ast.stmt]:
914
+ """Build the body for a delegating endpoint function."""
915
+ body: list[ast.stmt] = []
916
+
917
+ if self.config.docs:
918
+ body.append(
919
+ ast.Expr(value=ast.Constant(value=clean_docstring(self.config.docs)))
920
+ )
921
+
922
+ call_args, call_keywords = self._build_delegating_call_args()
923
+
924
+ method_name = self.config.client_method_name or self.config.fn_name
925
+ client_call = _call(
926
+ func=_attr(_call(_name('Client')), method_name),
927
+ args=call_args,
928
+ keywords=call_keywords,
929
+ )
930
+
931
+ if self.config.is_async:
932
+ client_call = ast.Await(value=client_call)
933
+
934
+ body.append(ast.Return(value=client_call))
935
+
936
+ return body
937
+
938
+ def _build_paginated_dataframe_body(self) -> list[ast.stmt]:
939
+ """Build the body for a paginated DataFrame endpoint function.
940
+
941
+ This combines pagination (to fetch all items) with DataFrame conversion.
942
+ """
943
+ body: list[ast.stmt] = []
944
+ pag_config = self.config.pagination_config or {}
945
+
946
+ library = self.config.dataframe_library
947
+ return_type_str = (
948
+ 'pd.DataFrame' if library == DataFrameLibrary.PANDAS else 'pl.DataFrame'
949
+ )
950
+
951
+ # Add docstring
952
+ doc_content = self.config.docs or ''
953
+ doc_suffix = f'\n\nReturns:\n {return_type_str}'
954
+ body.append(
955
+ ast.Expr(
956
+ value=ast.Constant(value=clean_docstring(doc_content + doc_suffix))
957
+ )
958
+ )
959
+
960
+ # c = client or Client()
961
+ body.append(
962
+ _assign(
963
+ _name('c'),
964
+ ast.BoolOp(
965
+ op=ast.Or(),
966
+ values=[_name('client'), _call(_name('Client'))],
967
+ ),
968
+ )
969
+ )
970
+
971
+ # Build the fetch_page inner function
972
+ fetch_page_fn = self._build_fetch_page_function()
973
+ body.append(fetch_page_fn)
974
+
975
+ # Build extract_items lambda
976
+ data_path = pag_config.get('data_path')
977
+ extract_items = self._build_extract_lambda(data_path)
978
+
979
+ # Build pagination call to get all items
980
+ if self.config.pagination_style == PaginationStyle.OFFSET:
981
+ paginate_fn = (
982
+ 'paginate_offset_async' if self.config.is_async else 'paginate_offset'
983
+ )
984
+
985
+ total_path = pag_config.get('total_path')
986
+ get_total = self._build_extract_lambda(total_path) if total_path else None
987
+
988
+ paginate_keywords = [
989
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
990
+ ast.keyword(arg='extract_items', value=extract_items),
991
+ ]
992
+ if get_total:
993
+ paginate_keywords.append(ast.keyword(arg='get_total', value=get_total))
994
+ paginate_keywords.extend(
995
+ [
996
+ ast.keyword(
997
+ arg='start_offset',
998
+ value=ast.BoolOp(
999
+ op=ast.Or(), values=[_name('offset'), ast.Constant(value=0)]
1000
+ ),
1001
+ ),
1002
+ ast.keyword(arg='page_size', value=_name('page_size')),
1003
+ ast.keyword(arg='max_items', value=_name('max_items')),
1004
+ ]
1005
+ )
1006
+
1007
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
1008
+ paginate_fn = (
1009
+ 'paginate_cursor_async' if self.config.is_async else 'paginate_cursor'
1010
+ )
1011
+
1012
+ next_cursor_path = pag_config.get('next_cursor_path')
1013
+ get_next_cursor = (
1014
+ self._build_extract_lambda(next_cursor_path)
1015
+ if next_cursor_path
1016
+ else ast.Lambda(
1017
+ args=ast.arguments(
1018
+ posonlyargs=[],
1019
+ args=[ast.arg(arg='page')],
1020
+ kwonlyargs=[],
1021
+ kw_defaults=[],
1022
+ defaults=[],
1023
+ ),
1024
+ body=ast.Constant(value=None),
1025
+ )
1026
+ )
1027
+
1028
+ paginate_keywords = [
1029
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1030
+ ast.keyword(arg='extract_items', value=extract_items),
1031
+ ast.keyword(arg='get_next_cursor', value=get_next_cursor),
1032
+ ast.keyword(arg='start_cursor', value=_name('cursor')),
1033
+ ast.keyword(arg='page_size', value=_name('page_size')),
1034
+ ast.keyword(arg='max_items', value=_name('max_items')),
1035
+ ]
1036
+
1037
+ elif self.config.pagination_style == PaginationStyle.PAGE:
1038
+ paginate_fn = (
1039
+ 'paginate_page_async' if self.config.is_async else 'paginate_page'
1040
+ )
1041
+
1042
+ total_pages_path = pag_config.get('total_pages_path')
1043
+ get_total_pages = (
1044
+ self._build_extract_lambda(total_pages_path)
1045
+ if total_pages_path
1046
+ else None
1047
+ )
1048
+
1049
+ paginate_keywords = [
1050
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1051
+ ast.keyword(arg='extract_items', value=extract_items),
1052
+ ]
1053
+ if get_total_pages:
1054
+ paginate_keywords.append(
1055
+ ast.keyword(arg='get_total_pages', value=get_total_pages)
1056
+ )
1057
+ paginate_keywords.extend(
1058
+ [
1059
+ ast.keyword(
1060
+ arg='start_page',
1061
+ value=ast.BoolOp(
1062
+ op=ast.Or(), values=[_name('page'), ast.Constant(value=1)]
1063
+ ),
1064
+ ),
1065
+ ast.keyword(arg='page_size', value=_name('page_size')),
1066
+ ast.keyword(arg='max_items', value=_name('max_items')),
1067
+ ]
1068
+ )
1069
+ else:
1070
+ raise ValueError(
1071
+ f'Unsupported pagination style: {self.config.pagination_style}'
1072
+ )
1073
+
1074
+ paginate_call = _call(
1075
+ func=_name(paginate_fn),
1076
+ keywords=paginate_keywords,
1077
+ )
1078
+
1079
+ if self.config.is_async:
1080
+ paginate_call = ast.Await(value=paginate_call)
1081
+
1082
+ # items = paginate_offset(...)
1083
+ body.append(_assign(_name('items'), paginate_call))
1084
+
1085
+ # Convert to DataFrame
1086
+ helper_fn = 'to_pandas' if library == DataFrameLibrary.PANDAS else 'to_polars'
1087
+
1088
+ # return to_pandas(items) or to_polars(items)
1089
+ body.append(
1090
+ ast.Return(
1091
+ value=_call(
1092
+ func=_name(helper_fn),
1093
+ args=[_name('items')],
1094
+ )
1095
+ )
1096
+ )
1097
+
1098
+ return body
1099
+
1100
+ def _build_dataframe_body(self) -> list[ast.stmt]:
1101
+ """Build the body for a DataFrame endpoint function."""
1102
+ body: list[ast.stmt] = []
1103
+
1104
+ library = self.config.dataframe_library
1105
+ return_type_str = (
1106
+ 'pd.DataFrame' if library == DataFrameLibrary.PANDAS else 'pl.DataFrame'
1107
+ )
1108
+
1109
+ doc_content = self.config.docs or ''
1110
+ doc_suffix = f'\n\nReturns:\n {return_type_str}'
1111
+ body.append(
1112
+ ast.Expr(
1113
+ value=ast.Constant(value=clean_docstring(doc_content + doc_suffix))
1114
+ )
1115
+ )
1116
+
1117
+ if self.config.mode == EndpointMode.STANDALONE:
1118
+ body.append(
1119
+ _assign(
1120
+ _name('c'),
1121
+ ast.BoolOp(
1122
+ op=ast.Or(),
1123
+ values=[_name('client'), _call(_name('Client'))],
1124
+ ),
1125
+ )
1126
+ )
1127
+
1128
+ request_keywords = self._build_request_keywords()
1129
+
1130
+ request_json_method = (
1131
+ '_request_json_async' if self.config.is_async else '_request_json'
1132
+ )
1133
+ request_call = _call(
1134
+ func=_attr('c', request_json_method),
1135
+ keywords=request_keywords,
1136
+ )
1137
+
1138
+ if self.config.is_async:
1139
+ request_call = ast.Await(value=request_call)
1140
+
1141
+ body.append(_assign(_name('data'), request_call))
1142
+ else:
1143
+ call_args, call_keywords = self._build_delegating_call_args()
1144
+ method_name = self.config.client_method_name or self.config.fn_name
1145
+ client_call = _call(
1146
+ func=_attr(_call(_name('Client')), method_name),
1147
+ args=call_args,
1148
+ keywords=call_keywords,
1149
+ )
1150
+
1151
+ if self.config.is_async:
1152
+ client_call = ast.Await(value=client_call)
1153
+
1154
+ body.append(ast.Return(value=client_call))
1155
+ return body
1156
+
1157
+ helper_fn = 'to_pandas' if library == DataFrameLibrary.PANDAS else 'to_polars'
1158
+
1159
+ body.append(
1160
+ ast.Return(
1161
+ value=_call(
1162
+ func=_name(helper_fn),
1163
+ args=[_name('data')],
1164
+ keywords=[ast.keyword(arg='path', value=_name('path'))],
1165
+ )
1166
+ )
1167
+ )
1168
+
1169
+ return body
1170
+
1171
+ def _build_paginated_body(self) -> list[ast.stmt]:
1172
+ """Build the body for a paginated endpoint function."""
1173
+ body: list[ast.stmt] = []
1174
+ pag_config = self.config.pagination_config or {}
1175
+
1176
+ # Add docstring
1177
+ if self.config.docs:
1178
+ body.append(
1179
+ ast.Expr(value=ast.Constant(value=clean_docstring(self.config.docs)))
1180
+ )
1181
+
1182
+ # c = client or Client()
1183
+ body.append(
1184
+ _assign(
1185
+ _name('c'),
1186
+ ast.BoolOp(
1187
+ op=ast.Or(),
1188
+ values=[_name('client'), _call(_name('Client'))],
1189
+ ),
1190
+ )
1191
+ )
1192
+
1193
+ # Build the fetch_page inner function
1194
+ fetch_page_fn = self._build_fetch_page_function()
1195
+ body.append(fetch_page_fn)
1196
+
1197
+ # Build extract_items lambda
1198
+ data_path = pag_config.get('data_path')
1199
+ extract_items = self._build_extract_lambda(data_path)
1200
+
1201
+ # Build pagination call
1202
+ if self.config.pagination_style == PaginationStyle.OFFSET:
1203
+ paginate_fn = (
1204
+ 'paginate_offset_async' if self.config.is_async else 'paginate_offset'
1205
+ )
1206
+
1207
+ # Build get_total lambda if total_path is specified
1208
+ total_path = pag_config.get('total_path')
1209
+ get_total = self._build_extract_lambda(total_path) if total_path else None
1210
+
1211
+ paginate_keywords = [
1212
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1213
+ ast.keyword(arg='extract_items', value=extract_items),
1214
+ ]
1215
+ if get_total:
1216
+ paginate_keywords.append(ast.keyword(arg='get_total', value=get_total))
1217
+ paginate_keywords.extend(
1218
+ [
1219
+ ast.keyword(
1220
+ arg='start_offset',
1221
+ value=ast.BoolOp(
1222
+ op=ast.Or(), values=[_name('offset'), ast.Constant(value=0)]
1223
+ ),
1224
+ ),
1225
+ ast.keyword(arg='page_size', value=_name('page_size')),
1226
+ ast.keyword(arg='max_items', value=_name('max_items')),
1227
+ ]
1228
+ )
1229
+
1230
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
1231
+ paginate_fn = (
1232
+ 'paginate_cursor_async' if self.config.is_async else 'paginate_cursor'
1233
+ )
1234
+
1235
+ next_cursor_path = pag_config.get('next_cursor_path')
1236
+ get_next_cursor = (
1237
+ self._build_extract_lambda(next_cursor_path)
1238
+ if next_cursor_path
1239
+ else ast.Lambda(
1240
+ args=ast.arguments(
1241
+ posonlyargs=[],
1242
+ args=[ast.arg(arg='page')],
1243
+ kwonlyargs=[],
1244
+ kw_defaults=[],
1245
+ defaults=[],
1246
+ ),
1247
+ body=ast.Constant(value=None),
1248
+ )
1249
+ )
1250
+
1251
+ paginate_keywords = [
1252
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1253
+ ast.keyword(arg='extract_items', value=extract_items),
1254
+ ast.keyword(arg='get_next_cursor', value=get_next_cursor),
1255
+ ast.keyword(arg='start_cursor', value=_name('cursor')),
1256
+ ast.keyword(arg='page_size', value=_name('page_size')),
1257
+ ast.keyword(arg='max_items', value=_name('max_items')),
1258
+ ]
1259
+
1260
+ elif self.config.pagination_style == PaginationStyle.PAGE:
1261
+ paginate_fn = (
1262
+ 'paginate_page_async' if self.config.is_async else 'paginate_page'
1263
+ )
1264
+
1265
+ total_pages_path = pag_config.get('total_pages_path')
1266
+ get_total_pages = (
1267
+ self._build_extract_lambda(total_pages_path)
1268
+ if total_pages_path
1269
+ else None
1270
+ )
1271
+
1272
+ paginate_keywords = [
1273
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1274
+ ast.keyword(arg='extract_items', value=extract_items),
1275
+ ]
1276
+ if get_total_pages:
1277
+ paginate_keywords.append(
1278
+ ast.keyword(arg='get_total_pages', value=get_total_pages)
1279
+ )
1280
+ paginate_keywords.extend(
1281
+ [
1282
+ ast.keyword(
1283
+ arg='start_page',
1284
+ value=ast.BoolOp(
1285
+ op=ast.Or(), values=[_name('page'), ast.Constant(value=1)]
1286
+ ),
1287
+ ),
1288
+ ast.keyword(arg='page_size', value=_name('page_size')),
1289
+ ast.keyword(arg='max_items', value=_name('max_items')),
1290
+ ]
1291
+ )
1292
+ else:
1293
+ raise ValueError(
1294
+ f'Unsupported pagination style: {self.config.pagination_style}'
1295
+ )
1296
+
1297
+ paginate_call = _call(
1298
+ func=_name(paginate_fn),
1299
+ keywords=paginate_keywords,
1300
+ )
1301
+
1302
+ if self.config.is_async:
1303
+ paginate_call = ast.Await(value=paginate_call)
1304
+
1305
+ body.append(ast.Return(value=paginate_call))
1306
+
1307
+ return body
1308
+
1309
+ def _build_paginated_iter_body(self) -> list[ast.stmt]:
1310
+ """Build the body for a paginated iterator endpoint function."""
1311
+ body: list[ast.stmt] = []
1312
+ pag_config = self.config.pagination_config or {}
1313
+
1314
+ # Add docstring
1315
+ if self.config.docs:
1316
+ body.append(
1317
+ ast.Expr(value=ast.Constant(value=clean_docstring(self.config.docs)))
1318
+ )
1319
+
1320
+ # c = client or Client()
1321
+ body.append(
1322
+ _assign(
1323
+ _name('c'),
1324
+ ast.BoolOp(
1325
+ op=ast.Or(),
1326
+ values=[_name('client'), _call(_name('Client'))],
1327
+ ),
1328
+ )
1329
+ )
1330
+
1331
+ # Build the fetch_page inner function
1332
+ fetch_page_fn = self._build_fetch_page_function()
1333
+ body.append(fetch_page_fn)
1334
+
1335
+ # Build extract_items lambda
1336
+ data_path = pag_config.get('data_path')
1337
+ extract_items = self._build_extract_lambda(data_path)
1338
+
1339
+ # Build iterate call
1340
+ if self.config.pagination_style == PaginationStyle.OFFSET:
1341
+ iterate_fn = (
1342
+ 'iterate_offset_async' if self.config.is_async else 'iterate_offset'
1343
+ )
1344
+
1345
+ total_path = pag_config.get('total_path')
1346
+ get_total = self._build_extract_lambda(total_path) if total_path else None
1347
+
1348
+ iterate_keywords = [
1349
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1350
+ ast.keyword(arg='extract_items', value=extract_items),
1351
+ ]
1352
+ if get_total:
1353
+ iterate_keywords.append(ast.keyword(arg='get_total', value=get_total))
1354
+ iterate_keywords.extend(
1355
+ [
1356
+ ast.keyword(
1357
+ arg='start_offset',
1358
+ value=ast.BoolOp(
1359
+ op=ast.Or(), values=[_name('offset'), ast.Constant(value=0)]
1360
+ ),
1361
+ ),
1362
+ ast.keyword(arg='page_size', value=_name('page_size')),
1363
+ ast.keyword(arg='max_items', value=_name('max_items')),
1364
+ ]
1365
+ )
1366
+
1367
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
1368
+ iterate_fn = (
1369
+ 'iterate_cursor_async' if self.config.is_async else 'iterate_cursor'
1370
+ )
1371
+
1372
+ next_cursor_path = pag_config.get('next_cursor_path')
1373
+ get_next_cursor = (
1374
+ self._build_extract_lambda(next_cursor_path)
1375
+ if next_cursor_path
1376
+ else ast.Lambda(
1377
+ args=ast.arguments(
1378
+ posonlyargs=[],
1379
+ args=[ast.arg(arg='page')],
1380
+ kwonlyargs=[],
1381
+ kw_defaults=[],
1382
+ defaults=[],
1383
+ ),
1384
+ body=ast.Constant(value=None),
1385
+ )
1386
+ )
1387
+
1388
+ iterate_keywords = [
1389
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1390
+ ast.keyword(arg='extract_items', value=extract_items),
1391
+ ast.keyword(arg='get_next_cursor', value=get_next_cursor),
1392
+ ast.keyword(arg='start_cursor', value=_name('cursor')),
1393
+ ast.keyword(arg='page_size', value=_name('page_size')),
1394
+ ast.keyword(arg='max_items', value=_name('max_items')),
1395
+ ]
1396
+
1397
+ elif self.config.pagination_style == PaginationStyle.PAGE:
1398
+ # Page-based pagination iterator
1399
+ iterate_fn = (
1400
+ 'iterate_page_async' if self.config.is_async else 'iterate_page'
1401
+ )
1402
+
1403
+ total_pages_path = pag_config.get('total_pages_path')
1404
+ get_total_pages = (
1405
+ self._build_extract_lambda(total_pages_path)
1406
+ if total_pages_path
1407
+ else None
1408
+ )
1409
+
1410
+ iterate_keywords = [
1411
+ ast.keyword(arg='fetch_page', value=_name('fetch_page')),
1412
+ ast.keyword(arg='extract_items', value=extract_items),
1413
+ ]
1414
+ if get_total_pages:
1415
+ iterate_keywords.append(
1416
+ ast.keyword(arg='get_total_pages', value=get_total_pages)
1417
+ )
1418
+ iterate_keywords.extend(
1419
+ [
1420
+ ast.keyword(
1421
+ arg='start_page',
1422
+ value=ast.BoolOp(
1423
+ op=ast.Or(), values=[_name('page'), ast.Constant(value=1)]
1424
+ ),
1425
+ ),
1426
+ ast.keyword(arg='page_size', value=_name('page_size')),
1427
+ ast.keyword(arg='max_items', value=_name('max_items')),
1428
+ ]
1429
+ )
1430
+ else:
1431
+ raise ValueError(
1432
+ f'Unsupported pagination style: {self.config.pagination_style}'
1433
+ )
1434
+
1435
+ iterate_call = _call(
1436
+ func=_name(iterate_fn),
1437
+ keywords=iterate_keywords,
1438
+ )
1439
+
1440
+ if self.config.is_async:
1441
+ # async for item in iterate_..._async(...): yield item
1442
+ body.append(
1443
+ ast.AsyncFor(
1444
+ target=ast.Name(id='item', ctx=ast.Store()),
1445
+ iter=iterate_call,
1446
+ body=[ast.Expr(value=ast.Yield(value=_name('item')))],
1447
+ orelse=[],
1448
+ )
1449
+ )
1450
+ else:
1451
+ # yield from iterate_...()
1452
+ body.append(ast.Expr(value=ast.YieldFrom(value=iterate_call)))
1453
+
1454
+ return body
1455
+
1456
+ def _build_fetch_page_function(self) -> ast.FunctionDef | ast.AsyncFunctionDef:
1457
+ """Build the inner fetch_page function for pagination."""
1458
+ pag_config = self.config.pagination_config or {}
1459
+
1460
+ # Determine parameter names based on pagination style
1461
+ if self.config.pagination_style == PaginationStyle.OFFSET:
1462
+ param1_name = 'off'
1463
+ param2_name = 'limit'
1464
+ param1_api_name = pag_config.get('offset_param', 'offset')
1465
+ param2_api_name = pag_config.get('limit_param', 'limit')
1466
+ elif self.config.pagination_style == PaginationStyle.CURSOR:
1467
+ param1_name = 'cur'
1468
+ param2_name = 'limit'
1469
+ param1_api_name = pag_config.get('cursor_param', 'cursor')
1470
+ param2_api_name = pag_config.get('limit_param', 'limit')
1471
+ elif self.config.pagination_style == PaginationStyle.PAGE:
1472
+ param1_name = 'pg'
1473
+ param2_name = 'per_page'
1474
+ param1_api_name = pag_config.get('page_param', 'page')
1475
+ param2_api_name = pag_config.get('per_page_param', 'per_page')
1476
+ else:
1477
+ param1_name = 'param1'
1478
+ param2_name = 'param2'
1479
+ param1_api_name = 'param1'
1480
+ param2_api_name = 'param2'
1481
+
1482
+ # Build the params dict for the request
1483
+ # Start with static params from original endpoint parameters
1484
+ param_keys = []
1485
+ param_values = []
1486
+
1487
+ # Add pagination params
1488
+ param_keys.append(ast.Constant(value=param1_api_name))
1489
+ param_values.append(_name(param1_name))
1490
+ param_keys.append(ast.Constant(value=param2_api_name))
1491
+ param_values.append(_name(param2_name))
1492
+
1493
+ # Add any other query parameters from the original endpoint
1494
+ if self.config.parameters:
1495
+ for param in self.config.parameters:
1496
+ if param.location == 'query':
1497
+ # Skip pagination params we're handling
1498
+ skip_params = {
1499
+ pag_config.get('offset_param'),
1500
+ pag_config.get('limit_param'),
1501
+ pag_config.get('cursor_param'),
1502
+ pag_config.get('page_param'),
1503
+ pag_config.get('per_page_param'),
1504
+ }
1505
+ if param.name not in skip_params:
1506
+ param_keys.append(ast.Constant(value=param.name))
1507
+ param_values.append(_name(param.name_sanitized))
1508
+
1509
+ params_dict = ast.Dict(keys=param_keys, values=param_values)
1510
+
1511
+ # Build request call
1512
+ request_method = '_request_async' if self.config.is_async else '_request'
1513
+
1514
+ # Build path expression (handle path parameters)
1515
+ path_expr = ParameterASTBuilder.build_path_expr(
1516
+ self.config.path, self.config.parameters or []
1517
+ )
1518
+
1519
+ request_keywords = [
1520
+ ast.keyword(
1521
+ arg='method', value=ast.Constant(value=self.config.method.lower())
1522
+ ),
1523
+ ast.keyword(arg='path', value=path_expr),
1524
+ ast.keyword(arg='params', value=params_dict),
1525
+ ast.keyword(arg=None, value=_name('kwargs')),
1526
+ ]
1527
+
1528
+ request_call = _call(
1529
+ func=_attr('c', request_method),
1530
+ keywords=request_keywords,
1531
+ )
1532
+
1533
+ if self.config.is_async:
1534
+ request_call = ast.Await(value=request_call)
1535
+
1536
+ # Build parse response call
1537
+ if self.config.response_type:
1538
+ parse_method = (
1539
+ '_parse_response_async' if self.config.is_async else '_parse_response'
1540
+ )
1541
+ parse_call = _call(
1542
+ func=_attr('c', parse_method),
1543
+ args=[_name('response'), self.config.response_type.annotation_ast],
1544
+ )
1545
+ if self.config.is_async:
1546
+ parse_call = ast.Await(value=parse_call)
1547
+
1548
+ fetch_body = [
1549
+ _assign(_name('response'), request_call),
1550
+ ast.Return(value=parse_call),
1551
+ ]
1552
+ else:
1553
+ fetch_body = [
1554
+ ast.Return(value=request_call),
1555
+ ]
1556
+
1557
+ # Determine param1 annotation
1558
+ if self.config.pagination_style == PaginationStyle.CURSOR:
1559
+ param1_annotation = _union_expr([_name('str'), ast.Constant(value=None)])
1560
+ else:
1561
+ param1_annotation = _name('int')
1562
+
1563
+ fetch_args = [
1564
+ _argument(param1_name, param1_annotation),
1565
+ _argument(param2_name, _name('int')),
1566
+ ]
1567
+
1568
+ if self.config.is_async:
1569
+ return ast.AsyncFunctionDef(
1570
+ name='fetch_page',
1571
+ args=ast.arguments(
1572
+ posonlyargs=[],
1573
+ args=fetch_args,
1574
+ kwonlyargs=[],
1575
+ kw_defaults=[],
1576
+ kwarg=None,
1577
+ defaults=[],
1578
+ ),
1579
+ body=fetch_body,
1580
+ decorator_list=[],
1581
+ returns=None,
1582
+ )
1583
+ else:
1584
+ return ast.FunctionDef(
1585
+ name='fetch_page',
1586
+ args=ast.arguments(
1587
+ posonlyargs=[],
1588
+ args=fetch_args,
1589
+ kwonlyargs=[],
1590
+ kw_defaults=[],
1591
+ kwarg=None,
1592
+ defaults=[],
1593
+ ),
1594
+ body=fetch_body,
1595
+ decorator_list=[],
1596
+ returns=None,
1597
+ )
1598
+
1599
+ def _build_extract_lambda(self, path: str | None) -> ast.expr:
1600
+ """Build a lambda to extract data from a response using a path."""
1601
+ page_arg = ast.arg(arg='page', annotation=None)
1602
+
1603
+ if path:
1604
+ # Check if path looks like a simple attribute access (no dots)
1605
+ if '.' not in path:
1606
+ # lambda page: page.attr
1607
+ body = ast.Attribute(
1608
+ value=ast.Name(id='page', ctx=ast.Load()),
1609
+ attr=path,
1610
+ ctx=ast.Load(),
1611
+ )
1612
+ else:
1613
+ # lambda page: extract_path(page, "path")
1614
+ body = ast.Call(
1615
+ func=_name('extract_path'),
1616
+ args=[
1617
+ ast.Name(id='page', ctx=ast.Load()),
1618
+ ast.Constant(value=path),
1619
+ ],
1620
+ keywords=[],
1621
+ )
1622
+ else:
1623
+ # lambda page: page
1624
+ body = ast.Name(id='page', ctx=ast.Load())
1625
+
1626
+ return ast.Lambda(
1627
+ args=ast.arguments(
1628
+ posonlyargs=[],
1629
+ args=[page_arg],
1630
+ kwonlyargs=[],
1631
+ kw_defaults=[],
1632
+ defaults=[],
1633
+ ),
1634
+ body=body,
1635
+ )
1636
+
1637
+ def _build_request_keywords(self) -> list[ast.keyword]:
1638
+ """Build the keywords for a request call."""
1639
+ parameters = self.config.parameters or []
1640
+
1641
+ path_expr = ParameterASTBuilder.build_path_expr(self.config.path, parameters)
1642
+ query_params = ParameterASTBuilder.build_query_params(parameters)
1643
+ header_params = ParameterASTBuilder.build_header_params(parameters)
1644
+ body_expr, body_param_name = ParameterASTBuilder.build_body_expr(
1645
+ self.config.request_body_info
1646
+ )
1647
+
1648
+ request_keywords: list[ast.keyword] = [
1649
+ ast.keyword(
1650
+ arg='method', value=ast.Constant(value=self.config.method.lower())
1651
+ ),
1652
+ ast.keyword(arg='path', value=path_expr),
1653
+ ]
1654
+
1655
+ if query_params:
1656
+ request_keywords.append(ast.keyword(arg='params', value=query_params))
1657
+
1658
+ if header_params:
1659
+ request_keywords.append(ast.keyword(arg='headers', value=header_params))
1660
+
1661
+ if body_expr and body_param_name:
1662
+ request_keywords.append(ast.keyword(arg=body_param_name, value=body_expr))
1663
+
1664
+ request_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
1665
+
1666
+ return request_keywords
1667
+
1668
+ def _build_delegating_call_args(
1669
+ self,
1670
+ ) -> tuple[list[ast.expr], list[ast.keyword]]:
1671
+ """Build the arguments for a delegating client method call."""
1672
+ call_args: list[ast.expr] = []
1673
+ call_keywords: list[ast.keyword] = []
1674
+
1675
+ builder = FunctionSignatureBuilder()
1676
+ builder.add_parameters(self.config.parameters)
1677
+ builder.add_request_body(self.config.request_body_info)
1678
+
1679
+ if self.config.dataframe_library:
1680
+ builder.add_path_parameter(default=self.config.dataframe_path)
1681
+
1682
+ signature = builder.build()
3
1683
 
4
- from otterapi.codegen.ast_utils import (
5
- _argument,
6
- _assign,
7
- _async_func,
8
- _attr,
9
- _call,
10
- _func,
11
- _name,
12
- _subscript,
13
- _union_expr,
14
- )
15
- from otterapi.codegen.type_generator import Parameter, Type
1684
+ for arg in signature.args:
1685
+ call_args.append(_name(arg.arg))
16
1686
 
1687
+ for kwarg in signature.kwonlyargs:
1688
+ call_keywords.append(ast.keyword(arg=kwarg.arg, value=_name(kwarg.arg)))
17
1689
 
18
- def clean_docstring(docstring: str) -> str:
19
- return textwrap.dedent(f'\n{docstring}\n').strip()
1690
+ call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
1691
+
1692
+ return call_args, call_keywords
1693
+
1694
+
1695
+ # =============================================================================
1696
+ # Base Request Function Generation
1697
+ # =============================================================================
20
1698
 
21
1699
 
22
- def get_base_request_arguments():
1700
+ def _get_base_request_arguments() -> tuple[list[ast.arg], list[ast.arg], ast.arg]:
1701
+ """Build the argument signature for base request functions.
1702
+
1703
+ Returns:
1704
+ A tuple of (args, kwonlyargs, kwargs).
1705
+ """
23
1706
  args = [
24
1707
  _argument('method', _name('str')),
25
1708
  _argument('path', _name('str')),
@@ -30,36 +1713,47 @@ def get_base_request_arguments():
30
1713
  _union_expr([_subscript('Type', _name('T')), ast.Constant(value=None)]),
31
1714
  ),
32
1715
  _argument('supported_status_codes', _subscript('list', _name('int'))),
1716
+ _argument('timeout', _union_expr([_name('float'), ast.Constant(value=None)])),
1717
+ _argument('stream', _name('bool')),
33
1718
  ]
34
1719
  kwargs = _argument('kwargs', _name('dict'))
35
1720
 
36
1721
  return args, kwonlyargs, kwargs
37
1722
 
38
1723
 
39
- def base_request_fn():
40
- args, kwonlyargs, kwargs = get_base_request_arguments()
1724
+ def _build_url_fstring(base_url_var: str = 'BASE_URL') -> ast.JoinedStr:
1725
+ """Build an f-string AST node for URL construction.
41
1726
 
42
- body = [
43
- _assign(
44
- target=_name('response'),
45
- value=_call(
46
- func=_name('request'),
47
- args=[
48
- _name('method'),
49
- ast.Expr(
50
- value=ast.JoinedStr(
51
- values=[
52
- ast.FormattedValue(
53
- value=_name('BASE_URL'), conversion=-1
54
- ),
55
- ast.FormattedValue(value=_name('path'), conversion=-1),
56
- ]
57
- )
58
- ),
59
- ],
60
- keywords=[ast.keyword(arg=None, value=_name('kwargs'))],
61
- ),
62
- ),
1727
+ Args:
1728
+ base_url_var: The variable name containing the base URL.
1729
+
1730
+ Returns:
1731
+ An AST JoinedStr node representing f"{base_url_var}{path}".
1732
+ """
1733
+ return ast.JoinedStr(
1734
+ values=[
1735
+ ast.FormattedValue(value=_name(base_url_var), conversion=-1),
1736
+ ast.FormattedValue(value=_name('path'), conversion=-1),
1737
+ ]
1738
+ )
1739
+
1740
+
1741
+ def _build_response_validation_body(is_async: bool = False) -> list[ast.stmt]:
1742
+ """Build the shared response validation AST statements.
1743
+
1744
+ Args:
1745
+ is_async: Whether this is for async functions.
1746
+
1747
+ Returns:
1748
+ List of AST statements for response validation.
1749
+ """
1750
+ stream_check = ast.If(
1751
+ test=_name('stream'),
1752
+ body=[ast.Return(value=_name('response'))],
1753
+ orelse=[],
1754
+ )
1755
+
1756
+ return [
63
1757
  ast.If(
64
1758
  test=ast.BoolOp(
65
1759
  op=ast.Or(),
@@ -72,129 +1766,36 @@ def base_request_fn():
72
1766
  ),
73
1767
  ],
74
1768
  ),
75
- body=[
76
- ast.Expr(
77
- value=_call(
78
- func=_attr('response', 'raise_for_status'),
79
- )
80
- )
81
- ],
1769
+ body=[ast.Expr(value=_call(func=_attr('response', 'raise_for_status')))],
82
1770
  orelse=[],
83
1771
  ),
1772
+ stream_check,
84
1773
  _assign(
85
- target=_name('data'),
1774
+ target=_name('content_type'),
86
1775
  value=_call(
87
- func=_attr('response', 'json'),
1776
+ func=_attr(_attr('response', 'headers'), 'get'),
1777
+ args=[ast.Constant(value='content-type'), ast.Constant(value='')],
88
1778
  ),
89
1779
  ),
90
1780
  ast.If(
91
- test=ast.UnaryOp(op=ast.Not(), operand=_name('response_model')),
92
- body=[ast.Return(value=_name('data'))],
93
- orelse=[],
94
- ),
95
- _assign(
96
- target=_name('validated_data'),
97
- value=_call(
98
- func=_attr(
1781
+ test=ast.BoolOp(
1782
+ op=ast.Or(),
1783
+ values=[
1784
+ ast.Compare(
1785
+ left=ast.Constant(value='application/json'),
1786
+ ops=[ast.In()],
1787
+ comparators=[_name('content_type')],
1788
+ ),
99
1789
  _call(
100
- func=_name('TypeAdapter'),
101
- args=[_name('response_model')],
1790
+ func=_attr(_name('content_type'), 'endswith'),
1791
+ args=[ast.Constant(value='+json')],
102
1792
  ),
103
- 'validate_python',
104
- ),
105
- args=[_name('data')],
106
- ),
107
- ),
108
- ast.If(
109
- test=_call(
110
- func=_name('isinstance'),
111
- args=[_name('validated_data'), _name('RootModel')],
1793
+ ],
112
1794
  ),
113
- body=[ast.Return(value=_attr('validated_data', 'root'))],
114
- orelse=[],
115
- ),
116
- ast.Return(value=_name('validated_data')),
117
- ]
118
-
119
- return _func(
120
- name='request_sync',
121
- args=args,
122
- body=body,
123
- kwargs=kwargs,
124
- kwonlyargs=kwonlyargs,
125
- kw_defaults=[_name('Json'), ast.Constant(value=None)],
126
- returns=_name('T'),
127
- ), {
128
- 'httpx': ['request'],
129
- 'pydantic': ['TypeAdapter', 'Json', 'RootModel'],
130
- 'typing': ['Type', 'TypeVar', 'Union'],
131
- }
132
-
133
-
134
- def base_async_request_fn():
135
- args, kwonlyargs, kwargs = get_base_request_arguments()
136
-
137
- body = [
138
- ast.AsyncWith(
139
- items=[
140
- ast.withitem(
141
- context_expr=_call(func=_name('AsyncClient')),
142
- optional_vars=_name('client'),
143
- )
144
- ],
145
1795
  body=[
146
- _assign(
147
- target=_name('response'),
148
- value=ast.Await(
149
- value=_call(
150
- func=_attr('client', 'request'),
151
- args=[
152
- _name('method'),
153
- ast.Expr(
154
- value=ast.JoinedStr(
155
- values=[
156
- ast.FormattedValue(
157
- value=_name('BASE_URL'), conversion=-1
158
- ),
159
- ast.FormattedValue(
160
- value=_name('path'), conversion=-1
161
- ),
162
- ]
163
- )
164
- ),
165
- ],
166
- keywords=[ast.keyword(arg=None, value=_name('kwargs'))],
167
- )
168
- ),
169
- ),
170
- ast.If(
171
- test=ast.BoolOp(
172
- op=ast.Or(),
173
- values=[
174
- ast.UnaryOp(
175
- op=ast.Not(), operand=_name('supported_status_codes')
176
- ),
177
- ast.Compare(
178
- left=_attr('response', 'status_code'),
179
- ops=[ast.NotIn()],
180
- comparators=[_name('supported_status_codes')],
181
- ),
182
- ],
183
- ),
184
- body=[
185
- ast.Expr(
186
- value=_call(
187
- func=_attr('response', 'raise_for_status'),
188
- )
189
- )
190
- ],
191
- orelse=[],
192
- ),
193
1796
  _assign(
194
1797
  target=_name('data'),
195
- value=_call(
196
- func=_attr('response', 'json'),
197
- ),
1798
+ value=_call(func=_attr('response', 'json')),
198
1799
  ),
199
1800
  ast.If(
200
1801
  test=ast.UnaryOp(op=ast.Not(), operand=_name('response_model')),
@@ -224,278 +1825,931 @@ def base_async_request_fn():
224
1825
  ),
225
1826
  ast.Return(value=_name('validated_data')),
226
1827
  ],
227
- )
1828
+ orelse=[
1829
+ ast.If(
1830
+ test=_call(
1831
+ func=_attr(_name('content_type'), 'startswith'),
1832
+ args=[ast.Constant(value='text/')],
1833
+ ),
1834
+ body=[
1835
+ ast.Return(value=_attr('response', 'text')),
1836
+ ],
1837
+ orelse=[
1838
+ ast.If(
1839
+ test=ast.BoolOp(
1840
+ op=ast.Or(),
1841
+ values=[
1842
+ ast.Compare(
1843
+ left=ast.Constant(
1844
+ value='application/octet-stream'
1845
+ ),
1846
+ ops=[ast.In()],
1847
+ comparators=[_name('content_type')],
1848
+ ),
1849
+ _call(
1850
+ func=_attr(_name('content_type'), 'startswith'),
1851
+ args=[ast.Constant(value='image/')],
1852
+ ),
1853
+ _call(
1854
+ func=_attr(_name('content_type'), 'startswith'),
1855
+ args=[ast.Constant(value='audio/')],
1856
+ ),
1857
+ _call(
1858
+ func=_attr(_name('content_type'), 'startswith'),
1859
+ args=[ast.Constant(value='video/')],
1860
+ ),
1861
+ _call(
1862
+ func=_attr(_name('content_type'), 'startswith'),
1863
+ args=[ast.Constant(value='application/pdf')],
1864
+ ),
1865
+ ],
1866
+ ),
1867
+ body=[
1868
+ ast.Return(value=_attr('response', 'content')),
1869
+ ],
1870
+ orelse=[
1871
+ ast.Return(value=_name('response')),
1872
+ ],
1873
+ ),
1874
+ ],
1875
+ ),
1876
+ ],
1877
+ ),
1878
+ ]
1879
+
1880
+
1881
+ def _build_base_request_fn(
1882
+ is_async: bool,
1883
+ base_url_var: str = 'BASE_URL',
1884
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
1885
+ """Build a base request function (sync or async).
1886
+
1887
+ Args:
1888
+ is_async: If True, generates an async function; otherwise sync.
1889
+ base_url_var: The variable name containing the base URL configuration.
1890
+
1891
+ Returns:
1892
+ A tuple of (function_ast, imports).
1893
+ """
1894
+ args, kwonlyargs, kwargs = _get_base_request_arguments()
1895
+ url_fstring = _build_url_fstring(base_url_var)
1896
+ validation_body = _build_response_validation_body(is_async)
1897
+
1898
+ request_keywords = [
1899
+ ast.keyword(arg=None, value=_name('kwargs')),
1900
+ ast.keyword(
1901
+ arg='timeout',
1902
+ value=_name('timeout'),
1903
+ ),
228
1904
  ]
229
1905
 
230
- return _async_func(
231
- name='request_async',
1906
+ if is_async:
1907
+ request_call = ast.Await(
1908
+ value=_call(
1909
+ func=_attr('client', 'request'),
1910
+ args=[_name('method'), ast.Expr(value=url_fstring)],
1911
+ keywords=request_keywords,
1912
+ )
1913
+ )
1914
+ inner_body = [
1915
+ _assign(target=_name('response'), value=request_call),
1916
+ *validation_body,
1917
+ ]
1918
+ body = [
1919
+ ast.AsyncWith(
1920
+ items=[
1921
+ ast.withitem(
1922
+ context_expr=_call(func=_name('AsyncClient')),
1923
+ optional_vars=_name('client'),
1924
+ )
1925
+ ],
1926
+ body=inner_body,
1927
+ )
1928
+ ]
1929
+ func_builder = _async_func
1930
+ func_name = 'request_async'
1931
+ http_imports = {'httpx': {'AsyncClient'}}
1932
+ else:
1933
+ request_call = _call(
1934
+ func=_name('request'),
1935
+ args=[_name('method'), ast.Expr(value=url_fstring)],
1936
+ keywords=request_keywords,
1937
+ )
1938
+ body = [
1939
+ _assign(target=_name('response'), value=request_call),
1940
+ *validation_body,
1941
+ ]
1942
+ func_builder = _func
1943
+ func_name = 'request_sync'
1944
+ http_imports = {'httpx': {'request'}}
1945
+
1946
+ func_ast = func_builder(
1947
+ name=func_name,
232
1948
  args=args,
233
1949
  body=body,
234
1950
  kwargs=kwargs,
235
1951
  kwonlyargs=kwonlyargs,
236
- kw_defaults=[_name('Json'), ast.Constant(value=None)],
1952
+ kw_defaults=[
1953
+ _name('Json'),
1954
+ ast.Constant(value=None),
1955
+ ast.Constant(value=None),
1956
+ ast.Constant(value=False),
1957
+ ],
237
1958
  returns=_name('T'),
238
- ), {
239
- 'httpx': ['AsyncClient'],
240
- 'pydantic': ['TypeAdapter', 'Json', 'RootModel'],
241
- 'typing': ['Type', 'TypeVar', 'Union'],
1959
+ )
1960
+
1961
+ imports: ImportDict = {
1962
+ **http_imports,
1963
+ 'pydantic': {'TypeAdapter', 'Json', 'RootModel'},
1964
+ 'typing': {'Type', 'TypeVar'},
242
1965
  }
243
1966
 
1967
+ return func_ast, imports
1968
+
1969
+
1970
+ def base_request_fn(
1971
+ base_url_var: str = 'BASE_URL',
1972
+ ) -> tuple[ast.FunctionDef, ImportDict]:
1973
+ """Generate a synchronous base request function.
1974
+
1975
+ Creates a `request_sync` function that handles HTTP requests with response
1976
+ validation and model deserialization.
1977
+
1978
+ Args:
1979
+ base_url_var: The variable name containing the base URL configuration.
1980
+
1981
+ Returns:
1982
+ A tuple of (function_ast, imports) for the sync request function.
1983
+ """
1984
+ return _build_base_request_fn(is_async=False, base_url_var=base_url_var)
1985
+
1986
+
1987
+ def base_async_request_fn(
1988
+ base_url_var: str = 'BASE_URL',
1989
+ ) -> tuple[ast.AsyncFunctionDef, ImportDict]:
1990
+ """Generate an asynchronous base request function.
1991
+
1992
+ Creates a `request_async` function that handles HTTP requests with response
1993
+ validation and model deserialization using httpx.AsyncClient.
1994
+
1995
+ Args:
1996
+ base_url_var: The variable name containing the base URL configuration.
1997
+
1998
+ Returns:
1999
+ A tuple of (function_ast, imports) for the async request function.
2000
+ """
2001
+ return _build_base_request_fn(is_async=True, base_url_var=base_url_var)
2002
+
2003
+
2004
+ # =============================================================================
2005
+ # Parameter and Call Building Helpers
2006
+ # =============================================================================
2007
+
244
2008
 
245
2009
  def get_parameters(
246
- parameters: list[Parameter],
247
- ) -> tuple[list[ast.arg], list[ast.arg], list[ast.expr], dict[str, set[str]]]:
248
- args = []
249
- kwonlyargs = []
250
- kw_defaults = []
251
- imports = {}
2010
+ parameters: list['Parameter'],
2011
+ ) -> tuple[list[ast.arg], list[ast.arg], list[ast.expr], ImportDict]:
2012
+ """Extract function arguments from OpenAPI parameters.
2013
+
2014
+ Separates required and optional parameters and generates appropriate
2015
+ AST argument nodes for each.
2016
+
2017
+ Args:
2018
+ parameters: List of Parameter objects from the OpenAPI spec.
2019
+
2020
+ Returns:
2021
+ A tuple of (args, kwonlyargs, kw_defaults, imports).
2022
+ """
2023
+ args: list[ast.arg] = []
2024
+ kwonlyargs: list[ast.arg] = []
2025
+ kw_defaults: list[ast.expr] = []
2026
+ imports: ImportDict = {}
252
2027
 
253
2028
  for param in parameters:
254
- if param.name == 'user-id':
255
- print()
256
- param_name = param.name_sanitized
257
- param_type = param.type.annotation_ast if param.type else None
258
- param_required = param.required
259
-
260
- if param_type is None:
261
- param_type = _name('Any')
2029
+ if param.type:
2030
+ annotation = param.type.annotation_ast
2031
+ imports.update(param.type.annotation_imports)
2032
+ else:
2033
+ annotation = _name('Any')
262
2034
  imports.setdefault('typing', set()).add('Any')
263
2035
 
264
- if param_required:
265
- # Required parameters go in regular args
266
- args.append(_argument(param_name, param_type))
2036
+ arg = _argument(param.name_sanitized, annotation)
2037
+
2038
+ if param.required:
2039
+ args.append(arg)
267
2040
  else:
268
- # Optional parameters go in kwonlyargs with None default
269
- kwonlyargs.append(_argument(param_name, param_type))
270
- kw_defaults.append(ast.Constant(None))
2041
+ kwonlyargs.append(arg)
2042
+ kw_defaults.append(ast.Constant(value=None))
271
2043
 
272
2044
  return args, kwonlyargs, kw_defaults, imports
273
2045
 
274
2046
 
275
2047
  def get_base_call_keywords(
276
- method, path, response_model: Type, supported_status_codes: list | None = None
2048
+ query_params: ast.expr | None,
2049
+ header_params: ast.expr | None,
2050
+ response_model_ast: ast.expr | None,
2051
+ supported_status_codes: list[int] | None,
2052
+ body_expr: ast.expr | None = None,
2053
+ body_param_name: str | None = None,
2054
+ timeout: float | None = None,
2055
+ stream: bool = False,
277
2056
  ) -> list[ast.keyword]:
278
- return [
279
- ast.keyword(arg='method', value=ast.Constant(value=method)),
280
- ast.keyword(arg='path', value=path),
281
- ast.keyword(
282
- arg='response_model',
283
- value=response_model.annotation_ast
284
- if response_model
285
- else ast.Constant(None),
286
- ),
2057
+ """Build the keyword arguments for a request function call.
2058
+
2059
+ Args:
2060
+ query_params: AST expression for query parameters dictionary.
2061
+ header_params: AST expression for header parameters dictionary.
2062
+ response_model_ast: AST expression for the response model type.
2063
+ supported_status_codes: List of valid HTTP status codes.
2064
+ body_expr: AST expression for the request body.
2065
+ body_param_name: The httpx parameter name for the body.
2066
+ timeout: Optional request timeout in seconds.
2067
+ stream: Whether to stream the response.
2068
+
2069
+ Returns:
2070
+ List of AST keyword nodes for the function call.
2071
+ """
2072
+ keywords: list[ast.keyword] = []
2073
+
2074
+ keywords.append(
287
2075
  ast.keyword(
288
- arg='supported_status_codes', value=ast.Constant(supported_status_codes)
289
- ),
290
- ]
2076
+ arg='response_model', value=response_model_ast or ast.Constant(value=None)
2077
+ )
2078
+ )
2079
+
2080
+ if supported_status_codes:
2081
+ keywords.append(
2082
+ ast.keyword(
2083
+ arg='supported_status_codes',
2084
+ value=ast.List(
2085
+ elts=[ast.Constant(value=code) for code in supported_status_codes]
2086
+ ),
2087
+ )
2088
+ )
2089
+ else:
2090
+ keywords.append(
2091
+ ast.keyword(arg='supported_status_codes', value=ast.Constant(value=None))
2092
+ )
291
2093
 
2094
+ if timeout is not None:
2095
+ keywords.append(ast.keyword(arg='timeout', value=ast.Constant(value=timeout)))
292
2096
 
293
- def build_header_params(headers: list[Parameter]) -> ast.Dict | None:
294
- if not headers:
295
- return None
2097
+ if stream:
2098
+ keywords.append(ast.keyword(arg='stream', value=ast.Constant(value=True)))
296
2099
 
297
- return ast.Dict(
298
- keys=[ast.Constant(value=header.name) for header in headers],
299
- values=[_name(header.name_sanitized) for header in headers],
300
- )
2100
+ if query_params:
2101
+ keywords.append(ast.keyword(arg='params', value=query_params))
301
2102
 
2103
+ if header_params:
2104
+ keywords.append(ast.keyword(arg='headers', value=header_params))
302
2105
 
303
- def build_query_params(queries: list[Parameter]) -> ast.Dict | None:
304
- if not queries:
305
- return None
2106
+ if body_expr and body_param_name:
2107
+ keywords.append(ast.keyword(arg=body_param_name, value=body_expr))
306
2108
 
307
- return ast.Dict(
308
- keys=[ast.Constant(value=query.name) for query in queries],
309
- values=[_name(query.name_sanitized) for query in queries],
310
- )
2109
+ return keywords
311
2110
 
312
2111
 
313
- def build_path_params(
314
- paths: list[Parameter], path: str
315
- ) -> ast.JoinedStr | ast.Constant:
316
- """Build an f-string AST that interpolates path parameters."""
317
- if not paths:
318
- return ast.Constant(value=path)
319
-
320
- # Split the path into parts and build the f-string
321
- values = []
322
- current_pos = 0
323
-
324
- for path_param in paths:
325
- param_placeholder = f'{{{path_param.name}}}'
326
- param_pos = path.find(param_placeholder, current_pos)
327
-
328
- if param_pos != -1:
329
- # Add any literal text before the parameter
330
- if param_pos > current_pos:
331
- literal_text = path[current_pos:param_pos]
332
- values.append(ast.Constant(value=literal_text))
333
-
334
- # Add the formatted value for the parameter
335
- values.append(
336
- ast.FormattedValue(
337
- value=_name(path_param.name_sanitized),
338
- conversion=-1, # No conversion (default)
339
- )
340
- )
2112
+ def build_header_params(
2113
+ parameters: list['Parameter'],
2114
+ ) -> ast.Dict | None:
2115
+ """Build a dictionary AST node for header parameters.
341
2116
 
342
- current_pos = param_pos + len(param_placeholder)
2117
+ Args:
2118
+ parameters: List of Parameter objects to filter for headers.
343
2119
 
344
- # Add any remaining literal text after the last parameter
345
- if current_pos < len(path):
346
- remaining_text = path[current_pos:]
347
- values.append(ast.Constant(value=remaining_text))
2120
+ Returns:
2121
+ An AST Dict node for header parameters, or None if no header params.
2122
+ """
2123
+ return ParameterASTBuilder.build_header_params(parameters)
348
2124
 
349
- return ast.JoinedStr(values=values)
350
2125
 
2126
+ def build_query_params(
2127
+ parameters: list['Parameter'],
2128
+ ) -> ast.Dict | None:
2129
+ """Build a dictionary AST node for query parameters.
351
2130
 
352
- def build_body_params(body: Parameter | None) -> ast.expr | None:
353
- if not body:
354
- return None
2131
+ Args:
2132
+ parameters: List of Parameter objects to filter for query params.
355
2133
 
356
- if body.type.type == 'model' or body.type.type == 'root_model':
357
- return _call(
358
- func=_attr(_name(body.name_sanitized), 'model_dump'),
359
- args=[],
360
- )
2134
+ Returns:
2135
+ An AST Dict node for query parameters, or None if no query params.
2136
+ """
2137
+ return ParameterASTBuilder.build_query_params(parameters)
2138
+
2139
+
2140
+ def build_path_params(
2141
+ path: str,
2142
+ parameters: list['Parameter'],
2143
+ ) -> ast.expr:
2144
+ """Build an f-string or constant for the request path.
2145
+
2146
+ Args:
2147
+ path: The OpenAPI path string with placeholders.
2148
+ parameters: List of Parameter objects to map placeholders to variables.
2149
+
2150
+ Returns:
2151
+ An AST expression (JoinedStr for f-strings, Constant for static paths).
2152
+ """
2153
+ return ParameterASTBuilder.build_path_expr(path, parameters)
361
2154
 
362
- return _name(body.name)
2155
+
2156
+ def build_body_params(
2157
+ body: 'RequestBodyInfo | None',
2158
+ ) -> tuple[ast.expr | None, str | None]:
2159
+ """Build an AST expression for the request body parameter.
2160
+
2161
+ Args:
2162
+ body: The RequestBodyInfo object, or None if no body.
2163
+
2164
+ Returns:
2165
+ A tuple of (body_expr, httpx_param_name).
2166
+ """
2167
+ return ParameterASTBuilder.build_body_expr(body)
363
2168
 
364
2169
 
365
2170
  def prepare_call_from_parameters(
366
- parameters: list[Parameter] | None, path: str
367
- ) -> tuple[ast.expr, ast.expr, ast.expr, ast.expr]:
368
- if not parameters:
369
- parameters = []
370
-
371
- query_params = [p for p in parameters if p.location == 'query']
372
- path_params = [p for p in parameters if p.location == 'path']
373
- header_params = [p for p in parameters if p.location == 'header']
374
- body_params = [p for p in parameters if p.location == 'body']
375
-
376
- if len(body_params) > 1:
377
- raise ValueError('Multiple body parameters are not supported.')
378
-
379
- return (
380
- build_query_params(query_params),
381
- build_header_params(header_params),
382
- build_body_params(body_params[0] if len(body_params) == 1 else None),
383
- build_path_params(path_params, path),
384
- )
2171
+ parameters: list['Parameter'] | None,
2172
+ path: str,
2173
+ request_body_info: 'RequestBodyInfo | None' = None,
2174
+ ) -> tuple[ast.expr | None, ast.expr | None, ast.expr | None, str | None, ast.expr]:
2175
+ """Prepare all parameter AST nodes for a request function call.
385
2176
 
2177
+ Args:
2178
+ parameters: List of Parameter objects, or None.
2179
+ path: The API path with optional placeholders.
2180
+ request_body_info: Request body info, or None.
386
2181
 
387
- def request_fn(
2182
+ Returns:
2183
+ A tuple of (query_params, header_params, body_expr, body_param_name, path_expr).
2184
+ """
2185
+ return ParameterASTBuilder.prepare_all_params(parameters, path, request_body_info)
2186
+
2187
+
2188
+ # =============================================================================
2189
+ # Endpoint Function Building
2190
+ # =============================================================================
2191
+
2192
+
2193
+ def _build_endpoint_fn(
388
2194
  name: str,
389
2195
  method: str,
390
2196
  path: str,
391
- response_model: Type,
2197
+ response_model: 'Type | None',
2198
+ is_async: bool,
392
2199
  docs: str | None = None,
393
- parameters: list | None = None,
394
- supported_status_codes: list | None = None,
395
- ):
396
- args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
2200
+ parameters: list['Parameter'] | None = None,
2201
+ response_infos: list['ResponseInfo'] | None = None,
2202
+ request_body_info: 'RequestBodyInfo | None' = None,
2203
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2204
+ """Build an endpoint function (sync or async).
2205
+
2206
+ Args:
2207
+ name: The function name.
2208
+ method: HTTP method (GET, POST, etc.).
2209
+ path: The API path with optional placeholders.
2210
+ response_model: The response Type for validation, or None.
2211
+ is_async: Whether to generate an async function.
2212
+ docs: Optional docstring.
2213
+ parameters: List of Parameter objects.
2214
+ response_infos: List of ResponseInfo objects.
2215
+ request_body_info: RequestBodyInfo object for the body, or None.
2216
+
2217
+ Returns:
2218
+ A tuple of (function_ast, imports).
2219
+ """
2220
+ imports: ImportDict = {}
2221
+ func_builder = _async_func if is_async else _func
2222
+ base_fn_name = 'request_async' if is_async else 'request_sync'
2223
+
2224
+ if parameters:
2225
+ args, kwonlyargs, kw_defaults, param_imports = get_parameters(parameters)
2226
+ imports.update(param_imports)
2227
+ else:
2228
+ args, kwonlyargs, kw_defaults = [], [], []
2229
+
2230
+ if request_body_info:
2231
+ body_annotation = (
2232
+ request_body_info.type.annotation_ast
2233
+ if request_body_info.type
2234
+ else _name('Any')
2235
+ )
2236
+ if request_body_info.type:
2237
+ imports.update(request_body_info.type.annotation_imports)
2238
+ else:
2239
+ imports.setdefault('typing', set()).add('Any')
2240
+
2241
+ body_arg = _argument('body', body_annotation)
2242
+ if request_body_info.required:
2243
+ args.append(body_arg)
2244
+ else:
2245
+ kwonlyargs.append(body_arg)
2246
+ kw_defaults.append(ast.Constant(value=None))
397
2247
 
398
- query_params, header_params, body_params, processed_path = (
399
- prepare_call_from_parameters(parameters, path)
2248
+ query_params, header_params, body_expr, body_param_name, path_expr = (
2249
+ prepare_call_from_parameters(parameters, path, request_body_info)
400
2250
  )
2251
+
2252
+ if response_model:
2253
+ response_model_ast = response_model.annotation_ast
2254
+ imports.update(response_model.annotation_imports)
2255
+ else:
2256
+ response_model_ast = ast.Constant(value=None)
2257
+
2258
+ supported_status_codes = None
2259
+ if response_infos:
2260
+ supported_status_codes = [
2261
+ r.status_code for r in response_infos if r.status_code
2262
+ ]
2263
+
401
2264
  call_keywords = get_base_call_keywords(
402
- method, processed_path, response_model, supported_status_codes
2265
+ query_params=query_params,
2266
+ header_params=header_params,
2267
+ response_model_ast=response_model_ast,
2268
+ supported_status_codes=supported_status_codes,
2269
+ body_expr=body_expr,
2270
+ body_param_name=body_param_name,
2271
+ stream=False,
403
2272
  )
404
2273
 
405
- if query_params:
406
- call_keywords.append(ast.keyword(arg='params', value=query_params))
407
- if header_params:
408
- call_keywords.append(ast.keyword(arg='headers', value=header_params))
409
- if body_params:
410
- call_keywords.append(ast.keyword(arg='json', value=body_params))
2274
+ call_args = [
2275
+ ast.keyword(arg='method', value=ast.Constant(value=method.lower())),
2276
+ ast.keyword(arg='path', value=path_expr),
2277
+ *call_keywords,
2278
+ ast.keyword(arg=None, value=_name('kwargs')),
2279
+ ]
411
2280
 
412
- call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
2281
+ request_call = _call(
2282
+ func=_name(base_fn_name),
2283
+ keywords=call_args,
2284
+ )
413
2285
 
414
- body = [
415
- ast.Return(
416
- value=_call(
417
- func=_name('request_sync'),
418
- args=[],
419
- keywords=call_keywords,
420
- )
421
- )
422
- ]
2286
+ if is_async:
2287
+ request_call = ast.Await(value=request_call)
2288
+
2289
+ body: list[ast.stmt] = []
423
2290
 
424
2291
  if docs:
425
- docs = textwrap.dedent(f'\n{docs}\n').strip()
426
- body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
2292
+ body.append(ast.Expr(value=ast.Constant(value=clean_docstring(docs))))
2293
+
2294
+ body.append(ast.Return(value=request_call))
427
2295
 
428
- response_model_ast = response_model.annotation_ast if response_model else None
429
- if not response_model_ast:
430
- response_model_ast = _name('Any')
431
- imports.setdefault('typing', set()).add('Any')
2296
+ if response_model:
2297
+ returns = response_model.annotation_ast
2298
+ else:
2299
+ returns = _name('Response')
2300
+ imports.setdefault('httpx', set()).add('Response')
432
2301
 
433
- return _func(
2302
+ func_ast = func_builder(
434
2303
  name=name,
435
2304
  args=args,
436
2305
  body=body,
437
2306
  kwargs=_argument('kwargs', _name('dict')),
438
2307
  kwonlyargs=kwonlyargs,
439
2308
  kw_defaults=kw_defaults,
440
- returns=response_model_ast,
441
- ), ({'httpx': ['request'], **imports})
2309
+ returns=returns,
2310
+ )
2311
+
2312
+ return func_ast, imports
2313
+
2314
+
2315
+ def request_fn(
2316
+ name: str,
2317
+ method: str,
2318
+ path: str,
2319
+ response_model: 'Type | None',
2320
+ docs: str | None = None,
2321
+ parameters: list['Parameter'] | None = None,
2322
+ response_infos: list['ResponseInfo'] | None = None,
2323
+ request_body_info: 'RequestBodyInfo | None' = None,
2324
+ ) -> tuple[ast.FunctionDef, ImportDict]:
2325
+ """Generate a synchronous endpoint function.
2326
+
2327
+ Args:
2328
+ name: The function name to generate.
2329
+ method: HTTP method (GET, POST, etc.).
2330
+ path: The API path with optional placeholders.
2331
+ response_model: The response type for validation, or None.
2332
+ docs: Optional docstring for the generated function.
2333
+ parameters: List of Parameter objects for the endpoint.
2334
+ response_infos: List of ResponseInfo objects describing response content types.
2335
+ request_body_info: RequestBodyInfo object for the request body, or None.
2336
+
2337
+ Returns:
2338
+ A tuple of (function_ast, imports) for the sync endpoint function.
2339
+ """
2340
+ return _build_endpoint_fn(
2341
+ name=name,
2342
+ method=method,
2343
+ path=path,
2344
+ response_model=response_model,
2345
+ is_async=False,
2346
+ docs=docs,
2347
+ parameters=parameters,
2348
+ response_infos=response_infos,
2349
+ request_body_info=request_body_info,
2350
+ )
442
2351
 
443
2352
 
444
2353
  def async_request_fn(
445
2354
  name: str,
446
2355
  method: str,
447
2356
  path: str,
448
- response_model: Type,
2357
+ response_model: 'Type | None',
449
2358
  docs: str | None = None,
450
- parameters: list | None = None,
451
- supported_status_codes: list | None = None,
452
- ):
453
- args, kwonlyargs, kw_defaults, imports = get_parameters(parameters)
2359
+ parameters: list['Parameter'] | None = None,
2360
+ response_infos: list['ResponseInfo'] | None = None,
2361
+ request_body_info: 'RequestBodyInfo | None' = None,
2362
+ ) -> tuple[ast.AsyncFunctionDef, ImportDict]:
2363
+ """Generate an asynchronous endpoint function.
2364
+
2365
+ Args:
2366
+ name: The function name to generate.
2367
+ method: HTTP method (GET, POST, etc.).
2368
+ path: The API path with optional placeholders.
2369
+ response_model: The response type for validation, or None.
2370
+ docs: Optional docstring for the generated function.
2371
+ parameters: List of Parameter objects for the endpoint.
2372
+ response_infos: List of ResponseInfo objects describing response content types.
2373
+ request_body_info: RequestBodyInfo object for the request body, or None.
2374
+
2375
+ Returns:
2376
+ A tuple of (function_ast, imports) for the async endpoint function.
2377
+ """
2378
+ return _build_endpoint_fn(
2379
+ name=name,
2380
+ method=method,
2381
+ path=path,
2382
+ response_model=response_model,
2383
+ is_async=True,
2384
+ docs=docs,
2385
+ parameters=parameters,
2386
+ response_infos=response_infos,
2387
+ request_body_info=request_body_info,
2388
+ )
2389
+
2390
+
2391
+ # =============================================================================
2392
+ # Default Client Code Generation
2393
+ # =============================================================================
2394
+
2395
+
2396
+ def build_default_client_code() -> tuple[list[ast.stmt], ImportDict]:
2397
+ """Build any module-level client code needed.
2398
+
2399
+ Previously this created a global singleton pattern. Now it returns
2400
+ an empty list since each endpoint creates its own Client instance
2401
+ when none is provided.
2402
+
2403
+ Returns:
2404
+ A tuple of (statements, imports) - currently empty.
2405
+ """
2406
+ imports: ImportDict = {}
2407
+ imports.setdefault('typing', set()).add('Union')
2408
+
2409
+ # No global state needed - endpoints use `client or Client()`
2410
+ return [], imports
2411
+
2412
+
2413
+ # =============================================================================
2414
+ # Convenience Functions (Factory Wrappers)
2415
+ # =============================================================================
454
2416
 
455
- query_params, header_params, body_params, processed_path = (
456
- prepare_call_from_parameters(parameters, path)
2417
+
2418
+ def build_standalone_endpoint_fn(
2419
+ fn_name: str,
2420
+ method: str,
2421
+ path: str,
2422
+ parameters: list['Parameter'] | None,
2423
+ request_body_info: 'RequestBodyInfo | None',
2424
+ response_type: 'Type | None',
2425
+ response_infos: list['ResponseInfo'] | None = None,
2426
+ docs: str | None = None,
2427
+ is_async: bool = False,
2428
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2429
+ """Build a standalone endpoint function with full implementation.
2430
+
2431
+ This is a convenience wrapper around EndpointFunctionFactory.
2432
+
2433
+ Args:
2434
+ fn_name: The function name to generate.
2435
+ method: HTTP method (GET, POST, etc.).
2436
+ path: The API path with optional placeholders.
2437
+ parameters: List of Parameter objects for the endpoint.
2438
+ request_body_info: RequestBodyInfo object for the request body, or None.
2439
+ response_type: The response Type for the return annotation.
2440
+ response_infos: List of ResponseInfo objects for status code handling.
2441
+ docs: Optional docstring for the generated function.
2442
+ is_async: Whether to generate an async function.
2443
+
2444
+ Returns:
2445
+ A tuple of (function_ast, imports).
2446
+ """
2447
+ config = EndpointFunctionConfig(
2448
+ fn_name=fn_name,
2449
+ method=method,
2450
+ path=path,
2451
+ parameters=parameters,
2452
+ request_body_info=request_body_info,
2453
+ response_type=response_type,
2454
+ response_infos=response_infos,
2455
+ docs=docs,
2456
+ is_async=is_async,
2457
+ mode=EndpointMode.STANDALONE,
457
2458
  )
458
- call_keywords = get_base_call_keywords(
459
- method, processed_path, response_model, supported_status_codes
2459
+ return EndpointFunctionFactory(config).build()
2460
+
2461
+
2462
+ def build_delegating_endpoint_fn(
2463
+ fn_name: str,
2464
+ client_method_name: str,
2465
+ parameters: list['Parameter'] | None,
2466
+ request_body_info: 'RequestBodyInfo | None',
2467
+ response_type: 'Type | None',
2468
+ docs: str | None = None,
2469
+ is_async: bool = False,
2470
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2471
+ """Build an endpoint function that delegates to a client method.
2472
+
2473
+ This is a convenience wrapper around EndpointFunctionFactory.
2474
+
2475
+ Args:
2476
+ fn_name: The function name to generate.
2477
+ client_method_name: The client method to delegate to.
2478
+ parameters: List of Parameter objects for the endpoint.
2479
+ request_body_info: RequestBodyInfo object for the request body, or None.
2480
+ response_type: The response Type for the return annotation.
2481
+ docs: Optional docstring for the generated function.
2482
+ is_async: Whether to generate an async function.
2483
+
2484
+ Returns:
2485
+ A tuple of (function_ast, imports).
2486
+ """
2487
+ config = EndpointFunctionConfig(
2488
+ fn_name=fn_name,
2489
+ method='',
2490
+ path='',
2491
+ parameters=parameters,
2492
+ request_body_info=request_body_info,
2493
+ response_type=response_type,
2494
+ docs=docs,
2495
+ is_async=is_async,
2496
+ mode=EndpointMode.DELEGATING,
2497
+ client_method_name=client_method_name,
460
2498
  )
2499
+ return EndpointFunctionFactory(config).build()
461
2500
 
462
- if query_params:
463
- call_keywords.append(ast.keyword(arg='params', value=query_params))
464
- if header_params:
465
- call_keywords.append(ast.keyword(arg='headers', value=header_params))
466
- if body_params:
467
- call_keywords.append(ast.keyword(arg='json', value=body_params))
468
2501
 
469
- # Add **kwargs to the call
470
- call_keywords.append(ast.keyword(arg=None, value=_name('kwargs')))
2502
+ def build_standalone_dataframe_fn(
2503
+ fn_name: str,
2504
+ method: str,
2505
+ path: str,
2506
+ parameters: list['Parameter'] | None,
2507
+ request_body_info: 'RequestBodyInfo | None',
2508
+ library: Literal['pandas', 'polars'],
2509
+ default_path: str | None = None,
2510
+ docs: str | None = None,
2511
+ is_async: bool = False,
2512
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2513
+ """Build a standalone DataFrame endpoint function.
2514
+
2515
+ This is a convenience wrapper around EndpointFunctionFactory.
2516
+
2517
+ Args:
2518
+ fn_name: The function name to generate.
2519
+ method: HTTP method (GET, POST, etc.).
2520
+ path: The API path with optional placeholders.
2521
+ parameters: List of Parameter objects for the endpoint.
2522
+ request_body_info: RequestBodyInfo object for the request body, or None.
2523
+ library: The DataFrame library ('pandas' or 'polars').
2524
+ default_path: Default path value for the path parameter.
2525
+ docs: Optional docstring for the generated function.
2526
+ is_async: Whether to generate an async function.
2527
+
2528
+ Returns:
2529
+ A tuple of (function_ast, imports).
2530
+ """
2531
+ df_library = (
2532
+ DataFrameLibrary.PANDAS if library == 'pandas' else DataFrameLibrary.POLARS
2533
+ )
2534
+ config = EndpointFunctionConfig(
2535
+ fn_name=fn_name,
2536
+ method=method,
2537
+ path=path,
2538
+ parameters=parameters,
2539
+ request_body_info=request_body_info,
2540
+ response_type=None,
2541
+ docs=docs,
2542
+ is_async=is_async,
2543
+ mode=EndpointMode.STANDALONE,
2544
+ dataframe_library=df_library,
2545
+ dataframe_path=default_path,
2546
+ )
2547
+ return EndpointFunctionFactory(config).build()
471
2548
 
472
- body = [
473
- ast.Return(
474
- value=ast.Await(
475
- value=_call(
476
- func=_name('request_async'),
477
- args=[],
478
- keywords=call_keywords,
479
- )
480
- )
481
- )
482
- ]
483
2549
 
484
- if docs:
485
- docs = textwrap.dedent(f'\n{docs}\n').strip()
486
- body.insert(0, ast.Expr(value=ast.Constant(value=docs)))
2550
+ def build_standalone_paginated_dataframe_fn(
2551
+ fn_name: str,
2552
+ method: str,
2553
+ path: str,
2554
+ parameters: list['Parameter'] | None,
2555
+ request_body_info: 'RequestBodyInfo | None',
2556
+ response_type: 'Type | None',
2557
+ pagination_style: str,
2558
+ pagination_config: dict,
2559
+ library: Literal['pandas', 'polars'],
2560
+ item_type_ast: ast.expr | None = None,
2561
+ docs: str | None = None,
2562
+ is_async: bool = False,
2563
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2564
+ """Build a standalone paginated DataFrame endpoint function.
2565
+
2566
+ This function fetches all paginated items and returns them as a DataFrame.
2567
+
2568
+ Args:
2569
+ fn_name: The function name to generate.
2570
+ method: HTTP method (GET, POST, etc.).
2571
+ path: The API path with optional placeholders.
2572
+ parameters: List of Parameter objects for the endpoint.
2573
+ request_body_info: RequestBodyInfo object for the request body, or None.
2574
+ response_type: The response Type for parsing.
2575
+ pagination_style: The pagination style ('offset', 'cursor', 'page').
2576
+ pagination_config: Dict with pagination parameter names and paths.
2577
+ library: The DataFrame library ('pandas' or 'polars').
2578
+ item_type_ast: AST for the item type in the list.
2579
+ docs: Optional docstring for the generated function.
2580
+ is_async: Whether to generate an async function.
2581
+
2582
+ Returns:
2583
+ A tuple of (function_ast, imports).
2584
+ """
2585
+ pag_style = PaginationStyle(pagination_style)
2586
+ df_library = (
2587
+ DataFrameLibrary.PANDAS if library == 'pandas' else DataFrameLibrary.POLARS
2588
+ )
2589
+ config = EndpointFunctionConfig(
2590
+ fn_name=fn_name,
2591
+ method=method,
2592
+ path=path,
2593
+ parameters=parameters,
2594
+ request_body_info=request_body_info,
2595
+ response_type=response_type,
2596
+ docs=docs,
2597
+ is_async=is_async,
2598
+ mode=EndpointMode.STANDALONE,
2599
+ pagination_style=pag_style,
2600
+ pagination_config=pagination_config,
2601
+ is_paginated_dataframe=True,
2602
+ dataframe_library=df_library,
2603
+ item_type_ast=item_type_ast,
2604
+ )
2605
+ return EndpointFunctionFactory(config).build()
487
2606
 
488
- response_model_ast = response_model.annotation_ast if response_model else None
489
- if not response_model_ast:
490
- response_model_ast = _name('Any')
491
- imports.setdefault('typing', set()).add('Any')
492
2607
 
493
- return _async_func(
494
- name=name,
495
- args=args,
496
- body=body,
497
- kwargs=_argument('kwargs', _name('dict')),
498
- kwonlyargs=kwonlyargs,
499
- kw_defaults=kw_defaults,
500
- returns=response_model_ast,
501
- ), {'httpx': ['AsyncClient'], **imports}
2608
+ def build_delegating_dataframe_fn(
2609
+ fn_name: str,
2610
+ client_method_name: str,
2611
+ parameters: list['Parameter'] | None,
2612
+ request_body_info: 'RequestBodyInfo | None',
2613
+ library: Literal['pandas', 'polars'],
2614
+ default_path: str | None = None,
2615
+ docs: str | None = None,
2616
+ is_async: bool = False,
2617
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2618
+ """Build a DataFrame endpoint function that delegates to a client method.
2619
+
2620
+ This is a convenience wrapper around EndpointFunctionFactory.
2621
+
2622
+ Args:
2623
+ fn_name: The function name to generate.
2624
+ client_method_name: The client method to delegate to.
2625
+ parameters: List of Parameter objects for the endpoint.
2626
+ request_body_info: RequestBodyInfo object for the request body, or None.
2627
+ library: The DataFrame library ('pandas' or 'polars').
2628
+ default_path: Default path value for the path parameter.
2629
+ docs: Optional docstring for the generated function.
2630
+ is_async: Whether to generate an async function.
2631
+
2632
+ Returns:
2633
+ A tuple of (function_ast, imports).
2634
+ """
2635
+ df_library = (
2636
+ DataFrameLibrary.PANDAS if library == 'pandas' else DataFrameLibrary.POLARS
2637
+ )
2638
+ config = EndpointFunctionConfig(
2639
+ fn_name=fn_name,
2640
+ method='',
2641
+ path='',
2642
+ parameters=parameters,
2643
+ request_body_info=request_body_info,
2644
+ response_type=None,
2645
+ docs=docs,
2646
+ is_async=is_async,
2647
+ mode=EndpointMode.DELEGATING,
2648
+ client_method_name=client_method_name,
2649
+ dataframe_library=df_library,
2650
+ dataframe_path=default_path,
2651
+ )
2652
+ return EndpointFunctionFactory(config).build()
2653
+
2654
+
2655
+ def build_standalone_paginated_fn(
2656
+ fn_name: str,
2657
+ method: str,
2658
+ path: str,
2659
+ parameters: list['Parameter'] | None,
2660
+ request_body_info: 'RequestBodyInfo | None',
2661
+ response_type: 'Type | None',
2662
+ pagination_style: str,
2663
+ pagination_config: dict,
2664
+ item_type_ast: ast.expr | None = None,
2665
+ docs: str | None = None,
2666
+ is_async: bool = False,
2667
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2668
+ """Build a standalone paginated endpoint function.
2669
+
2670
+ This function returns all items by automatically handling pagination.
2671
+
2672
+ Args:
2673
+ fn_name: The function name to generate.
2674
+ method: HTTP method (GET, POST, etc.).
2675
+ path: The API path with optional placeholders.
2676
+ parameters: List of Parameter objects for the endpoint.
2677
+ request_body_info: RequestBodyInfo object for the request body, or None.
2678
+ response_type: The response Type for parsing.
2679
+ pagination_style: The pagination style ('offset', 'cursor', 'page').
2680
+ pagination_config: Dict with pagination parameter names and paths.
2681
+ item_type_ast: AST for the item type in the list.
2682
+ docs: Optional docstring for the generated function.
2683
+ is_async: Whether to generate an async function.
2684
+
2685
+ Returns:
2686
+ A tuple of (function_ast, imports).
2687
+ """
2688
+ pag_style = PaginationStyle(pagination_style)
2689
+ config = EndpointFunctionConfig(
2690
+ fn_name=fn_name,
2691
+ method=method,
2692
+ path=path,
2693
+ parameters=parameters,
2694
+ request_body_info=request_body_info,
2695
+ response_type=response_type,
2696
+ docs=docs,
2697
+ is_async=is_async,
2698
+ mode=EndpointMode.STANDALONE,
2699
+ pagination_style=pag_style,
2700
+ pagination_config=pagination_config,
2701
+ item_type_ast=item_type_ast,
2702
+ )
2703
+ return EndpointFunctionFactory(config).build()
2704
+
2705
+
2706
+ def build_standalone_paginated_iter_fn(
2707
+ fn_name: str,
2708
+ method: str,
2709
+ path: str,
2710
+ parameters: list['Parameter'] | None,
2711
+ request_body_info: 'RequestBodyInfo | None',
2712
+ response_type: 'Type | None',
2713
+ pagination_style: str,
2714
+ pagination_config: dict,
2715
+ item_type_ast: ast.expr | None = None,
2716
+ docs: str | None = None,
2717
+ is_async: bool = False,
2718
+ ) -> tuple[ast.FunctionDef | ast.AsyncFunctionDef, ImportDict]:
2719
+ """Build a standalone paginated iterator endpoint function.
2720
+
2721
+ This function yields items one at a time for memory-efficient streaming.
2722
+
2723
+ Args:
2724
+ fn_name: The function name to generate.
2725
+ method: HTTP method (GET, POST, etc.).
2726
+ path: The API path with optional placeholders.
2727
+ parameters: List of Parameter objects for the endpoint.
2728
+ request_body_info: RequestBodyInfo object for the request body, or None.
2729
+ response_type: The response Type for parsing.
2730
+ pagination_style: The pagination style ('offset', 'cursor', 'page').
2731
+ pagination_config: Dict with pagination parameter names and paths.
2732
+ item_type_ast: AST for the item type in the list.
2733
+ docs: Optional docstring for the generated function.
2734
+ is_async: Whether to generate an async function.
2735
+
2736
+ Returns:
2737
+ A tuple of (function_ast, imports).
2738
+ """
2739
+ pag_style = PaginationStyle(pagination_style)
2740
+ config = EndpointFunctionConfig(
2741
+ fn_name=fn_name,
2742
+ method=method,
2743
+ path=path,
2744
+ parameters=parameters,
2745
+ request_body_info=request_body_info,
2746
+ response_type=response_type,
2747
+ docs=docs,
2748
+ is_async=is_async,
2749
+ mode=EndpointMode.STANDALONE,
2750
+ pagination_style=pag_style,
2751
+ pagination_config=pagination_config,
2752
+ is_iterator=True,
2753
+ item_type_ast=item_type_ast,
2754
+ )
2755
+ return EndpointFunctionFactory(config).build()