otterapi 0.0.5__py3-none-any.whl → 0.0.6__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 (52) hide show
  1. README.md +581 -8
  2. otterapi/__init__.py +73 -0
  3. otterapi/cli.py +327 -29
  4. otterapi/codegen/__init__.py +115 -0
  5. otterapi/codegen/ast_utils.py +134 -5
  6. otterapi/codegen/client.py +1271 -0
  7. otterapi/codegen/codegen.py +1736 -0
  8. otterapi/codegen/dataframes.py +392 -0
  9. otterapi/codegen/emitter.py +473 -0
  10. otterapi/codegen/endpoints.py +2597 -343
  11. otterapi/codegen/pagination.py +1026 -0
  12. otterapi/codegen/schema.py +593 -0
  13. otterapi/codegen/splitting.py +1397 -0
  14. otterapi/codegen/types.py +1345 -0
  15. otterapi/codegen/utils.py +180 -1
  16. otterapi/config.py +1017 -24
  17. otterapi/exceptions.py +231 -0
  18. otterapi/openapi/__init__.py +46 -0
  19. otterapi/openapi/v2/__init__.py +86 -0
  20. otterapi/openapi/v2/spec.json +1607 -0
  21. otterapi/openapi/v2/v2.py +1776 -0
  22. otterapi/openapi/v3/__init__.py +131 -0
  23. otterapi/openapi/v3/spec.json +1651 -0
  24. otterapi/openapi/v3/v3.py +1557 -0
  25. otterapi/openapi/v3_1/__init__.py +133 -0
  26. otterapi/openapi/v3_1/spec.json +1411 -0
  27. otterapi/openapi/v3_1/v3_1.py +798 -0
  28. otterapi/openapi/v3_2/__init__.py +133 -0
  29. otterapi/openapi/v3_2/spec.json +1666 -0
  30. otterapi/openapi/v3_2/v3_2.py +777 -0
  31. otterapi/tests/__init__.py +3 -0
  32. otterapi/tests/fixtures/__init__.py +455 -0
  33. otterapi/tests/test_ast_utils.py +680 -0
  34. otterapi/tests/test_codegen.py +610 -0
  35. otterapi/tests/test_dataframe.py +1038 -0
  36. otterapi/tests/test_exceptions.py +493 -0
  37. otterapi/tests/test_openapi_support.py +616 -0
  38. otterapi/tests/test_openapi_upgrade.py +215 -0
  39. otterapi/tests/test_pagination.py +1101 -0
  40. otterapi/tests/test_splitting_config.py +319 -0
  41. otterapi/tests/test_splitting_integration.py +427 -0
  42. otterapi/tests/test_splitting_resolver.py +512 -0
  43. otterapi/tests/test_splitting_tree.py +525 -0
  44. otterapi-0.0.6.dist-info/METADATA +627 -0
  45. otterapi-0.0.6.dist-info/RECORD +48 -0
  46. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/WHEEL +1 -1
  47. otterapi/codegen/generator.py +0 -358
  48. otterapi/codegen/openapi_processor.py +0 -27
  49. otterapi/codegen/type_generator.py +0 -559
  50. otterapi-0.0.5.dist-info/METADATA +0 -54
  51. otterapi-0.0.5.dist-info/RECORD +0 -16
  52. {otterapi-0.0.5.dist-info → otterapi-0.0.6.dist-info}/entry_points.txt +0 -0
otterapi/codegen/utils.py CHANGED
@@ -1,8 +1,17 @@
1
+ import ast
2
+ import py_compile
1
3
  import re
4
+ import tempfile
2
5
  import unicodedata
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
3
8
  from urllib.parse import urlparse
4
9
 
5
- __all__ = ('is_url', 'sanitize_identifier')
10
+ from upath import UPath
11
+
12
+ from otterapi.openapi.v3_2 import OpenAPI, Reference, Schema
13
+
14
+ __all__ = ('is_url', 'sanitize_identifier', 'to_snake_case')
6
15
 
7
16
 
8
17
  def capitalize(input_string):
@@ -51,6 +60,34 @@ def sanitize_parameter_field_name(name: str) -> str:
51
60
  return sanitized
52
61
 
53
62
 
63
+ def to_snake_case(name: str) -> str:
64
+ """Convert a camelCase or PascalCase string to snake_case.
65
+
66
+ Args:
67
+ name: The string to convert (e.g., 'getUserById', 'HTTPResponse').
68
+
69
+ Returns:
70
+ The snake_case version (e.g., 'get_user_by_id', 'http_response').
71
+ """
72
+ if not name:
73
+ return name
74
+
75
+ # Handle acronyms and consecutive uppercase letters
76
+ # Insert underscore before uppercase letters that are followed by lowercase
77
+ # or before uppercase letters that follow lowercase letters
78
+ result = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', name)
79
+ # Handle consecutive uppercase followed by lowercase (e.g., HTTPResponse -> HTTP_Response)
80
+ result = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', result)
81
+ # Convert to lowercase
82
+ result = result.lower()
83
+ # Replace any non-alphanumeric characters with underscores
84
+ result = re.sub(r'[^a-z0-9]+', '_', result)
85
+ # Remove leading/trailing underscores and collapse multiple underscores
86
+ result = re.sub(r'_+', '_', result).strip('_')
87
+
88
+ return sanitize_name_python_keywords(result)
89
+
90
+
54
91
  def sanitize_identifier(name: str) -> str:
55
92
  """Convert a string into a valid Python identifier.
56
93
 
@@ -80,3 +117,145 @@ def sanitize_identifier(name: str) -> str:
80
117
 
81
118
  # If empty after sanitization, return a default
82
119
  return sanitized or 'UnnamedType'
120
+
121
+
122
+ def validate_python_syntax(content: str) -> None:
123
+ """Validate that the content is valid Python code.
124
+
125
+ Args:
126
+ content: Python source code as a string.
127
+
128
+ Raises:
129
+ SyntaxError: If the code is not valid Python.
130
+ """
131
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
132
+ f.write(content)
133
+ f.flush()
134
+ temp_path = f.name
135
+
136
+ try:
137
+ # Compile to check for syntax errors
138
+ py_compile.compile(temp_path, doraise=True)
139
+ finally:
140
+ # Clean up temp file
141
+ Path(temp_path).unlink(missing_ok=True)
142
+
143
+
144
+ def format_source(source: str) -> str:
145
+ """Format Python source code using ruff or black if available.
146
+
147
+ Args:
148
+ source: The source code to format.
149
+
150
+ Returns:
151
+ Formatted source code, or original if formatters unavailable.
152
+ """
153
+ # Try ruff first
154
+ try:
155
+ import subprocess
156
+
157
+ result = subprocess.run(
158
+ ['ruff', 'format', '-'],
159
+ input=source,
160
+ capture_output=True,
161
+ text=True,
162
+ )
163
+ if result.returncode == 0:
164
+ return result.stdout
165
+ except (FileNotFoundError, subprocess.SubprocessError):
166
+ pass
167
+
168
+ # Try black
169
+ try:
170
+ import black
171
+
172
+ return black.format_str(source, mode=black.Mode())
173
+ except ImportError:
174
+ pass
175
+ except Exception:
176
+ pass
177
+
178
+ # Return original if no formatter available
179
+ return source
180
+
181
+
182
+ def write_mod(
183
+ body: list[ast.stmt], path: UPath | Path | str, format_code: bool = True
184
+ ) -> None:
185
+ """Write a list of AST statements to a Python file.
186
+
187
+ This method:
188
+ 1. Creates an AST Module from the statements
189
+ 2. Fixes missing locations in the AST
190
+ 3. Unparses the AST to Python source code
191
+ 4. Validates the code by compiling it
192
+ 5. Optionally formats the code with ruff/black
193
+ 6. Writes the code to the specified file
194
+
195
+ Args:
196
+ body: List of AST statement nodes to write.
197
+ path: Path where the file should be written.
198
+ format_code: Whether to format the code with ruff/black. Defaults to True.
199
+
200
+ Raises:
201
+ SyntaxError: If the generated code is not valid Python.
202
+ OSError: If the file cannot be written.
203
+ """
204
+ # Convert path to string for consistency
205
+ path = UPath(path)
206
+
207
+ # Create and prepare the AST module
208
+ mod = ast.Module(body=body, type_ignores=[])
209
+ ast.fix_missing_locations(mod)
210
+
211
+ # Convert AST to Python source code
212
+ file_content = ast.unparse(mod)
213
+
214
+ # Validate the generated code by compiling it
215
+ validate_python_syntax(file_content)
216
+
217
+ # Format code if requested
218
+ if format_code:
219
+ file_content = format_source(file_content)
220
+
221
+ # Write to file
222
+ path.parent.mkdir(parents=True, exist_ok=True)
223
+ with open(str(path), 'w', encoding='utf-8') as f:
224
+ f.write(file_content)
225
+
226
+
227
+ def write_init_file(directory: UPath | Path | str) -> None:
228
+ """Create an empty __init__.py file in the specified directory.
229
+
230
+ Args:
231
+ directory: Directory where __init__.py should be created.
232
+ """
233
+ directory = UPath(directory)
234
+ init_file = directory / '__init__.py'
235
+
236
+ if not init_file.exists():
237
+ directory.mkdir(parents=True, exist_ok=True)
238
+ init_file.touch()
239
+
240
+
241
+ @dataclass
242
+ class OpenAPIProcessor:
243
+ openapi: OpenAPI | None = None
244
+
245
+ def _resolve_reference(self, reference: Reference | Schema) -> tuple[Schema, str]:
246
+ if isinstance(reference, Reference):
247
+ if not reference.ref.startswith('#/components/schemas/'):
248
+ raise ValueError(f'Unsupported reference format: {reference.ref}')
249
+
250
+ schema_name = reference.ref.split('/')[-1]
251
+ schemas = self.openapi.components.schemas
252
+
253
+ if schema_name not in schemas:
254
+ raise ValueError(
255
+ f"Referenced schema '{schema_name}' not found in components.schemas"
256
+ )
257
+
258
+ return schemas[schema_name], sanitize_identifier(schema_name)
259
+ return reference, sanitize_identifier(reference.title) if hasattr(
260
+ reference, 'title'
261
+ ) and reference.title else None