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,477 @@
1
+ import logging # Added for logging
2
+ import re
3
+ import textwrap
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ from pyopenapi_gen import IRSpec
7
+
8
+ from ..context.render_context import RenderContext
9
+ from ..core.utils import NameSanitizer
10
+ from ..core.writers.code_writer import CodeWriter
11
+ from ..core.writers.documentation_writer import DocumentationBlock, DocumentationWriter
12
+
13
+ if TYPE_CHECKING:
14
+ # To prevent circular imports if any type from core itself is needed for hints
15
+ pass
16
+
17
+ logger = logging.getLogger(__name__) # Added for logging
18
+
19
+
20
+ class ClientVisitor:
21
+ """Visitor for rendering the Python API client from IRSpec."""
22
+
23
+ def __init__(self) -> None:
24
+ pass
25
+
26
+ def visit(self, spec: IRSpec, context: RenderContext) -> str:
27
+ # Step 1: Process tags and build tag_tuples
28
+ tag_candidates: dict[str, list[str]] = {}
29
+ for op in spec.operations:
30
+ # Use DEFAULT_TAG consistent with EndpointsEmitter
31
+ tags = op.tags or ["default"] # Use literal "default" here
32
+ # Loop through the determined tags (original or default)
33
+ for tag in tags:
34
+ key = NameSanitizer.normalize_tag_key(tag)
35
+ if key not in tag_candidates:
36
+ tag_candidates[key] = []
37
+ tag_candidates[key].append(tag)
38
+ # Ensure the old logic is removed (idempotent if already gone)
39
+ # if op.tags:
40
+ # ...
41
+ # else:
42
+ # ...
43
+
44
+ def tag_score(t: str) -> tuple[bool, int, int, str]:
45
+ is_pascal = bool(re.search(r"[a-z][A-Z]", t)) or bool(re.search(r"[A-Z]{2,}", t))
46
+ words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+", t)
47
+ words += re.split(r"[_-]+", t)
48
+ word_count = len([w for w in words if w])
49
+ upper = sum(1 for c in t if c.isupper())
50
+ return (is_pascal, word_count, upper, t)
51
+
52
+ tag_map = {}
53
+ for key, candidates in tag_candidates.items():
54
+ best = max(candidates, key=tag_score)
55
+ tag_map[key] = best
56
+ tag_tuples = [
57
+ (
58
+ tag_map[key],
59
+ NameSanitizer.sanitize_class_name(tag_map[key]) + "Client",
60
+ NameSanitizer.sanitize_module_name(tag_map[key]),
61
+ )
62
+ for key in sorted(tag_map)
63
+ ]
64
+
65
+ # Step 2: Generate Protocol definition
66
+ protocol_code = self.generate_client_protocol(spec, context, tag_tuples)
67
+
68
+ # Step 3: Generate implementation class
69
+ impl_code = self._generate_client_implementation(spec, context, tag_tuples)
70
+
71
+ # Step 4: Combine Protocol and implementation
72
+ return f"{protocol_code}\n\n\n{impl_code}"
73
+
74
+ def _generate_client_implementation(
75
+ self, spec: IRSpec, context: RenderContext, tag_tuples: list[tuple[str, str, str]]
76
+ ) -> str:
77
+ """
78
+ Generate the APIClient implementation class.
79
+
80
+ Args:
81
+ spec: The IR specification
82
+ context: Render context for import management
83
+ tag_tuples: List of (tag_name, class_name, module_name) tuples
84
+
85
+ Returns:
86
+ Implementation class code as string
87
+ """
88
+ writer = CodeWriter()
89
+ # Register all endpoint client imports using relative imports (endpoints are within the same package)
90
+ for _, class_name, module_name in tag_tuples:
91
+ # Use relative imports for endpoints since they're part of the same generated package
92
+ # Import both the implementation and the Protocol
93
+ protocol_name = f"{class_name}Protocol"
94
+ context.import_collector.add_relative_import(f".endpoints.{module_name}", class_name)
95
+ context.import_collector.add_relative_import(f".endpoints.{module_name}", protocol_name)
96
+
97
+ # Register core/config/typing imports for class signature
98
+ # Use LOGICAL import path for core components
99
+
100
+ # Use the core_package name from the context to form the base of the import path
101
+ # RenderContext.add_import will handle making it relative correctly based on the current file.
102
+ context.add_import(f"{context.core_package_name}.http_transport", "HttpTransport")
103
+ context.add_import(f"{context.core_package_name}.http_transport", "HttpxTransport")
104
+ context.add_import(f"{context.core_package_name}.config", "ClientConfig")
105
+ # If security schemes are present and an auth plugin like ApiKeyAuth is used by the client itself,
106
+ # it would also be registered here using context.core_package.
107
+ # For now, the client_py_content check in the test looks for this for ApiKeyAuth specifically:
108
+ context.add_import(f"{context.core_package_name}.auth.plugins", "ApiKeyAuth")
109
+
110
+ context.add_typing_imports_for_type("HttpTransport | None")
111
+ context.add_typing_imports_for_type("Any")
112
+ context.add_typing_imports_for_type("Dict")
113
+ # Class definition - implements Protocol
114
+ writer.write_line("class APIClient(APIClientProtocol):")
115
+ writer.indent()
116
+ # Build docstring for APIClient
117
+ docstring_lines = []
118
+ # Add API title and version
119
+ docstring_lines.append(f"{spec.title} (version {spec.version})")
120
+ # Add API description if present
121
+ if getattr(spec, "description", None):
122
+ desc = spec.description
123
+ if desc is not None:
124
+ # Remove triple quotes, escape backslashes, and dedent
125
+ desc_clean = desc.replace('"""', "'").replace("'''", "'").replace("\\", "\\\\").strip()
126
+ desc_clean = textwrap.dedent(desc_clean)
127
+ docstring_lines.append("")
128
+ docstring_lines.append(desc_clean)
129
+ # Add a blank line before the generated summary/args
130
+ docstring_lines.append("")
131
+ summary = "Async API client with pluggable transport, tag-specific clients, and client-level headers."
132
+ args: list[tuple[str, str, str]] = [
133
+ ("config", "ClientConfig", "Client configuration object."),
134
+ ("transport", "HttpTransport | None", "Custom HTTP transport (optional)."),
135
+ ]
136
+ for tag, class_name, module_name in tag_tuples:
137
+ args.append((module_name, class_name, f"Client for '{tag}' endpoints."))
138
+ doc_block = DocumentationBlock(
139
+ summary=summary,
140
+ args=cast(list[tuple[str, str, str] | tuple[str, str]], args),
141
+ )
142
+ docstring = DocumentationWriter(width=88).render_docstring(doc_block, indent=0)
143
+ docstring_lines.extend([line for line in docstring.splitlines()])
144
+ # Write only one docstring, no extra triple quotes after
145
+ writer.write_line('"""') # At class indent (1)
146
+ writer.dedent() # Go to indent 0 for docstring content
147
+ for line in docstring_lines:
148
+ writer.write_line(line.rstrip('"'))
149
+ writer.indent() # Back to class indent (1)
150
+ writer.write_line('"""')
151
+ # __init__
152
+ writer.write_line("def __init__(self, config: ClientConfig, transport: HttpTransport | None = None) -> None:")
153
+ writer.indent()
154
+ writer.write_line("self.config = config")
155
+ writer.write_line(
156
+ "self.transport = transport if transport is not None else "
157
+ "HttpxTransport(str(config.base_url), config.timeout)"
158
+ )
159
+ writer.write_line("self._base_url: str = str(self.config.base_url)")
160
+ # Initialize private fields for each tag client
161
+ for tag, class_name, module_name in tag_tuples:
162
+ context.add_typing_imports_for_type(f"{class_name} | None")
163
+ writer.write_line(f"self._{module_name}: {class_name} | None = None")
164
+ writer.dedent()
165
+ writer.write_line("")
166
+ # @property for each tag client
167
+ for tag, class_name, module_name in tag_tuples:
168
+ writer.write_line(f"@property")
169
+ # Use context.add_import here too
170
+ current_gen_pkg_name_prop = context.get_current_package_name_for_generated_code()
171
+ if not current_gen_pkg_name_prop:
172
+ logger.error(
173
+ f"[ClientVisitor Property] Could not determine generated package name from context. "
174
+ f"Cannot form fully qualified import for endpoints.{module_name}.{class_name}"
175
+ )
176
+ # Fallback or raise error
177
+ context.add_import(f"endpoints.{module_name}", class_name)
178
+ else:
179
+ logical_module_for_add_import_prop = f"{current_gen_pkg_name_prop}.endpoints.{module_name}"
180
+ context.add_import(logical_module_for_add_import_prop, class_name)
181
+
182
+ writer.write_line(f"def {module_name}(self) -> {class_name}:")
183
+ writer.indent()
184
+ writer.write_line(f'"""Client for \'{tag}\' endpoints."""')
185
+ writer.write_line(f"if self._{module_name} is None:")
186
+ writer.indent()
187
+ writer.write_line(f"self._{module_name} = {class_name}(self.transport, self._base_url)")
188
+ writer.dedent()
189
+ writer.write_line(f"return self._{module_name}")
190
+ writer.dedent()
191
+ writer.write_line("")
192
+ # request method
193
+ context.add_typing_imports_for_type("Any")
194
+ writer.write_line("async def request(self, method: str, url: str, **kwargs: Any) -> Any:")
195
+ writer.indent()
196
+ writer.write_line('"""Send an HTTP request via the transport."""')
197
+ writer.write_line("return await self.transport.request(method, url, **kwargs)")
198
+ writer.dedent()
199
+ writer.write_line("")
200
+ # close method
201
+ context.add_typing_imports_for_type("None")
202
+ writer.write_line("async def close(self) -> None:")
203
+ writer.indent()
204
+ writer.write_line('"""Close the underlying transport if supported."""')
205
+ writer.write_line("if hasattr(self.transport, 'close'):")
206
+ writer.indent()
207
+ writer.write_line("await self.transport.close()")
208
+ writer.dedent()
209
+ writer.write_line("else:")
210
+ writer.indent()
211
+ writer.write_line("pass # Or log a warning if close is expected but not found")
212
+ writer.dedent()
213
+ writer.dedent()
214
+ writer.write_line("")
215
+ # __aenter__ for async context management (dedented)
216
+ writer.write_line("async def __aenter__(self) -> 'APIClient':")
217
+ writer.indent()
218
+ writer.write_line('"""Enter the async context manager. Returns self."""')
219
+ writer.write_line("if hasattr(self.transport, '__aenter__'):")
220
+ writer.indent()
221
+ writer.write_line("await self.transport.__aenter__()")
222
+ writer.dedent()
223
+ writer.write_line("return self")
224
+ writer.dedent()
225
+ writer.write_line("")
226
+ # __aexit__ for async context management (dedented)
227
+ context.add_typing_imports_for_type("type[BaseException] | None")
228
+ context.add_typing_imports_for_type("BaseException | None")
229
+ context.add_typing_imports_for_type("object | None")
230
+ writer.write_line(
231
+ "async def __aexit__(self, exc_type: type[BaseException] | None, "
232
+ "exc_val: BaseException | None, exc_tb: object | None) -> None:"
233
+ )
234
+ writer.indent()
235
+ writer.write_line('"""Exit the async context manager, ensuring transport is closed."""')
236
+ # Close internal transport if it supports __aexit__
237
+ writer.write_line("if hasattr(self.transport, '__aexit__'):")
238
+ writer.indent()
239
+ writer.write_line("await self.transport.__aexit__(exc_type, exc_val, exc_tb)")
240
+ writer.dedent()
241
+ writer.write_line("else:") # Fallback if transport doesn't have __aexit__ but has close()
242
+ writer.indent()
243
+ writer.write_line("await self.close()")
244
+ writer.dedent()
245
+ writer.dedent()
246
+ writer.write_line("")
247
+
248
+ # Ensure crucial core imports are present before final rendering
249
+ # This is a fallback / re-emphasis due to potential issues with ImportCollector
250
+ context.add_import(f"{context.core_package_name}.http_transport", "HttpTransport")
251
+ context.add_import(f"{context.core_package_name}.http_transport", "HttpxTransport")
252
+ context.add_import(f"{context.core_package_name}.config", "ClientConfig")
253
+
254
+ return writer.get_code()
255
+
256
+ def generate_client_protocol(
257
+ self, spec: IRSpec, context: RenderContext, tag_tuples: list[tuple[str, str, str]]
258
+ ) -> str:
259
+ """
260
+ Generate APIClientProtocol defining the client interface.
261
+
262
+ Args:
263
+ spec: The IR specification
264
+ context: Render context for import management
265
+ tag_tuples: List of (tag_name, class_name, module_name) tuples
266
+
267
+ Returns:
268
+ Protocol class code as string with:
269
+ - Tag-based endpoint properties
270
+ - Standard methods (request, close, __aenter__, __aexit__)
271
+ """
272
+ # Register Protocol imports
273
+ context.add_typing_imports_for_type("Protocol")
274
+ context.add_import("typing", "runtime_checkable")
275
+ context.add_typing_imports_for_type("Any")
276
+
277
+ writer = CodeWriter()
278
+
279
+ # Protocol class header
280
+ writer.write_line("@runtime_checkable")
281
+ writer.write_line("class APIClientProtocol(Protocol):")
282
+ writer.indent()
283
+
284
+ # Docstring
285
+ writer.write_line('"""Protocol defining the interface of APIClient for dependency injection."""')
286
+ writer.write_line("")
287
+
288
+ # Tag-based endpoint properties
289
+ for tag, class_name, module_name in tag_tuples:
290
+ # Use forward reference for tag client protocol
291
+ protocol_name = f"{class_name}Protocol"
292
+ writer.write_line("@property")
293
+ writer.write_line(f"def {module_name}(self) -> '{protocol_name}':")
294
+ writer.indent()
295
+ writer.write_line("...")
296
+ writer.dedent()
297
+ writer.write_line("")
298
+
299
+ # Standard methods
300
+ # request method
301
+ writer.write_line("async def request(self, method: str, url: str, **kwargs: Any) -> Any:")
302
+ writer.indent()
303
+ writer.write_line("...")
304
+ writer.dedent()
305
+ writer.write_line("")
306
+
307
+ # close method
308
+ writer.write_line("async def close(self) -> None:")
309
+ writer.indent()
310
+ writer.write_line("...")
311
+ writer.dedent()
312
+ writer.write_line("")
313
+
314
+ # __aenter__ method
315
+ writer.write_line("async def __aenter__(self) -> 'APIClientProtocol':")
316
+ writer.indent()
317
+ writer.write_line("...")
318
+ writer.dedent()
319
+ writer.write_line("")
320
+
321
+ # __aexit__ method
322
+ context.add_typing_imports_for_type("type[BaseException] | None")
323
+ context.add_typing_imports_for_type("BaseException | None")
324
+ context.add_typing_imports_for_type("object | None")
325
+ writer.write_line(
326
+ "async def __aexit__(self, exc_type: type[BaseException] | None, "
327
+ "exc_val: BaseException | None, exc_tb: object | None) -> None:"
328
+ )
329
+ writer.indent()
330
+ writer.write_line("...")
331
+ writer.dedent()
332
+
333
+ writer.dedent() # Close class
334
+ return writer.get_code()
335
+
336
+ def generate_client_mock_class(
337
+ self, spec: IRSpec, context: RenderContext, tag_tuples: list[tuple[str, str, str]]
338
+ ) -> str:
339
+ """
340
+ Generate MockAPIClient for testing.
341
+
342
+ Args:
343
+ spec: The IR specification
344
+ context: Render context for import management
345
+ tag_tuples: List of (tag, class_name, module_name) tuples
346
+
347
+ Returns:
348
+ Mock client class code as string
349
+ """
350
+ # Import TYPE_CHECKING for Protocol imports
351
+ context.add_import("typing", "TYPE_CHECKING")
352
+ context.add_import("typing", "Any")
353
+
354
+ writer = CodeWriter()
355
+
356
+ # TYPE_CHECKING imports
357
+ writer.write_line("if TYPE_CHECKING:")
358
+ writer.indent()
359
+ writer.write_line("from ..client import APIClientProtocol")
360
+ for tag, class_name, module_name in tag_tuples:
361
+ protocol_name = f"{class_name}Protocol"
362
+ writer.write_line(f"from ..endpoints.{module_name} import {protocol_name}")
363
+ writer.dedent()
364
+ writer.write_line("")
365
+
366
+ # Import mock endpoint classes
367
+ for tag, class_name, module_name in tag_tuples:
368
+ mock_class_name = f"Mock{class_name}"
369
+ writer.write_line(f"from .endpoints.mock_{module_name} import {mock_class_name}")
370
+ writer.write_line("")
371
+
372
+ # Class definition
373
+ writer.write_line("class MockAPIClient:")
374
+ writer.indent()
375
+
376
+ # Docstring
377
+ writer.write_line('"""')
378
+ writer.write_line("Mock implementation of APIClient for testing.")
379
+ writer.write_line("")
380
+ writer.write_line("Auto-creates default mock implementations for all tag-based endpoint clients.")
381
+ writer.write_line("You can override specific tag clients by passing them to the constructor.")
382
+ writer.write_line("")
383
+ writer.write_line("Example:")
384
+ writer.write_line(" # Use all defaults")
385
+ writer.write_line(" client = MockAPIClient()")
386
+ writer.write_line("")
387
+ writer.write_line(" # Override specific tag client")
388
+ for tag, class_name, module_name in tag_tuples[:1]: # Show example with first tag
389
+ mock_class_name = f"Mock{class_name}"
390
+ writer.write_line(f" class My{class_name}Mock({mock_class_name}):")
391
+ writer.write_line(" async def method_name(self, ...) -> ReturnType:")
392
+ writer.write_line(" return test_data")
393
+ writer.write_line("")
394
+ writer.write_line(f" client = MockAPIClient({module_name}=My{class_name}Mock())")
395
+ break
396
+ writer.write_line('"""')
397
+ writer.write_line("")
398
+
399
+ # Constructor
400
+ writer.write_line("def __init__(")
401
+ writer.indent()
402
+ writer.write_line("self,")
403
+ for tag, class_name, module_name in tag_tuples:
404
+ protocol_name = f"{class_name}Protocol"
405
+ writer.write_line(f'{module_name}: "{protocol_name} | None" = None,')
406
+ writer.dedent()
407
+ writer.write_line(") -> None:")
408
+ writer.indent()
409
+
410
+ # Initialize tag clients
411
+ for tag, class_name, module_name in tag_tuples:
412
+ mock_class_name = f"Mock{class_name}"
413
+ writer.write_line(
414
+ f"self._{module_name} = {module_name} if {module_name} is not None else {mock_class_name}()"
415
+ )
416
+ writer.dedent()
417
+ writer.write_line("")
418
+
419
+ # Properties for tag clients
420
+ for tag, class_name, module_name in tag_tuples:
421
+ protocol_name = f"{class_name}Protocol"
422
+ writer.write_line("@property")
423
+ writer.write_line(f'def {module_name}(self) -> "{protocol_name}":')
424
+ writer.indent()
425
+ writer.write_line(f"return self._{module_name}")
426
+ writer.dedent()
427
+ writer.write_line("")
428
+
429
+ # request() method
430
+ writer.write_line("async def request(self, method: str, url: str, **kwargs: Any) -> Any:")
431
+ writer.indent()
432
+ writer.write_line('"""')
433
+ writer.write_line("Mock request method - raises NotImplementedError.")
434
+ writer.write_line("")
435
+ writer.write_line("This is a low-level method - consider using tag-specific methods instead.")
436
+ writer.write_line('"""')
437
+ writer.write_line(
438
+ "raise NotImplementedError("
439
+ '"MockAPIClient.request() not implemented. '
440
+ 'Use tag-specific methods instead."'
441
+ ")"
442
+ )
443
+ writer.dedent()
444
+ writer.write_line("")
445
+
446
+ # close() method
447
+ writer.write_line("async def close(self) -> None:")
448
+ writer.indent()
449
+ writer.write_line('"""Mock close method - no-op for testing."""')
450
+ writer.write_line("pass # No cleanup needed for mocks")
451
+ writer.dedent()
452
+ writer.write_line("")
453
+
454
+ # __aenter__() method
455
+ writer.write_line('async def __aenter__(self) -> "APIClientProtocol":')
456
+ writer.indent()
457
+ writer.write_line('"""Enter async context manager."""')
458
+ writer.write_line("return self")
459
+ writer.dedent()
460
+ writer.write_line("")
461
+
462
+ # __aexit__() method
463
+ writer.write_line(
464
+ "async def __aexit__("
465
+ "self, "
466
+ "exc_type: type[BaseException] | None, "
467
+ "exc_val: BaseException | None, "
468
+ "exc_tb: object | None"
469
+ ") -> None:"
470
+ )
471
+ writer.indent()
472
+ writer.write_line('"""Exit async context manager - no-op for mocks."""')
473
+ writer.write_line("pass # No cleanup needed for mocks")
474
+ writer.dedent()
475
+
476
+ writer.dedent() # Close class
477
+ return writer.get_code()
@@ -0,0 +1,38 @@
1
+ from pyopenapi_gen import IRSpec
2
+
3
+ from ..context.render_context import RenderContext
4
+ from ..core.utils import NameSanitizer
5
+ from ..core.writers.code_writer import CodeWriter
6
+
7
+
8
+ class DocsVisitor:
9
+ """Visitor for rendering markdown documentation from IRSpec."""
10
+
11
+ def visit(self, spec: IRSpec, context: RenderContext) -> dict[str, str]:
12
+ # List tags
13
+ tags = sorted({t for op in spec.operations for t in op.tags})
14
+ # Generate index.md with sanitized links
15
+ writer = CodeWriter()
16
+ writer.write_line("# API Documentation\n")
17
+ writer.write_line("Generated documentation for the API.\n")
18
+ writer.write_line("## Tags")
19
+ for tag in tags:
20
+ writer.write_line(f"- [{tag}]({NameSanitizer.sanitize_module_name(tag)}.md)")
21
+ index_content = writer.get_code()
22
+ result = {"index.md": index_content}
23
+ # Generate docs per tag
24
+ for tag in tags:
25
+ ops = [op for op in spec.operations if tag in op.tags]
26
+ tag_writer = CodeWriter()
27
+ tag_writer.write_line(f"# {tag.capitalize()} Operations\n")
28
+ for op in ops:
29
+ tag_writer.write_line(f"### {op.operation_id}\n")
30
+ tag_writer.write_line(f"**Method:** `{op.method.value}` ")
31
+ tag_writer.write_line(f"**Path:** `{op.path}` \n")
32
+ desc = op.description or ""
33
+ if desc:
34
+ tag_writer.write_line(desc)
35
+ tag_writer.write_line("")
36
+ filename = NameSanitizer.sanitize_module_name(tag) + ".md"
37
+ result[filename] = tag_writer.get_code()
38
+ return result
@@ -0,0 +1 @@
1
+ # This directory will contain helper classes for EndpointMethodGenerator