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.
Files changed (122) hide show
  1. pyopenapi_gen/__init__.py +114 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +86 -0
  4. pyopenapi_gen/context/file_manager.py +52 -0
  5. pyopenapi_gen/context/import_collector.py +382 -0
  6. pyopenapi_gen/context/render_context.py +630 -0
  7. pyopenapi_gen/core/__init__.py +0 -0
  8. pyopenapi_gen/core/auth/base.py +22 -0
  9. pyopenapi_gen/core/auth/plugins.py +89 -0
  10. pyopenapi_gen/core/exceptions.py +25 -0
  11. pyopenapi_gen/core/http_transport.py +219 -0
  12. pyopenapi_gen/core/loader/__init__.py +12 -0
  13. pyopenapi_gen/core/loader/loader.py +158 -0
  14. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  15. pyopenapi_gen/core/loader/operations/parser.py +155 -0
  16. pyopenapi_gen/core/loader/operations/post_processor.py +60 -0
  17. pyopenapi_gen/core/loader/operations/request_body.py +85 -0
  18. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  19. pyopenapi_gen/core/loader/parameters/parser.py +121 -0
  20. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  21. pyopenapi_gen/core/loader/responses/parser.py +104 -0
  22. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  23. pyopenapi_gen/core/loader/schemas/extractor.py +184 -0
  24. pyopenapi_gen/core/pagination.py +64 -0
  25. pyopenapi_gen/core/parsing/__init__.py +13 -0
  26. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  27. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  28. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  29. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  30. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  37. pyopenapi_gen/core/parsing/common/type_parser.py +74 -0
  38. pyopenapi_gen/core/parsing/context.py +184 -0
  39. pyopenapi_gen/core/parsing/cycle_helpers.py +123 -0
  40. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  41. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +77 -0
  42. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +79 -0
  43. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +69 -0
  44. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +72 -0
  45. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  46. pyopenapi_gen/core/parsing/schema_finalizer.py +166 -0
  47. pyopenapi_gen/core/parsing/schema_parser.py +610 -0
  48. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  49. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  50. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +117 -0
  51. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  52. pyopenapi_gen/core/postprocess_manager.py +161 -0
  53. pyopenapi_gen/core/schemas.py +40 -0
  54. pyopenapi_gen/core/streaming_helpers.py +86 -0
  55. pyopenapi_gen/core/telemetry.py +67 -0
  56. pyopenapi_gen/core/utils.py +409 -0
  57. pyopenapi_gen/core/warning_collector.py +83 -0
  58. pyopenapi_gen/core/writers/code_writer.py +135 -0
  59. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  60. pyopenapi_gen/core/writers/line_writer.py +217 -0
  61. pyopenapi_gen/core/writers/python_construct_renderer.py +274 -0
  62. pyopenapi_gen/core_package_template/README.md +21 -0
  63. pyopenapi_gen/emit/models_emitter.py +143 -0
  64. pyopenapi_gen/emitters/client_emitter.py +51 -0
  65. pyopenapi_gen/emitters/core_emitter.py +181 -0
  66. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  67. pyopenapi_gen/emitters/endpoints_emitter.py +223 -0
  68. pyopenapi_gen/emitters/exceptions_emitter.py +52 -0
  69. pyopenapi_gen/emitters/models_emitter.py +428 -0
  70. pyopenapi_gen/generator/client_generator.py +562 -0
  71. pyopenapi_gen/helpers/__init__.py +1 -0
  72. pyopenapi_gen/helpers/endpoint_utils.py +552 -0
  73. pyopenapi_gen/helpers/type_cleaner.py +341 -0
  74. pyopenapi_gen/helpers/type_helper.py +112 -0
  75. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  76. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  77. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  78. pyopenapi_gen/helpers/type_resolution/finalizer.py +89 -0
  79. pyopenapi_gen/helpers/type_resolution/named_resolver.py +174 -0
  80. pyopenapi_gen/helpers/type_resolution/object_resolver.py +212 -0
  81. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +57 -0
  82. pyopenapi_gen/helpers/type_resolution/resolver.py +48 -0
  83. pyopenapi_gen/helpers/url_utils.py +14 -0
  84. pyopenapi_gen/http_types.py +20 -0
  85. pyopenapi_gen/ir.py +167 -0
  86. pyopenapi_gen/py.typed +1 -0
  87. pyopenapi_gen/types/__init__.py +11 -0
  88. pyopenapi_gen/types/contracts/__init__.py +13 -0
  89. pyopenapi_gen/types/contracts/protocols.py +106 -0
  90. pyopenapi_gen/types/contracts/types.py +30 -0
  91. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  92. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  93. pyopenapi_gen/types/resolvers/response_resolver.py +203 -0
  94. pyopenapi_gen/types/resolvers/schema_resolver.py +367 -0
  95. pyopenapi_gen/types/services/__init__.py +5 -0
  96. pyopenapi_gen/types/services/type_service.py +133 -0
  97. pyopenapi_gen/visit/client_visitor.py +228 -0
  98. pyopenapi_gen/visit/docs_visitor.py +38 -0
  99. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  100. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +103 -0
  101. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  102. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +121 -0
  103. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +87 -0
  104. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  105. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +497 -0
  106. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +88 -0
  107. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +183 -0
  108. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  109. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +76 -0
  110. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  111. pyopenapi_gen/visit/exception_visitor.py +52 -0
  112. pyopenapi_gen/visit/model/__init__.py +0 -0
  113. pyopenapi_gen/visit/model/alias_generator.py +89 -0
  114. pyopenapi_gen/visit/model/dataclass_generator.py +197 -0
  115. pyopenapi_gen/visit/model/enum_generator.py +200 -0
  116. pyopenapi_gen/visit/model/model_visitor.py +197 -0
  117. pyopenapi_gen/visit/visitor.py +97 -0
  118. pyopenapi_gen-0.8.3.dist-info/METADATA +224 -0
  119. pyopenapi_gen-0.8.3.dist-info/RECORD +122 -0
  120. pyopenapi_gen-0.8.3.dist-info/WHEEL +4 -0
  121. pyopenapi_gen-0.8.3.dist-info/entry_points.txt +2 -0
  122. 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