pydantic-openapi-generator 2.2.8__tar.gz → 2.3.0__tar.gz

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 (31) hide show
  1. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/PKG-INFO +1 -1
  2. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/pyproject.toml +1 -1
  3. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/client_generator.py +251 -106
  4. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/async_client_httpx_pydantic_2.jinja2 +52 -17
  5. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/sync_client_httpx_pydantic_2.jinja2 +52 -17
  6. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/models.py +21 -1
  7. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/.gitignore +0 -0
  8. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/LICENSE +0 -0
  9. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/README.md +0 -0
  10. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/__init__.py +0 -0
  11. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/__main__.py +0 -0
  12. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/common.py +0 -0
  13. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/generate_data.py +0 -0
  14. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/__init__.py +0 -0
  15. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/__init__.py +0 -0
  16. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/common.py +0 -0
  17. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/exception_generator.py +0 -0
  18. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/generator.py +0 -0
  19. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/jinja_config.py +0 -0
  20. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/model_generator.py +0 -0
  21. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/alias_union.jinja2 +0 -0
  22. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/discriminator_enum.jinja2 +0 -0
  23. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/enum.jinja2 +0 -0
  24. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/http_exception.jinja2 +0 -0
  25. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/models.jinja2 +0 -0
  26. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/language_converters/python/templates/models_pydantic_2.jinja2 +0 -0
  27. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/parsers/__init__.py +0 -0
  28. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/parsers/openapi_30.py +0 -0
  29. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/parsers/openapi_31.py +0 -0
  30. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/py.typed +0 -0
  31. {pydantic_openapi_generator-2.2.8 → pydantic_openapi_generator-2.3.0}/src/pydantic_openapi_generator/version_detector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pydantic-openapi-generator
3
- Version: 2.2.8
3
+ Version: 2.3.0
4
4
  Summary: Openapi Python Generator
5
5
  Project-URL: Homepage, https://github.com/mattcoulter7/pydantic-openapi-generator
6
6
  Project-URL: Repository, https://github.com/mattcoulter7/pydantic-openapi-generator
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pydantic-openapi-generator"
3
- version = "2.2.8"
3
+ version = "2.3.0"
4
4
  description = "Openapi Python Generator"
