pyopenapi-gen 2.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. pyopenapi_gen/__init__.py +224 -0
  2. pyopenapi_gen/__main__.py +6 -0
  3. pyopenapi_gen/cli.py +62 -0
  4. pyopenapi_gen/context/CLAUDE.md +284 -0
  5. pyopenapi_gen/context/file_manager.py +52 -0
  6. pyopenapi_gen/context/import_collector.py +382 -0
  7. pyopenapi_gen/context/render_context.py +726 -0
  8. pyopenapi_gen/core/CLAUDE.md +224 -0
  9. pyopenapi_gen/core/__init__.py +0 -0
  10. pyopenapi_gen/core/auth/base.py +22 -0
  11. pyopenapi_gen/core/auth/plugins.py +89 -0
  12. pyopenapi_gen/core/cattrs_converter.py +810 -0
  13. pyopenapi_gen/core/exceptions.py +20 -0
  14. pyopenapi_gen/core/http_status_codes.py +218 -0
  15. pyopenapi_gen/core/http_transport.py +222 -0
  16. pyopenapi_gen/core/loader/__init__.py +12 -0
  17. pyopenapi_gen/core/loader/loader.py +174 -0
  18. pyopenapi_gen/core/loader/operations/__init__.py +12 -0
  19. pyopenapi_gen/core/loader/operations/parser.py +161 -0
  20. pyopenapi_gen/core/loader/operations/post_processor.py +62 -0
  21. pyopenapi_gen/core/loader/operations/request_body.py +90 -0
  22. pyopenapi_gen/core/loader/parameters/__init__.py +10 -0
  23. pyopenapi_gen/core/loader/parameters/parser.py +186 -0
  24. pyopenapi_gen/core/loader/responses/__init__.py +10 -0
  25. pyopenapi_gen/core/loader/responses/parser.py +111 -0
  26. pyopenapi_gen/core/loader/schemas/__init__.py +11 -0
  27. pyopenapi_gen/core/loader/schemas/extractor.py +275 -0
  28. pyopenapi_gen/core/pagination.py +64 -0
  29. pyopenapi_gen/core/parsing/__init__.py +13 -0
  30. pyopenapi_gen/core/parsing/common/__init__.py +1 -0
  31. pyopenapi_gen/core/parsing/common/ref_resolution/__init__.py +9 -0
  32. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/__init__.py +0 -0
  33. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/cyclic_properties.py +66 -0
  34. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/direct_cycle.py +33 -0
  35. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/existing_schema.py +22 -0
  36. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/list_response.py +54 -0
  37. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/missing_ref.py +52 -0
  38. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/new_schema.py +50 -0
  39. pyopenapi_gen/core/parsing/common/ref_resolution/helpers/stripped_suffix.py +51 -0
  40. pyopenapi_gen/core/parsing/common/ref_resolution/resolve_schema_ref.py +86 -0
  41. pyopenapi_gen/core/parsing/common/type_parser.py +73 -0
  42. pyopenapi_gen/core/parsing/context.py +187 -0
  43. pyopenapi_gen/core/parsing/cycle_helpers.py +126 -0
  44. pyopenapi_gen/core/parsing/keywords/__init__.py +1 -0
  45. pyopenapi_gen/core/parsing/keywords/all_of_parser.py +81 -0
  46. pyopenapi_gen/core/parsing/keywords/any_of_parser.py +84 -0
  47. pyopenapi_gen/core/parsing/keywords/array_items_parser.py +72 -0
  48. pyopenapi_gen/core/parsing/keywords/one_of_parser.py +77 -0
  49. pyopenapi_gen/core/parsing/keywords/properties_parser.py +98 -0
  50. pyopenapi_gen/core/parsing/schema_finalizer.py +169 -0
  51. pyopenapi_gen/core/parsing/schema_parser.py +804 -0
  52. pyopenapi_gen/core/parsing/transformers/__init__.py +0 -0
  53. pyopenapi_gen/core/parsing/transformers/inline_enum_extractor.py +285 -0
  54. pyopenapi_gen/core/parsing/transformers/inline_object_promoter.py +120 -0
  55. pyopenapi_gen/core/parsing/unified_cycle_detection.py +293 -0
  56. pyopenapi_gen/core/postprocess_manager.py +260 -0
  57. pyopenapi_gen/core/spec_fetcher.py +148 -0
  58. pyopenapi_gen/core/streaming_helpers.py +84 -0
  59. pyopenapi_gen/core/telemetry.py +69 -0
  60. pyopenapi_gen/core/utils.py +456 -0
  61. pyopenapi_gen/core/warning_collector.py +83 -0
  62. pyopenapi_gen/core/writers/code_writer.py +135 -0
  63. pyopenapi_gen/core/writers/documentation_writer.py +222 -0
  64. pyopenapi_gen/core/writers/line_writer.py +217 -0
  65. pyopenapi_gen/core/writers/python_construct_renderer.py +321 -0
  66. pyopenapi_gen/core_package_template/README.md +21 -0
  67. pyopenapi_gen/emit/models_emitter.py +143 -0
  68. pyopenapi_gen/emitters/CLAUDE.md +286 -0
  69. pyopenapi_gen/emitters/client_emitter.py +51 -0
  70. pyopenapi_gen/emitters/core_emitter.py +181 -0
  71. pyopenapi_gen/emitters/docs_emitter.py +44 -0
  72. pyopenapi_gen/emitters/endpoints_emitter.py +247 -0
  73. pyopenapi_gen/emitters/exceptions_emitter.py +187 -0
  74. pyopenapi_gen/emitters/mocks_emitter.py +185 -0
  75. pyopenapi_gen/emitters/models_emitter.py +426 -0
  76. pyopenapi_gen/generator/CLAUDE.md +352 -0
  77. pyopenapi_gen/generator/client_generator.py +567 -0
  78. pyopenapi_gen/generator/exceptions.py +7 -0
  79. pyopenapi_gen/helpers/CLAUDE.md +325 -0
  80. pyopenapi_gen/helpers/__init__.py +1 -0
  81. pyopenapi_gen/helpers/endpoint_utils.py +532 -0
  82. pyopenapi_gen/helpers/type_cleaner.py +334 -0
  83. pyopenapi_gen/helpers/type_helper.py +112 -0
  84. pyopenapi_gen/helpers/type_resolution/__init__.py +1 -0
  85. pyopenapi_gen/helpers/type_resolution/array_resolver.py +57 -0
  86. pyopenapi_gen/helpers/type_resolution/composition_resolver.py +79 -0
  87. pyopenapi_gen/helpers/type_resolution/finalizer.py +105 -0
  88. pyopenapi_gen/helpers/type_resolution/named_resolver.py +172 -0
  89. pyopenapi_gen/helpers/type_resolution/object_resolver.py +216 -0
  90. pyopenapi_gen/helpers/type_resolution/primitive_resolver.py +109 -0
  91. pyopenapi_gen/helpers/type_resolution/resolver.py +47 -0
  92. pyopenapi_gen/helpers/url_utils.py +14 -0
  93. pyopenapi_gen/http_types.py +20 -0
  94. pyopenapi_gen/ir.py +165 -0
  95. pyopenapi_gen/py.typed +1 -0
  96. pyopenapi_gen/types/CLAUDE.md +140 -0
  97. pyopenapi_gen/types/__init__.py +11 -0
  98. pyopenapi_gen/types/contracts/__init__.py +13 -0
  99. pyopenapi_gen/types/contracts/protocols.py +106 -0
  100. pyopenapi_gen/types/contracts/types.py +28 -0
  101. pyopenapi_gen/types/resolvers/__init__.py +7 -0
  102. pyopenapi_gen/types/resolvers/reference_resolver.py +71 -0
  103. pyopenapi_gen/types/resolvers/response_resolver.py +177 -0
  104. pyopenapi_gen/types/resolvers/schema_resolver.py +498 -0
  105. pyopenapi_gen/types/services/__init__.py +5 -0
  106. pyopenapi_gen/types/services/type_service.py +165 -0
  107. pyopenapi_gen/types/strategies/__init__.py +5 -0
  108. pyopenapi_gen/types/strategies/response_strategy.py +310 -0
  109. pyopenapi_gen/visit/CLAUDE.md +272 -0
  110. pyopenapi_gen/visit/client_visitor.py +477 -0
  111. pyopenapi_gen/visit/docs_visitor.py +38 -0
  112. pyopenapi_gen/visit/endpoint/__init__.py +1 -0
  113. pyopenapi_gen/visit/endpoint/endpoint_visitor.py +292 -0
  114. pyopenapi_gen/visit/endpoint/generators/__init__.py +1 -0
  115. pyopenapi_gen/visit/endpoint/generators/docstring_generator.py +123 -0
  116. pyopenapi_gen/visit/endpoint/generators/endpoint_method_generator.py +222 -0
  117. pyopenapi_gen/visit/endpoint/generators/mock_generator.py +140 -0
  118. pyopenapi_gen/visit/endpoint/generators/overload_generator.py +252 -0
  119. pyopenapi_gen/visit/endpoint/generators/request_generator.py +103 -0
  120. pyopenapi_gen/visit/endpoint/generators/response_handler_generator.py +705 -0
  121. pyopenapi_gen/visit/endpoint/generators/signature_generator.py +83 -0
  122. pyopenapi_gen/visit/endpoint/generators/url_args_generator.py +207 -0
  123. pyopenapi_gen/visit/endpoint/processors/__init__.py +1 -0
  124. pyopenapi_gen/visit/endpoint/processors/import_analyzer.py +78 -0
  125. pyopenapi_gen/visit/endpoint/processors/parameter_processor.py +171 -0
  126. pyopenapi_gen/visit/exception_visitor.py +90 -0
  127. pyopenapi_gen/visit/model/__init__.py +0 -0
  128. pyopenapi_gen/visit/model/alias_generator.py +93 -0
  129. pyopenapi_gen/visit/model/dataclass_generator.py +553 -0
  130. pyopenapi_gen/visit/model/enum_generator.py +212 -0
  131. pyopenapi_gen/visit/model/model_visitor.py +198 -0
  132. pyopenapi_gen/visit/visitor.py +97 -0
  133. pyopenapi_gen-2.7.2.dist-info/METADATA +1169 -0
  134. pyopenapi_gen-2.7.2.dist-info/RECORD +137 -0
  135. pyopenapi_gen-2.7.2.dist-info/WHEEL +4 -0
  136. pyopenapi_gen-2.7.2.dist-info/entry_points.txt +2 -0
  137. pyopenapi_gen-2.7.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,84 @@
