/ sdks / sandbox / python / scripts / generate_api.py
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()