5
5
  authors = [
6
6
  { name = "Marco Müllner", email = "muellnermarco@gmail.com" },
@@ -52,6 +52,8 @@ from pydantic_openapi_generator.models import (
52
52
  LibraryConfig,
53
53
  Model,
54
54
  OpReturnType,
55
+ RequestBodyDefinition,
56
+ ResponseVariant,
55
57
  ServiceOperation,
56
58
  TypeConversion,
57
59
  )
@@ -141,52 +143,98 @@ def operation_is_sse(op: Operation) -> bool:
141
143
  HTTP_OPERATIONS = ["get", "post", "put", "delete", "options", "head", "patch", "trace"]
142
144
 
143
145
 
144
- def generate_body_param(operation: Operation) -> Union[str, None]:
146
+ def _json_body_expression(media_type_schema: Any) -> str:
147
+ if isinstance(media_type_schema, (Reference, Reference30, Reference31)) or hasattr(media_type_schema, "ref"):
148
+ return "data.model_dump(by_alias=True, exclude_none=True)"
149
+
150
+ if isinstance(media_type_schema, (Schema, Schema30, Schema31)):
151
+ schema = media_type_schema
152
+
153
+ if schema.type == "array":
154
+ return "[i.model_dump(by_alias=True, exclude_none=True) if hasattr(i, 'model_dump') else i for i in data]"
155
+
156
+ if schema.type == "object":
157
+ return "data.model_dump(by_alias=True, exclude_none=True) if hasattr(data, 'model_dump') else data"
158
+
159
+ return "data"
160
+
161
+ raise Exception(f"Unsupported schema type for request body: {type(media_type_schema)}") # pragma: no cover
162
+
163
+
164
+ def generate_request_body(operation: Operation) -> Union[RequestBodyDefinition, None]:
145
165
  if operation.requestBody is None:
146
166
  return None
147
167
 
148
168
  # If requestBody is a $ref, it will be a Pydantic model instance in the client.
149
169
  if isinstance(operation.requestBody, (Reference30, Reference31)):
150
- return "data.model_dump(by_alias=True, exclude_none=True)"
170
+ return RequestBodyDefinition(
171
+ content_type="application/json",
172
+ encoding="json",
173
+ expression="data.model_dump(by_alias=True, exclude_none=True)",
174
+ )
151
175
 
152
176
  rb_content = getattr(operation.requestBody, "content", None)
153
177
  if rb_content is None:
154
178
  return None # pragma: no cover
155
179
 
156
- if rb_content.get("application/json") is None:
157
- return None # pragma: no cover
180
+ ordered_content_types = [
181
+ "application/json",
182
+ "multipart/form-data",
183
+ "application/x-www-form-urlencoded",
184
+ "application/octet-stream",
185
+ "text/plain",
186
+ ]
187
+ content_type = next((ct for ct in ordered_content_types if rb_content.get(ct) is not None), None)
188
+ if content_type is None:
189
+ return None
158
190
 
159
- media_type = rb_content.get("application/json")
191
+ media_type = rb_content.get(content_type)
160
192
  if media_type is None:
161
193
  return None # pragma: no cover
162
194
 
163
195
  mts = getattr(media_type, "media_type_schema", None)
164
- if mts is None:
165
- return None # pragma: no cover
196
+ if content_type == "application/json":
197
+ if mts is None:
198
+ return None # pragma: no cover
199
+ return RequestBodyDefinition(
200
+ content_type=content_type,
201
+ encoding="json",
202
+ expression=_json_body_expression(mts),
203
+ )
166
204
 
167
- # $ref schema -> model
168
- if isinstance(mts, (Reference, Reference30, Reference31)) or hasattr(mts, "ref"):
169
- return "data.model_dump(by_alias=True, exclude_none=True)"
205
+ if content_type == "multipart/form-data":
206
+ return RequestBodyDefinition(content_type=None, encoding="multipart", expression="data")
170
207
 
171
- # Concrete schema
172
- if isinstance(mts, (Schema, Schema30, Schema31)):
173
- schema = mts
208
+ if content_type == "application/x-www-form-urlencoded":
209
+ return RequestBodyDefinition(content_type=content_type, encoding="form", expression="data")
174
210
 
175
- if schema.type == "array":
176
- # List of models or primitives
177
- return "[i.model_dump(by_alias=True, exclude_none=True) if hasattr(i, 'model_dump') else i for i in data]"
211
+ if content_type == "application/octet-stream":
212
+ return RequestBodyDefinition(content_type=content_type, encoding="binary", expression="data")
178
213
 
179
- if schema.type == "object":
180
- # Model or dict-like
181
- return "data.model_dump(by_alias=True, exclude_none=True) if hasattr(data, 'model_dump') else data"
214
+ if content_type == "text/plain":
215
+ return RequestBodyDefinition(content_type=content_type, encoding="text", expression="data")
216
+
217
+ return None # pragma: no cover
182
218
 
183
- # Primitive (string/int/etc.)
184
- return "data"
185
219
 
186
- raise Exception(f"Unsupported schema type for request body: {type(mts)}") # pragma: no cover
220
+ def generate_body_param(operation: Operation) -> Union[str, None]:
221
+ request_body = generate_request_body(operation)
222
+ return None if request_body is None else request_body.expression
187
223
 
188
224
 
189
225
  def generate_params(operation: Operation) -> str:
226
+ def _schema_default(schema: Any) -> Any:
227
+ default = getattr(schema, "default", None)
228
+ if default is None:
229
+ return None
230
+ return default
231
+
232
+ def _default_suffix(schema: Any, required: bool) -> str:
233
+ if required:
234
+ return ""
235
+ default = _schema_default(schema)
236
+ return f" = {default!r}" if default is not None else " = None"
237
+
190
238
  def _generate_params_from_content(content: Any):
191
239
  # Accept reference from either 3.0 or 3.1
192
240
  if isinstance(content, (Reference, Reference30, Reference31)):
@@ -212,7 +260,7 @@ def generate_params(operation: Operation) -> str:
212
260
  if isinstance(param.param_schema, Schema30) or isinstance(param.param_schema, Schema31):
213
261
  converted_result = (
214
262
  f"{param_name_cleaned} : {type_converter(param.param_schema, param.required).converted_type}"
215
- + ("" if param.required else " = None")
263
+ + _default_suffix(param.param_schema, param.required)
216
264
  )
217
265
  required = param.required
218
266
  elif isinstance(param.param_schema, Reference30) or isinstance(param.param_schema, Reference31):
@@ -232,6 +280,7 @@ def generate_params(operation: Operation) -> str:
232
280
  "application/json",
233
281
  "text/plain",
234
282
  "multipart/form-data",
283
+ "application/x-www-form-urlencoded",
235
284
  "application/octet-stream",
236
285
  ]
237
286
 
