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,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