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,44 @@
1
+ import os
2
+
3
+ from pyopenapi_gen import IRSpec
4
+ from pyopenapi_gen.context.render_context import RenderContext
5
+
6
+ from ..visit.docs_visitor import DocsVisitor
7
+
8
+ """Simple documentation emitter using markdown with Python str.format placeholders."""
9
+ DOCS_INDEX_TEMPLATE = """# API Documentation
10
+
11
+ Generated documentation for the API.
12
+
13
+ ## Tags
14
+ {tags_list}
15
+ """
16
+
17
+ DOCS_TAG_TEMPLATE = """# {tag} Operations
18
+
19
+ {operations_list}
20
+ """
21
+
22
+ DOCS_OPERATION_TEMPLATE = """### {operation_id}
23
+
24
+ **Method:** `{method}`
25
+ **Path:** `{path}`
26
+
27
+ {description}
28
+ """
29
+
30
+
31
+ class DocsEmitter:
32
+ """Generates markdown documentation per tag from IRSpec using visitor/context."""
33
+
34
+ def __init__(self) -> None:
35
+ self.visitor = DocsVisitor()
36
+
37
+ def emit(self, spec: IRSpec, output_dir: str) -> None:
38
+ """Render docs into <output_dir> as markdown files."""
39
+ docs_dir = os.path.join(output_dir)
40
+ context = RenderContext()
41
+ context.file_manager.ensure_dir(docs_dir)
42
+ docs = self.visitor.visit(spec, context)
43
+ for filename, content in docs.items():
44
+ context.file_manager.write_file(os.path.join(docs_dir, filename), content)
@@ -0,0 +1,223 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Dict, List, Optional, Tuple
4
+
5
+ from pyopenapi_gen import IROperation, IRParameter, IRRequestBody
6
+ from pyopenapi_gen.context.render_context import RenderContext
7
+ from pyopenapi_gen.visit.endpoint.endpoint_visitor import EndpointVisitor
8
+
9
+ from ..core.utils import Formatter, NameSanitizer
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # Basic OpenAPI schema to Python type mapping for parameters
14
+ PARAM_TYPE_MAPPING = {
15
+ "integer": "int",
16
+ "number": "float",
17
+ "boolean": "bool",
18
+ "string": "str",
19
+ "array": "List",
20
+ "object": "Dict[str, Any]",
21
+ }
22
+ # Format-specific overrides
23
+ PARAM_FORMAT_MAPPING = {
24
+ "int32": "int",
25
+ "int64": "int",
26
+ "float": "float",
27
+ "double": "float",
28
+ "byte": "str",
29
+ "binary": "bytes",
30
+ "date": "date",
31
+ "date-time": "datetime",
32
+ }
33
+
34
+ # Default tag for untagged operations
35
+ DEFAULT_TAG = "default"
36
+
37
+
38
+ def schema_to_type(schema: IRParameter) -> str:
39
+ """Convert an IRParameter's schema to a Python type string."""
40
+ s = schema.schema # s is an IRSchema instance
41
+ py_type: str = "Any" # Default base type
42
+
43
+ # 1. Determine base type (without Optional wrapper yet)
44
+ # Format-specific override has highest precedence for base type determination
45
+ if s.format and s.format in PARAM_FORMAT_MAPPING:
46
+ py_type = PARAM_FORMAT_MAPPING[s.format]
47
+ # Array handling
48
+ elif s.type == "array" and s.items:
49
+ # For array items, we recursively call schema_to_type.
50
+ # The nullability of the item_type itself (e.g. List[Optional[int]])
51
+ # will be handled by the recursive call based on s.items.is_nullable.
52
+ item_schema_as_param = IRParameter(name="_item", param_in="_internal", required=False, schema=s.items)
53
+ item_type_str = schema_to_type(item_schema_as_param)
54
+ py_type = f"List[{item_type_str}]"
55
+ # Default mapping based on s.type (primary type)
56
+ elif s.type and s.type in PARAM_TYPE_MAPPING:
57
+ py_type = PARAM_TYPE_MAPPING[s.type]
58
+ # Fallback if type is None or not in mappings (and not format override/array)
59
+ # If s.type is None and there was no format override, it defaults to "Any".
60
+ # If s.type is something not recognized, it also defaults to "Any".
61
+ elif not s.type and not s.format: # Type is None, no format override
62
+ py_type = "Any"
63
+ elif s.type: # Type is some string not in PARAM_TYPE_MAPPING and not an array handled above
64
+ # This could be a reference to a model. For now, schema_to_type is simple and returns Any.
65
+ # A more sophisticated version would return the schema name for model visitor to handle.
66
+ # However, based on existing PARAM_TYPE_MAPPING, unknown types become "Any".
67
+ py_type = "Any"
68
+ # If py_type is still "Any" here, it means none of the above conditions strongly set a type.
69
+
70
+ # 2. Apply nullability based on IRSchema's is_nullable field
71
+ # This s.is_nullable should be the source of truth from the IR after parsing.
72
+ if s.is_nullable:
73
+ # Ensure "Any" also gets wrapped, e.g. Optional[Any]
74
+ py_type = f"Optional[{py_type}]"
75
+
76
+ return py_type
77
+
78
+
79
+ def _get_request_body_type(body: IRRequestBody) -> str:
80
+ """Determine the Python type for a request body schema."""
81
+ for mt, sch in body.content.items():
82
+ if "json" in mt.lower():
83
+ return schema_to_type(IRParameter(name="body", param_in="body", required=body.required, schema=sch))
84
+ # Fallback to generic dict
85
+ return "Dict[str, Any]"
86
+
87
+
88
+ def _deduplicate_tag_clients(client_classes: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
89
+ """
90
+ Deduplicate client class/module pairs by canonical module/class name.
91
+ Returns a list of unique (class_name, module_name) pairs.
92
+ """
93
+ seen = set()
94
+ unique = []
95
+ for cls, mod in client_classes:
96
+ key = (cls.lower(), mod.lower())
97
+ if key not in seen:
98
+ seen.add(key)
99
+ unique.append((cls, mod))
100
+ return unique
101
+
102
+
103
+ class EndpointsEmitter:
104
+ """Generates endpoint modules organized by tag from IRSpec using the visitor/context architecture."""
105
+
106
+ def __init__(self, context: RenderContext) -> None:
107
+ self.context = context
108
+ self.formatter = Formatter()
109
+ self.visitor: Optional[EndpointVisitor] = None
110
+
111
+ def _deduplicate_operation_ids(self, operations: List[IROperation]) -> None:
112
+ """
113
+ Ensures all operations have unique method names within a tag.
114
+
115
+ Args:
116
+ operations: List of operations for a single tag.
117
+ """
118
+ seen_methods: Dict[str, int] = {}
119
+ for op in operations:
120
+ method_name = NameSanitizer.sanitize_method_name(op.operation_id)
121
+ if method_name in seen_methods:
122
+ seen_methods[method_name] += 1
123
+ new_op_id = f"{op.operation_id}_{seen_methods[method_name]}"
124
+ op.operation_id = new_op_id
125
+ else:
126
+ seen_methods[method_name] = 1
127
+
128
+ def emit(self, operations: List[IROperation], output_dir_str: str) -> List[str]:
129
+ """Render endpoint client files per tag under <output_dir>/endpoints.
130
+ Returns a list of generated file paths."""
131
+ output_dir = Path(output_dir_str)
132
+ endpoints_dir = output_dir / "endpoints"
133
+
134
+ self.context.file_manager.ensure_dir(str(endpoints_dir))
135
+
136
+ # Manage __init__.py and py.typed files
137
+ common_files_to_ensure = [
138
+ (endpoints_dir / "__init__.py", ""),
139
+ (output_dir / "__init__.py", ""), # Ensure root client package __init__.py
140
+ (endpoints_dir / "py.typed", ""),
141
+ ]
142
+ for file_path, content in common_files_to_ensure:
143
+ if not file_path.exists():
144
+ self.context.file_manager.write_file(str(file_path), content)
145
+
146
+ # Ensure parsed_schemas is at least an empty dict if None,
147
+ # as EndpointVisitor expects Dict[str, IRSchema]
148
+ current_parsed_schemas = self.context.parsed_schemas
149
+ if current_parsed_schemas is None:
150
+ logger.warning(
151
+ "[EndpointsEmitter] RenderContext.parsed_schemas was None. "
152
+ "Defaulting to empty dict for EndpointVisitor."
153
+ )
154
+ current_parsed_schemas = {} # Default to empty dict if None
155
+
156
+ if self.visitor is None:
157
+ self.visitor = EndpointVisitor(current_parsed_schemas) # Pass the (potentially defaulted) dict
158
+
159
+ tag_key_to_ops: Dict[str, List[IROperation]] = {}
160
+ tag_key_to_candidates: Dict[str, List[str]] = {}
161
+ for op in operations:
162
+ tags = op.tags or [DEFAULT_TAG]
163
+ for tag in tags:
164
+ key = NameSanitizer.normalize_tag_key(tag)
165
+ tag_key_to_ops.setdefault(key, []).append(op)
166
+ tag_key_to_candidates.setdefault(key, []).append(tag)
167
+
168
+ def tag_score(t: str) -> tuple[bool, int, int, str]:
169
+ import re
170
+
171
+ is_pascal = bool(re.search(r"[a-z][A-Z]", t)) or bool(re.search(r"[A-Z]{2,}", t))
172
+ words = re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+", t)
173
+ words += re.split(r"[_-]+", t)
174
+ word_count = len([w for w in words if w])
175
+ upper = sum(1 for c in t if c.isupper())
176
+ return (is_pascal, word_count, upper, t)
177
+
178
+ tag_map: Dict[str, str] = {}
179
+ for key, candidates in tag_key_to_candidates.items():
180
+ best_tag_for_key = DEFAULT_TAG # Default if no candidates somehow
181
+ if candidates:
182
+ best_tag_for_key = max(candidates, key=tag_score)
183
+ tag_map[key] = best_tag_for_key
184
+
185
+ generated_files: List[str] = []
186
+ client_classes: List[Tuple[str, str]] = []
187
+
188
+ for key, ops_for_tag in tag_key_to_ops.items():
189
+ canonical_tag_name = tag_map[key]
190
+ module_name = NameSanitizer.sanitize_module_name(canonical_tag_name)
191
+ class_name = NameSanitizer.sanitize_class_name(canonical_tag_name) + "Client"
192
+ file_path = endpoints_dir / f"{module_name}.py"
193
+
194
+ # This will set current_file and reset+reinit import_collector's context
195
+ self.context.set_current_file(str(file_path))
196
+
197
+ self._deduplicate_operation_ids(ops_for_tag)
198
+
199
+ # EndpointVisitor must exist here due to check above
200
+ assert self.visitor is not None, "EndpointVisitor not initialized"
201
+ methods = [self.visitor.visit(op, self.context) for op in ops_for_tag]
202
+ class_content = self.visitor.emit_endpoint_client_class(canonical_tag_name, methods, self.context)
203
+
204
+ imports = self.context.render_imports()
205
+ file_content = imports + "\n\n" + class_content
206
+ self.context.file_manager.write_file(str(file_path), file_content)
207
+ client_classes.append((class_name, module_name))
208
+ generated_files.append(str(file_path))
209
+
210
+ unique_clients = _deduplicate_tag_clients(client_classes)
211
+ init_lines = []
212
+ if unique_clients:
213
+ all_list_items = sorted([f'"{cls}"' for cls, _ in unique_clients])
214
+ init_lines.append(f"__all__ = [{', '.join(all_list_items)}]")
215
+ for cls, mod in sorted(unique_clients):
216
+ init_lines.append(f"from .{mod} import {cls}")
217
+
218
+ endpoints_init_path = endpoints_dir / "__init__.py"
219
+ self.context.file_manager.write_file(str(endpoints_init_path), "\n".join(init_lines) + "\n")
220
+ if str(endpoints_init_path) not in generated_files:
221
+ generated_files.append(str(endpoints_init_path))
222
+
223
+ return generated_files
@@ -0,0 +1,52 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ from pyopenapi_gen import IRSpec
5
+ from pyopenapi_gen.context.render_context import RenderContext
6
+
7
+ from ..visit.exception_visitor import ExceptionVisitor
8
+
9
+ # Template for spec-specific exception aliases
10
+ EXCEPTIONS_ALIASES_TEMPLATE = '''
11
+ from .exceptions import HTTPError, ClientError, ServerError
12
+
13
+ # Generated exception aliases for specific status codes
14
+ {% for code in codes %}
15
+ class Error{{ code }}({% if code < 500 %}ClientError{% else %}ServerError{% endif %}):
16
+ """Exception alias for HTTP {{ code }} responses."""
17
+ pass
18
+ {% endfor %}
19
+ '''
20
+
21
+
22
+ class ExceptionsEmitter:
23
+ """Generates spec-specific exception aliases in exceptions.py using visitor/context."""
24
+
25
+ def __init__(self, core_package_name: str = "core", overall_project_root: Optional[str] = None) -> None:
26
+ self.visitor = ExceptionVisitor()
27
+ self.core_package_name = core_package_name
28
+ self.overall_project_root = overall_project_root
29
+
30
+ def emit(self, spec: IRSpec, output_dir: str) -> tuple[list[str], list[str]]:
31
+ file_path = os.path.join(output_dir, "exception_aliases.py")
32
+
33
+ context = RenderContext(
34
+ package_root_for_generated_code=output_dir,
35
+ core_package_name=self.core_package_name,
36
+ overall_project_root=self.overall_project_root,
37
+ )
38
+ context.set_current_file(file_path)
39
+
40
+ generated_code, alias_names = self.visitor.visit(spec, context)
41
+ generated_imports = context.render_imports()
42
+
43
+ # Add __all__ list
44
+ if alias_names:
45
+ all_list_str = ", ".join([f'"{name}"' for name in alias_names])
46
+ all_assignment = f"\n\n__all__ = [{all_list_str}]\n"
47
+ generated_code += all_assignment
48
+
49
+ full_content = f"{generated_imports}\n\n{generated_code}"
50
+ with open(file_path, "w") as f:
51
+ f.write(full_content)
52
+ return [file_path], alias_names