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
@@ -0,0 +1,1271 @@
1
+ """Client class generation module for OtterAPI.
2
+
3
+ This module provides utilities for generating a client class that wraps
4
+ all API endpoints with configurable base URL, timeout, headers, and
5
+ HTTP client injection support.
6
+ """
7
+
8
+ import ast
9
+ from dataclasses import dataclass, field
10
+ from typing import TYPE_CHECKING
11
+
12
+ from otterapi.codegen.ast_utils import (
13
+ _argument,
14
+ _assign,
15
+ _attr,
16
+ _call,
17
+ _name,
18
+ _subscript,
19
+ _union_expr,
20
+ )
21
+
22
+ # Re-export DataFrameMethodConfig from dataframes for backward compatibility
23
+ from otterapi.codegen.dataframes import DataFrameMethodConfig
24
+ from otterapi.codegen.endpoints import ParameterASTBuilder
25
+
26
+ if TYPE_CHECKING:
27
+ from otterapi.codegen.types import Parameter, RequestBodyInfo, ResponseInfo, Type
28
+
29
+ # Type alias for import dictionaries
30
+ ImportDict = dict[str, set[str]]
31
+
32
+
33
+ @dataclass
34
+ class EndpointInfo:
35
+ """Information about an endpoint for client method generation."""
36
+
37
+ name: str
38
+ async_name: str
39
+ method: str
40
+ path: str
41
+ parameters: list['Parameter'] | None
42
+ request_body: 'RequestBodyInfo | None'
43
+ response_type: 'Type | None'
44
+ response_infos: list['ResponseInfo'] | None
45
+ description: str | None
46
+ dataframe_config: DataFrameMethodConfig = field(
47
+ default_factory=DataFrameMethodConfig
48
+ )
49
+
50
+
51
+ def generate_api_error_class() -> ast.ClassDef:
52
+ """Generate the APIError exception class for detailed error handling.
53
+
54
+ Returns:
55
+ AST ClassDef for the APIError class.
56
+ """
57
+ # Build __init__ method
58
+ init_body = [
59
+ # self.status_code = status_code
60
+ _assign(_attr('self', 'status_code'), _name('status_code')),
61
+ # self.response = response
62
+ _assign(_attr('self', 'response'), _name('response')),
63
+ # self.detail = detail
64
+ _assign(_attr('self', 'detail'), _name('detail')),
65
+ # self.body = body
66
+ _assign(_attr('self', 'body'), _name('body')),
67
+ # super().__init__(message)
68
+ ast.Expr(
69
+ value=_call(
70
+ _attr(_call(_name('super')), '__init__'),
71
+ args=[_name('message')],
72
+ )
73
+ ),
74
+ ]
75
+
76
+ init_method = ast.FunctionDef(
77
+ name='__init__',
78
+ args=ast.arguments(
79
+ posonlyargs=[],
80
+ args=[
81
+ _argument('self'),
82
+ _argument('message', _name('str')),
83
+ ],
84
+ kwonlyargs=[
85
+ _argument('status_code', _name('int')),
86
+ _argument('response', _name('Response')),
87
+ _argument(
88
+ 'detail', _union_expr([_name('Any'), ast.Constant(value=None)])
89
+ ),
90
+ _argument('body', _name('str')),
91
+ ],
92
+ kw_defaults=[
93
+ None, # status_code - required, no default
94
+ None, # response - required, no default
95
+ ast.Constant(value=None), # detail - default None
96
+ ast.Constant(value=''), # body - default ''
97
+ ],
98
+ kwarg=None,
99
+ defaults=[],
100
+ ),
101
+ body=init_body,
102
+ decorator_list=[],
103
+ returns=ast.Constant(value=None),
104
+ )
105
+
106
+ # Build from_response classmethod
107
+ from_response_body = [
108
+ # status_code = response.status_code
109
+ _assign(_name('status_code'), _attr('response', 'status_code')),
110
+ # body = response.text
111
+ _assign(_name('body'), _attr('response', 'text')),
112
+ # detail = None
113
+ _assign(_name('detail'), ast.Constant(value=None)),
114
+ # try: ... except: ...
115
+ ast.Try(
116
+ body=[
117
+ # json_body = response.json()
118
+ _assign(_name('json_body'), _call(_attr('response', 'json'))),
119
+ # if isinstance(json_body, dict):
120
+ ast.If(
121
+ test=_call(
122
+ _name('isinstance'), args=[_name('json_body'), _name('dict')]
123
+ ),
124
+ body=[
125
+ # detail = json_body.get('detail', json_body)
126
+ _assign(
127
+ _name('detail'),
128
+ _call(
129
+ _attr('json_body', 'get'),
130
+ args=[ast.Constant(value='detail'), _name('json_body')],
131
+ ),
132
+ ),
133
+ ],
134
+ orelse=[
135
+ # detail = json_body
136
+ _assign(_name('detail'), _name('json_body')),
137
+ ],
138
+ ),
139
+ ],
140
+ handlers=[
141
+ ast.ExceptHandler(
142
+ type=_name('Exception'),
143
+ name=None,
144
+ body=[
145
+ # detail = body if body else None
146
+ _assign(
147
+ _name('detail'),
148
+ ast.IfExp(
149
+ test=_name('body'),
150
+ body=_name('body'),
151
+ orelse=ast.Constant(value=None),
152
+ ),
153
+ ),
154
+ ],
155
+ ),
156
+ ],
157
+ orelse=[],
158
+ finalbody=[],
159
+ ),
160
+ # message = f'HTTP {status_code} Error'
161
+ _assign(
162
+ _name('message'),
163
+ ast.JoinedStr(
164
+ values=[
165
+ ast.Constant(value='HTTP '),
166
+ ast.FormattedValue(value=_name('status_code'), conversion=-1),
167
+ ast.Constant(value=' Error'),
168
+ ]
169
+ ),
170
+ ),
171
+ # if detail:
172
+ ast.If(
173
+ test=_name('detail'),
174
+ body=[
175
+ # if isinstance(detail, list):
176
+ ast.If(
177
+ test=_call(
178
+ _name('isinstance'), args=[_name('detail'), _name('list')]
179
+ ),
180
+ body=[
181
+ # error_msgs = []
182
+ _assign(_name('error_msgs'), ast.List(elts=[], ctx=ast.Load())),
183
+ # for err in detail:
184
+ ast.For(
185
+ target=ast.Name(id='err', ctx=ast.Store()),
186
+ iter=_name('detail'),
187
+ body=[
188
+ ast.If(
189
+ test=_call(
190
+ _name('isinstance'),
191
+ args=[_name('err'), _name('dict')],
192
+ ),
193
+ body=[
194
+ _assign(
195
+ _name('loc'),
196
+ _call(
197
+ _attr('err', 'get'),
198
+ args=[
199
+ ast.Constant(value='loc'),
200
+ ast.List(elts=[], ctx=ast.Load()),
201
+ ],
202
+ ),
203
+ ),
204
+ _assign(
205
+ _name('msg'),
206
+ _call(
207
+ _attr('err', 'get'),
208
+ args=[
209
+ ast.Constant(value='msg'),
210
+ _call(
211
+ _name('str'),
212
+ args=[_name('err')],
213
+ ),
214
+ ],
215
+ ),
216
+ ),
217
+ _assign(
218
+ _name('loc_str'),
219
+ ast.IfExp(
220
+ test=_name('loc'),
221
+ body=_call(
222
+ _attr(
223
+ ast.Constant(value=' -> '),
224
+ 'join',
225
+ ),
226
+ args=[
227
+ ast.GeneratorExp(
228
+ elt=_call(
229
+ _name('str'),
230
+ args=[_name('x')],
231
+ ),
232
+ generators=[
233
+ ast.comprehension(
234
+ target=ast.Name(
235
+ id='x',
236
+ ctx=ast.Store(),
237
+ ),
238
+ iter=_name('loc'),
239
+ ifs=[],
240
+ is_async=0,
241
+ )
242
+ ],
243
+ )
244
+ ],
245
+ ),
246
+ orelse=ast.Constant(value='unknown'),
247
+ ),
248
+ ),
249
+ ast.Expr(
250
+ value=_call(
251
+ _attr('error_msgs', 'append'),
252
+ args=[
253
+ ast.JoinedStr(
254
+ values=[
255
+ ast.Constant(value=' - '),
256
+ ast.FormattedValue(
257
+ value=_name('loc_str'),
258
+ conversion=-1,
259
+ ),
260
+ ast.Constant(value=': '),
261
+ ast.FormattedValue(
262
+ value=_name('msg'),
263
+ conversion=-1,
264
+ ),
265
+ ]
266
+ )
267
+ ],
268
+ )
269
+ ),
270
+ ],
271
+ orelse=[
272
+ ast.Expr(
273
+ value=_call(
274
+ _attr('error_msgs', 'append'),
275
+ args=[
276
+ ast.JoinedStr(
277
+ values=[
278
+ ast.Constant(value=' - '),
279
+ ast.FormattedValue(
280
+ value=_name('err'),
281
+ conversion=-1,
282
+ ),
283
+ ]
284
+ )
285
+ ],
286
+ )
287
+ ),
288
+ ],
289
+ ),
290
+ ],
291
+ orelse=[],
292
+ ),
293
+ # if error_msgs:
294
+ ast.If(
295
+ test=_name('error_msgs'),
296
+ body=[
297
+ _assign(
298
+ _name('message'),
299
+ ast.BinOp(
300
+ left=ast.JoinedStr(
301
+ values=[
302
+ ast.Constant(value='HTTP '),
303
+ ast.FormattedValue(
304
+ value=_name('status_code'),
305
+ conversion=-1,
306
+ ),
307
+ ast.Constant(
308
+ value=' Validation Error:\n'
309
+ ),
310
+ ]
311
+ ),
312
+ op=ast.Add(),
313
+ right=_call(
314
+ _attr(ast.Constant(value='\n'), 'join'),
315
+ args=[_name('error_msgs')],
316
+ ),
317
+ ),
318
+ ),
319
+ ],
320
+ orelse=[],
321
+ ),
322
+ ],
323
+ orelse=[
324
+ # elif isinstance(detail, str):
325
+ ast.If(
326
+ test=_call(
327
+ _name('isinstance'),
328
+ args=[_name('detail'), _name('str')],
329
+ ),
330
+ body=[
331
+ _assign(
332
+ _name('message'),
333
+ ast.JoinedStr(
334
+ values=[
335
+ ast.Constant(value='HTTP '),
336
+ ast.FormattedValue(
337
+ value=_name('status_code'),
338
+ conversion=-1,
339
+ ),
340
+ ast.Constant(value=' Error: '),
341
+ ast.FormattedValue(
342
+ value=_name('detail'), conversion=-1
343
+ ),
344
+ ]
345
+ ),
346
+ ),
347
+ ],
348
+ orelse=[
349
+ _assign(
350
+ _name('message'),
351
+ ast.JoinedStr(
352
+ values=[
353
+ ast.Constant(value='HTTP '),
354
+ ast.FormattedValue(
355
+ value=_name('status_code'),
356
+ conversion=-1,
357
+ ),
358
+ ast.Constant(value=' Error: '),
359
+ ast.FormattedValue(
360
+ value=_name('detail'), conversion=-1
361
+ ),
362
+ ]
363
+ ),
364
+ ),
365
+ ],
366
+ ),
367
+ ],
368
+ ),
369
+ ],
370
+ orelse=[],
371
+ ),
372
+ # return cls(message, status_code=status_code, response=response, detail=detail, body=body)
373
+ ast.Return(
374
+ value=_call(
375
+ _name('cls'),
376
+ args=[_name('message')],
377
+ keywords=[
378
+ ast.keyword(arg='status_code', value=_name('status_code')),
379
+ ast.keyword(arg='response', value=_name('response')),
380
+ ast.keyword(arg='detail', value=_name('detail')),
381
+ ast.keyword(arg='body', value=_name('body')),
382
+ ],
383
+ )
384
+ ),
385
+ ]
386
+
387
+ from_response_method = ast.FunctionDef(
388
+ name='from_response',
389
+ args=ast.arguments(
390
+ posonlyargs=[],
391
+ args=[
392
+ _argument('cls'),
393
+ _argument('response', _name('Response')),
394
+ ],
395
+ kwonlyargs=[],
396
+ kw_defaults=[],
397
+ kwarg=None,
398
+ defaults=[],
399
+ ),
400
+ body=from_response_body,
401
+ decorator_list=[_name('classmethod')],
402
+ returns=ast.Constant(value='APIError'),
403
+ )
404
+
405
+ # Build __str__ method
406
+ str_method = ast.FunctionDef(
407
+ name='__str__',
408
+ args=ast.arguments(
409
+ posonlyargs=[],
410
+ args=[_argument('self')],
411
+ kwonlyargs=[],
412
+ kw_defaults=[],
413
+ kwarg=None,
414
+ defaults=[],
415
+ ),
416
+ body=[
417
+ ast.Return(
418
+ value=ast.Subscript(
419
+ value=_attr('self', 'args'),
420
+ slice=ast.Constant(value=0),
421
+ ctx=ast.Load(),
422
+ )
423
+ ),
424
+ ],
425
+ decorator_list=[],
426
+ returns=_name('str'),
427
+ )
428
+
429
+ # Build __repr__ method
430
+ repr_method = ast.FunctionDef(
431
+ name='__repr__',
432
+ args=ast.arguments(
433
+ posonlyargs=[],
434
+ args=[_argument('self')],
435
+ kwonlyargs=[],
436
+ kw_defaults=[],
437
+ kwarg=None,
438
+ defaults=[],
439
+ ),
440
+ body=[
441
+ ast.Return(
442
+ value=ast.JoinedStr(
443
+ values=[
444
+ ast.Constant(value='APIError(status_code='),
445
+ ast.FormattedValue(
446
+ value=_attr('self', 'status_code'), conversion=-1
447
+ ),
448
+ ast.Constant(value=', detail='),
449
+ ast.FormattedValue(
450
+ value=_attr('self', 'detail'), conversion=114
451
+ ), # 114 = 'r' for repr
452
+ ast.Constant(value=')'),
453
+ ]
454
+ )
455
+ ),
456
+ ],
457
+ decorator_list=[],
458
+ returns=_name('str'),
459
+ )
460
+
461
+ # Build class docstring
462
+ docstring = ast.Expr(
463
+ value=ast.Constant(
464
+ value="""Exception raised when an API request fails with an error response.
465
+
466
+ This exception provides detailed error information from the API response,
467
+ including the HTTP status code, error message, and full response body.
468
+
469
+ Attributes:
470
+ status_code: The HTTP status code of the response.
471
+ response: The httpx Response object.
472
+ detail: Parsed error detail from the response body (if available).
473
+ body: Raw response body text.
474
+ """
475
+ )
476
+ )
477
+
478
+ class_def = ast.ClassDef(
479
+ name='APIError',
480
+ bases=[_name('Exception')],
481
+ keywords=[],
482
+ body=[docstring, init_method, from_response_method, str_method, repr_method],
483
+ decorator_list=[],
484
+ )
485
+
486
+ return class_def
487
+
488
+
489
+ def generate_base_client_class(
490
+ class_name: str,
491
+ default_base_url: str,
492
+ default_timeout: float = 30.0,
493
+ ) -> tuple[ast.ClassDef, ImportDict]:
494
+ """Generate a BaseClient class with only request infrastructure.
495
+
496
+ This class contains only the HTTP request plumbing (__init__, _request,
497
+ _request_async). Endpoint implementations live in the module files.
498
+
499
+ Args:
500
+ class_name: Name for the generated class (e.g., 'BasePetStoreClient').
501
+ default_base_url: Default base URL from the OpenAPI spec.
502
+ default_timeout: Default request timeout in seconds.
503
+
504
+ Returns:
505
+ Tuple of (class AST node, required imports).
506
+ """
507
+ imports: ImportDict = {
508
+ 'httpx': {'Client', 'AsyncClient', 'Response'},
509
+ 'typing': {'Any', 'Type', 'TypeVar'},
510
+ 'pydantic': {'TypeAdapter', 'RootModel'},
511
+ }
512
+
513
+ # Build __init__ method
514
+ init_method = _build_init_method(default_base_url, default_timeout)
515
+
516
+ # Build _request method (sync)
517
+ request_method = _build_request_method(is_async=False)
518
+
519
+ # Build _request_async method (async)
520
+ async_request_method = _build_request_method(is_async=True)
521
+
522
+ # Build _request_json method (sync) - request + json parsing
523
+ request_json_method = _build_request_json_method(is_async=False)
524
+
525
+ # Build _request_json_async method (async) - request + json parsing
526
+ async_request_json_method = _build_request_json_method(is_async=True)
527
+
528
+ # Build _parse_response method (sync)
529
+ parse_response_method = _build_parse_response_method(is_async=False)
530
+
531
+ # Build _parse_response_async method (async)
532
+ async_parse_response_method = _build_parse_response_method(is_async=True)
533
+
534
+ # Build class body
535
+ class_body: list[ast.stmt] = [
536
+ ast.Expr(
537
+ value=ast.Constant(
538
+ value=f"""Base HTTP client with request infrastructure.
539
+
540
+ This class is regenerated on each code generation run.
541
+ To customize, subclass this in client.py.
542
+
543
+ Endpoint implementations are in the module files (e.g., pet.py, store.py).
544
+
545
+ Args:
546
+ base_url: Base URL for API requests. Default: {default_base_url}
547
+ timeout: Request timeout in seconds. Default: {default_timeout}
548
+ headers: Default headers to include in all requests.
549
+ http_client: Custom httpx.Client for sync requests.
550
+ async_http_client: Custom httpx.AsyncClient for async requests.
551
+ """
552
+ )
553
+ ),
554
+ init_method,
555
+ request_method,
556
+ async_request_method,
557
+ request_json_method,
558
+ async_request_json_method,
559
+ parse_response_method,
560
+ async_parse_response_method,
561
+ ]
562
+
563
+ class_def = ast.ClassDef(
564
+ name=class_name,
565
+ bases=[],
566
+ keywords=[],
567
+ body=class_body,
568
+ decorator_list=[],
569
+ )
570
+
571
+ return class_def, imports
572
+
573
+
574
+ def _build_init_method(
575
+ default_base_url: str, default_timeout: float
576
+ ) -> ast.FunctionDef:
577
+ """Build the __init__ method for the client class."""
578
+ init_body: list[ast.stmt] = [
579
+ # self.base_url = base_url.rstrip('/')
580
+ _assign(
581
+ _attr('self', 'base_url'),
582
+ _call(_attr(_name('base_url'), 'rstrip'), [ast.Constant(value='/')]),
583
+ ),
584
+ # self.timeout = timeout
585
+ _assign(
586
+ _attr('self', 'timeout'),
587
+ _name('timeout'),
588
+ ),
589
+ # self.headers = headers or {}
590
+ _assign(
591
+ _attr('self', 'headers'),
592
+ ast.BoolOp(
593
+ op=ast.Or(),
594
+ values=[_name('headers'), ast.Dict(keys=[], values=[])],
595
+ ),
596
+ ),
597
+ # self._client = http_client
598
+ _assign(
599
+ _attr('self', '_client'),
600
+ _name('http_client'),
601
+ ),
602
+ # self._async_client = async_http_client
603
+ _assign(
604
+ _attr('self', '_async_client'),
605
+ _name('async_http_client'),
606
+ ),
607
+ ]
608
+
609
+ init_method = ast.FunctionDef(
610
+ name='__init__',
611
+ args=ast.arguments(
612
+ posonlyargs=[],
613
+ args=[
614
+ _argument('self'),
615
+ _argument('base_url', _name('str')),
616
+ _argument('timeout', _name('float')),
617
+ _argument(
618
+ 'headers',
619
+ _union_expr(
620
+ [
621
+ _subscript(
622
+ 'dict', ast.Tuple(elts=[_name('str'), _name('str')])
623
+ ),
624
+ ast.Constant(value=None),
625
+ ]
626
+ ),
627
+ ),
628
+ _argument(
629
+ 'http_client',
630
+ _union_expr([_name('Client'), ast.Constant(value=None)]),
631
+ ),
632
+ _argument(
633
+ 'async_http_client',
634
+ _union_expr([_name('AsyncClient'), ast.Constant(value=None)]),
635
+ ),
636
+ ],
637
+ kwonlyargs=[],
638
+ kw_defaults=[],
639
+ kwarg=None,
640
+ defaults=[
641
+ ast.Constant(value=default_base_url),
642
+ ast.Constant(value=default_timeout),
643
+ ast.Constant(value=None),
644
+ ast.Constant(value=None),
645
+ ast.Constant(value=None),
646
+ ],
647
+ ),
648
+ body=init_body,
649
+ decorator_list=[],
650
+ returns=ast.Constant(value=None),
651
+ )
652
+
653
+ return init_method
654
+
655
+
656
+ def _build_parse_response_method(
657
+ is_async: bool,
658
+ ) -> ast.FunctionDef | ast.AsyncFunctionDef:
659
+ """Build the _parse_response or _parse_response_async method.
660
+
661
+ This method handles JSON parsing and Pydantic validation of responses.
662
+ """
663
+ method_name = '_parse_response_async' if is_async else '_parse_response'
664
+
665
+ # T = TypeVar('T') is module-level, we reference it here
666
+ args = ast.arguments(
667
+ posonlyargs=[],
668
+ args=[
669
+ _argument('self'),
670
+ _argument('response', _name('Response')),
671
+ _argument('response_type', _subscript('Type', _name('T'))),
672
+ ],
673
+ kwonlyargs=[],
674
+ kw_defaults=[],
675
+ kwarg=None,
676
+ defaults=[],
677
+ )
678
+
679
+ # Build the method body:
680
+ # data = response.json()
681
+ # validated = TypeAdapter(response_type).validate_python(data)
682
+ # if isinstance(validated, RootModel):
683
+ # return validated.root
684
+ # return validated
685
+ body: list[ast.stmt] = [
686
+ # data = response.json()
687
+ _assign(
688
+ _name('data'),
689
+ _call(func=_attr('response', 'json')),
690
+ ),
691
+ # validated = TypeAdapter(response_type).validate_python(data)
692
+ _assign(
693
+ _name('validated'),
694
+ _call(
695
+ func=_attr(
696
+ _call(
697
+ func=_name('TypeAdapter'),
698
+ args=[_name('response_type')],
699
+ ),
700
+ 'validate_python',
701
+ ),
702
+ args=[_name('data')],
703
+ ),
704
+ ),
705
+ # if isinstance(validated, RootModel): return validated.root
706
+ ast.If(
707
+ test=_call(
708
+ func=_name('isinstance'),
709
+ args=[_name('validated'), _name('RootModel')],
710
+ ),
711
+ body=[ast.Return(value=_attr('validated', 'root'))],
712
+ orelse=[],
713
+ ),
714
+ # return validated
715
+ ast.Return(value=_name('validated')),
716
+ ]
717
+
718
+ if is_async:
719
+ return ast.AsyncFunctionDef(
720
+ name=method_name,
721
+ args=args,
722
+ body=body,
723
+ decorator_list=[],
724
+ returns=_name('T'),
725
+ )
726
+ else:
727
+ return ast.FunctionDef(
728
+ name=method_name,
729
+ args=args,
730
+ body=body,
731
+ decorator_list=[],
732
+ returns=_name('T'),
733
+ )
734
+
735
+
736
+ def _build_request_json_method(
737
+ is_async: bool,
738
+ ) -> ast.FunctionDef | ast.AsyncFunctionDef:
739
+ """Build the _request_json or _request_json_async method.
740
+
741
+ This method combines _request and .json() parsing for convenience.
742
+ """
743
+ method_name = '_request_json_async' if is_async else '_request_json'
744
+ request_method = '_request_async' if is_async else '_request'
745
+
746
+ args = ast.arguments(
747
+ posonlyargs=[],
748
+ args=[
749
+ _argument('self'),
750
+ _argument('method', _name('str')),
751
+ _argument('path', _name('str')),
752
+ ],
753
+ kwonlyargs=[
754
+ _argument('params', _union_expr([_name('dict'), ast.Constant(value=None)])),
755
+ _argument(
756
+ 'headers', _union_expr([_name('dict'), ast.Constant(value=None)])
757
+ ),
758
+ _argument('json', _union_expr([_name('Any'), ast.Constant(value=None)])),
759
+ _argument('data', _union_expr([_name('Any'), ast.Constant(value=None)])),
760
+ _argument('files', _union_expr([_name('Any'), ast.Constant(value=None)])),
761
+ _argument('content', _union_expr([_name('Any'), ast.Constant(value=None)])),
762
+ _argument(
763
+ 'timeout', _union_expr([_name('float'), ast.Constant(value=None)])
764
+ ),
765
+ ],
766
+ kw_defaults=[
767
+ ast.Constant(value=None), # params
768
+ ast.Constant(value=None), # headers
769
+ ast.Constant(value=None), # json
770
+ ast.Constant(value=None), # data
771
+ ast.Constant(value=None), # files
772
+ ast.Constant(value=None), # content
773
+ ast.Constant(value=None), # timeout
774
+ ],
775
+ kwarg=None,
776
+ defaults=[],
777
+ )
778
+
779
+ # Build the request call with all parameters
780
+ request_call = _call(
781
+ func=_attr('self', request_method),
782
+ args=[_name('method'), _name('path')],
783
+ keywords=[
784
+ ast.keyword(arg='params', value=_name('params')),
785
+ ast.keyword(arg='headers', value=_name('headers')),
786
+ ast.keyword(arg='json', value=_name('json')),
787
+ ast.keyword(arg='data', value=_name('data')),
788
+ ast.keyword(arg='files', value=_name('files')),
789
+ ast.keyword(arg='content', value=_name('content')),
790
+ ast.keyword(arg='timeout', value=_name('timeout')),
791
+ ],
792
+ )
793
+
794
+ if is_async:
795
+ request_call = ast.Await(value=request_call)
796
+
797
+ # response = self._request(...) or await self._request_async(...)
798
+ # return response.json()
799
+ body: list[ast.stmt] = [
800
+ _assign(_name('response'), request_call),
801
+ ast.Return(value=_call(func=_attr('response', 'json'))),
802
+ ]
803
+
804
+ if is_async:
805
+ return ast.AsyncFunctionDef(
806
+ name=method_name,
807
+ args=args,
808
+ body=body,
809
+ decorator_list=[],
810
+ returns=_name('Any'),
811
+ )
812
+ else:
813
+ return ast.FunctionDef(
814
+ name=method_name,
815
+ args=args,
816
+ body=body,
817
+ decorator_list=[],
818
+ returns=_name('Any'),
819
+ )
820
+
821
+
822
+ def _build_request_method(is_async: bool) -> ast.FunctionDef | ast.AsyncFunctionDef:
823
+ """Build the internal _request or _request_async method."""
824
+ method_name = '_request_async' if is_async else '_request'
825
+
826
+ args = ast.arguments(
827
+ posonlyargs=[],
828
+ args=[
829
+ _argument('self'),
830
+ _argument('method', _name('str')),
831
+ _argument('path', _name('str')),
832
+ ],
833
+ kwonlyargs=[
834
+ _argument('params', _union_expr([_name('dict'), ast.Constant(value=None)])),
835
+ _argument(
836
+ 'headers', _union_expr([_name('dict'), ast.Constant(value=None)])
837
+ ),
838
+ _argument('json', _union_expr([_name('Any'), ast.Constant(value=None)])),
839
+ _argument('data', _union_expr([_name('Any'), ast.Constant(value=None)])),
840
+ _argument('files', _union_expr([_name('Any'), ast.Constant(value=None)])),
841
+ _argument('content', _union_expr([_name('Any'), ast.Constant(value=None)])),
842
+ _argument(
843
+ 'timeout', _union_expr([_name('float'), ast.Constant(value=None)])
844
+ ),
845
+ ],
846
+ kw_defaults=[
847
+ ast.Constant(value=None), # params
848
+ ast.Constant(value=None), # headers
849
+ ast.Constant(value=None), # json
850
+ ast.Constant(value=None), # data
851
+ ast.Constant(value=None), # files
852
+ ast.Constant(value=None), # content
853
+ ast.Constant(value=None), # timeout
854
+ ],
855
+ kwarg=None,
856
+ defaults=[],
857
+ )
858
+
859
+ # Build URL: f"{self.base_url}{path}"
860
+ url_expr = ast.JoinedStr(
861
+ values=[
862
+ ast.FormattedValue(value=_attr('self', 'base_url'), conversion=-1),
863
+ ast.FormattedValue(value=_name('path'), conversion=-1),
864
+ ]
865
+ )
866
+
867
+ # merged_headers = {**self.headers, **(headers or {})}
868
+ merged_headers = ast.Dict(
869
+ keys=[None, None],
870
+ values=[
871
+ _attr('self', 'headers'),
872
+ ast.BoolOp(
873
+ op=ast.Or(),
874
+ values=[_name('headers'), ast.Dict(keys=[], values=[])],
875
+ ),
876
+ ],
877
+ )
878
+
879
+ # actual_timeout = timeout if timeout is not None else self.timeout
880
+ timeout_expr = ast.IfExp(
881
+ test=ast.Compare(
882
+ left=_name('timeout'),
883
+ ops=[ast.IsNot()],
884
+ comparators=[ast.Constant(value=None)],
885
+ ),
886
+ body=_name('timeout'),
887
+ orelse=_attr('self', 'timeout'),
888
+ )
889
+
890
+ if is_async:
891
+ body = _build_async_request_body(url_expr, merged_headers, timeout_expr)
892
+ return ast.AsyncFunctionDef(
893
+ name=method_name,
894
+ args=args,
895
+ body=body,
896
+ decorator_list=[],
897
+ returns=_name('Response'),
898
+ )
899
+ else:
900
+ body = _build_sync_request_body(url_expr, merged_headers, timeout_expr)
901
+ return ast.FunctionDef(
902
+ name=method_name,
903
+ args=args,
904
+ body=body,
905
+ decorator_list=[],
906
+ returns=_name('Response'),
907
+ )
908
+
909
+
910
+ def _build_filtered_params_expr() -> ast.expr:
911
+ """Build expression to filter None values from params dict.
912
+
913
+ Generates: {k: v for k, v in params.items() if v is not None} if params else None
914
+ """
915
+ # Build the dict comprehension: {k: v for k, v in params.items() if v is not None}
916
+ dict_comp = ast.DictComp(
917
+ key=_name('k'),
918
+ value=_name('v'),
919
+ generators=[
920
+ ast.comprehension(
921
+ target=ast.Tuple(
922
+ elts=[
923
+ ast.Name(id='k', ctx=ast.Store()),
924
+ ast.Name(id='v', ctx=ast.Store()),
925
+ ],
926
+ ctx=ast.Store(),
927
+ ),
928
+ iter=_call(_attr('params', 'items')),
929
+ ifs=[
930
+ ast.Compare(
931
+ left=_name('v'),
932
+ ops=[ast.IsNot()],
933
+ comparators=[ast.Constant(value=None)],
934
+ )
935
+ ],
936
+ is_async=0,
937
+ )
938
+ ],
939
+ )
940
+
941
+ # Build the conditional: dict_comp if params else None
942
+ return ast.IfExp(
943
+ test=_name('params'),
944
+ body=dict_comp,
945
+ orelse=ast.Constant(value=None),
946
+ )
947
+
948
+
949
+ def _build_sync_request_body(
950
+ url_expr: ast.expr, merged_headers: ast.expr, timeout_expr: ast.expr
951
+ ) -> list[ast.stmt]:
952
+ """Build the body for sync _request method."""
953
+ # Build filtered_params assignment
954
+ filtered_params_stmt = _assign(
955
+ _name('filtered_params'),
956
+ _build_filtered_params_expr(),
957
+ )
958
+
959
+ request_call = _call(
960
+ _attr('client', 'request'),
961
+ args=[_name('method'), url_expr],
962
+ keywords=[
963
+ ast.keyword(arg='params', value=_name('filtered_params')),
964
+ ast.keyword(arg='headers', value=merged_headers),
965
+ ast.keyword(arg='json', value=_name('json')),
966
+ ast.keyword(arg='data', value=_name('data')),
967
+ ast.keyword(arg='files', value=_name('files')),
968
+ ast.keyword(arg='content', value=_name('content')),
969
+ ast.keyword(arg='timeout', value=timeout_expr),
970
+ ],
971
+ )
972
+
973
+ return [
974
+ filtered_params_stmt,
975
+ ast.If(
976
+ test=_attr('self', '_client'),
977
+ body=[
978
+ _assign(
979
+ _name('response'),
980
+ _call(
981
+ _attr(_attr('self', '_client'), 'request'),
982
+ args=[_name('method'), url_expr],
983
+ keywords=[
984
+ ast.keyword(arg='params', value=_name('filtered_params')),
985
+ ast.keyword(arg='headers', value=merged_headers),
986
+ ast.keyword(arg='json', value=_name('json')),
987
+ ast.keyword(arg='data', value=_name('data')),
988
+ ast.keyword(arg='files', value=_name('files')),
989
+ ast.keyword(arg='content', value=_name('content')),
990
+ ast.keyword(arg='timeout', value=timeout_expr),
991
+ ],
992
+ ),
993
+ ),
994
+ ],
995
+ orelse=[
996
+ ast.With(
997
+ items=[
998
+ ast.withitem(
999
+ context_expr=_call(_name('Client')),
1000
+ optional_vars=_name('client'),
1001
+ )
1002
+ ],
1003
+ body=[
1004
+ _assign(_name('response'), request_call),
1005
+ ],
1006
+ ),
1007
+ ],
1008
+ ),
1009
+ # if response.is_error: raise APIError.from_response(response)
1010
+ ast.If(
1011
+ test=_attr('response', 'is_error'),
1012
+ body=[
1013
+ ast.Raise(
1014
+ exc=_call(
1015
+ _attr(_name('APIError'), 'from_response'),
1016
+ args=[_name('response')],
1017
+ )
1018
+ ),
1019
+ ],
1020
+ orelse=[],
1021
+ ),
1022
+ ast.Return(value=_name('response')),
1023
+ ]
1024
+
1025
+
1026
+ def _build_async_request_body(
1027
+ url_expr: ast.expr, merged_headers: ast.expr, timeout_expr: ast.expr
1028
+ ) -> list[ast.stmt]:
1029
+ """Build the body for async _request_async method."""
1030
+ # Build filtered_params assignment
1031
+ filtered_params_stmt = _assign(
1032
+ _name('filtered_params'),
1033
+ _build_filtered_params_expr(),
1034
+ )
1035
+
1036
+ request_call = ast.Await(
1037
+ value=_call(
1038
+ _attr('client', 'request'),
1039
+ args=[_name('method'), url_expr],
1040
+ keywords=[
1041
+ ast.keyword(arg='params', value=_name('filtered_params')),
1042
+ ast.keyword(arg='headers', value=merged_headers),
1043
+ ast.keyword(arg='json', value=_name('json')),
1044
+ ast.keyword(arg='data', value=_name('data')),
1045
+ ast.keyword(arg='files', value=_name('files')),
1046
+ ast.keyword(arg='content', value=_name('content')),
1047
+ ast.keyword(arg='timeout', value=timeout_expr),
1048
+ ],
1049
+ )
1050
+ )
1051
+
1052
+ return [
1053
+ filtered_params_stmt,
1054
+ ast.If(
1055
+ test=_attr('self', '_async_client'),
1056
+ body=[
1057
+ _assign(
1058
+ _name('response'),
1059
+ ast.Await(
1060
+ value=_call(
1061
+ _attr(_attr('self', '_async_client'), 'request'),
1062
+ args=[_name('method'), url_expr],
1063
+ keywords=[
1064
+ ast.keyword(
1065
+ arg='params', value=_name('filtered_params')
1066
+ ),
1067
+ ast.keyword(arg='headers', value=merged_headers),
1068
+ ast.keyword(arg='json', value=_name('json')),
1069
+ ast.keyword(arg='data', value=_name('data')),
1070
+ ast.keyword(arg='files', value=_name('files')),
1071
+ ast.keyword(arg='content', value=_name('content')),
1072
+ ast.keyword(arg='timeout', value=timeout_expr),
1073
+ ],
1074
+ )
1075
+ ),
1076
+ ),
1077
+ ],
1078
+ orelse=[
1079
+ ast.AsyncWith(
1080
+ items=[
1081
+ ast.withitem(
1082
+ context_expr=_call(_name('AsyncClient')),
1083
+ optional_vars=_name('client'),
1084
+ )
1085
+ ],
1086
+ body=[
1087
+ _assign(_name('response'), request_call),
1088
+ ],
1089
+ ),
1090
+ ],
1091
+ ),
1092
+ # if response.is_error: raise APIError.from_response(response)
1093
+ ast.If(
1094
+ test=_attr('response', 'is_error'),
1095
+ body=[
1096
+ ast.Raise(
1097
+ exc=_call(
1098
+ _attr(_name('APIError'), 'from_response'),
1099
+ args=[_name('response')],
1100
+ )
1101
+ ),
1102
+ ],
1103
+ orelse=[],
1104
+ ),
1105
+ ast.Return(value=_name('response')),
1106
+ ]
1107
+
1108
+
1109
+ def _build_path_expr(path: str, parameters: list['Parameter'] | None) -> ast.expr:
1110
+ """Build the path expression with parameter substitution.
1111
+
1112
+ Note:
1113
+ This function delegates to ParameterASTBuilder.build_path_expr().
1114
+ """
1115
+ return ParameterASTBuilder.build_path_expr(path, parameters or [])
1116
+
1117
+
1118
+ def _build_query_params(parameters: list['Parameter'] | None) -> ast.Dict | None:
1119
+ """Build query parameters dict.
1120
+
1121
+ Note:
1122
+ This function delegates to ParameterASTBuilder.build_query_params().
1123
+ """
1124
+ if not parameters:
1125
+ return None
1126
+ return ParameterASTBuilder.build_query_params(parameters)
1127
+
1128
+
1129
+ def _build_header_params(parameters: list['Parameter'] | None) -> ast.Dict | None:
1130
+ """Build header parameters dict.
1131
+
1132
+ Note:
1133
+ This function delegates to ParameterASTBuilder.build_header_params().
1134
+ """
1135
+ if not parameters:
1136
+ return None
1137
+ return ParameterASTBuilder.build_header_params(parameters)
1138
+
1139
+
1140
+ def _build_body_expr(
1141
+ request_body: 'RequestBodyInfo',
1142
+ ) -> tuple[ast.expr | None, str | None]:
1143
+ """Build the body expression for the request.
1144
+
1145
+ Note:
1146
+ This function delegates to ParameterASTBuilder.build_body_expr().
1147
+ """
1148
+ return ParameterASTBuilder.build_body_expr(request_body)
1149
+
1150
+
1151
+ def _build_response_processing(
1152
+ response_type: 'Type', response_infos: list['ResponseInfo'] | None
1153
+ ) -> list[ast.stmt]:
1154
+ """Build statements for processing the response."""
1155
+ stmts = []
1156
+
1157
+ # data = response.json()
1158
+ stmts.append(_assign(_name('data'), _call(_attr('response', 'json'))))
1159
+
1160
+ # validated = TypeAdapter(response_type).validate_python(data)
1161
+ stmts.append(
1162
+ _assign(
1163
+ _name('validated'),
1164
+ _call(
1165
+ _attr(
1166
+ _call(_name('TypeAdapter'), [response_type.annotation_ast]),
1167
+ 'validate_python',
1168
+ ),
1169
+ [_name('data')],
1170
+ ),
1171
+ )
1172
+ )
1173
+
1174
+ # if isinstance(validated, RootModel): return validated.root
1175
+ stmts.append(
1176
+ ast.If(
1177
+ test=_call(_name('isinstance'), [_name('validated'), _name('RootModel')]),
1178
+ body=[ast.Return(value=_attr('validated', 'root'))],
1179
+ orelse=[],
1180
+ )
1181
+ )
1182
+
1183
+ # return validated
1184
+ stmts.append(ast.Return(value=_name('validated')))
1185
+
1186
+ return stmts
1187
+
1188
+
1189
+ def _merge_imports(target: ImportDict, source: ImportDict) -> None:
1190
+ """Merge source imports into target imports."""
1191
+ for module, names in source.items():
1192
+ if module not in target:
1193
+ target[module] = set()
1194
+ target[module].update(names)
1195
+
1196
+
1197
+ def generate_client_stub(
1198
+ class_name: str,
1199
+ base_class_name: str,
1200
+ module_name: str = '_client',
1201
+ ) -> str:
1202
+ """Generate the user-customizable client.py stub content.
1203
+
1204
+ Args:
1205
+ class_name: Name for the client class (e.g., 'PetStoreClient').
1206
+ base_class_name: Name of the base class to inherit from.
1207
+ module_name: Module name where base class is defined.
1208
+
1209
+ Returns:
1210
+ String content for client.py file.
1211
+ """
1212
+ return f'''"""API Client.
1213
+
1214
+ This file is generated once and will NOT be overwritten on regeneration.
1215
+ You can safely customize this file to add authentication, logging,
1216
+ error handling, or other client-specific functionality.
1217
+ """
1218
+
1219
+ from .{module_name} import {base_class_name}
1220
+
1221
+
1222
+ class {class_name}({base_class_name}):
1223
+ """API client with customizable configuration.
1224
+
1225
+ This class inherits from the generated {base_class_name} and can be
1226
+ customized without being overwritten on code regeneration.
1227
+
1228
+ Example:
1229
+ >>> client = {class_name}()
1230
+ >>> # Use default base URL from OpenAPI spec
1231
+
1232
+ >>> client = {class_name}(base_url="https://staging.api.example.com")
1233
+ >>> # Override base URL
1234
+
1235
+ >>> client = {class_name}(timeout=60.0, headers={{"Authorization": "Bearer token"}})
1236
+ >>> # Custom timeout and headers
1237
+
1238
+ >>> import httpx
1239
+ >>> with httpx.Client() as http_client:
1240
+ ... client = {class_name}(http_client=http_client)
1241
+ ... # Use custom HTTP client (useful for testing/mocking)
1242
+ """
1243
+
1244
+ pass
1245
+
1246
+ # Add custom methods or override base class methods below.
1247
+ #
1248
+ # Example - adding authentication:
1249
+ #
1250
+ # def __init__(self, api_key: str | None = None, **kwargs):
1251
+ # super().__init__(**kwargs)
1252
+ # if api_key:
1253
+ # self.headers["Authorization"] = f"Bearer {{api_key}}"
1254
+ #
1255
+ # Example - overriding a method:
1256
+ #
1257
+ # def get_pet_by_id(self, pet_id: int, **kwargs):
1258
+ # \"\"\"Get pet with custom error handling.\"\"\"
1259
+ # try:
1260
+ # return super().get_pet_by_id(pet_id, **kwargs)
1261
+ # except httpx.HTTPStatusError as e:
1262
+ # if e.response.status_code == 404:
1263
+ # return None
1264
+ # raise
1265
+
1266
+
1267
+ # Convenience alias for shorter imports
1268
+ Client = {class_name}
1269
+
1270
+ __all__ = ["{class_name}", "Client"]
1271
+ '''