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