generate_api.py
1 #!/usr/bin/env python3 2 3 # 4 # Copyright 2025 Alibaba Group Holding Ltd. 5 # 6 # Licensed under the Apache License, Version 2.0 (the "License"); 7 # you may not use this file except in compliance with the License. 8 # You may obtain a copy of the License at 9 # 10 # http://www.apache.org/licenses/LICENSE-2.0 11 # 12 # Unless required by applicable law or agreed to in writing, software 13 # distributed under the License is distributed on an "AS IS" BASIS, 14 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 # See the License for the specific language governing permissions and 16 # limitations under the License. 17 # 18 """ 19 OpenAPI client generation script for OpenSandbox Python SDK. 20 21 This script generates Python client code from OpenAPI specifications 22 using openapi-python-client, which generates httpx-based async clients 23 that support custom httpx.AsyncClient injection. 24 """ 25 26 import shutil 27 import subprocess 28 import sys 29 from pathlib import Path 30 31 APACHE_2_LICENSE_HEADER = """#\n# Copyright 2026 Alibaba Group Holding Ltd.\n#\n# Licensed under the Apache License, Version 2.0 (the "License");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an "AS IS" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n""" 32 33 34 def run_command(cmd: list[str], description: str) -> subprocess.CompletedProcess: 35 """Run a command and handle errors.""" 36 print(f"Running: {description}") 37 print(f"Command: {' '.join(cmd)}") 38 39 try: 40 result = subprocess.run(cmd, check=True, capture_output=True, text=True) 41 print("ā Success!") 42 if result.stdout: 43 print(f"Output: {result.stdout}") 44 return result 45 except subprocess.CalledProcessError as e: 46 print(f"ā Error: {e}") 47 if e.stdout: 48 print(f"Stdout: {e.stdout}") 49 if e.stderr: 50 print(f"Stderr: {e.stderr}") 51 raise 52 53 54 def generate_execd_api_client() -> None: 55 """Generate the execd API client from OpenAPI spec.""" 56 print("\nš§ Generating execd API client...") 57 58 spec_path = Path("../../../specs/execd-api.yaml").resolve() 59 output_path = Path("src/opensandbox/api/execd") 60 config_path = Path("scripts/openapi_execd_config.yaml") 61 temp_output = Path("temp_execd_client") 62 63 if not spec_path.exists(): 64 print(f"ā OpenAPI spec not found at {spec_path}") 65 print("Please ensure the specs directory is available") 66 return 67 68 # Remove existing generated code 69 if output_path.exists(): 70 shutil.rmtree(output_path) 71 72 # Remove temp directory if exists 73 if temp_output.exists(): 74 shutil.rmtree(temp_output) 75 76 # Generate using openapi-python-client 77 cmd = [ 78 "openapi-python-client", 79 "generate", 80 "--path", 81 str(spec_path), 82 "--output-path", 83 str(temp_output), 84 "--config", 85 str(config_path), 86 "--overwrite", 87 ] 88 89 try: 90 run_command(cmd, "Generating execd API client") 91 except subprocess.CalledProcessError: 92 print("ā Failed to generate execd API client") 93 return 94 95 # Move generated files to correct location 96 # openapi-python-client generates package inside the output directory 97 generated_package = temp_output / "opensandbox_api_execd" 98 if generated_package.exists(): 99 output_path.parent.mkdir(parents=True, exist_ok=True) 100 shutil.move(str(generated_package), str(output_path)) 101 shutil.rmtree(temp_output) 102 print(f"ā Moved generated code to {output_path}") 103 else: 104 # If package name doesn't match, find the generated package 105 for item in temp_output.iterdir(): 106 if item.is_dir() and not item.name.startswith("."): 107 output_path.parent.mkdir(parents=True, exist_ok=True) 108 shutil.move(str(item), str(output_path)) 109 shutil.rmtree(temp_output) 110 print(f"ā Moved generated code from {item} to {output_path}") 111 break 112 113 114 def generate_egress_api_client() -> None: 115 """Generate the egress API client from OpenAPI spec.""" 116 print("\nš§ Generating egress API client...") 117 118 spec_path = Path("../../../specs/egress-api.yaml").resolve() 119 output_path = Path("src/opensandbox/api/egress") 120 config_path = Path("scripts/openapi_egress_config.yaml") 121 temp_output = Path("temp_egress_client") 122 123 if not spec_path.exists(): 124 print(f"ā OpenAPI spec not found at {spec_path}") 125 print("Please ensure the specs directory is available") 126 return 127 128 if output_path.exists(): 129 shutil.rmtree(output_path) 130 131 if temp_output.exists(): 132 shutil.rmtree(temp_output) 133 134 cmd = [ 135 "openapi-python-client", 136 "generate", 137 "--path", 138 str(spec_path), 139 "--output-path", 140 str(temp_output), 141 "--config", 142 str(config_path), 143 "--overwrite", 144 ] 145 146 try: 147 run_command(cmd, "Generating egress API client") 148 except subprocess.CalledProcessError: 149 print("ā Failed to generate egress API client") 150 return 151 152 generated_package = temp_output / "opensandbox_api_egress" 153 if generated_package.exists(): 154 output_path.parent.mkdir(parents=True, exist_ok=True) 155 shutil.move(str(generated_package), str(output_path)) 156 shutil.rmtree(temp_output) 157 print(f"ā Moved generated code to {output_path}") 158 else: 159 for item in temp_output.iterdir(): 160 if item.is_dir() and not item.name.startswith("."): 161 output_path.parent.mkdir(parents=True, exist_ok=True) 162 shutil.move(str(item), str(output_path)) 163 shutil.rmtree(temp_output) 164 print(f"ā Moved generated code from {item} to {output_path}") 165 break 166 167 168 def generate_sandbox_lifecycle_api() -> None: 169 """Generate the sandbox lifecycle API client.""" 170 print("\nš§ Generating sandbox lifecycle API client...") 171 172 spec_path = Path("../../../specs/sandbox-lifecycle.yml").resolve() 173 output_path = Path("src/opensandbox/api/lifecycle") 174 config_path = Path("scripts/openapi_lifecycle_config.yaml") 175 temp_output = Path("temp_lifecycle_client") 176 177 if not spec_path.exists(): 178 print(f"ā OpenAPI spec not found at {spec_path}") 179 return 180 181 # Remove existing generated code 182 if output_path.exists(): 183 shutil.rmtree(output_path) 184 185 # Remove temp directory if exists 186 if temp_output.exists(): 187 shutil.rmtree(temp_output) 188 189 # Generate using openapi-python-client 190 cmd = [ 191 "openapi-python-client", 192 "generate", 193 "--path", 194 str(spec_path), 195 "--output-path", 196 str(temp_output), 197 "--config", 198 str(config_path), 199 "--overwrite", 200 ] 201 202 try: 203 run_command(cmd, "Generating sandbox lifecycle API client") 204 except subprocess.CalledProcessError: 205 print("ā Failed to generate lifecycle API client") 206 return 207 208 # Move generated files to correct location 209 generated_package = temp_output / "opensandbox_api_lifecycle" 210 if generated_package.exists(): 211 output_path.parent.mkdir(parents=True, exist_ok=True) 212 shutil.move(str(generated_package), str(output_path)) 213 shutil.rmtree(temp_output) 214 print(f"ā Moved generated code to {output_path}") 215 else: 216 # If package name doesn't match, find the generated package 217 for item in temp_output.iterdir(): 218 if item.is_dir() and not item.name.startswith("."): 219 output_path.parent.mkdir(parents=True, exist_ok=True) 220 shutil.move(str(item), str(output_path)) 221 shutil.rmtree(temp_output) 222 print(f"ā Moved generated code from {item} to {output_path}") 223 break 224 225 226 def add_license_headers(root: Path) -> None: 227 """Add Apache-2.0 license header to generated python files (idempotent).""" 228 if not root.exists(): 229 return 230 231 touched = 0 232 skipped = 0 233 234 for file_path in root.rglob("*.py"): 235 content = file_path.read_text(encoding="utf-8") 236 237 # Avoid double-inserting if generation already includes headers. 238 # Keep the check lightweight and tolerant to minor variations. 239 head = "\n".join(content.splitlines()[:50]) 240 if "Licensed under the Apache License, Version 2.0" in head: 241 skipped += 1 242 continue 243 244 file_path.write_text(APACHE_2_LICENSE_HEADER + content, encoding="utf-8") 245 touched += 1 246 247 print( 248 f"ā Added license headers under {root} (updated={touched}, skipped={skipped})" 249 ) 250 251 252 253 def post_process_generated_code() -> None: 254 """Post-process the generated code to ensure proper package structure.""" 255 print("\nš§ Post-processing generated code...") 256 257 # Ensure API directory has __init__.py 258 api_dir = Path("src/opensandbox/api") 259 if api_dir.exists(): 260 init_file = api_dir / "__init__.py" 261 if not init_file.exists(): 262 init_file.write_text( 263 '"""OpenSandbox API clients generated from OpenAPI specs."""\n' 264 ) 265 print(f"ā Created {init_file}") 266 267 # Ensure all generated python files have a license header. 268 add_license_headers(Path("src/opensandbox/api/execd")) 269 add_license_headers(Path("src/opensandbox/api/egress")) 270 add_license_headers(Path("src/opensandbox/api/lifecycle")) 271 add_license_headers(Path("src/opensandbox/api")) 272 273 274 def main() -> None: 275 """Main function to generate all API clients.""" 276 print("š OpenSandbox Python SDK API Generator") 277 print("=" * 50) 278 print("Using openapi-python-client for httpx-based async clients") 279 print("=" * 50) 280 281 # Check if openapi-python-client is available 282 try: 283 result = subprocess.run( 284 ["openapi-python-client", "--version"], check=True, capture_output=True 285 ) 286 version = result.stdout.decode().strip() or result.stderr.decode().strip() 287 print(f"openapi-python-client version: {version}") 288 except (subprocess.CalledProcessError, FileNotFoundError): 289 print("ā openapi-python-client not found!") 290 print("Please install it with: pip install openapi-python-client") 291 print("Or: uv add --dev openapi-python-client") 292 sys.exit(1) 293 294 # Create API directories 295 Path("src/opensandbox/api").mkdir(parents=True, exist_ok=True) 296 297 # Generate API clients 298 generate_execd_api_client() 299 generate_egress_api_client() 300 generate_sandbox_lifecycle_api() 301 302 # Post-process 303 post_process_generated_code() 304 305 print("\nā API client generation completed!") 306 print("Generated clients:") 307 print(" - src/opensandbox/api/execd/") 308 print(" - src/opensandbox/api/egress/") 309 print(" - src/opensandbox/api/lifecycle/") 310 print("\nThe generated clients support custom httpx.AsyncClient injection:") 311 print(" from opensandbox.api.execd import Client, AuthenticatedClient") 312 print( 313 ' client = AuthenticatedClient(base_url="...", token="...", httpx_client=custom_client)' 314 ) 315 316 317 if __name__ == "__main__": 318 main()