/ cli / src / opensandbox_cli / config.py
config.py
  1  # Copyright 2026 Alibaba Group Holding Ltd.
  2  #
  3  # Licensed under the Apache License, Version 2.0 (the "License");
  4  # you may not use this file except in compliance with the License.
  5  # You may obtain a copy of the License at
  6  #
  7  #     http://www.apache.org/licenses/LICENSE-2.0
  8  #
  9  # Unless required by applicable law or agreed to in writing, software
 10  # distributed under the License is distributed on an "AS IS" BASIS,
 11  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12  # See the License for the specific language governing permissions and
 13  # limitations under the License.
 14  
 15  """CLI configuration loading and management.
 16  
 17  Priority (highest to lowest):
 18    1. CLI flags
 19    2. Environment variables
 20    3. Config file (~/.opensandbox/config.toml)
 21    4. SDK defaults
 22  """
 23  
 24  from __future__ import annotations
 25  
 26  import os
 27  import sys
 28  from pathlib import Path
 29  from typing import Any
 30  
 31  if sys.version_info >= (3, 11):
 32      import tomllib
 33  else:
 34      try:
 35          import tomli as tomllib  # type: ignore[no-redef]
 36      except ModuleNotFoundError:  # pragma: no cover
 37          tomllib = None  # type: ignore[assignment]
 38  
 39  
 40  DEFAULT_CONFIG_DIR = Path.home() / ".opensandbox"
 41  DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
 42  
 43  DEFAULT_CONFIG_TEMPLATE = """\
 44  # OpenSandbox CLI configuration
 45  # Priority: CLI flags > environment variables > this file > SDK defaults
 46  
 47  [connection]
 48  # api_key = "your-api-key"
 49  # domain = "localhost:8080"
 50  # protocol = "http"
 51  # request_timeout = 30
 52  # use_server_proxy = false
 53  
 54  [output]
 55  # color = true
 56  
 57  [defaults]
 58  # image = "python:3.11"
 59  # timeout = "10m"  # or "none" for manual cleanup mode
 60  """
 61  
 62  
 63  def load_config_file(config_path: Path | None = None) -> dict[str, Any]:
 64      """Load and parse the TOML config file.
 65  
 66      Returns an empty dict if the file doesn't exist or tomllib is unavailable.
 67      """
 68      path = config_path or DEFAULT_CONFIG_PATH
 69      if not path.exists():
 70          return {}
 71      if tomllib is None:
 72          return {}
 73      with open(path, "rb") as f:
 74          return tomllib.load(f)
 75  
 76  
 77  def resolve_config(
 78      *,
 79      cli_api_key: str | None = None,
 80      cli_domain: str | None = None,
 81      cli_protocol: str | None = None,
 82      cli_timeout: int | None = None,
 83      cli_use_server_proxy: bool | None = None,
 84      config_path: Path | None = None,
 85  ) -> dict[str, Any]:
 86      """Merge config from all sources and return a flat dict.
 87  
 88      Keys returned:
 89        - api_key, domain, protocol, request_timeout (int seconds), use_server_proxy (bool)
 90        - default_image, default_timeout (str like "10m")
 91      """
 92      file_cfg = load_config_file(config_path)
 93      conn = file_cfg.get("connection", {})
 94      output_cfg = file_cfg.get("output", {})
 95      defaults = file_cfg.get("defaults", {})
 96  
 97      return {
 98          "api_key": cli_api_key
 99          or os.getenv("OPEN_SANDBOX_API_KEY")
100          or conn.get("api_key"),
101          "domain": cli_domain
102          or os.getenv("OPEN_SANDBOX_DOMAIN")
103          or conn.get("domain"),
104          "protocol": cli_protocol
105          or os.getenv("OPEN_SANDBOX_PROTOCOL")
106          or conn.get("protocol")
107          or "http",
108          "request_timeout": cli_timeout
109          or _int_or_none(os.getenv("OPEN_SANDBOX_REQUEST_TIMEOUT"))
110          or conn.get("request_timeout")
111          or 30,
112          "use_server_proxy": _coalesce(
113              cli_use_server_proxy,
114              _bool_or_none(os.getenv("OPEN_SANDBOX_USE_SERVER_PROXY")),
115              conn.get("use_server_proxy"),
116              False,
117          ),
118          "color": output_cfg.get("color", True),
119          "default_image": defaults.get("image"),
120          "default_timeout": defaults.get("timeout"),
121      }
122  
123  
124  def init_config_file(config_path: Path | None = None, *, force: bool = False) -> Path:
125      """Create a default config file. Returns the path written."""
126      path = config_path or DEFAULT_CONFIG_PATH
127      if path.exists() and not force:
128          raise FileExistsError(
129              f"Config file already exists at {path}. Use --force to overwrite."
130          )
131      path.parent.mkdir(parents=True, exist_ok=True)
132      path.write_text(DEFAULT_CONFIG_TEMPLATE)
133      return path
134  
135  
136  def _int_or_none(value: str | None) -> int | None:
137      if value is None:
138          return None
139      try:
140          return int(value)
141      except ValueError:
142          return None
143  
144  
145  def _bool_or_none(value: str | None) -> bool | None:
146      if value is None:
147          return None
148      normalized = value.strip().lower()
149      if normalized in ("1", "true", "yes", "on"):
150          return True
151      if normalized in ("0", "false", "no", "off"):
152          return False
153      return None
154  
155  
156  def _coalesce(*values: Any) -> Any:
157      for value in values:
158          if value is not None:
159              return value
160      return None