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,409 @@
|
|
1
|
+
"""Utilities for pyopenapi_gen.
|
2
|
+
|
3
|
+
This module contains utility classes and functions used across the code generation process.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import dataclasses
|
7
|
+
import keyword
|
8
|
+
import logging
|
9
|
+
import re
|
10
|
+
from datetime import datetime
|
11
|
+
from typing import Any, Dict, Set, Type, TypeVar, cast
|
12
|
+
|
13
|
+
logger = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
T = TypeVar("T")
|
16
|
+
|
17
|
+
|
18
|
+
class NameSanitizer:
|
19
|
+
"""Helper to sanitize spec names and tags into valid Python identifiers and filenames."""
|
20
|
+
|
21
|
+
# Python built-ins and common problematic names that should be avoided in module names
|
22
|
+
RESERVED_NAMES = {
|
23
|
+
# Built-in types
|
24
|
+
"type",
|
25
|
+
"int",
|
26
|
+
"str",
|
27
|
+
"float",
|
28
|
+
"bool",
|
29
|
+
"list",
|
30
|
+
"dict",
|
31
|
+
"set",
|
32
|
+
"tuple",
|
33
|
+
"bytes",
|
34
|
+
"object",
|
35
|
+
"complex",
|
36
|
+
"frozenset",
|
37
|
+
"bytearray",
|
38
|
+
"memoryview",
|
39
|
+
"range",
|
40
|
+
# Built-in functions
|
41
|
+
"abs",
|
42
|
+
"all",
|
43
|
+
"any",
|
44
|
+
"bin",
|
45
|
+
"callable",
|
46
|
+
"chr",
|
47
|
+
"classmethod",
|
48
|
+
"compile",
|
49
|
+
"delattr",
|
50
|
+
"dir",
|
51
|
+
"divmod",
|
52
|
+
"enumerate",
|
53
|
+
"eval",
|
54
|
+
"exec",
|
55
|
+
"filter",
|
56
|
+
"format",
|
57
|
+
"getattr",
|
58
|
+
"globals",
|
59
|
+
"hasattr",
|
60
|
+
"hash",
|
61
|
+
"help",
|
62
|
+
"hex",
|
63
|
+
"id",
|
64
|
+
"input",
|
65
|
+
"isinstance",
|
66
|
+
"issubclass",
|
67
|
+
"iter",
|
68
|
+
"len",
|
69
|
+
"locals",
|
70
|
+
"map",
|
71
|
+
"max",
|
72
|
+
"min",
|
73
|
+
"next",
|
74
|
+
"oct",
|
75
|
+
"open",
|
76
|
+
"ord",
|
77
|
+
"pow",
|
78
|
+
"print",
|
79
|
+
"property",
|
80
|
+
"repr",
|
81
|
+
"reversed",
|
82
|
+
"round",
|
83
|
+
"setattr",
|
84
|
+
"slice",
|
85
|
+
"sorted",
|
86
|
+
"staticmethod",
|
87
|
+
"sum",
|
88
|
+
"super",
|
89
|
+
"vars",
|
90
|
+
"zip",
|
91
|
+
# Common standard library modules
|
92
|
+
"os",
|
93
|
+
"sys",
|
94
|
+
"json",
|
95
|
+
"time",
|
96
|
+
"datetime",
|
97
|
+
"math",
|
98
|
+
"random",
|
99
|
+
"string",
|
100
|
+
"collections",
|
101
|
+
"itertools",
|
102
|
+
"functools",
|
103
|
+
"typing",
|
104
|
+
"pathlib",
|
105
|
+
"logging",
|
106
|
+
"urllib",
|
107
|
+
"http",
|
108
|
+
"email",
|
109
|
+
"uuid",
|
110
|
+
"hashlib",
|
111
|
+
"base64",
|
112
|
+
"copy",
|
113
|
+
"re",
|
114
|
+
# Other problematic names
|
115
|
+
"data",
|
116
|
+
"model",
|
117
|
+
"models",
|
118
|
+
"client",
|
119
|
+
"api",
|
120
|
+
"config",
|
121
|
+
"utils",
|
122
|
+
"helpers",
|
123
|
+
}
|
124
|
+
|
125
|
+
@staticmethod
|
126
|
+
def sanitize_module_name(name: str) -> str:
|
127
|
+
"""Convert a raw name into a valid Python module name in snake_case, splitting camel case and PascalCase."""
|
128
|
+
# # <<< Add Check for problematic input >>>
|
129
|
+
# if '[' in name or ']' in name or ',' in name:
|
130
|
+
# logger.error(f"sanitize_module_name received potentially invalid input: '{name}'")
|
131
|
+
# # Optionally, return a default/error value or raise exception
|
132
|
+
# # For now, just log and continue
|
133
|
+
# # <<< End Check >>>
|
134
|
+
|
135
|
+
# Split on non-alphanumeric and camel case boundaries
|
136
|
+
words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
|
137
|
+
if not words:
|
138
|
+
# fallback: split on non-alphanumerics
|
139
|
+
words = re.split(r"\W+", name)
|
140
|
+
module = "_".join(word.lower() for word in words if word)
|
141
|
+
# If it starts with a digit, prefix with underscore
|
142
|
+
if module and module[0].isdigit():
|
143
|
+
module = "_" + module
|
144
|
+
# Avoid Python keywords and reserved names
|
145
|
+
if keyword.iskeyword(module) or module in NameSanitizer.RESERVED_NAMES:
|
146
|
+
module += "_"
|
147
|
+
return module
|
148
|
+
|
149
|
+
@staticmethod
|
150
|
+
def sanitize_class_name(name: str) -> str:
|
151
|
+
"""Convert a raw name into a valid Python class name in PascalCase."""
|
152
|
+
# Split on non-alphanumeric and camel case boundaries
|
153
|
+
words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
|
154
|
+
if not words: # Fallback if findall is empty (e.g. if name was all symbols)
|
155
|
+
# Basic split on non-alphanumeric as a last resort if findall yields nothing
|
156
|
+
words = [part for part in re.split(r"[^a-zA-Z0-9]+", name) if part]
|
157
|
+
|
158
|
+
# Capitalize each word and join
|
159
|
+
cls_name = "".join(word.capitalize() for word in words if word)
|
160
|
+
|
161
|
+
if not cls_name: # If name was e.g. "-" or "_"
|
162
|
+
cls_name = "UnnamedClass" # Or some other default
|
163
|
+
|
164
|
+
# If it starts with a digit, prefix with underscore
|
165
|
+
if cls_name[0].isdigit(): # Check after ensuring cls_name is not empty
|
166
|
+
cls_name = "_" + cls_name
|
167
|
+
# Avoid Python keywords and reserved names (case-insensitive)
|
168
|
+
if keyword.iskeyword(cls_name.lower()) or cls_name.lower() in NameSanitizer.RESERVED_NAMES:
|
169
|
+
cls_name += "_"
|
170
|
+
return cls_name
|
171
|
+
|
172
|
+
@staticmethod
|
173
|
+
def sanitize_tag_class_name(tag: str) -> str:
|
174
|
+
"""Sanitize a tag for use as a PascalCase client class name (e.g., DataSourcesClient)."""
|
175
|
+
words = re.split(r"[\W_]+", tag)
|
176
|
+
return "".join(word.capitalize() for word in words if word) + "Client"
|
177
|
+
|
178
|
+
@staticmethod
|
179
|
+
def sanitize_tag_attr_name(tag: str) -> str:
|
180
|
+
"""Sanitize a tag for use as a snake_case attribute name (e.g., data_sources)."""
|
181
|
+
attr = re.sub(r"[\W]+", "_", tag).lower()
|
182
|
+
return attr.strip("_")
|
183
|
+
|
184
|
+
@staticmethod
|
185
|
+
def normalize_tag_key(tag: str) -> str:
|
186
|
+
"""Normalize a tag for case-insensitive uniqueness (e.g., datasources)."""
|
187
|
+
return re.sub(r"[\W_]+", "", tag).lower()
|
188
|
+
|
189
|
+
@staticmethod
|
190
|
+
def sanitize_filename(name: str, suffix: str = ".py") -> str:
|
191
|
+
"""Generate a valid Python filename from raw name in snake_case."""
|
192
|
+
module = NameSanitizer.sanitize_module_name(name)
|
193
|
+
return module + suffix
|
194
|
+
|
195
|
+
@staticmethod
|
196
|
+
def sanitize_method_name(name: str) -> str:
|
197
|
+
"""Convert a raw name into a valid Python method name in snake_case, splitting camelCase and PascalCase."""
|
198
|
+
# Remove curly braces
|
199
|
+
name = re.sub(r"[{}]", "", name)
|
200
|
+
# Split camelCase and PascalCase to snake_case
|
201
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
202
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
203
|
+
# Replace non-alphanumerics with underscores
|
204
|
+
name = re.sub(r"[^0-9a-zA-Z_]", "_", name)
|
205
|
+
# Lowercase and collapse multiple underscores
|
206
|
+
name = re.sub(r"_+", "_", name).strip("_").lower()
|
207
|
+
# If it starts with a digit, prefix with underscore
|
208
|
+
if name and name[0].isdigit():
|
209
|
+
name = "_" + name
|
210
|
+
# Avoid Python keywords and reserved names
|
211
|
+
if keyword.iskeyword(name) or name in NameSanitizer.RESERVED_NAMES:
|
212
|
+
name += "_"
|
213
|
+
return name
|
214
|
+
|
215
|
+
@staticmethod
|
216
|
+
def is_valid_python_identifier(name: str) -> bool:
|
217
|
+
"""Check if a string is a valid Python identifier."""
|
218
|
+
if not isinstance(name, str) or not name:
|
219
|
+
return False
|
220
|
+
# Check if it's a keyword
|
221
|
+
if keyword.iskeyword(name):
|
222
|
+
return False
|
223
|
+
# Check pattern: starts with letter/underscore, then letter/digit/underscore
|
224
|
+
return re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name) is not None
|
225
|
+
|
226
|
+
|
227
|
+
class ParamSubstitutor:
|
228
|
+
"""Helper for rendering path templates with path parameters."""
|
229
|
+
|
230
|
+
@staticmethod
|
231
|
+
def render_path(template: str, values: Dict[str, Any]) -> str:
|
232
|
+
"""Replace placeholders in a URL path template using provided values."""
|
233
|
+
rendered = template
|
234
|
+
for key, val in values.items():
|
235
|
+
rendered = rendered.replace(f"{{{key}}}", str(val))
|
236
|
+
return rendered
|
237
|
+
|
238
|
+
|
239
|
+
class KwargsBuilder:
|
240
|
+
"""Builder for assembling HTTP request keyword arguments."""
|
241
|
+
|
242
|
+
def __init__(self) -> None:
|
243
|
+
self._kwargs: Dict[str, Any] = {}
|
244
|
+
|
245
|
+
def with_params(self, **params: Any) -> "KwargsBuilder":
|
246
|
+
"""Add query parameters, skipping None values."""
|
247
|
+
filtered = {k: v for k, v in params.items() if v is not None}
|
248
|
+
if filtered:
|
249
|
+
self._kwargs["params"] = filtered
|
250
|
+
return self
|
251
|
+
|
252
|
+
def with_json(self, body: Any) -> "KwargsBuilder":
|
253
|
+
"""Add a JSON body to the request."""
|
254
|
+
self._kwargs["json"] = body
|
255
|
+
return self
|
256
|
+
|
257
|
+
def build(self) -> Dict[str, Any]:
|
258
|
+
"""Return the assembled kwargs dictionary."""
|
259
|
+
return self._kwargs
|
260
|
+
|
261
|
+
|
262
|
+
class Formatter:
|
263
|
+
"""Helper to format code using Black, falling back to unformatted content if Black is unavailable or errors."""
|
264
|
+
|
265
|
+
def __init__(self) -> None:
|
266
|
+
try:
|
267
|
+
from black import FileMode, format_str
|
268
|
+
|
269
|
+
# Suppress blib2to3 debug logging that floods output during formatting
|
270
|
+
blib2to3_logger = logging.getLogger("blib2to3")
|
271
|
+
blib2to3_logger.setLevel(logging.WARNING)
|
272
|
+
|
273
|
+
# Also suppress the driver logger specifically
|
274
|
+
driver_logger = logging.getLogger("blib2to3.pgen2.driver")
|
275
|
+
driver_logger.setLevel(logging.WARNING)
|
276
|
+
|
277
|
+
# Initialize Black formatter
|
278
|
+
self._file_mode = FileMode()
|
279
|
+
self._format_str = format_str
|
280
|
+
except ImportError:
|
281
|
+
self._file_mode = None # type: ignore[assignment]
|
282
|
+
self._format_str = None # type: ignore[assignment]
|
283
|
+
|
284
|
+
def format(self, code: str) -> str:
|
285
|
+
"""Format the given code string with Black if possible."""
|
286
|
+
if self._format_str is not None and self._file_mode is not None:
|
287
|
+
try:
|
288
|
+
return self._format_str(code, mode=self._file_mode)
|
289
|
+
except Exception:
|
290
|
+
# On any Black formatting error, return original code
|
291
|
+
return code
|
292
|
+
return code
|
293
|
+
|
294
|
+
|
295
|
+
# --- Casting Helper ---
|
296
|
+
|
297
|
+
|
298
|
+
def safe_cast(expected_type: Type[T], data: Any) -> T:
|
299
|
+
"""
|
300
|
+
Performs a cast for the type checker using object cast.
|
301
|
+
(Validation temporarily removed).
|
302
|
+
"""
|
303
|
+
# No validation for now
|
304
|
+
# Cast to object first, then to expected_type
|
305
|
+
return cast(expected_type, cast(object, data)) # type: ignore[valid-type]
|
306
|
+
|
307
|
+
|
308
|
+
class DataclassSerializer:
|
309
|
+
"""Utility for converting dataclass instances to dictionaries for API serialization.
|
310
|
+
|
311
|
+
This enables automatic conversion of dataclass request bodies to JSON-compatible
|
312
|
+
dictionaries in generated client code, providing a better developer experience.
|
313
|
+
"""
|
314
|
+
|
315
|
+
@staticmethod
|
316
|
+
def serialize(obj: Any) -> Any:
|
317
|
+
"""Convert dataclass instances to dictionaries recursively.
|
318
|
+
|
319
|
+
Args:
|
320
|
+
obj: The object to serialize. Can be a dataclass, list, dict, or primitive.
|
321
|
+
|
322
|
+
Returns:
|
323
|
+
The serialized object with dataclasses converted to dictionaries.
|
324
|
+
|
325
|
+
Handles:
|
326
|
+
- Dataclass instances: Converted to dictionaries
|
327
|
+
- Lists: Recursively serialize each item
|
328
|
+
- Dictionaries: Recursively serialize values
|
329
|
+
- datetime: Convert to ISO format string
|
330
|
+
- Primitives: Return unchanged
|
331
|
+
- None values: Excluded from output
|
332
|
+
"""
|
333
|
+
# Track visited objects to handle circular references
|
334
|
+
return DataclassSerializer._serialize_with_tracking(obj, set())
|
335
|
+
|
336
|
+
@staticmethod
|
337
|
+
def _serialize_with_tracking(obj: Any, visited: Set[int]) -> Any:
|
338
|
+
"""Internal serialization method with circular reference tracking."""
|
339
|
+
|
340
|
+
# Handle None values by excluding them
|
341
|
+
if obj is None:
|
342
|
+
return None
|
343
|
+
|
344
|
+
# Handle circular references
|
345
|
+
obj_id = id(obj)
|
346
|
+
if obj_id in visited:
|
347
|
+
# For circular references, return a simple representation
|
348
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
349
|
+
return f"<Circular reference to {obj.__class__.__name__}>"
|
350
|
+
return obj
|
351
|
+
|
352
|
+
# Handle datetime objects
|
353
|
+
if isinstance(obj, datetime):
|
354
|
+
return obj.isoformat()
|
355
|
+
|
356
|
+
# Handle dataclass instances
|
357
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
358
|
+
visited.add(obj_id)
|
359
|
+
try:
|
360
|
+
result = {}
|
361
|
+
for field in dataclasses.fields(obj):
|
362
|
+
value = getattr(obj, field.name)
|
363
|
+
# Skip None values to keep JSON clean
|
364
|
+
if value is not None:
|
365
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
366
|
+
if serialized_value is not None:
|
367
|
+
result[field.name] = serialized_value
|
368
|
+
return result
|
369
|
+
finally:
|
370
|
+
visited.discard(obj_id)
|
371
|
+
|
372
|
+
# Handle lists and tuples
|
373
|
+
if isinstance(obj, (list, tuple)):
|
374
|
+
return [DataclassSerializer._serialize_with_tracking(item, visited) for item in obj]
|
375
|
+
|
376
|
+
# Handle dictionaries
|
377
|
+
if isinstance(obj, dict):
|
378
|
+
result = {}
|
379
|
+
for key, value in obj.items():
|
380
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
381
|
+
if serialized_value is not None:
|
382
|
+
result[key] = serialized_value
|
383
|
+
return result
|
384
|
+
|
385
|
+
# Handle primitive types and unknown objects
|
386
|
+
if isinstance(obj, (str, int, float, bool)):
|
387
|
+
return obj
|
388
|
+
|
389
|
+
# For unknown types, try to convert to string as fallback
|
390
|
+
try:
|
391
|
+
# If the object has a __dict__, try to serialize it like a dataclass
|
392
|
+
if hasattr(obj, "__dict__"):
|
393
|
+
visited.add(obj_id)
|
394
|
+
try:
|
395
|
+
result = {}
|
396
|
+
for key, value in obj.__dict__.items():
|
397
|
+
if not key.startswith("_"): # Skip private attributes
|
398
|
+
serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
|
399
|
+
if serialized_value is not None:
|
400
|
+
result[key] = serialized_value
|
401
|
+
return result
|
402
|
+
finally:
|
403
|
+
visited.discard(obj_id)
|
404
|
+
else:
|
405
|
+
# Fallback to string representation
|
406
|
+
return str(obj)
|
407
|
+
except Exception:
|
408
|
+
# Ultimate fallback
|
409
|
+
return str(obj)
|
@@ -0,0 +1,83 @@
|
|
1
|
+
"""
|
2
|
+
Warning collector for the IR layer.
|
3
|
+
|
4
|
+
This module provides utilities to collect actionable warnings for incomplete
|
5
|
+
metadata in the IR (Intermediate Representation) objects, such as missing tags,
|
6
|
+
descriptions, or other quality issues in the OpenAPI spec that may lead to
|
7
|
+
suboptimal generated code.
|
8
|
+
"""
|
9
|
+
|
10
|
+
from dataclasses import dataclass
|
11
|
+
from typing import List
|
12
|
+
|
13
|
+
from pyopenapi_gen import IRSpec
|
14
|
+
|
15
|
+
__all__ = ["WarningReport", "WarningCollector"]
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class WarningReport:
|
20
|
+
"""
|
21
|
+
Structured warning with a code, human-readable message, and remediation hint.
|
22
|
+
|
23
|
+
Attributes:
|
24
|
+
code: A machine-readable warning code (e.g., "missing_tags")
|
25
|
+
message: A human-readable description of the warning
|
26
|
+
hint: A suggestion for how to fix or improve the issue
|
27
|
+
"""
|
28
|
+
|
29
|
+
code: str
|
30
|
+
message: str
|
31
|
+
hint: str
|
32
|
+
|
33
|
+
|
34
|
+
class WarningCollector:
|
35
|
+
"""
|
36
|
+
Collects warnings about missing or incomplete information in an IRSpec.
|
37
|
+
|
38
|
+
This class analyzes an IRSpec object and identifies potential issues or
|
39
|
+
missing information that might lead to lower quality generated code or
|
40
|
+
documentation. It provides actionable warnings with hints for improvement.
|
41
|
+
|
42
|
+
Attributes:
|
43
|
+
warnings: List of collected WarningReport objects
|
44
|
+
"""
|
45
|
+
|
46
|
+
def __init__(self) -> None:
|
47
|
+
"""Initialize a new WarningCollector with an empty warning list."""
|
48
|
+
self.warnings: List[WarningReport] = []
|
49
|
+
|
50
|
+
def collect(self, spec: IRSpec) -> List[WarningReport]:
|
51
|
+
"""
|
52
|
+
Analyze an IRSpec and collect warnings about potential issues.
|
53
|
+
|
54
|
+
This method traverses the IRSpec and checks for common issues like
|
55
|
+
missing tags, descriptions, or other metadata that would improve
|
56
|
+
the quality of the generated code.
|
57
|
+
|
58
|
+
Args:
|
59
|
+
spec: The IRSpec object to analyze
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
A list of WarningReport objects describing identified issues
|
63
|
+
"""
|
64
|
+
# Operations without tags
|
65
|
+
for op in spec.operations:
|
66
|
+
if not op.tags:
|
67
|
+
self.warnings.append(
|
68
|
+
WarningReport(
|
69
|
+
code="missing_tags",
|
70
|
+
message=f"Operation '{op.operation_id}' has no tags.",
|
71
|
+
hint="Add tags to operations in the OpenAPI spec.",
|
72
|
+
)
|
73
|
+
)
|
74
|
+
# Missing summary and description
|
75
|
+
if not op.summary and not op.description:
|
76
|
+
self.warnings.append(
|
77
|
+
WarningReport(
|
78
|
+
code="missing_description",
|
79
|
+
message=f"Operation '{op.operation_id}' missing summary/description.",
|
80
|
+
hint="Provide a summary or description for the operation.",
|
81
|
+
)
|
82
|
+
)
|
83
|
+
return self.warnings
|
@@ -0,0 +1,135 @@
|
|
1
|
+
"""
|
2
|
+
CodeWriter: Utility for building indented, well-formatted Python code blocks.
|
3
|
+
|
4
|
+
This module provides the CodeWriter class, which is responsible for managing code indentation,
|
5
|
+
writing lines and blocks, and supporting wrapped output for code and docstrings. It is designed
|
6
|
+
to be used by code generation visitors and emitters to ensure consistent, readable output.
|
7
|
+
"""
|
8
|
+
|
9
|
+
from typing import List, Optional
|
10
|
+
|
11
|
+
from .line_writer import LineWriter
|
12
|
+
|
13
|
+
|
14
|
+
class CodeWriter:
|
15
|
+
"""
|
16
|
+
Utility for writing indented code blocks with support for line wrapping and function signatures.
|
17
|
+
|
18
|
+
Attributes:
|
19
|
+
writer (LineWriter): The LineWriter instance used for writing lines and blocks.
|
20
|
+
"""
|
21
|
+
|
22
|
+
def __init__(self, indent_str: str = " ", max_width: int = 120) -> None:
|
23
|
+
"""
|
24
|
+
Initialize a new CodeWriter.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
indent_str (str): The string to use for one indentation level (default: 4 spaces).
|
28
|
+
max_width (int): The maximum line width for wrapping (default: 120).
|
29
|
+
"""
|
30
|
+
self.writer = LineWriter(indent_str=indent_str, max_width=max_width)
|
31
|
+
|
32
|
+
def write_line(self, line: str = "") -> None:
|
33
|
+
"""
|
34
|
+
Write a single line, respecting the current indentation level.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
line (str): The line to write. Defaults to an empty line.
|
38
|
+
"""
|
39
|
+
self.writer.append(line)
|
40
|
+
self.writer.newline()
|
41
|
+
|
42
|
+
def indent(self) -> None:
|
43
|
+
"""
|
44
|
+
Increase the indentation level by one.
|
45
|
+
"""
|
46
|
+
self.writer.indent()
|
47
|
+
|
48
|
+
def dedent(self) -> None:
|
49
|
+
"""
|
50
|
+
Decrease the indentation level by one (never below zero).
|
51
|
+
"""
|
52
|
+
self.writer.dedent()
|
53
|
+
|
54
|
+
def write_block(self, code: str) -> None:
|
55
|
+
"""
|
56
|
+
Write a multi-line code block using the current indentation level.
|
57
|
+
Each non-empty line is prefixed with the current indentation.
|
58
|
+
Preserves empty lines.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
code (str): The code block to write (may be multiple lines).
|
62
|
+
"""
|
63
|
+
for line in code.splitlines():
|
64
|
+
self.write_line(line)
|
65
|
+
|
66
|
+
def get_code(self) -> str:
|
67
|
+
"""
|
68
|
+
Get the full code as a single string.
|
69
|
+
|
70
|
+
Returns:
|
71
|
+
str: The accumulated code, joined by newlines.
|
72
|
+
"""
|
73
|
+
return self.writer.getvalue().rstrip("\n")
|
74
|
+
|
75
|
+
def write_wrapped_line(self, text: str, width: int = 120) -> None:
|
76
|
+
"""
|
77
|
+
Write a line (or lines) wrapped to the given width, respecting current indentation.
|
78
|
+
|
79
|
+
Args:
|
80
|
+
text (str): The text to write and wrap.
|
81
|
+
width (int): The maximum line width (default: 120).
|
82
|
+
"""
|
83
|
+
# Temporarily set max_width for this operation
|
84
|
+
old_width = self.writer.max_width
|
85
|
+
self.writer.max_width = width
|
86
|
+
self.writer.append_wrapped(text)
|
87
|
+
self.writer.newline()
|
88
|
+
self.writer.max_width = old_width
|
89
|
+
|
90
|
+
def write_function_signature(
|
91
|
+
self, name: str, args: List[str], return_type: Optional[str] = None, async_: bool = False
|
92
|
+
) -> None:
|
93
|
+
"""
|
94
|
+
Write a function or method signature, with each argument on its own line and correct indentation.
|
95
|
+
|
96
|
+
Args:
|
97
|
+
name (str): The function or method name.
|
98
|
+
args (list): The list of argument strings.
|
99
|
+
return_type (str): The return type annotation, if any.
|
100
|
+
async_ (bool): Whether to emit 'async def' (default: False).
|
101
|
+
"""
|
102
|
+
def_prefix = "async def" if async_ else "def"
|
103
|
+
if args:
|
104
|
+
self.write_line(f"{def_prefix} {name}(")
|
105
|
+
self.indent()
|
106
|
+
for arg in args:
|
107
|
+
self.write_line(f"{arg},")
|
108
|
+
self.dedent()
|
109
|
+
if return_type:
|
110
|
+
self.write_line(f") -> {return_type}:")
|
111
|
+
else:
|
112
|
+
self.write_line("):")
|
113
|
+
else:
|
114
|
+
if return_type:
|
115
|
+
self.write_line(f"{def_prefix} {name}(self) -> {return_type}:")
|
116
|
+
else:
|
117
|
+
self.write_line(f"{def_prefix} {name}(self):")
|
118
|
+
|
119
|
+
def write_wrapped_docstring_line(self, prefix: str, text: str, width: int = 88) -> None:
|
120
|
+
"""
|
121
|
+
Write a docstring line (or lines) wrapped to the given width, with wrapped lines
|
122
|
+
indented to align after the prefix (for Args, Returns, etc).
|
123
|
+
|
124
|
+
Args:
|
125
|
+
prefix (str): The prefix for the first line (e.g., 'param (type): ').
|
126
|
+
text (str): The docstring text to wrap.
|
127
|
+
width (int): The maximum line width (default: 88).
|
128
|
+
"""
|
129
|
+
# Temporarily set max_width for this operation
|
130
|
+
old_width = self.writer.max_width
|
131
|
+
self.writer.max_width = width
|
132
|
+
self.writer.append(prefix)
|
133
|
+
self.writer.append_wrapped(text)
|
134
|
+
self.writer.newline()
|
135
|
+
self.writer.max_width = old_width
|