pyopenapi-gen 2.7.2__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.
- pyopenapi_gen/__init__.py +224 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +62 -0
- pyopenapi_gen/context/CLAUDE.md +284 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +726 -0
- pyopenapi_gen/core/CLAUDE.md +224 -0
- pyopenapi_gen/core/__init__.py +0 -0
- pyopenapi_gen/core/auth/base.py +22 -0
- pyopenapi_gen/core/auth/plugins.py +89 -0
- pyopenapi_gen/core/cattrs_converter.py +810 -0
- pyopenapi_gen/core/exceptions.py +20 -0
- pyopenapi_gen/core/http_status_codes.py +218 -0
- pyopenapi_gen/core/http_transport.py +222 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +174 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +161 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
- pyopenapi_gen/core/loader/operations/request_body.py +90 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +186 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +111 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
- pyopenapi_gen/core/pagination.py +64 -0
- pyopenapi_gen/core/parsing/__init__.py +13 -0
- pyopenapi_gen/core/parsing/common/__init__.py +1 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
- pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
- pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
- pyopenapi_gen/core/parsing/context.py +187 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
- pyopenapi_gen/core/parsing/schema_parser.py +804 -0
- pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
- pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
- pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +260 -0
- pyopenapi_gen/core/spec_fetcher.py +148 -0
- pyopenapi_gen/core/streaming_helpers.py +84 -0
- pyopenapi_gen/core/telemetry.py +69 -0
- pyopenapi_gen/core/utils.py +456 -0
- pyopenapi_gen/core/warning_collector.py +83 -0
- pyopenapi_gen/core/writers/code_writer.py +135 -0
- pyopenapi_gen/core/writers/documentation_writer.py +222 -0
- pyopenapi_gen/core/writers/line_writer.py +217 -0
- pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -0
- pyopenapi_gen/emitters/CLAUDE.md +286 -0
- pyopenapi_gen/emitters/client_emitter.py +51 -0
- pyopenapi_gen/emitters/core_emitter.py +181 -0
- pyopenapi_gen/emitters/docs_emitter.py +44 -0
- pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
- pyopenapi_gen/emitters/mocks_emitter.py +185 -0
- pyopenapi_gen/emitters/models_emitter.py +426 -0
- pyopenapi_gen/generator/CLAUDE.md +352 -0
- pyopenapi_gen/generator/client_generator.py +567 -0
- pyopenapi_gen/generator/exceptions.py +7 -0
- pyopenapi_gen/helpers/CLAUDE.md +325 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +532 -0
- pyopenapi_gen/helpers/type_cleaner.py +334 -0
- pyopenapi_gen/helpers/type_helper.py +112 -0
- pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
- pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
- pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +165 -0
- pyopenapi_gen/py.typed +1 -0
- pyopenapi_gen/types/CLAUDE.md +140 -0
- pyopenapi_gen/types/__init__.py +11 -0
- pyopenapi_gen/types/contracts/__init__.py +13 -0
- pyopenapi_gen/types/contracts/protocols.py +106 -0
- pyopenapi_gen/types/contracts/types.py +28 -0
- pyopenapi_gen/types/resolvers/__init__.py +7 -0
- pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
- pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +165 -0
- pyopenapi_gen/types/strategies/__init__.py +5 -0
- pyopenapi_gen/types/strategies/response_strategy.py +310 -0
- pyopenapi_gen/visit/CLAUDE.md +272 -0
- pyopenapi_gen/visit/client_visitor.py +477 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
- pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
- pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +90 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +93 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
- pyopenapi_gen/visit/model/enum_generator.py +212 -0
- pyopenapi_gen/visit/model/model_visitor.py +198 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
- pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
- pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
- pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class HTTPError(Exception):
|
|
2
|
+
"""Base HTTP error with status code and message."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, status_code: int, message: str, response: object | None = None) -> None:
|
|
5
|
+
super().__init__(f"{status_code}: {message}")
|
|
6
|
+
self.status_code = status_code
|
|
7
|
+
self.message = message
|
|
8
|
+
self.response = response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClientError(HTTPError):
|
|
12
|
+
"""4XX client error responses."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ServerError(HTTPError):
|
|
18
|
+
"""5XX server error responses."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""HTTP status code definitions and human-readable names.
|
|
2
|
+
|
|
3
|
+
This module provides a registry of standard HTTP status codes with their
|
|
4
|
+
canonical names according to RFC specifications.
|
|
5
|
+
|
|
6
|
+
References:
|
|
7
|
+
- RFC 9110 (HTTP Semantics): https://www.rfc-editor.org/rfc/rfc9110.html
|
|
8
|
+
- IANA HTTP Status Code Registry: https://www.iana.org/assignments/http-status-codes/
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# Standard HTTP status codes with human-readable names
|
|
12
|
+
HTTP_STATUS_CODES: dict[int, str] = {
|
|
13
|
+
# 1xx Informational
|
|
14
|
+
100: "Continue",
|
|
15
|
+
101: "Switching Protocols",
|
|
16
|
+
102: "Processing",
|
|
17
|
+
103: "Early Hints",
|
|
18
|
+
# 2xx Success
|
|
19
|
+
200: "OK",
|
|
20
|
+
201: "Created",
|
|
21
|
+
202: "Accepted",
|
|
22
|
+
203: "Non-Authoritative Information",
|
|
23
|
+
204: "No Content",
|
|
24
|
+
205: "Reset Content",
|
|
25
|
+
206: "Partial Content",
|
|
26
|
+
207: "Multi-Status",
|
|
27
|
+
208: "Already Reported",
|
|
28
|
+
226: "IM Used",
|
|
29
|
+
# 3xx Redirection
|
|
30
|
+
300: "Multiple Choices",
|
|
31
|
+
301: "Moved Permanently",
|
|
32
|
+
302: "Found",
|
|
33
|
+
303: "See Other",
|
|
34
|
+
304: "Not Modified",
|
|
35
|
+
305: "Use Proxy",
|
|
36
|
+
307: "Temporary Redirect",
|
|
37
|
+
308: "Permanent Redirect",
|
|
38
|
+
# 4xx Client Error
|
|
39
|
+
400: "Bad Request",
|
|
40
|
+
401: "Unauthorised",
|
|
41
|
+
402: "Payment Required",
|
|
42
|
+
403: "Forbidden",
|
|
43
|
+
404: "Not Found",
|
|
44
|
+
405: "Method Not Allowed",
|
|
45
|
+
406: "Not Acceptable",
|
|
46
|
+
407: "Proxy Authentication Required",
|
|
47
|
+
408: "Request Timeout",
|
|
48
|
+
409: "Conflict",
|
|
49
|
+
410: "Gone",
|
|
50
|
+
411: "Length Required",
|
|
51
|
+
412: "Precondition Failed",
|
|
52
|
+
413: "Payload Too Large",
|
|
53
|
+
414: "URI Too Long",
|
|
54
|
+
415: "Unsupported Media Type",
|
|
55
|
+
416: "Range Not Satisfiable",
|
|
56
|
+
417: "Expectation Failed",
|
|
57
|
+
418: "I'm a teapot",
|
|
58
|
+
421: "Misdirected Request",
|
|
59
|
+
422: "Unprocessable Entity",
|
|
60
|
+
423: "Locked",
|
|
61
|
+
424: "Failed Dependency",
|
|
62
|
+
425: "Too Early",
|
|
63
|
+
426: "Upgrade Required",
|
|
64
|
+
428: "Precondition Required",
|
|
65
|
+
429: "Too Many Requests",
|
|
66
|
+
431: "Request Header Fields Too Large",
|
|
67
|
+
451: "Unavailable For Legal Reasons",
|
|
68
|
+
# 5xx Server Error
|
|
69
|
+
500: "Internal Server Error",
|
|
70
|
+
501: "Not Implemented",
|
|
71
|
+
502: "Bad Gateway",
|
|
72
|
+
503: "Service Unavailable",
|
|
73
|
+
504: "Gateway Timeout",
|
|
74
|
+
505: "HTTP Version Not Supported",
|
|
75
|
+
506: "Variant Also Negotiates",
|
|
76
|
+
507: "Insufficient Storage",
|
|
77
|
+
508: "Loop Detected",
|
|
78
|
+
510: "Not Extended",
|
|
79
|
+
511: "Network Authentication Required",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_status_name(code: int) -> str:
|
|
84
|
+
"""Get the human-readable name for an HTTP status code.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
code: HTTP status code (e.g., 404)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Human-readable status name (e.g., "Not Found"), or "Unknown" if not found
|
|
91
|
+
"""
|
|
92
|
+
return HTTP_STATUS_CODES.get(code, "Unknown")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_error_code(code: int) -> bool:
|
|
96
|
+
"""Check if a status code represents an error (4xx or 5xx).
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
code: HTTP status code
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if the code is a client or server error, False otherwise
|
|
103
|
+
"""
|
|
104
|
+
return 400 <= code < 600
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def is_client_error(code: int) -> bool:
|
|
108
|
+
"""Check if a status code represents a client error (4xx).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
code: HTTP status code
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
True if the code is a client error, False otherwise
|
|
115
|
+
"""
|
|
116
|
+
return 400 <= code < 500
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_server_error(code: int) -> bool:
|
|
120
|
+
"""Check if a status code represents a server error (5xx).
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
code: HTTP status code
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
True if the code is a server error, False otherwise
|
|
127
|
+
"""
|
|
128
|
+
return 500 <= code < 600
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_success_code(code: int) -> bool:
|
|
132
|
+
"""Check if a status code represents success (2xx).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
code: HTTP status code
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
True if the code is a success code, False otherwise
|
|
139
|
+
"""
|
|
140
|
+
return 200 <= code < 300
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# Mapping of HTTP status codes to Python exception class names
|
|
144
|
+
# These are semantically meaningful names that Python developers expect
|
|
145
|
+
HTTP_EXCEPTION_NAMES: dict[int, str] = {
|
|
146
|
+
# 4xx Client Errors
|
|
147
|
+
400: "BadRequestError",
|
|
148
|
+
401: "UnauthorisedError",
|
|
149
|
+
402: "PaymentRequiredError",
|
|
150
|
+
403: "ForbiddenError",
|
|
151
|
+
404: "NotFoundError",
|
|
152
|
+
405: "MethodNotAllowedError",
|
|
153
|
+
406: "NotAcceptableError",
|
|
154
|
+
407: "ProxyAuthenticationRequiredError",
|
|
155
|
+
408: "RequestTimeoutError",
|
|
156
|
+
409: "ConflictError",
|
|
157
|
+
410: "GoneError",
|
|
158
|
+
411: "LengthRequiredError",
|
|
159
|
+
412: "PreconditionFailedError",
|
|
160
|
+
413: "PayloadTooLargeError",
|
|
161
|
+
414: "UriTooLongError",
|
|
162
|
+
415: "UnsupportedMediaTypeError",
|
|
163
|
+
416: "RangeNotSatisfiableError",
|
|
164
|
+
417: "ExpectationFailedError",
|
|
165
|
+
418: "ImATeapotError",
|
|
166
|
+
421: "MisdirectedRequestError",
|
|
167
|
+
422: "UnprocessableEntityError",
|
|
168
|
+
423: "LockedError",
|
|
169
|
+
424: "FailedDependencyError",
|
|
170
|
+
425: "TooEarlyError",
|
|
171
|
+
426: "UpgradeRequiredError",
|
|
172
|
+
428: "PreconditionRequiredError",
|
|
173
|
+
429: "TooManyRequestsError",
|
|
174
|
+
431: "RequestHeaderFieldsTooLargeError",
|
|
175
|
+
451: "UnavailableForLegalReasonsError",
|
|
176
|
+
# 5xx Server Errors
|
|
177
|
+
500: "InternalServerError",
|
|
178
|
+
501: "NotImplementedError", # Note: Conflicts with Python built-in, will be handled
|
|
179
|
+
502: "BadGatewayError",
|
|
180
|
+
503: "ServiceUnavailableError",
|
|
181
|
+
504: "GatewayTimeoutError",
|
|
182
|
+
505: "HttpVersionNotSupportedError",
|
|
183
|
+
506: "VariantAlsoNegotiatesError",
|
|
184
|
+
507: "InsufficientStorageError",
|
|
185
|
+
508: "LoopDetectedError",
|
|
186
|
+
510: "NotExtendedError",
|
|
187
|
+
511: "NetworkAuthenticationRequiredError",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_exception_class_name(code: int) -> str:
|
|
192
|
+
"""Get the Python exception class name for an HTTP status code.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
code: HTTP status code (e.g., 404)
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Python exception class name (e.g., "NotFoundError"), or "Error{code}" as fallback
|
|
199
|
+
|
|
200
|
+
Examples:
|
|
201
|
+
>>> get_exception_class_name(404)
|
|
202
|
+
'NotFoundError'
|
|
203
|
+
>>> get_exception_class_name(429)
|
|
204
|
+
'TooManyRequestsError'
|
|
205
|
+
>>> get_exception_class_name(999) # Unknown code
|
|
206
|
+
'Error999'
|
|
207
|
+
"""
|
|
208
|
+
# Check if we have a semantic name for this code
|
|
209
|
+
if code in HTTP_EXCEPTION_NAMES:
|
|
210
|
+
name = HTTP_EXCEPTION_NAMES[code]
|
|
211
|
+
# Handle Python keyword conflicts
|
|
212
|
+
if name == "NotImplementedError":
|
|
213
|
+
# Avoid conflict with Python's built-in NotImplementedError
|
|
214
|
+
return "HttpNotImplementedError"
|
|
215
|
+
return name
|
|
216
|
+
|
|
217
|
+
# Fallback to Error{code} for codes we don't have semantic names for
|
|
218
|
+
return f"Error{code}"
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
from typing import Any, Protocol
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .auth.base import BaseAuth
|
|
6
|
+
from .exceptions import HTTPError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HttpTransport(Protocol):
|
|
10
|
+
"""
|
|
11
|
+
Defines the interface for an asynchronous HTTP transport layer.
|
|
12
|
+
|
|
13
|
+
This protocol allows different HTTP client implementations (like httpx, aiohttp)
|
|
14
|
+
to be used interchangeably by the generated API client. It requires
|
|
15
|
+
implementing classes to provide an async `request` method.
|
|
16
|
+
|
|
17
|
+
All implementations must:
|
|
18
|
+
- Provide a fully type-annotated async `request` method.
|
|
19
|
+
- Return an `httpx.Response` object for all requests.
|
|
20
|
+
- Be safe for use in async contexts.
|
|
21
|
+
- STRICT REQUIREMENT: Raise `HTTPError` for all HTTP responses with status codes < 200 or >= 300.
|
|
22
|
+
This ensures that only successful (2xx) responses are returned to the caller, and all error
|
|
23
|
+
or informational responses are handled via exceptions. Implementors must not return non-2xx
|
|
24
|
+
responses directly; instead, they must raise `HTTPError` with the status code and response body.
|
|
25
|
+
This contract guarantees consistent error handling for all generated clients.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
async def request(
|
|
29
|
+
self,
|
|
30
|
+
method: str,
|
|
31
|
+
url: str,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> httpx.Response:
|
|
34
|
+
"""
|
|
35
|
+
Sends an asynchronous HTTP request.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
method: The HTTP method (e.g., 'GET', 'POST').
|
|
39
|
+
url: The target URL for the request.
|
|
40
|
+
**kwargs: Additional keyword arguments for the HTTP client (e.g., headers, params, json, data).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
httpx.Response: The HTTP response object.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
HTTPError: For all HTTP responses with status codes < 200 or >= 300. Implementors MUST raise
|
|
47
|
+
this exception for any non-2xx response, passing the status code and response body.
|
|
48
|
+
Exception: Implementations may raise exceptions for network errors or invalid responses.
|
|
49
|
+
|
|
50
|
+
Protocol Contract:
|
|
51
|
+
- Only successful (2xx) responses are returned to the caller.
|
|
52
|
+
- All other responses (including redirects, client errors, and server errors) must result in
|
|
53
|
+
an HTTPError being raised. This ensures that error handling is consistent and explicit
|
|
54
|
+
across all generated clients and transport implementations.
|
|
55
|
+
"""
|
|
56
|
+
raise NotImplementedError()
|
|
57
|
+
|
|
58
|
+
async def close(self) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Closes any resources held by the transport (e.g., HTTP connections).
|
|
61
|
+
|
|
62
|
+
All implementations must provide this method. If the transport does not hold resources,
|
|
63
|
+
this should be a no-op.
|
|
64
|
+
"""
|
|
65
|
+
raise NotImplementedError()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class HttpxTransport:
|
|
69
|
+
"""
|
|
70
|
+
A concrete implementation of the HttpTransport protocol using the `httpx` library.
|
|
71
|
+
|
|
72
|
+
This class provides the default asynchronous HTTP transport mechanism for the
|
|
73
|
+
generated API client. It wraps an `httpx.AsyncClient` instance to handle
|
|
74
|
+
request sending, connection pooling, and resource management.
|
|
75
|
+
|
|
76
|
+
Optionally supports authentication via any BaseAuth-compatible plugin, including CompositeAuth.
|
|
77
|
+
|
|
78
|
+
CONTRACT:
|
|
79
|
+
- This implementation strictly raises `HTTPError` for all HTTP responses with status codes < 200 or >= 300.
|
|
80
|
+
Only successful (2xx) responses are returned to the caller. This ensures that all error, informational,
|
|
81
|
+
and redirect responses are handled via exceptions, providing a consistent error handling model for the
|
|
82
|
+
generated client code.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
_client (httpx.AsyncClient): Configured HTTPX async client for all requests.
|
|
86
|
+
_auth (BaseAuth | None): Optional authentication plugin for request signing (can be CompositeAuth).
|
|
87
|
+
_bearer_token (str | None): Optional bearer token for Authorization header.
|
|
88
|
+
_default_headers (dict[str, str] | None): Default headers to apply to all requests.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
base_url: str,
|
|
94
|
+
timeout: float | None = None,
|
|
95
|
+
auth: BaseAuth | None = None,
|
|
96
|
+
bearer_token: str | None = None,
|
|
97
|
+
default_headers: dict[str, str] | None = None,
|
|
98
|
+
verify_ssl: bool = True,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""
|
|
101
|
+
Initializes the HttpxTransport.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
base_url (str): The base URL for all API requests made through this transport.
|
|
105
|
+
timeout (float | None): The default timeout in seconds for requests. If None, httpx's default is used.
|
|
106
|
+
auth (BaseAuth | None): Optional authentication plugin for request signing (can be CompositeAuth).
|
|
107
|
+
bearer_token (str | None): Optional raw bearer token string for Authorization header.
|
|
108
|
+
default_headers (dict[str, str] | None): Default headers to apply to all requests.
|
|
109
|
+
verify_ssl (bool): Whether to verify SSL certificates. Defaults to True.
|
|
110
|
+
Set to False for local development with self-signed certificates.
|
|
111
|
+
|
|
112
|
+
Note:
|
|
113
|
+
If both auth and bearer_token are provided, auth takes precedence.
|
|
114
|
+
"""
|
|
115
|
+
self._client: httpx.AsyncClient = httpx.AsyncClient(base_url=base_url, timeout=timeout, verify=verify_ssl)
|
|
116
|
+
self._auth: BaseAuth | None = auth
|
|
117
|
+
self._bearer_token: str | None = bearer_token
|
|
118
|
+
self._default_headers: dict[str, str] | None = default_headers
|
|
119
|
+
|
|
120
|
+
async def _prepare_headers(
|
|
121
|
+
self,
|
|
122
|
+
current_request_kwargs: dict[str, Any],
|
|
123
|
+
) -> dict[str, str]:
|
|
124
|
+
"""
|
|
125
|
+
Prepares headers for an HTTP request, incorporating default headers,
|
|
126
|
+
request-specific headers, and authentication.
|
|
127
|
+
"""
|
|
128
|
+
# Initialize headers for the current request
|
|
129
|
+
prepared_headers: dict[str, str] = {}
|
|
130
|
+
|
|
131
|
+
# 1. Apply transport-level default headers
|
|
132
|
+
if self._default_headers:
|
|
133
|
+
prepared_headers.update(self._default_headers)
|
|
134
|
+
|
|
135
|
+
# 2. Merge headers passed specifically for this request (overriding transport defaults)
|
|
136
|
+
if "headers" in current_request_kwargs and isinstance(current_request_kwargs["headers"], dict):
|
|
137
|
+
prepared_headers.update(current_request_kwargs["headers"])
|
|
138
|
+
|
|
139
|
+
# 3. Apply authentication plugin or bearer token (which can further modify headers)
|
|
140
|
+
# We pass a temporary request_args dict containing only the headers to the auth plugin,
|
|
141
|
+
# as the auth plugin might expect other keys which are not relevant for header preparation.
|
|
142
|
+
# The auth plugin is expected to modify the 'headers' key in the passed dict.
|
|
143
|
+
temp_request_args_for_auth = {"headers": prepared_headers.copy()}
|
|
144
|
+
|
|
145
|
+
if self._auth is not None:
|
|
146
|
+
authenticated_args = await self._auth.authenticate_request(temp_request_args_for_auth)
|
|
147
|
+
# Ensure 'headers' key exists and is a dict after authentication
|
|
148
|
+
if "headers" in authenticated_args and isinstance(authenticated_args["headers"], dict):
|
|
149
|
+
prepared_headers = authenticated_args["headers"]
|
|
150
|
+
else:
|
|
151
|
+
# Handle cases where auth plugin might not return headers as expected
|
|
152
|
+
# This could be an error or a specific design of an auth plugin.
|
|
153
|
+
# For now, we assume it should always return a 'headers' dict.
|
|
154
|
+
# If not, we retain the headers we had before calling the auth plugin.
|
|
155
|
+
pass # Or raise an error, or log a warning.
|
|
156
|
+
elif self._bearer_token is not None:
|
|
157
|
+
# If no auth plugin, but bearer token is present, add/overwrite Authorization header.
|
|
158
|
+
prepared_headers["Authorization"] = f"Bearer {self._bearer_token}"
|
|
159
|
+
|
|
160
|
+
return prepared_headers
|
|
161
|
+
|
|
162
|
+
async def request(
|
|
163
|
+
self,
|
|
164
|
+
method: str,
|
|
165
|
+
url: str,
|
|
166
|
+
**kwargs: Any,
|
|
167
|
+
) -> httpx.Response:
|
|
168
|
+
"""
|
|
169
|
+
Sends an asynchronous HTTP request using the underlying httpx.AsyncClient.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
method (str): The HTTP method (e.g., 'GET', 'POST').
|
|
173
|
+
url (str): The target URL path, relative to the `base_url` provided during initialization, or an absolute
|
|
174
|
+
URL.
|
|
175
|
+
**kwargs: Additional keyword arguments passed directly to `httpx.AsyncClient.request` (e.g., headers,
|
|
176
|
+
params, json, data).
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
httpx.Response: The HTTP response object from the server.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
httpx.HTTPError: For network errors or invalid responses.
|
|
183
|
+
HTTPError: For non-2xx HTTP responses.
|
|
184
|
+
"""
|
|
185
|
+
# Prepare request arguments, excluding headers initially
|
|
186
|
+
request_args: dict[str, Any] = {k: v for k, v in kwargs.items() if k != "headers"}
|
|
187
|
+
|
|
188
|
+
# This method handles default headers, request-specific headers, and authentication
|
|
189
|
+
prepared_headers = await self._prepare_headers(kwargs)
|
|
190
|
+
request_args["headers"] = prepared_headers
|
|
191
|
+
|
|
192
|
+
response = await self._client.request(method, url, **request_args)
|
|
193
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
194
|
+
raise HTTPError(status_code=response.status_code, message=response.text, response=response)
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
async def close(self) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Closes the underlying httpx.AsyncClient and releases resources.
|
|
200
|
+
|
|
201
|
+
This should be called when the transport is no longer needed, typically
|
|
202
|
+
when the main API client is being shut down, to ensure proper cleanup
|
|
203
|
+
of network connections.
|
|
204
|
+
"""
|
|
205
|
+
await self._client.aclose()
|
|
206
|
+
|
|
207
|
+
async def __aenter__(self) -> "HttpxTransport":
|
|
208
|
+
"""
|
|
209
|
+
Enter the async context manager. Returns self.
|
|
210
|
+
"""
|
|
211
|
+
return self
|
|
212
|
+
|
|
213
|
+
async def __aexit__(
|
|
214
|
+
self,
|
|
215
|
+
exc_type: type[BaseException] | None,
|
|
216
|
+
exc_val: BaseException | None,
|
|
217
|
+
exc_tb: object | None,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""
|
|
220
|
+
Exit the async context manager. Calls close().
|
|
221
|
+
"""
|
|
222
|
+
await self.close()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Loader package to transform OpenAPI specs into Intermediate Representation.
|
|
2
|
+
|
|
3
|
+
This package contains the classes and functions to load OpenAPI specifications
|
|
4
|
+
and transform them into the internal IR dataclasses, which are then used for
|
|
5
|
+
code generation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .loader import SpecLoader, load_ir_from_spec
|
|
11
|
+
|
|
12
|
+
__all__ = ["SpecLoader", "load_ir_from_spec"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""OpenAPI Spec Loader.
|
|
2
|
+
|
|
3
|
+
Provides the main SpecLoader class and utilities to transform a validated OpenAPI spec
|
|
4
|
+
into the internal IR dataclasses. This implementation covers a subset of the OpenAPI
|
|
5
|
+
surface, sufficient for the code emitter prototypes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import warnings
|
|
13
|
+
from typing import Any, List, Mapping, cast
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
# Use the newer validate() API if available to avoid deprecation warnings
|
|
17
|
+
from openapi_spec_validator import validate as validate_spec
|
|
18
|
+
except ImportError:
|
|
19
|
+
try:
|
|
20
|
+
from openapi_spec_validator import validate_spec # type: ignore
|
|
21
|
+
except ImportError: # pragma: no cover – optional in early bootstrapping
|
|
22
|
+
validate_spec = None # type: ignore[assignment]
|
|
23
|
+
|
|
24
|
+
from pyopenapi_gen import IRSpec
|
|
25
|
+
from pyopenapi_gen.core.loader.operations import parse_operations
|
|
26
|
+
from pyopenapi_gen.core.loader.schemas import build_schemas, extract_inline_enums
|
|
27
|
+
|
|
28
|
+
__all__ = ["SpecLoader", "load_ir_from_spec"]
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Check for cycle detection debug flags in environment
|
|
33
|
+
MAX_CYCLES = int(os.environ.get("PYOPENAPI_MAX_CYCLES", "0"))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SpecLoader:
|
|
37
|
+
"""Transforms a validated OpenAPI spec into IR dataclasses.
|
|
38
|
+
|
|
39
|
+
This class follows the Design by Contract principles and ensures that
|
|
40
|
+
all operations maintain proper invariants and verify their inputs/outputs.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, spec: Mapping[str, Any]):
|
|
44
|
+
"""Initialize the spec loader with an OpenAPI spec.
|
|
45
|
+
|
|
46
|
+
Contracts:
|
|
47
|
+
Preconditions:
|
|
48
|
+
- spec is a valid OpenAPI spec mapping
|
|
49
|
+
- spec contains required OpenAPI fields
|
|
50
|
+
Postconditions:
|
|
51
|
+
- Instance is ready to load IR from the spec
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(spec, Mapping):
|
|
54
|
+
raise ValueError("spec must be a Mapping")
|
|
55
|
+
if "openapi" not in spec:
|
|
56
|
+
raise ValueError("Missing 'openapi' field in the specification")
|
|
57
|
+
if "paths" not in spec:
|
|
58
|
+
raise ValueError("Missing 'paths' section in the specification")
|
|
59
|
+
|
|
60
|
+
self.spec = spec
|
|
61
|
+
self.info = spec.get("info", {})
|
|
62
|
+
self.title = self.info.get("title", "API Client")
|
|
63
|
+
self.version = self.info.get("version", "0.0.0")
|
|
64
|
+
self.description = self.info.get("description")
|
|
65
|
+
self.raw_components = spec.get("components", {})
|
|
66
|
+
self.raw_schemas = self.raw_components.get("schemas", {})
|
|
67
|
+
self.raw_parameters = self.raw_components.get("parameters", {})
|
|
68
|
+
self.raw_responses = self.raw_components.get("responses", {})
|
|
69
|
+
self.raw_request_bodies = self.raw_components.get("requestBodies", {})
|
|
70
|
+
self.paths = spec["paths"]
|
|
71
|
+
self.servers = [s.get("url") for s in spec.get("servers", []) if "url" in s]
|
|
72
|
+
|
|
73
|
+
def validate(self) -> List[str]:
|
|
74
|
+
"""Validate the OpenAPI spec but continue on errors.
|
|
75
|
+
|
|
76
|
+
Contracts:
|
|
77
|
+
Postconditions:
|
|
78
|
+
- Returns a list of validation warnings
|
|
79
|
+
- The spec is validated using openapi-spec-validator if available
|
|
80
|
+
"""
|
|
81
|
+
warnings_list = []
|
|
82
|
+
|
|
83
|
+
if validate_spec is not None:
|
|
84
|
+
try:
|
|
85
|
+
from typing import Hashable
|
|
86
|
+
|
|
87
|
+
validate_spec(cast(Mapping[Hashable, Any], self.spec))
|
|
88
|
+
except Exception as e:
|
|
89
|
+
warning_msg = f"OpenAPI spec validation error: {e}"
|
|
90
|
+
# Always collect the message
|
|
91
|
+
warnings_list.append(warning_msg)
|
|
92
|
+
|
|
93
|
+
# Heuristic: if this error originates from jsonschema or
|
|
94
|
+
# openapi_spec_validator, prefer logging over global warnings
|
|
95
|
+
# to avoid noisy test output while still surfacing the issue.
|
|
96
|
+
origin_module = getattr(e.__class__, "__module__", "")
|
|
97
|
+
if (
|
|
98
|
+
isinstance(e, RecursionError)
|
|
99
|
+
or origin_module.startswith("jsonschema")
|
|
100
|
+
or origin_module.startswith("openapi_spec_validator")
|
|
101
|
+
):
|
|
102
|
+
logger.warning(warning_msg)
|
|
103
|
+
else:
|
|
104
|
+
# Preserve explicit warning behavior for unexpected failures
|
|
105
|
+
warnings.warn(warning_msg, UserWarning)
|
|
106
|
+
|
|
107
|
+
return warnings_list
|
|
108
|
+
|
|
109
|
+
def load_ir(self) -> IRSpec:
|
|
110
|
+
"""Transform the spec into an IRSpec object.
|
|
111
|
+
|
|
112
|
+
Contracts:
|
|
113
|
+
Postconditions:
|
|
114
|
+
- Returns a fully populated IRSpec object
|
|
115
|
+
- All schemas are properly processed and named
|
|
116
|
+
- All operations are properly parsed and linked to schemas
|
|
117
|
+
"""
|
|
118
|
+
# First validate the spec
|
|
119
|
+
self.validate()
|
|
120
|
+
|
|
121
|
+
# Build schemas and create context
|
|
122
|
+
context = build_schemas(self.raw_schemas, self.raw_components)
|
|
123
|
+
|
|
124
|
+
# Parse operations
|
|
125
|
+
operations = parse_operations(
|
|
126
|
+
self.paths,
|
|
127
|
+
self.raw_parameters,
|
|
128
|
+
self.raw_responses,
|
|
129
|
+
self.raw_request_bodies,
|
|
130
|
+
context,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Extract inline enums and add them to the schemas map
|
|
134
|
+
schemas_dict = extract_inline_enums(context.parsed_schemas)
|
|
135
|
+
|
|
136
|
+
# Emit collected warnings after all parsing is done
|
|
137
|
+
for warning_msg in context.collected_warnings:
|
|
138
|
+
warnings.warn(warning_msg, UserWarning)
|
|
139
|
+
|
|
140
|
+
# Create and return the IR spec
|
|
141
|
+
ir_spec = IRSpec(
|
|
142
|
+
title=self.title,
|
|
143
|
+
version=self.version,
|
|
144
|
+
description=self.description,
|
|
145
|
+
schemas=schemas_dict,
|
|
146
|
+
operations=operations,
|
|
147
|
+
servers=self.servers,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Post-condition check
|
|
151
|
+
if ir_spec.schemas != schemas_dict:
|
|
152
|
+
raise RuntimeError("Schemas mismatch in IRSpec")
|
|
153
|
+
if ir_spec.operations != operations:
|
|
154
|
+
raise RuntimeError("Operations mismatch in IRSpec")
|
|
155
|
+
|
|
156
|
+
return ir_spec
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_ir_from_spec(spec: Mapping[str, Any]) -> IRSpec:
|
|
160
|
+
"""Orchestrate the transformation of a spec dict into IRSpec.
|
|
161
|
+
|
|
162
|
+
This is a convenience function that creates a SpecLoader and calls load_ir().
|
|
163
|
+
|
|
164
|
+
Contracts:
|
|
165
|
+
Preconditions:
|
|
166
|
+
- spec is a valid OpenAPI spec mapping
|
|
167
|
+
Postconditions:
|
|
168
|
+
- Returns a fully populated IRSpec object
|
|
169
|
+
"""
|
|
170
|
+
if not isinstance(spec, Mapping):
|
|
171
|
+
raise ValueError("spec must be a Mapping")
|
|
172
|
+
|
|
173
|
+
loader = SpecLoader(spec)
|
|
174
|
+
return loader.load_ir()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Operation parsing utilities.
|
|
2
|
+
|
|
3
|
+
Functions to extract and transform operations from raw OpenAPI specifications.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .parser import parse_operations
|
|
9
|
+
from .post_processor import post_process_operation
|
|
10
|
+
from .request_body import parse_request_body
|
|
11
|
+
|
|
12
|
+
__all__ = ["parse_operations", "post_process_operation", "parse_request_body"]
|