pyopenapi-gen 0.8.3__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 +114 -0
- pyopenapi_gen/__main__.py +6 -0
- pyopenapi_gen/cli.py +86 -0
- pyopenapi_gen/context/file_manager.py +52 -0
- pyopenapi_gen/context/import_collector.py +382 -0
- pyopenapi_gen/context/render_context.py +630 -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/exceptions.py +25 -0
- pyopenapi_gen/core/http_transport.py +219 -0
- pyopenapi_gen/core/loader/__init__.py +12 -0
- pyopenapi_gen/core/loader/loader.py +158 -0
- pyopenapi_gen/core/loader/operations/__init__.py +12 -0
- pyopenapi_gen/core/loader/operations/parser.py +155 -0
- pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
- pyopenapi_gen/core/loader/operations/request_body.py +85 -0
- pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
- pyopenapi_gen/core/loader/parameters/parser.py +121 -0
- pyopenapi_gen/core/loader/responses/__init__.py +10 -0
- pyopenapi_gen/core/loader/responses/parser.py +104 -0
- pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
- pyopenapi_gen/core/loader/schemas/extractor.py +184 -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 +74 -0
- pyopenapi_gen/core/parsing/context.py +184 -0
- pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
- pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
- pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
- pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
- pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
- pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
- pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
- pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
- pyopenapi_gen/core/parsing/schema_parser.py +610 -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 +117 -0
- pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
- pyopenapi_gen/core/postprocess_manager.py +161 -0
- pyopenapi_gen/core/schemas.py +40 -0
- pyopenapi_gen/core/streaming_helpers.py +86 -0
- pyopenapi_gen/core/telemetry.py +67 -0
- pyopenapi_gen/core/utils.py +409 -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 +274 -0
- pyopenapi_gen/core_package_template/README.md +21 -0
- pyopenapi_gen/emit/models_emitter.py +143 -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 +223 -0
- pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
- pyopenapi_gen/emitters/models_emitter.py +428 -0
- pyopenapi_gen/generator/client_generator.py +562 -0
- pyopenapi_gen/helpers/__init__.py +1 -0
- pyopenapi_gen/helpers/endpoint_utils.py +552 -0
- pyopenapi_gen/helpers/type_cleaner.py +341 -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 +89 -0
- pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
- pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
- pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
- pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
- pyopenapi_gen/helpers/url_utils.py +14 -0
- pyopenapi_gen/http_types.py +20 -0
- pyopenapi_gen/ir.py +167 -0
- pyopenapi_gen/py.typed +1 -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 +30 -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 +203 -0
- pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
- pyopenapi_gen/types/services/__init__.py +5 -0
- pyopenapi_gen/types/services/type_service.py +133 -0
- pyopenapi_gen/visit/client_visitor.py +228 -0
- pyopenapi_gen/visit/docs_visitor.py +38 -0
- pyopenapi_gen/visit/endpoint/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
- pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
- pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
- pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
- pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
- pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
- pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
- pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
- pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
- pyopenapi_gen/visit/exception_visitor.py +52 -0
- pyopenapi_gen/visit/model/__init__.py +0 -0
- pyopenapi_gen/visit/model/alias_generator.py +89 -0
- pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
- pyopenapi_gen/visit/model/enum_generator.py +200 -0
- pyopenapi_gen/visit/model/model_visitor.py +197 -0
- pyopenapi_gen/visit/visitor.py +97 -0
- pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
- pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
- pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
- pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
- pyopenapi_gen-0.8.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,89 @@
|
|
1
|
+
from typing import Any, Awaitable, Callable, Dict, Optional
|
2
|
+
|
3
|
+
from .base import BaseAuth
|
4
|
+
|
5
|
+
|
6
|
+
class BearerAuth(BaseAuth):
|
7
|
+
"""Authentication plugin for Bearer tokens."""
|
8
|
+
|
9
|
+
def __init__(self, token: str) -> None:
|
10
|
+
self.token = token
|
11
|
+
|
12
|
+
async def authenticate_request(self, request_args: Dict[str, Any]) -> Dict[str, Any]:
|
13
|
+
# Ensure headers dict exists
|
14
|
+
headers = dict(request_args.get("headers", {}))
|
15
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
16
|
+
request_args["headers"] = headers
|
17
|
+
return request_args
|
18
|
+
|
19
|
+
|
20
|
+
class HeadersAuth(BaseAuth):
|
21
|
+
"""Authentication plugin for arbitrary headers."""
|
22
|
+
|
23
|
+
def __init__(self, headers: Dict[str, str]) -> None:
|
24
|
+
self.headers = headers
|
25
|
+
|
26
|
+
async def authenticate_request(self, request_args: Dict[str, Any]) -> Dict[str, Any]:
|
27
|
+
# Merge custom headers
|
28
|
+
hdrs = dict(request_args.get("headers", {}))
|
29
|
+
hdrs.update(self.headers)
|
30
|
+
request_args["headers"] = hdrs
|
31
|
+
return request_args
|
32
|
+
|
33
|
+
|
34
|
+
class ApiKeyAuth(BaseAuth):
|
35
|
+
"""Authentication plugin for API keys (header, query, or cookie)."""
|
36
|
+
|
37
|
+
def __init__(self, key: str, location: str = "header", name: str = "X-API-Key") -> None:
|
38
|
+
"""
|
39
|
+
Args:
|
40
|
+
key: The API key value.
|
41
|
+
location: Where to add the key ("header", "query", or "cookie").
|
42
|
+
name: The name of the header/query/cookie parameter.
|
43
|
+
"""
|
44
|
+
self.key = key
|
45
|
+
self.location = location
|
46
|
+
self.name = name
|
47
|
+
|
48
|
+
async def authenticate_request(self, request_args: Dict[str, Any]) -> Dict[str, Any]:
|
49
|
+
if self.location == "header":
|
50
|
+
headers = dict(request_args.get("headers", {}))
|
51
|
+
headers[self.name] = self.key
|
52
|
+
request_args["headers"] = headers
|
53
|
+
elif self.location == "query":
|
54
|
+
params = dict(request_args.get("params", {}))
|
55
|
+
params[self.name] = self.key
|
56
|
+
request_args["params"] = params
|
57
|
+
elif self.location == "cookie":
|
58
|
+
cookies = dict(request_args.get("cookies", {}))
|
59
|
+
cookies[self.name] = self.key
|
60
|
+
request_args["cookies"] = cookies
|
61
|
+
else:
|
62
|
+
raise ValueError(f"Invalid API key location: {self.location}")
|
63
|
+
return request_args
|
64
|
+
|
65
|
+
|
66
|
+
class OAuth2Auth(BaseAuth):
|
67
|
+
"""Authentication plugin for OAuth2 Bearer tokens, with optional auto-refresh."""
|
68
|
+
|
69
|
+
def __init__(self, access_token: str, refresh_callback: Optional[Callable[[str], Awaitable[str]]] = None) -> None:
|
70
|
+
"""
|
71
|
+
Args:
|
72
|
+
access_token: The OAuth2 access token.
|
73
|
+
refresh_callback: Optional async function to refresh the token. If provided, will be called
|
74
|
+
if token is expired.
|
75
|
+
"""
|
76
|
+
self.access_token = access_token
|
77
|
+
self.refresh_callback = refresh_callback
|
78
|
+
|
79
|
+
async def authenticate_request(self, request_args: Dict[str, Any]) -> Dict[str, Any]:
|
80
|
+
# In a real implementation, check expiry and refresh if needed
|
81
|
+
if self.refresh_callback is not None:
|
82
|
+
# Optionally refresh token (user must implement expiry logic)
|
83
|
+
new_token = await self.refresh_callback(self.access_token)
|
84
|
+
if new_token and new_token != self.access_token:
|
85
|
+
self.access_token = new_token
|
86
|
+
headers = dict(request_args.get("headers", {}))
|
87
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
88
|
+
request_args["headers"] = headers
|
89
|
+
return request_args
|
@@ -0,0 +1,25 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
import httpx
|
4
|
+
|
5
|
+
|
6
|
+
class HTTPError(Exception):
|
7
|
+
"""Base HTTP error with status code and message."""
|
8
|
+
|
9
|
+
def __init__(self, status_code: int, message: str, response: Optional[httpx.Response] = None) -> None:
|
10
|
+
super().__init__(f"{status_code}: {message}")
|
11
|
+
self.status_code = status_code
|
12
|
+
self.message = message
|
13
|
+
self.response = response
|
14
|
+
|
15
|
+
|
16
|
+
class ClientError(HTTPError):
|
17
|
+
"""4XX client error responses."""
|
18
|
+
|
19
|
+
pass
|
20
|
+
|
21
|
+
|
22
|
+
class ServerError(HTTPError):
|
23
|
+
"""5XX server error responses."""
|
24
|
+
|
25
|
+
pass
|
@@ -0,0 +1,219 @@
|
|
1
|
+
from typing import Any, Dict, Optional, 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 (Optional[BaseAuth]): Optional authentication plugin for request signing (can be CompositeAuth).
|
87
|
+
_bearer_token (Optional[str]): Optional bearer token for Authorization header.
|
88
|
+
_default_headers (Optional[Dict[str, str]]): Default headers to apply to all requests.
|
89
|
+
"""
|
90
|
+
|
91
|
+
def __init__(
|
92
|
+
self,
|
93
|
+
base_url: str,
|
94
|
+
timeout: Optional[float] = None,
|
95
|
+
auth: Optional[BaseAuth] = None,
|
96
|
+
bearer_token: Optional[str] = None,
|
97
|
+
default_headers: Optional[Dict[str, str]] = None,
|
98
|
+
) -> None:
|
99
|
+
"""
|
100
|
+
Initializes the HttpxTransport.
|
101
|
+
|
102
|
+
Args:
|
103
|
+
base_url (str): The base URL for all API requests made through this transport.
|
104
|
+
timeout (Optional[float]): The default timeout in seconds for requests. If None, httpx's default is used.
|
105
|
+
auth (Optional[BaseAuth]): Optional authentication plugin for request signing (can be CompositeAuth).
|
106
|
+
bearer_token (Optional[str]): Optional raw bearer token string for Authorization header.
|
107
|
+
default_headers (Optional[Dict[str, str]]): Default headers to apply to all requests.
|
108
|
+
|
109
|
+
Note:
|
110
|
+
If both auth and bearer_token are provided, auth takes precedence.
|
111
|
+
"""
|
112
|
+
self._client: httpx.AsyncClient = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
113
|
+
self._auth: Optional[BaseAuth] = auth
|
114
|
+
self._bearer_token: Optional[str] = bearer_token
|
115
|
+
self._default_headers: Optional[Dict[str, str]] = default_headers
|
116
|
+
|
117
|
+
async def _prepare_headers(
|
118
|
+
self,
|
119
|
+
current_request_kwargs: Dict[str, Any],
|
120
|
+
) -> Dict[str, str]:
|
121
|
+
"""
|
122
|
+
Prepares headers for an HTTP request, incorporating default headers,
|
123
|
+
request-specific headers, and authentication.
|
124
|
+
"""
|
125
|
+
# Initialize headers for the current request
|
126
|
+
prepared_headers: Dict[str, str] = {}
|
127
|
+
|
128
|
+
# 1. Apply transport-level default headers
|
129
|
+
if self._default_headers:
|
130
|
+
prepared_headers.update(self._default_headers)
|
131
|
+
|
132
|
+
# 2. Merge headers passed specifically for this request (overriding transport defaults)
|
133
|
+
if "headers" in current_request_kwargs and isinstance(current_request_kwargs["headers"], dict):
|
134
|
+
prepared_headers.update(current_request_kwargs["headers"])
|
135
|
+
|
136
|
+
# 3. Apply authentication plugin or bearer token (which can further modify headers)
|
137
|
+
# We pass a temporary request_args dict containing only the headers to the auth plugin,
|
138
|
+
# as the auth plugin might expect other keys which are not relevant for header preparation.
|
139
|
+
# The auth plugin is expected to modify the 'headers' key in the passed dict.
|
140
|
+
temp_request_args_for_auth = {"headers": prepared_headers.copy()}
|
141
|
+
|
142
|
+
if self._auth is not None:
|
143
|
+
authenticated_args = await self._auth.authenticate_request(temp_request_args_for_auth)
|
144
|
+
# Ensure 'headers' key exists and is a dict after authentication
|
145
|
+
if "headers" in authenticated_args and isinstance(authenticated_args["headers"], dict):
|
146
|
+
prepared_headers = authenticated_args["headers"]
|
147
|
+
else:
|
148
|
+
# Handle cases where auth plugin might not return headers as expected
|
149
|
+
# This could be an error or a specific design of an auth plugin.
|
150
|
+
# For now, we assume it should always return a 'headers' dict.
|
151
|
+
# If not, we retain the headers we had before calling the auth plugin.
|
152
|
+
pass # Or raise an error, or log a warning.
|
153
|
+
elif self._bearer_token is not None:
|
154
|
+
# If no auth plugin, but bearer token is present, add/overwrite Authorization header.
|
155
|
+
prepared_headers["Authorization"] = f"Bearer {self._bearer_token}"
|
156
|
+
|
157
|
+
return prepared_headers
|
158
|
+
|
159
|
+
async def request(
|
160
|
+
self,
|
161
|
+
method: str,
|
162
|
+
url: str,
|
163
|
+
**kwargs: Any,
|
164
|
+
) -> httpx.Response:
|
165
|
+
"""
|
166
|
+
Sends an asynchronous HTTP request using the underlying httpx.AsyncClient.
|
167
|
+
|
168
|
+
Args:
|
169
|
+
method (str): The HTTP method (e.g., 'GET', 'POST').
|
170
|
+
url (str): The target URL path, relative to the `base_url` provided during initialization, or an absolute
|
171
|
+
URL.
|
172
|
+
**kwargs: Additional keyword arguments passed directly to `httpx.AsyncClient.request` (e.g., headers,
|
173
|
+
params, json, data).
|
174
|
+
|
175
|
+
Returns:
|
176
|
+
httpx.Response: The HTTP response object from the server.
|
177
|
+
|
178
|
+
Raises:
|
179
|
+
httpx.HTTPError: For network errors or invalid responses.
|
180
|
+
HTTPError: For non-2xx HTTP responses.
|
181
|
+
"""
|
182
|
+
# Prepare request arguments, excluding headers initially
|
183
|
+
request_args: Dict[str, Any] = {k: v for k, v in kwargs.items() if k != "headers"}
|
184
|
+
|
185
|
+
# This method handles default headers, request-specific headers, and authentication
|
186
|
+
prepared_headers = await self._prepare_headers(kwargs)
|
187
|
+
request_args["headers"] = prepared_headers
|
188
|
+
|
189
|
+
response = await self._client.request(method, url, **request_args)
|
190
|
+
if response.status_code < 200 or response.status_code >= 300:
|
191
|
+
raise HTTPError(status_code=response.status_code, message=response.text, response=response)
|
192
|
+
return response
|
193
|
+
|
194
|
+
async def close(self) -> None:
|
195
|
+
"""
|
196
|
+
Closes the underlying httpx.AsyncClient and releases resources.
|
197
|
+
|
198
|
+
This should be called when the transport is no longer needed, typically
|
199
|
+
when the main API client is being shut down, to ensure proper cleanup
|
200
|
+
of network connections.
|
201
|
+
"""
|
202
|
+
await self._client.aclose()
|
203
|
+
|
204
|
+
async def __aenter__(self) -> "HttpxTransport":
|
205
|
+
"""
|
206
|
+
Enter the async context manager. Returns self.
|
207
|
+
"""
|
208
|
+
return self
|
209
|
+
|
210
|
+
async def __aexit__(
|
211
|
+
self,
|
212
|
+
exc_type: type[BaseException] | None,
|
213
|
+
exc_val: BaseException | None,
|
214
|
+
exc_tb: object | None,
|
215
|
+
) -> None:
|
216
|
+
"""
|
217
|
+
Exit the async context manager. Calls close().
|
218
|
+
"""
|
219
|
+
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,158 @@
|
|
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
|
+
warnings_list.append(warning_msg)
|
91
|
+
warnings.warn(warning_msg, UserWarning)
|
92
|
+
|
93
|
+
return warnings_list
|
94
|
+
|
95
|
+
def load_ir(self) -> IRSpec:
|
96
|
+
"""Transform the spec into an IRSpec object.
|
97
|
+
|
98
|
+
Contracts:
|
99
|
+
Postconditions:
|
100
|
+
- Returns a fully populated IRSpec object
|
101
|
+
- All schemas are properly processed and named
|
102
|
+
- All operations are properly parsed and linked to schemas
|
103
|
+
"""
|
104
|
+
# First validate the spec
|
105
|
+
self.validate()
|
106
|
+
|
107
|
+
# Build schemas and create context
|
108
|
+
context = build_schemas(self.raw_schemas, self.raw_components)
|
109
|
+
|
110
|
+
# Parse operations
|
111
|
+
operations = parse_operations(
|
112
|
+
self.paths,
|
113
|
+
self.raw_parameters,
|
114
|
+
self.raw_responses,
|
115
|
+
self.raw_request_bodies,
|
116
|
+
context,
|
117
|
+
)
|
118
|
+
|
119
|
+
# Extract inline enums and add them to the schemas map
|
120
|
+
schemas_dict = extract_inline_enums(context.parsed_schemas)
|
121
|
+
|
122
|
+
# Emit collected warnings after all parsing is done
|
123
|
+
for warning_msg in context.collected_warnings:
|
124
|
+
warnings.warn(warning_msg, UserWarning)
|
125
|
+
|
126
|
+
# Create and return the IR spec
|
127
|
+
ir_spec = IRSpec(
|
128
|
+
title=self.title,
|
129
|
+
version=self.version,
|
130
|
+
description=self.description,
|
131
|
+
schemas=schemas_dict,
|
132
|
+
operations=operations,
|
133
|
+
servers=self.servers,
|
134
|
+
)
|
135
|
+
|
136
|
+
# Post-condition check
|
137
|
+
assert ir_spec.schemas == schemas_dict, "Schemas mismatch in IRSpec"
|
138
|
+
assert ir_spec.operations == operations, "Operations mismatch in IRSpec"
|
139
|
+
|
140
|
+
return ir_spec
|
141
|
+
|
142
|
+
|
143
|
+
def load_ir_from_spec(spec: Mapping[str, Any]) -> IRSpec:
|
144
|
+
"""Orchestrate the transformation of a spec dict into IRSpec.
|
145
|
+
|
146
|
+
This is a convenience function that creates a SpecLoader and calls load_ir().
|
147
|
+
|
148
|
+
Contracts:
|
149
|
+
Preconditions:
|
150
|
+
- spec is a valid OpenAPI spec mapping
|
151
|
+
Postconditions:
|
152
|
+
- Returns a fully populated IRSpec object
|
153
|
+
"""
|
154
|
+
if not isinstance(spec, Mapping):
|
155
|
+
raise ValueError("spec must be a Mapping")
|
156
|
+
|
157
|
+
loader = SpecLoader(spec)
|
158
|
+
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"]
|
@@ -0,0 +1,155 @@
|
|
1
|
+
"""Operation parsers for OpenAPI IR transformation.
|
2
|
+
|
3
|
+
Provides the main parse_operations function to transform OpenAPI paths into IR operations.
|
4
|
+
"""
|
5
|
+
|
6
|
+
from __future__ import annotations
|
7
|
+
|
8
|
+
import logging
|
9
|
+
import warnings
|
10
|
+
from typing import Any, List, Mapping, Optional, cast
|
11
|
+
|
12
|
+
from pyopenapi_gen import HTTPMethod, IROperation, IRParameter, IRRequestBody, IRResponse
|
13
|
+
from pyopenapi_gen.core.loader.operations.post_processor import post_process_operation
|
14
|
+
from pyopenapi_gen.core.loader.operations.request_body import parse_request_body
|
15
|
+
from pyopenapi_gen.core.loader.parameters import parse_parameter, resolve_parameter_node_if_ref
|
16
|
+
from pyopenapi_gen.core.loader.responses import parse_response
|
17
|
+
from pyopenapi_gen.core.parsing.context import ParsingContext
|
18
|
+
from pyopenapi_gen.core.utils import NameSanitizer
|
19
|
+
|
20
|
+
logger = logging.getLogger(__name__)
|
21
|
+
|
22
|
+
|
23
|
+
def parse_operations(
|
24
|
+
paths: Mapping[str, Any],
|
25
|
+
raw_parameters: Mapping[str, Any],
|
26
|
+
raw_responses: Mapping[str, Any],
|
27
|
+
raw_request_bodies: Mapping[str, Any],
|
28
|
+
context: ParsingContext,
|
29
|
+
) -> List[IROperation]:
|
30
|
+
"""Iterate paths to build IROperation list.
|
31
|
+
|
32
|
+
Contracts:
|
33
|
+
Preconditions:
|
34
|
+
- paths is a valid paths object from OpenAPI spec
|
35
|
+
- raw_parameters, raw_responses, raw_request_bodies are component mappings
|
36
|
+
- context is properly initialized with schemas
|
37
|
+
Postconditions:
|
38
|
+
- Returns a list of IROperation objects
|
39
|
+
- All operations have correct path, method, parameters, responses, etc.
|
40
|
+
- All referenced schemas are properly stored in context
|
41
|
+
"""
|
42
|
+
assert isinstance(paths, Mapping), "paths must be a Mapping"
|
43
|
+
assert isinstance(raw_parameters, Mapping), "raw_parameters must be a Mapping"
|
44
|
+
assert isinstance(raw_responses, Mapping), "raw_responses must be a Mapping"
|
45
|
+
assert isinstance(raw_request_bodies, Mapping), "raw_request_bodies must be a Mapping"
|
46
|
+
assert isinstance(context, ParsingContext), "context must be a ParsingContext"
|
47
|
+
|
48
|
+
ops: List[IROperation] = []
|
49
|
+
|
50
|
+
for path, item in paths.items():
|
51
|
+
if not isinstance(item, Mapping):
|
52
|
+
continue
|
53
|
+
entry = cast(Mapping[str, Any], item)
|
54
|
+
|
55
|
+
base_params_nodes = cast(List[Mapping[str, Any]], entry.get("parameters", []))
|
56
|
+
|
57
|
+
for method, on in entry.items():
|
58
|
+
try:
|
59
|
+
if method in {
|
60
|
+
"parameters",
|
61
|
+
"summary",
|
62
|
+
"description",
|
63
|
+
"servers",
|
64
|
+
"$ref",
|
65
|
+
}:
|
66
|
+
continue
|
67
|
+
mu = method.upper()
|
68
|
+
if mu not in HTTPMethod.__members__:
|
69
|
+
continue
|
70
|
+
|
71
|
+
node_op = cast(Mapping[str, Any], on)
|
72
|
+
|
73
|
+
# Get operation_id for this specific operation
|
74
|
+
if "operationId" in node_op:
|
75
|
+
operation_id = node_op["operationId"]
|
76
|
+
else:
|
77
|
+
operation_id = NameSanitizer.sanitize_method_name(f"{mu}_{path}".strip("/"))
|
78
|
+
|
79
|
+
# Parse base parameters (path-level) with operation_id context
|
80
|
+
base_params: List[IRParameter] = []
|
81
|
+
for p_node_data_raw in base_params_nodes:
|
82
|
+
resolved_p_node_data = resolve_parameter_node_if_ref(p_node_data_raw, context)
|
83
|
+
base_params.append(
|
84
|
+
parse_parameter(resolved_p_node_data, context, operation_id_for_promo=operation_id)
|
85
|
+
)
|
86
|
+
|
87
|
+
# Parse operation-specific parameters
|
88
|
+
params: List[IRParameter] = list(base_params) # Start with copies of path-level params
|
89
|
+
for p_param_node_raw in cast(List[Mapping[str, Any]], node_op.get("parameters", [])):
|
90
|
+
resolved_p_param_node = resolve_parameter_node_if_ref(p_param_node_raw, context)
|
91
|
+
params.append(parse_parameter(resolved_p_param_node, context, operation_id_for_promo=operation_id))
|
92
|
+
|
93
|
+
# Parse request body
|
94
|
+
rb: Optional[IRRequestBody] = None
|
95
|
+
if "requestBody" in node_op:
|
96
|
+
rb = parse_request_body(
|
97
|
+
cast(Mapping[str, Any], node_op["requestBody"]),
|
98
|
+
raw_request_bodies,
|
99
|
+
context,
|
100
|
+
operation_id,
|
101
|
+
)
|
102
|
+
|
103
|
+
# Parse responses
|
104
|
+
resps: List[IRResponse] = []
|
105
|
+
for sc, rn_node in cast(Mapping[str, Any], node_op.get("responses", {})).items():
|
106
|
+
if (
|
107
|
+
isinstance(rn_node, Mapping)
|
108
|
+
and "$ref" in rn_node
|
109
|
+
and isinstance(rn_node.get("$ref"), str)
|
110
|
+
and rn_node["$ref"].startswith("#/components/responses/")
|
111
|
+
):
|
112
|
+
ref_name = rn_node["$ref"].split("/")[-1]
|
113
|
+
resp_node_resolved = raw_responses.get(ref_name, {}) or rn_node
|
114
|
+
elif (
|
115
|
+
isinstance(rn_node, Mapping)
|
116
|
+
and "$ref" in rn_node
|
117
|
+
and isinstance(rn_node.get("$ref"), str)
|
118
|
+
and rn_node["$ref"].startswith("#/components/schemas/")
|
119
|
+
):
|
120
|
+
# Handle direct schema references in responses
|
121
|
+
# Convert schema reference to a response with content
|
122
|
+
resp_node_resolved = {
|
123
|
+
"description": f"Response with {rn_node['$ref'].split('/')[-1]} schema",
|
124
|
+
"content": {"application/json": {"schema": {"$ref": rn_node["$ref"]}}},
|
125
|
+
}
|
126
|
+
else:
|
127
|
+
resp_node_resolved = rn_node
|
128
|
+
resps.append(parse_response(sc, resp_node_resolved, context, operation_id_for_promo=operation_id))
|
129
|
+
|
130
|
+
op = IROperation(
|
131
|
+
operation_id=operation_id,
|
132
|
+
method=HTTPMethod[mu],
|
133
|
+
path=path,
|
134
|
+
summary=node_op.get("summary"),
|
135
|
+
description=node_op.get("description"),
|
136
|
+
parameters=params,
|
137
|
+
request_body=rb,
|
138
|
+
responses=resps,
|
139
|
+
tags=list(node_op.get("tags", [])),
|
140
|
+
)
|
141
|
+
except Exception as e:
|
142
|
+
warnings.warn(
|
143
|
+
f"Skipping operation parsing for {method.upper()} {path}: {e}",
|
144
|
+
UserWarning,
|
145
|
+
)
|
146
|
+
continue
|
147
|
+
else:
|
148
|
+
# Post-process the parsed operation to fill in schema names
|
149
|
+
post_process_operation(op, context)
|
150
|
+
ops.append(op)
|
151
|
+
|
152
|
+
# Post-condition check
|
153
|
+
assert all(isinstance(op, IROperation) for op in ops), "All items must be IROperation objects"
|
154
|
+
|
155
|
+
return ops
|