1
+ import json
2
+ from typing import Any, AsyncIterator, List
3
+
4
+ import httpx
5
+
6
+
7
+ class SSEEvent:
8
+ def __init__(self, data: str, event: str | None = None, id: str | None = None, retry: int | None = None) -> None:
9
+ self.data: str = data
10
+ self.event: str | None = event
11
+ self.id: str | None = id
12
+ self.retry: int | None = retry
13
+
14
+ def __repr__(self) -> str:
15
+ return f"SSEEvent(data={self.data!r}, event={self.event!r}, id={self.id!r}, retry={self.retry!r})"
16
+
17
+
18
+ async def iter_bytes(response: httpx.Response) -> AsyncIterator[bytes]:
19
+ async for chunk in response.aiter_bytes():
20
+ yield chunk
21
+
22
+
23
+ async def iter_ndjson(response: httpx.Response) -> AsyncIterator[Any]:
24
+ async for line in response.aiter_lines():
25
+ line = line.strip()
26
+ if line:
27
+ yield json.loads(line)
28
+
29
+
30
+ async def iter_sse(response: httpx.Response) -> AsyncIterator[SSEEvent]:
31
+ """Parse Server-Sent Events (SSE) from a streaming response."""
32
+ event_lines: list[str] = []
33
+ async for line in response.aiter_lines():
34
+ if line == "":
35
+ # End of event
36
+ if event_lines:
37
+ event = _parse_sse_event(event_lines)
38
+ if event:
39
+ yield event
40
+ event_lines = []
41
+ else:
42
+ event_lines.append(line)
43
+ # Last event (if any)
44
+ if event_lines:
45
+ event = _parse_sse_event(event_lines)
46
+ if event:
47
+ yield event
48
+
49
+
50
+ def _parse_sse_event(lines: List[str]) -> SSEEvent:
51
+ data = []
52
+ event = None
53
+ id = None
54
+ retry = None
55
+ for line in lines:
56
+ if line.startswith(":"):
57
+ continue # comment
58
+ if ":" in line:
59
+ field, value = line.split(":", 1)
60
+ value = value.lstrip()
61
+ if field == "data":
62
+ data.append(value)
63
+ elif field == "event":
64
+ event = value
65
+ elif field == "id":
66
+ id = value
67
+ elif field == "retry":
68
+ try:
69
+ retry = int(value)
70
+ except ValueError:
71
+ pass
72
+ return SSEEvent(data="\n".join(data), event=event, id=id, retry=retry)
73
+
74
+
75
+ async def iter_sse_events_text(response: httpx.Response) -> AsyncIterator[str]:
76
+ """
77
+ Parses a Server-Sent Events (SSE) stream and yields the `data` field content
78
+ as a string for each event.
79
+ This is specifically for cases where the event data is expected to be a
80
+ single text payload (e.g., a JSON string) per event.
81
+ """
82
+ async for sse_event in iter_sse(response):
83
+ if sse_event.data: # Ensure data is not empty
84
+ yield sse_event.data
@@ -0,0 +1,69 @@
1
+ """
2
+ Telemetry client for usage tracking and analytics.
3
+
4
+ This module provides the TelemetryClient class, which handles anonymous
5
+ usage telemetry for PyOpenAPI Generator. Telemetry is opt-in only.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import time
11
+ from typing import Any
12
+
13
+
14
+ class TelemetryClient:
15
+ """
16
+ Client for sending opt-in telemetry events.
17
+
18
+ This class handles emitting usage events to understand how the generator
19
+ is being used. Telemetry is disabled by default and must be explicitly
20
+ enabled either through the PYOPENAPI_TELEMETRY_ENABLED environment
21
+ variable or by passing enabled=True to the constructor.
22
+
23
+ Attributes:
24
+ enabled: Whether telemetry is currently enabled
25
+ """
26
+
27
+ def __init__(self, enabled: bool | None = None) -> None:
28
+ """
29
+ Initialize a new TelemetryClient.
30
+
31
+ Args:
32
+ enabled: Explicitly enable or disable telemetry. If None, the environment
33
+ variable PYOPENAPI_TELEMETRY_ENABLED is checked.
34
+ """
35
+ if enabled is None:
36
+ env = os.getenv("PYOPENAPI_TELEMETRY_ENABLED", "false").lower()
37
+ self.enabled = env in ("1", "true", "yes")
38
+ else:
39
+ self.enabled = enabled
40
+
41
+ def track_event(self, event: str, properties: dict[str, Any] | None = None) -> None:
42
+ """
43
+ Track a telemetry event if telemetry is enabled.
44
+
45
+ This method sends a telemetry event with additional properties.
46
+ Events are silently dropped if telemetry is disabled.
47
+
48
+ Args:
49
+ event: The name of the event to track
50
+ properties: Optional dictionary of additional properties to include
51
+ """
52
+ if not self.enabled:
53
+ return
54
+
55
+ data: dict[str, Any] = {
56
+ "event": event,
57
+ "properties": properties or {},
58
+ "timestamp": time.time(),
59
+ }
60
+
61
+ try:
62
+ # Using print as a stub for actual telemetry transport
63
+ # In production, this would be replaced with a proper telemetry client
64
+ print("TELEMETRY", json.dumps(data))
65
+ except Exception as e:
66
+ # Telemetry failures should not affect execution, but log for debugging
67
+ import logging
68
+
69
+ logging.getLogger(__name__).debug(f"Telemetry event failed: {e}")
@@ -0,0 +1,456 @@
1
+ """Utilities for pyopenapi_gen.
2
+
3
+ This module contains utility classes and functions used across the code generation process.
4
+ """
5
+
6
+ import base64
7
+ import dataclasses
8
+ import keyword
9
+ import logging
10
+ import re
11
+ from datetime import datetime
12
+ from typing import Any, Set, Type, TypeVar, cast
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class NameSanitizer:
20
+ """Helper to sanitize spec names and tags into valid Python identifiers and filenames."""
21
+
22
+ # Python built-ins and common problematic names that should be avoided in module names
23
+ RESERVED_NAMES = {
24
+ # Built-in types
25
+ "type",
26
+ "int",
27
+ "str",
28
+ "float",
29
+ "bool",
30
+ "list",
31
+ "dict",
32
+ "set",
33
+ "tuple",
34
+ "bytes",
35
+ "object",
36
+ "complex",
37
+ "frozenset",
38
+ "bytearray",
39
+ "memoryview",
40
+ "range",
41
+ # Built-in functions
42
+ "abs",
43
+ "all",
44
+ "any",
45
+ "bin",
46
+ "callable",
47
+ "chr",
48
+ "classmethod",
49
+ "compile",
50
+ "delattr",
51
+ "dir",
52
+ "divmod",
53
+ "enumerate",
54
+ "eval",
55
+ "exec",
56
+ "filter",
57
+ "format",
58
+ "getattr",
59
+ "globals",
60
+ "hasattr",
61
+ "hash",
62
+ "help",
63
+ "hex",
64
+ "id",
65
+ "input",
66
+ "isinstance",
67
+ "issubclass",
68
+ "iter",
69
+ "len",
70
+ "locals",
71
+ "map",
72
+ "max",
73
+ "min",
74
+ "next",
75
+ "oct",
76
+ "open",
77
+ "ord",
78
+ "pow",
79
+ "print",
80
+ "property",
81
+ "repr",
82
+ "reversed",
83
+ "round",
84
+ "setattr",
85
+ "slice",
86
+ "sorted",
87
+ "staticmethod",
88
+ "sum",
89
+ "super",
90
+ "vars",
91
+ "zip",
92
+ # Common standard library modules
93
+ "os",
94
+ "sys",
95
+ "json",
96
+ "time",
97
+ "datetime",
98
+ "math",
99
+ "random",
100
+ "string",
101
+ "collections",
102
+ "itertools",
103
+ "functools",
104
+ "typing",
105
+ "pathlib",
106
+ "logging",
107
+ "urllib",
108
+ "http",
109
+ "email",
110
+ "uuid",
111
+ "hashlib",
112
+ "base64",
113
+ "copy",
114
+ "re",
115
+ # Other problematic names
116
+ "data",
117
+ "model",
118
+ "models",
119
+ "client",
120
+ "api",
121
+ "config",
122
+ "utils",
123
+ "helpers",
124
+ }
125
+
126
+ @staticmethod
127
+ def sanitize_module_name(name: str) -> str:
128
+ """Convert a raw name into a valid Python module name in snake_case, splitting camel case and PascalCase."""
129
+ # # <<< Add Check for problematic input >>>
130
+ # if '[' in name or ']' in name or ',' in name:
131
+ # logger.error(f"sanitize_module_name received potentially invalid input: '{name}'")
132
+ # # Optionally, return a default/error value or raise exception
133
+ # # For now, just log and continue
134
+ # # <<< End Check >>>
135
+
136
+ # Split on non-alphanumeric and camel case boundaries
137
+ words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
138
+ if not words:
139
+ # fallback: split on non-alphanumerics
140
+ words = re.split(r"\W+", name)
141
+ module = "_".join(word.lower() for word in words if word)
142
+ # If it starts with a digit, prefix with underscore
143
+ if module and module[0].isdigit():
144
+ module = "_" + module
145
+ # Avoid Python keywords and reserved names
146
+ if keyword.iskeyword(module) or module in NameSanitizer.RESERVED_NAMES:
147
+ module += "_"
148
+ return module
149
+
150
+ @staticmethod
151
+ def sanitize_class_name(name: str) -> str:
152
+ """Convert a raw name into a valid Python class name in PascalCase."""
153
+ # Split on non-alphanumeric and camel case boundaries
154
+ words = re.findall(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+", name)
155
+ if not words: # Fallback if findall is empty (e.g. if name was all symbols)
156
+ # Basic split on non-alphanumeric as a last resort if findall yields nothing
157
+ words = [part for part in re.split(r"[^a-zA-Z0-9]+", name) if part]
158
+
159
+ # Capitalize each word and join
160
+ cls_name = "".join(word.capitalize() for word in words if word)
161
+
162
+ if not cls_name: # If name was e.g. "-" or "_"
163
+ cls_name = "UnnamedClass" # Or some other default
164
+
165
+ # If it starts with a digit, prefix with underscore
166
+ if cls_name[0].isdigit(): # Check after ensuring cls_name is not empty
167
+ cls_name = "_" + cls_name
168
+ # Avoid Python keywords and reserved names (case-insensitive)
169
+ if keyword.iskeyword(cls_name.lower()) or cls_name.lower() in NameSanitizer.RESERVED_NAMES:
170
+ cls_name += "_"
171
+ return cls_name
172
+
173
+ @staticmethod
174
+ def sanitize_tag_class_name(tag: str) -> str:
175
+ """Sanitize a tag for use as a PascalCase client class name (e.g., DataSourcesClient)."""
176
+ words = re.split(r"[\W_]+", tag)
177
+ return "".join(word.capitalize() for word in words if word) + "Client"
178
+
179
+ @staticmethod
180
+ def sanitize_tag_attr_name(tag: str) -> str:
181
+ """Sanitize a tag for use as a snake_case attribute name (e.g., data_sources)."""
182
+ attr = re.sub(r"[\W]+", "_", tag).lower()
183
+ return attr.strip("_")
184
+
185
+ @staticmethod
186
+ def normalize_tag_key(tag: str) -> str:
187
+ """Normalize a tag for case-insensitive uniqueness (e.g., datasources)."""
188
+ return re.sub(r"[\W_]+", "", tag).lower()
189
+
190
+ @staticmethod
191
+ def sanitize_filename(name: str, suffix: str = ".py") -> str:
192
+ """Generate a valid Python filename from raw name in snake_case."""
193
+ module = NameSanitizer.sanitize_module_name(name)
194
+ return module + suffix
195
+
196
+ @staticmethod
197
+ def sanitize_method_name(name: str) -> str:
198
+ """Convert a raw name into a valid Python method name in snake_case, splitting camelCase and PascalCase."""
199
+ # Remove curly braces
200
+ name = re.sub(r"[{}]", "", name)
201
+ # Split camelCase and PascalCase to snake_case
202
+ name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
203
+ name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
204
+ # Replace non-alphanumerics with underscores
205
+ name = re.sub(r"[^0-9a-zA-Z_]", "_", name)
206
+ # Lowercase and collapse multiple underscores
207
+ name = re.sub(r"_+", "_", name).strip("_").lower()
208
+ # If it starts with a digit, prefix with underscore
209
+ if name and name[0].isdigit():
210
+ name = "_" + name
211
+ # Avoid Python keywords and reserved names
212
+ if keyword.iskeyword(name) or name in NameSanitizer.RESERVED_NAMES:
213
+ name += "_"
214
+ return name
215
+
216
+ @staticmethod
217
+ def is_valid_python_identifier(name: str) -> bool:
218
+ """Check if a string is a valid Python identifier."""
219
+ if not isinstance(name, str) or not name:
220
+ return False
221
+ # Check if it's a keyword
222
+ if keyword.iskeyword(name):
223
+ return False
224
+ # Check pattern: starts with letter/underscore, then letter/digit/underscore
225
+ return re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name) is not None
226
+
227
+
228
+ class ParamSubstitutor:
229
+ """Helper for rendering path templates with path parameters."""
230
+
231
+ @staticmethod
232
+ def render_path(template: str, values: dict[str, Any]) -> str:
233
+ """Replace placeholders in a URL path template using provided values."""
234
+ rendered = template
235
+ for key, val in values.items():
236
+ rendered = rendered.replace(f"{{{key}}}", str(val))
237
+ return rendered
238
+
239
+
240
+ class KwargsBuilder:
241
+ """Builder for assembling HTTP request keyword arguments."""
242
+
243
+ def __init__(self) -> None:
244
+ self._kwargs: dict[str, Any] = {}
245
+
246
+ def with_params(self, **params: Any) -> "KwargsBuilder":
247
+ """Add query parameters, skipping None values."""
248
+ filtered = {k: v for k, v in params.items() if v is not None}
249
+ if filtered:
250
+ self._kwargs["params"] = filtered
251
+ return self
252
+
253
+ def with_json(self, body: Any) -> "KwargsBuilder":
254
+ """Add a JSON body to the request."""
255
+ self._kwargs["json"] = body
256
+ return self
257
+
258
+ def build(self) -> dict[str, Any]:
259
+ """Return the assembled kwargs dictionary."""
260
+ return self._kwargs
261
+
262
+
263
+ class Formatter:
264
+ """Helper to format code using Black, falling back to unformatted content if Black is unavailable or errors."""
265
+
266
+ def __init__(self) -> None:
267
+ from typing import Any, Callable
268
+
269
+ self._file_mode: Any | None = None
270
+ self._format_str: Callable[..., str] | None = None
271
+ try:
272
+ from black import FileMode, format_str
273
+
274
+ # Suppress blib2to3 debug logging that floods output during formatting
275
+ blib2to3_logger = logging.getLogger("blib2to3")
276
+ blib2to3_logger.setLevel(logging.WARNING)
277
+
278
+ # Also suppress the driver logger specifically
279
+ driver_logger = logging.getLogger("blib2to3.pgen2.driver")
280
+ driver_logger.setLevel(logging.WARNING)
281
+
282
+ # Initialize Black formatter
283
+ self._file_mode = FileMode()
284
+ self._format_str = format_str
285
+ except ImportError:
286
+ self._file_mode = None
287
+ self._format_str = None
288
+
289
+ def format(self, code: str) -> str:
290
+ """Format the given code string with Black if possible."""
291
+ if self._format_str is not None and self._file_mode is not None:
292
+ try:
293
+ formatted: str = self._format_str(code, mode=self._file_mode)
294
+ return formatted
295
+ except Exception:
296
+ # On any Black formatting error, return original code
297
+ return code
298
+ return code
299
+
300
+
301
+ # --- Casting Helper ---
302
+
303
+
304
+ def safe_cast(expected_type: Type[T], data: Any) -> T:
305
+ """
306
+ Performs a cast for the type checker using object cast.
307
+ (Validation temporarily removed).
308
+ """
309
+ # No validation for now
310
+ # Cast to object first, then to expected_type
311
+ return cast(expected_type, cast(object, data)) # type: ignore[valid-type]
312
+
313
+
314
+ class DataclassSerializer:
315
+ """Utility for converting dataclass instances to dictionaries for API serialization.
316
+
317
+ This enables automatic conversion of dataclass request bodies to JSON-compatible
318
+ dictionaries in generated client code, providing a better developer experience.
319
+ """
320
+
321
+ @staticmethod
322
+ def serialize(obj: Any) -> Any:
323
+ """Convert dataclass instances to dictionaries recursively.
324
+
325
+ Args:
326
+ obj: The object to serialize. Can be a dataclass, list, dict, or primitive.
327
+
328
+ Returns:
329
+ The serialized object with dataclasses converted to dictionaries.
330
+
331
+ Handles:
332
+ - Dataclass instances with Meta.key_transform_with_dump: Applies field name mapping (snake_case → camelCase)
333
+ - Legacy BaseSchema instances: Falls back to to_dict() if present (backward compatibility)
334
+ - Regular dataclass instances: Converted to dictionaries using field names
335
+ - Lists: Recursively serialize each item
336
+ - Dictionaries: Recursively serialize values
337
+ - datetime: Convert to ISO format string
338
+ - Enums: Convert to their value
339
+ - bytes/bytearray: Convert to base64-encoded ASCII string
340
+ - Primitives: Return unchanged
341
+ - None values: Excluded from output
342
+ """
343
+ # Track visited objects to handle circular references
344
+ return DataclassSerializer._serialize_with_tracking(obj, set())
345
+
346
+ @staticmethod
347
+ def _serialize_with_tracking(obj: Any, visited: Set[int]) -> Any:
348
+ """Internal serialization method with circular reference tracking."""
349
+ from enum import Enum
350
+
351
+ # Handle None values by excluding them
352
+ if obj is None:
353
+ return None
354
+
355
+ # Handle circular references
356
+ obj_id = id(obj)
357
+ if obj_id in visited:
358
+ # For circular references, return a simple representation
359
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
360
+ return f"<Circular reference to {obj.__class__.__name__}>"
361
+ return obj
362
+
363
+ # Handle datetime objects
364
+ if isinstance(obj, datetime):
365
+ return obj.isoformat()
366
+
367
+ # Handle enum instances
368
+ if isinstance(obj, Enum) and not isinstance(obj, type):
369
+ return obj.value
370
+
371
+ # Handle legacy BaseSchema instances (backward compatibility)
372
+ # Check for BaseSchema by looking for both to_dict and _get_field_mappings methods
373
+ if hasattr(obj, "to_dict") and hasattr(obj, "_get_field_mappings") and callable(obj.to_dict):
374
+ visited.add(obj_id)
375
+ try:
376
+ # Use legacy BaseSchema's to_dict() which handles field name mapping
377
+ result_dict = obj.to_dict(exclude_none=True)
378
+ # Recursively serialize nested objects in the result
379
+ serialized_result = {}
380
+ for key, value in result_dict.items():
381
+ serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
382
+ if serialized_value is not None:
383
+ serialized_result[key] = serialized_value
384
+ return serialized_result
385
+ finally:
386
+ visited.discard(obj_id)
387
+
388
+ # Handle regular dataclass instances (with cattrs field mapping support)
389
+ if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
390
+ visited.add(obj_id)
391
+ try:
392
+ # Build dict from dataclass fields without recursive unstructuring
393
+ data: dict[str, Any] = {}
394
+ for field in dataclasses.fields(obj):
395
+ value = getattr(obj, field.name)
396
+ data[field.name] = value
397
+
398
+ # Apply field name mapping if Meta class exists
399
+ if hasattr(obj, "Meta") and hasattr(obj.Meta, "key_transform_with_dump"):
400
+ mappings = obj.Meta.key_transform_with_dump
401
+ # Transform keys according to mapping
402
+ data = {mappings.get(k, k): v for k, v in data.items()}
403
+
404
+ # Recursively serialize nested objects and skip None values
405
+ result = {}
406
+ for key, value in data.items():
407
+ if value is not None:
408
+ serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
409
+ if serialized_value is not None:
410
+ result[key] = serialized_value
411
+ return result
412
+ finally:
413
+ visited.discard(obj_id)
414
+
415
+ # Handle lists and tuples
416
+ if isinstance(obj, (list, tuple)):
417
+ return [DataclassSerializer._serialize_with_tracking(item, visited) for item in obj]
418
+
419
+ # Handle dictionaries
420
+ if isinstance(obj, dict):
421
+ result = {}
422
+ for key, value in obj.items():
423
+ serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
424
+ if serialized_value is not None:
425
+ result[key] = serialized_value
426
+ return result
427
+
428
+ # Handle primitive types and unknown objects
429
+ if isinstance(obj, (str, int, float, bool)):
430
+ return obj
431
+
432
+ # Handle bytes - encode as base64 for JSON serialization
433
+ if isinstance(obj, (bytes, bytearray)):
434
+ return base64.b64encode(bytes(obj)).decode("ascii")
435
+
436
+ # For unknown types, try to convert to string as fallback
437
+ try:
438
+ # If the object has a __dict__, try to serialize it like a dataclass
439
+ if hasattr(obj, "__dict__"):
440
+ visited.add(obj_id)
441
+ try:
442
+ result = {}
443
+ for key, value in obj.__dict__.items():
444
+ if not key.startswith("_"): # Skip private attributes
445
+ serialized_value = DataclassSerializer._serialize_with_tracking(value, visited)
446
+ if serialized_value is not None:
447
+ result[key] = serialized_value
448
+ return result
449
+ finally:
450
+ visited.discard(obj_id)
451
+ else:
452
+ # Fallback to string representation
453
+ return str(obj)
454
+ except Exception:
455
+ # Ultimate fallback
456
+ 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