@@ -290,95 +339,175 @@ def generate_header_params(operation: Operation) -> List[str]:
290
339
  return _generate_params(operation, "header")
291
340
 
292
341
 
293
- def generate_return_type(operation: Operation) -> OpReturnType:
294
- if operation.responses is None:
295
- return OpReturnType(type=None, status_code=200, complex_type=False)
342
+ def _is_binary_schema(schema: Any) -> bool:
343
+ schema_format = getattr(schema, "schema_format", None)
344
+ schema_type = getattr(schema, "type", None)
345
+ return (schema_type == "string" or str(schema_type) == "DataType.STRING") and schema_format == "binary"
346
+
347
+
348
+ def _body_kind_for_content(
349
+ content_type: Optional[str], schema: Any = None
350
+ ) -> Literal["empty", "json", "text", "binary"]:
351
+ if content_type is None:
352
+ return "empty"
353
+ lowered = content_type.lower()
354
+ if lowered == "application/json" or lowered.endswith("+json"):
355
+ return "json"
356
+ if lowered == "application/pdf" or lowered == "application/octet-stream" or _is_binary_schema(schema):
357
+ return "binary"
358
+ if lowered.startswith("text/"):
359
+ return "text"
360
+ return "binary"
361
+
362
+
363
+ def _response_variant_from_schema(
364
+ status_code: int,
365
+ content_type: Optional[str],
366
+ inner_schema: Any,
367
+ ) -> ResponseVariant:
368
+ body_kind = _body_kind_for_content(content_type, inner_schema)
369
+ if body_kind == "empty":
370
+ return ResponseVariant(status_code=status_code, content_type=content_type, body_kind="empty")
371
+
372
+ if body_kind == "binary":
373
+ return ResponseVariant(
374
+ status_code=status_code,
375
+ content_type=content_type,
376
+ type=TypeConversion(original_type=content_type or "binary", converted_type="bytes"),
377
+ body_kind="binary",
378
+ )
296
379
 
297
- good_responses: List[Tuple[int, Union[Response, Reference]]] = [
298
- (int(status_code), response)
299
- for status_code, response in operation.responses.items()
300
- if status_code.startswith("2")
301
- ]
302
- if len(good_responses) == 0:
303
- return OpReturnType(type=None, status_code=200, complex_type=False)
304
-
305
- chosen_response = good_responses[0][1]
306
- media_type_schema = None
307
-
308
- if is_response_type(chosen_response):
309
- # It's a Response type, access content safely
310
- if hasattr(chosen_response, "content") and chosen_response.content is not None: # type: ignore
311
- content = chosen_response.content # type: ignore
312
- # Prefer application/json, then text/event-stream, then first available
313
- if isinstance(content, dict):
314
- media_type_schema = (
315
- content.get("application/json")
316
- or content.get("text/event-stream")
317
- or next(iter(content.values()), None)
318
- )
319
- else:
320
- media_type_schema = None
321
- elif is_reference_type(chosen_response):
322
- media_type_schema = create_media_type_for_reference(chosen_response)
380
+ if body_kind == "text":
381
+ return ResponseVariant(
382
+ status_code=status_code,
383
+ content_type=content_type,
384
+ type=TypeConversion(original_type=content_type or "text", converted_type="str"),
385
+ body_kind="text",
386
+ )
387
+
388
+ if is_reference_type(inner_schema):
389
+ type_conv = TypeConversion(
390
+ original_type=inner_schema.ref, # type: ignore
391
+ converted_type=inner_schema.ref.split("/")[-1], # type: ignore
392
+ import_types=[inner_schema.ref.split("/")[-1]], # type: ignore
393
+ )
394
+ return ResponseVariant(
395
+ status_code=status_code,
396
+ content_type=content_type,
397
+ type=type_conv,
398
+ complex_type=True,
399
+ body_kind="json",
400
+ )
401
+
402
+ if is_schema_type(inner_schema):
403
+ disc = getattr(inner_schema, "discriminator", None)
404
+ used = getattr(inner_schema, "oneOf", None) or getattr(inner_schema, "anyOf", None)
405
+ disc_key = getattr(disc, "propertyName", None) if disc is not None else None
323
406
 
324
- if media_type_schema is None:
325
- return OpReturnType(type=None, status_code=good_responses[0][0], complex_type=False)
407
+ if disc_key and used and all(is_reference_type(s) for s in used):
408
+ member_models = [common.normalize_symbol(s.ref.split("/")[-1]) for s in used] # type: ignore
409
+ alias_name = common.normalize_symbol(_common_suffix_many(member_models)) or "Response"
326
410
 
327
- if is_media_type(media_type_schema):
328
- inner_schema = getattr(media_type_schema, "media_type_schema", None)
329
- if is_reference_type(inner_schema):
330
411
  type_conv = TypeConversion(
331
- original_type=inner_schema.ref, # type: ignore
332
- converted_type=inner_schema.ref.split("/")[-1], # type: ignore
333
- import_types=[inner_schema.ref.split("/")[-1]], # type: ignore
412
+ original_type="discriminated_union",
413
+ converted_type=alias_name,
414
+ import_types=None,
334
415
  )
335
- return OpReturnType(
416
+ return ResponseVariant(
417
+ status_code=status_code,
418
+ content_type=content_type,
336
419
  type=type_conv,
337
- status_code=good_responses[0][0],
338
420
  complex_type=True,
421
+ body_kind="json",
339
422
  )
340
- elif is_schema_type(inner_schema):
341
- # NEW: if this is a discriminated response union of refs, prefer a named alias
342
- disc = getattr(inner_schema, "discriminator", None)
343
- used = getattr(inner_schema, "oneOf", None) or getattr(inner_schema, "anyOf", None)
344
- disc_key = getattr(disc, "propertyName", None) if disc is not None else None
345
-
346
- if disc_key and used and all(is_reference_type(s) for s in used):
347
- member_models = [common.normalize_symbol(s.ref.split("/")[-1]) for s in used] # type: ignore
348
- alias_name = common.normalize_symbol(_common_suffix_many(member_models)) or "Response"
349
-
350
- type_conv = TypeConversion(
351
- original_type="discriminated_union",
352
- converted_type=alias_name,
353
- import_types=None,
354
- )
355
- return OpReturnType(type=type_conv, status_code=good_responses[0][0], complex_type=True)
356
423
 
357
- converted_result = type_converter(inner_schema, True) # type: ignore
358
- if "array" in converted_result.original_type and isinstance(converted_result.import_types, list):
359
- matched = re.findall(r"List\[(.+)\]", converted_result.converted_type)
360
- if len(matched) > 0:
361
- list_type = matched[0]
362
- else: # pragma: no cover
363
- raise Exception(f"Unable to parse list type from {converted_result.converted_type}")
364
- else:
365
- list_type = None
366
- return OpReturnType(
367
- type=converted_result,
368
- status_code=good_responses[0][0],
369
- complex_type=bool(converted_result.import_types and len(converted_result.import_types) > 0),
370
- list_type=list_type,
371
- )
372
- else: # pragma: no cover
373
- raise Exception("Unknown media type schema type")
374
- elif media_type_schema is None:
375
- return OpReturnType(
376
- type=None,
377
- status_code=good_responses[0][0],
378
- complex_type=False,
424
+ converted_result = type_converter(inner_schema, True) # type: ignore
425
+ list_type = None
426
+ if "array" in converted_result.original_type and isinstance(converted_result.import_types, list):
427
+ matched = re.findall(r"List\[(.+)\]", converted_result.converted_type)
428
+ if len(matched) > 0:
429
+ list_type = matched[0]
430
+ else: # pragma: no cover
431
+ raise Exception(f"Unable to parse list type from {converted_result.converted_type}")
432
+
433
+ return ResponseVariant(
434
+ status_code=status_code,
435
+ content_type=content_type,
436
+ type=converted_result,
437
+ complex_type=bool(converted_result.import_types and len(converted_result.import_types) > 0),
438
+ list_type=list_type,
439
+ body_kind="json",
379
440
  )
380
- else:
381
- raise Exception("Unknown media type schema type") # pragma: no cover
441
+
442
+ return ResponseVariant(status_code=status_code, content_type=content_type, body_kind="empty")
443
+
444
+
445
+ def _response_variants_for_response(status_code: int, response: Union[Response, Reference]) -> List[ResponseVariant]:
446
+ if is_reference_type(response):
447
+ media_type = create_media_type_for_reference(response)
448
+ inner_schema = getattr(media_type, "media_type_schema", None)
449
+ return [_response_variant_from_schema(status_code, "application/json", inner_schema)]
450
+
451
+ if not is_response_type(response):
452
+ return []
453
+
454
+ content = getattr(response, "content", None)
455
+ if not isinstance(content, dict) or not content:
456
+ return [ResponseVariant(status_code=status_code, content_type=None, body_kind="empty")]
457
+
458
+ variants: List[ResponseVariant] = []
459
+ for content_type, media_type in content.items():
460
+ if not is_media_type(media_type):
461
+ continue
462
+ inner_schema = getattr(media_type, "media_type_schema", None)
463
+ variants.append(_response_variant_from_schema(status_code, content_type, inner_schema))
464
+
465
+ return variants or [ResponseVariant(status_code=status_code, content_type=None, body_kind="empty")]
466
+
467
+
468
+ def _return_type_hint(variants: List[ResponseVariant]) -> str:
469
+ hints: List[str] = []
470
+ for variant in variants:
471
+ if variant.body_kind == "empty" or variant.type is None:
472
+ hint = "None"
473
+ elif variant.list_type is not None:
474
+ hint = f"list[{variant.list_type}]"
475
+ else:
476
+ hint = variant.type.converted_type
477
+ if hint not in hints:
478
+ hints.append(hint)
479
+ if not hints:
480
+ return "None"
481
+ return hints[0] if len(hints) == 1 else " | ".join(hints)
482
+
483
+
484
+ def generate_return_type(operation: Operation) -> OpReturnType:
485
+ if operation.responses is None:
486
+ return OpReturnType(type=None, status_code=200, complex_type=False, accepted_status_codes=[200])
487
+
488
+ good_responses: List[Tuple[int, Union[Response, Reference]]] = [
489
+ (int(status_code), response)
490
+ for status_code, response in operation.responses.items()
491
+ if status_code.startswith("2")
492
+ ]
493
+ if len(good_responses) == 0:
494
+ return OpReturnType(type=None, status_code=200, complex_type=False, accepted_status_codes=[200])
495
+
496
+ variants: List[ResponseVariant] = []
497
+ for status_code, response in good_responses:
498
+ variants.extend(_response_variants_for_response(status_code, response))
499
+
500
+ first_variant = variants[0] if variants else ResponseVariant(status_code=good_responses[0][0])
501
+ return OpReturnType(
502
+ type=first_variant.type,
503
+ status_code=first_variant.status_code,
504
+ complex_type=first_variant.complex_type,
505
+ list_type=first_variant.list_type,
506
+ variants=variants,
507
+ accepted_status_codes=sorted({status_code for status_code, _ in good_responses}),
508
+ accept_content_types=list(dict.fromkeys(v.content_type for v in variants if v.content_type is not None)),
509
+ return_type_hint=_return_type_hint(variants),
510
+ )
382
511
 
383
512
 
384
513
  def clean_up_path_name(path_name: str) -> str:
@@ -406,7 +535,11 @@ def generate_clients(
406
535
  service_ops: List[ServiceOperation] = []
407
536
 
408
537
  def _generate_service_operation(
409
- op: Operation, path_obj: PathItem, path_name: str, http_operation: str, async_type: bool
538
+ op: Operation,
539
+ path_obj: PathItem,
540
+ path_name: str,
541
+ http_operation: str,
542
+ async_type: bool,
410
543
  ) -> ServiceOperation:
411
544
  path_level_params = []
412
545
  if hasattr(path_obj, "parameters") and path_obj.parameters is not None:
@@ -435,7 +568,8 @@ def generate_clients(
435
568
  query_params = generate_query_params(op)
436
569
  header_params = generate_header_params(op)
437
570
  return_type = generate_return_type(op)
438
- body_param = generate_body_param(op)
571
+ request_body = generate_request_body(op)
572
+ body_param = None if request_body is None else request_body.expression
439
573
 
440
574
  so = ServiceOperation(
441
575
  params=params,
@@ -448,6 +582,7 @@ def generate_clients(
448
582
  content="",
449
583
  async_client=async_type,
450
584
  body_param=body_param,
585
+ request_body=request_body,
451
586
  path_name=path_name,
452
587
  method=http_operation,
453
588
  is_sse=operation_is_sse(op),
@@ -488,8 +623,18 @@ def generate_clients(
488
623
  compile(async_content, "<string>", "exec")
489
624
 
490
625
  clients: List[Model] = [
491
- Model(file_name="sync_client", content=sync_content, openapi_object={}, properties=[]),
492
- Model(file_name="async_client", content=async_content, openapi_object={}, properties=[]),
626
+ Model(
627
+ file_name="sync_client",
628
+ content=sync_content,
629
+ openapi_object={},
630
+ properties=[],
631
+ ),
632
+ Model(
633
+ file_name="async_client",
634
+ content=async_content,
635
+ openapi_object={},
636
+ properties=[],
637
+ ),
493
638
  ]
494
639
 
495
640
  return clients
@@ -48,24 +48,27 @@ class AsyncClient(BaseModel):
48
48
  async def {{ op.operation_id }}(self{% if op.params %}, {{ op.params }}{% endif %}) -> {%- if op.is_sse -%}
49
49
  AsyncGenerator[str | dict[str, Any], None]
50
50
  {%- else -%}
51
- {%- if op.return_type.type is none or op.return_type.type.converted_type is none -%}None
52
- {%- elif op.return_type.list_type is not none -%}list[{{ op.return_type.list_type }}]
53
- {%- else -%}{{ op.return_type.type.converted_type }}
54
- {%- endif -%}
51
+ {{ op.return_type.return_type_hint or "None" }}
55
52
  {%- endif -%}:
56
53
  base_url = self.base_url
57
54
  path = f"{{ op.path_name }}"
58
55
 
59
56
  headers = {
60
- {% if op.body_param %}
57
+ {% if op.request_body and op.request_body.content_type %}
58
+ "Content-Type": "{{ op.request_body.content_type }}",
59
+ {% elif op.body_param and not op.request_body %}
61
60
  "Content-Type": "application/json",
62
61
  {% endif %}
63
62
  {% if op.is_sse %}
64
63
  "Accept": "text/event-stream",
65
64
  {% else %}
66
- "Accept": "application/json",
65
+ "Accept": "{{ op.return_type.accept_content_types | join(', ') or 'application/json' }}",
67
66
  {% endif %}
67
+ {% for hp in op.header_params %}
68
+ {{ hp }},
69
+ {% endfor %}
68
70
  }
71
+ headers = {k: v for (k, v) in headers.items() if v is not None}
69
72
 
70
73
  _token = await self.get_access_token()
71
74
  if _token:
@@ -86,10 +89,20 @@ AsyncGenerator[str | dict[str, Any], None]
86
89
  headers=headers,
87
90
  params=query_params,
88
91
  {% if op.body_param %}
92
+ {% if op.request_body and op.request_body.encoding == "json" %}
93
+ json={{ op.request_body.expression }},
94
+ {% elif op.request_body and op.request_body.encoding == "multipart" %}
95
+ files={{ op.request_body.expression }},
96
+ {% elif op.request_body and op.request_body.encoding == "form" %}
97
+ data={{ op.request_body.expression }},
98
+ {% elif op.request_body and op.request_body.encoding in ["binary", "text"] %}
99
+ content={{ op.request_body.expression }},
100
+ {% else %}
89
101
  json={{ op.body_param }},
102
+ {% endif %}
90
103
  {% endif %}
91
104
  ) as response:
92
- if response.status_code != {{ op.return_type.status_code }}:
105
+ if response.status_code not in {{ op.return_type.accepted_status_codes or [op.return_type.status_code] }}:
93
106
  raise HTTPException(
94
107
  response.status_code,
95
108
  f"{{ op.operation_id }} failed with status code: {response.status_code}",
@@ -123,29 +136,51 @@ AsyncGenerator[str | dict[str, Any], None]
123
136
  headers=headers,
124
137
  params=query_params,
125
138
  {% if op.body_param %}
139
+ {% if op.request_body and op.request_body.encoding == "json" %}
140
+ json={{ op.request_body.expression }},
141
+ {% elif op.request_body and op.request_body.encoding == "multipart" %}
142
+ files={{ op.request_body.expression }},
143
+ {% elif op.request_body and op.request_body.encoding == "form" %}
144
+ data={{ op.request_body.expression }},
145
+ {% elif op.request_body and op.request_body.encoding in ["binary", "text"] %}
146
+ content={{ op.request_body.expression }},
147
+ {% else %}
126
148
  json={{ op.body_param }},
149
+ {% endif %}
127
150
  {% endif %}
128
151
  )
129
152
 
130
- if response.status_code != {{ op.return_type.status_code }}:
153
+ if response.status_code not in {{ op.return_type.accepted_status_codes or [op.return_type.status_code] }}:
131
154
  raise HTTPException(
132
155
  response.status_code,
133
156
  f"{{ op.operation_id }} failed with status code: {response.status_code}",
134
157
  )
135
158
 
136
- body = None if {{ op.return_type.status_code }} == 204 else response.json()
137
-
138
- {% if op.return_type.type is none or op.return_type.type.converted_type is none %}
139
- return None
140
- {% elif op.return_type.complex_type %}
141
- {% if op.return_type.list_type is none %}
142
- return TypeAdapter({{ op.return_type.type.converted_type }}).validate_python(body)
159
+ _response_content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
160
+ {% for variant in op.return_type.variants %}
161
+ {% set same_status_variants = op.return_type.variants | selectattr("status_code", "equalto", variant.status_code) | list %}
162
+ if response.status_code == {{ variant.status_code }}{% if same_status_variants|length > 1 and variant.content_type %} and _response_content_type == "{{ variant.content_type }}"{% endif %}:
163
+ {% if variant.body_kind == "empty" or variant.type is none or variant.type.converted_type is none %}
164
+ return None
165
+ {% elif variant.body_kind == "binary" %}
166
+ return response.content
167
+ {% elif variant.body_kind == "text" %}
168
+ return response.text
169
+ {% elif variant.complex_type %}
170
+ {% if variant.list_type is none %}
171
+ return TypeAdapter({{ variant.type.converted_type }}).validate_python(response.json())
143
172
  {% else %}
144
- return TypeAdapter(list[{{ op.return_type.list_type }}]).validate_python(body)
173
+ return TypeAdapter(list[{{ variant.list_type }}]).validate_python(response.json())
145
174
  {% endif %}
146
175
  {% else %}
147
- return body
176
+ return response.json()
148
177
  {% endif %}
178
+
179
+ {% endfor %}
180
+ raise HTTPException(
181
+ response.status_code,
182
+ f"{{ op.operation_id }} failed with unsupported content type: {_response_content_type}",
183
+ )
149
184
  {% endif %}
150
185
 
151
186
  {% endfor %}
@@ -48,24 +48,27 @@ class SyncClient(BaseModel):
48
48
  def {{ op.operation_id }}(self{% if op.params %}, {{ op.params }}{% endif %}) -> {%- if op.is_sse -%}
49
49
  Generator[str | dict[str, Any], None, None]
50
50
  {%- else -%}
51
- {%- if op.return_type.type is none or op.return_type.type.converted_type is none -%}None
52
- {%- elif op.return_type.list_type is not none -%}list[{{ op.return_type.list_type }}]
53
- {%- else -%}{{ op.return_type.type.converted_type }}
54
- {%- endif -%}
51
+ {{ op.return_type.return_type_hint or "None" }}
55
52
  {%- endif -%}:
56
53
  base_url = self.base_url
57
54
  path = f"{{ op.path_name }}"
58
55
 
59
56
  headers = {
60
- {% if op.body_param %}
57
+ {% if op.request_body and op.request_body.content_type %}
58
+ "Content-Type": "{{ op.request_body.content_type }}",
59
+ {% elif op.body_param and not op.request_body %}
61
60
  "Content-Type": "application/json",
62
61
  {% endif %}
63
62
  {% if op.is_sse %}
64
63
  "Accept": "text/event-stream",
65
64
  {% else %}
66
- "Accept": "application/json",
65
+ "Accept": "{{ op.return_type.accept_content_types | join(', ') or 'application/json' }}",
67
66
  {% endif %}
67
+ {% for hp in op.header_params %}
68
+ {{ hp }},
69
+ {% endfor %}
68
70
  }
71
+ headers = {k: v for (k, v) in headers.items() if v is not None}
69
72
 
70
73
  _token = self.get_access_token()
71
74
  if _token:
@@ -86,10 +89,20 @@ Generator[str | dict[str, Any], None, None]
86
89
  headers=headers,
87
90
  params=query_params,
88
91
  {% if op.body_param %}
92
+ {% if op.request_body and op.request_body.encoding == "json" %}
93
+ json={{ op.request_body.expression }},
94
+ {% elif op.request_body and op.request_body.encoding == "multipart" %}
95
+ files={{ op.request_body.expression }},
96
+ {% elif op.request_body and op.request_body.encoding == "form" %}
97
+ data={{ op.request_body.expression }},
98
+ {% elif op.request_body and op.request_body.encoding in ["binary", "text"] %}
99
+ content={{ op.request_body.expression }},
100
+ {% else %}
89
101
  json={{ op.body_param }},
102
+ {% endif %}
90
103
  {% endif %}
91
104
  ) as response:
92
- if response.status_code != {{ op.return_type.status_code }}:
105
+ if response.status_code not in {{ op.return_type.accepted_status_codes or [op.return_type.status_code] }}:
93
106
  raise HTTPException(
94
107
  response.status_code,
95
108
  f"{{ op.operation_id }} failed with status code: {response.status_code}",
@@ -122,29 +135,51 @@ Generator[str | dict[str, Any], None, None]
122
135
  headers=headers,
123
136
  params=query_params,
124
137
  {% if op.body_param %}
138
+ {% if op.request_body and op.request_body.encoding == "json" %}
139
+ json={{ op.request_body.expression }},
140
+ {% elif op.request_body and op.request_body.encoding == "multipart" %}
141
+ files={{ op.request_body.expression }},
142
+ {% elif op.request_body and op.request_body.encoding == "form" %}
143
+ data={{ op.request_body.expression }},
144
+ {% elif op.request_body and op.request_body.encoding in ["binary", "text"] %}
145
+ content={{ op.request_body.expression }},
146
+ {% else %}
125
147
  json={{ op.body_param }},
148
+ {% endif %}
126
149
  {% endif %}
127
150
  )
128
151
 
129
- if response.status_code != {{ op.return_type.status_code }}:
152
+ if response.status_code not in {{ op.return_type.accepted_status_codes or [op.return_type.status_code] }}:
130
153
  raise HTTPException(
131
154
  response.status_code,
132
155
  f"{{ op.operation_id }} failed with status code: {response.status_code}",
133
156
  )
134
157
 
135
- body = None if {{ op.return_type.status_code }} == 204 else response.json()
136
-
137
- {% if op.return_type.type is none or op.return_type.type.converted_type is none %}
138
- return None
139
- {% elif op.return_type.complex_type %}
140
- {% if op.return_type.list_type is none %}
141
- return TypeAdapter({{ op.return_type.type.converted_type }}).validate_python(body)
158
+ _response_content_type = response.headers.get("content-type", "").split(";", 1)[0].lower()
159
+ {% for variant in op.return_type.variants %}
160
+ {% set same_status_variants = op.return_type.variants | selectattr("status_code", "equalto", variant.status_code) | list %}
161
+ if response.status_code == {{ variant.status_code }}{% if same_status_variants|length > 1 and variant.content_type %} and _response_content_type == "{{ variant.content_type }}"{% endif %}:
162
+ {% if variant.body_kind == "empty" or variant.type is none or variant.type.converted_type is none %}
163
+ return None
164
+ {% elif variant.body_kind == "binary" %}
165
+ return response.content
166
+ {% elif variant.body_kind == "text" %}
167
+ return response.text
168
+ {% elif variant.complex_type %}
169
+ {% if variant.list_type is none %}
170
+ return TypeAdapter({{ variant.type.converted_type }}).validate_python(response.json())
142
171
  {% else %}
143
- return TypeAdapter(list[{{ op.return_type.list_type }}]).validate_python(body)
172
+ return TypeAdapter(list[{{ variant.list_type }}]).validate_python(response.json())
144
173
  {% endif %}
145
174
  {% else %}
146
- return body
175
+ return response.json()
147
176
  {% endif %}
177
+
178
+ {% endfor %}
179
+ raise HTTPException(
180
+ response.status_code,
181
+ f"{{ op.operation_id }} failed with unsupported content type: {_response_content_type}",
182
+ )
148
183
  {% endif %}
149
184
 
150
185
  {% endfor %}
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Union
1
+ from typing import List, Literal, Optional, Union
2
2
 
3
3
  from openapi_pydantic.v3.v3_0 import (
4
4
  Operation as Operation30,
@@ -45,6 +45,25 @@ class OpReturnType(BaseModel):
45
45
  status_code: int
46
46
  complex_type: bool = False
47
47
  list_type: Optional[str] = None
48
+ variants: List["ResponseVariant"] = Field(default_factory=list)
49
+ accepted_status_codes: List[int] = Field(default_factory=list)
50
+ accept_content_types: List[str] = Field(default_factory=list)
51
+ return_type_hint: Optional[str] = None
52
+
53
+
54
+ class ResponseVariant(BaseModel):
55
+ status_code: int
56
+ content_type: Optional[str] = None
57
+ type: Optional[TypeConversion] = None
58
+ complex_type: bool = False
59
+ list_type: Optional[str] = None
60
+ body_kind: Literal["empty", "json", "text", "binary"] = "empty"
61
+
62
+
63
+ class RequestBodyDefinition(BaseModel):
64
+ content_type: Optional[str] = None
65
+ encoding: Literal["json", "form", "multipart", "binary", "text"]
66
+ expression: str
48
67
 
49
68
 
50
69
  class ServiceOperation(BaseModel):
@@ -60,6 +79,7 @@ class ServiceOperation(BaseModel):
60
79
  tag: Optional[str] = None
61
80
  path_name: str
62
81
  body_param: Optional[str] = None
82
+ request_body: Optional[RequestBodyDefinition] = None
63
83
  method: str
64
84
  is_sse: bool = False
65
85
  use_orjson: bool = False