Coverage for src / sentry_tool / commands / config.py: 84.21%

133 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-07 20:19 -0500

1"""Configuration management commands.""" 

2 

3import json 

4import os 

5from typing import Annotated, Any 

6 

7import typer 

8from rich.console import Console 

9 

10from sentry_tool.client import NotFoundError, api_call 

11from sentry_tool.config import AppConfig, load_config 

12from sentry_tool.exceptions import ConfigurationError 

13from sentry_tool.monitoring import get_logger 

14from sentry_tool.output import Column, OutputFormat, render 

15from sentry_tool.utils import mask_token 

16 

17config_app = typer.Typer(help="Configuration management commands") 

18 

19 

20@config_app.command("show") 

21def show( 

22 format: Annotated[ 

23 OutputFormat, 

24 typer.Option("--format", "-f", help="Output format"), 

25 ] = OutputFormat.table, 

26) -> None: 

27 """Display current configuration including active profile and all configured profiles. 

28 

29 Examples: 

30 sentry-tool config show 

31 sentry-tool config show --format json 

32 """ 

33 try: 

34 app_config = load_config() 

35 except ConfigurationError as e: 

36 Console().print(f"[red]Config error: {e}[/red]") 

37 raise typer.Exit(1) from e 

38 

39 env: dict[str, str | None] = { 

40 "profile": os.getenv("SENTRY_PROFILE"), 

41 "url": os.getenv("SENTRY_URL"), 

42 "org": os.getenv("SENTRY_ORG"), 

43 "project": os.getenv("SENTRY_PROJECT"), 

44 "auth_token": os.getenv("SENTRY_AUTH_TOKEN"), 

45 } 

46 

47 active_name = env["profile"] or app_config.default_profile 

48 active_profile = app_config.profiles.get(active_name) 

49 

50 if format == OutputFormat.json: 

51 _print_show_json(app_config, env, active_name, active_profile) 

52 else: 

53 _print_show_tables(app_config, env, active_name, active_profile) 

54 

55 

56def _print_show_json( 

57 app_config: AppConfig, 

58 env: dict[str, str | None], 

59 active_name: str | None, 

60 active_profile: Any, 

61) -> None: 

62 def _effective(field: str) -> Any: 

63 env_val = env.get(field) 

64 if env_val is not None: 

65 return env_val 

66 return getattr(active_profile, field, None) if active_profile else None 

67 

68 effective_token = env["auth_token"] or ( 

69 active_profile.auth_token if active_profile else None 

70 ) 

71 output = { 

72 "default_profile": app_config.default_profile, 

73 "active_profile": active_name, 

74 "effective": { 

75 "url": _effective("url"), 

76 "org": _effective("org"), 

77 "project": _effective("project"), 

78 "auth_token": mask_token(effective_token), 

79 }, 

80 "profiles": { 

81 name: { 

82 "url": profile.url, 

83 "org": profile.org, 

84 "project": profile.project, 

85 "auth_token": mask_token(profile.auth_token), 

86 } 

87 for name, profile in app_config.profiles.items() 

88 }, 

89 } 

90 print(json.dumps(output, indent=2)) 

91 

92 

93def _print_show_tables( 

94 app_config: AppConfig, 

95 env: dict[str, str | None], 

96 active_name: str | None, 

97 active_profile: Any, 

98) -> None: 

99 console = Console() 

100 

101 console.print(f"\n[bold]Default profile:[/bold] {app_config.default_profile}") 

102 if env["profile"]: 

103 console.print(f" [dim](override: SENTRY_PROFILE={env['profile']})[/dim]") 

104 

105 if active_profile: 

106 rows = [] 

107 for field, env_key in [("url", "url"), ("org", "org"), ("project", "project")]: 

108 value = env[env_key] if env[env_key] else getattr(active_profile, field) 

109 source = f"SENTRY_{env_key.upper()}" if env[env_key] else "profile" 

110 rows.append({"setting": field, "value": str(value), "source": source}) 

111 

112 auth_token = env["auth_token"] if env["auth_token"] else active_profile.auth_token 

113 source = "SENTRY_AUTH_TOKEN" if env["auth_token"] and auth_token else "profile" 

114 rows.append({ 

115 "setting": "auth_token", 

116 "value": mask_token(auth_token), 

117 "source": source, 

118 }) 

119 

120 columns = [ 

121 Column("Setting", "setting", style="cyan"), 

122 Column("Value", "value"), 

123 Column("Source", "source", style="dim"), 

124 ] 

125 console.print() 

126 render(rows, OutputFormat.table, columns=columns, footer="Effective Settings") 

127 

128 if not app_config.profiles: 

129 console.print("\nNo profiles configured") 

130 return 

131 

132 profile_rows = [ 

133 { 

134 "name": name, 

135 "default": "*" if name == app_config.default_profile else "", 

136 "url": profile.url, 

137 "org": profile.org, 

138 "project": profile.project, 

139 "auth_token": mask_token(profile.auth_token), 

140 } 

141 for name, profile in app_config.profiles.items() 

142 ] 

143 

144 profile_columns = [ 

145 Column("Name", "name", style="cyan"), 

146 Column("Default", "default", justify="center"), 

147 Column("URL", "url"), 

148 Column("Org", "org"), 

149 Column("Project", "project"), 

150 Column("Auth Token", "auth_token", style="dim"), 

151 ] 

152 console.print() 

153 render( 

154 profile_rows, 

155 OutputFormat.table, 

156 columns=profile_columns, 

157 footer=f"{len(app_config.profiles)} profiles", 

158 ) 

159 

160 

161@config_app.command("profiles") 

162def list_profiles( 

163 format: Annotated[ 

164 OutputFormat, 

165 typer.Option("--format", "-f", help="Output format"), 

166 ] = OutputFormat.table, 

167) -> None: 

168 """List configured profile names with default marked. 

169 

170 Examples: 

171 sentry-tool config profiles 

172 sentry-tool config profiles --format json 

173 """ 

174 try: 

175 app_config = load_config() 

176 except ConfigurationError as e: 

177 Console().print(f"[red]Config error: {e}[/red]") 

178 raise typer.Exit(1) from e 

179 

180 if not app_config.profiles: 

181 Console().print("No profiles configured.") 

182 return 

183 

184 rows = [ 

185 { 

186 "name": name, 

187 "default": "*" if name == app_config.default_profile else "", 

188 } 

189 for name in app_config.profiles 

190 ] 

191 

192 columns = [ 

193 Column("Name", "name", style="cyan"), 

194 Column("Default", "default", justify="center"), 

195 ] 

196 

197 render(rows, format, columns=columns, footer=f"{len(rows)} profiles") 

198 

199 

200@config_app.command("list-projects") 

201def list_projects( 

202 format: Annotated[ 

203 OutputFormat, 

204 typer.Option("--format", "-f", help="Output format"), 

205 ] = OutputFormat.table, 

206) -> None: 

207 """Enumerate Sentry projects for each configured profile. 

208 

209 Profiles with missing auth tokens are skipped with error message. 

210 

211 Examples: 

212 sentry-tool config list-projects 

213 sentry-tool config list-projects --format json 

214 """ 

215 log = get_logger("config") 

216 console = Console() 

217 

218 try: 

219 app_config = load_config() 

220 except ConfigurationError as e: 

221 console.print(f"[red]Config error: {e}[/red]") 

222 raise typer.Exit(1) from e 

223 

224 if not app_config.profiles: 

225 console.print("No profiles configured.") 

226 return 

227 

228 rows: list[dict[str, str]] = [] 

229 for profile_name, profile in app_config.profiles.items(): 

230 if not profile.auth_token or not profile.auth_token.strip(): 

231 rows.append({"profile": profile_name, "project": "(no auth token)"}) 

232 log.warning("profile_missing_token", profile=profile_name) 

233 continue 

234 

235 try: 

236 projects = api_call( 

237 f"/organizations/{profile.org}/projects/", 

238 token=profile.auth_token.strip(), 

239 base_url=profile.url, 

240 ) 

241 

242 if not projects: 

243 rows.append({"profile": profile_name, "project": "(no projects)"}) 

244 else: 

245 rows.extend( 

246 {"profile": profile_name, "project": proj.get("slug", "unknown")} 

247 for proj in projects 

248 ) 

249 

250 except NotFoundError: 

251 rows.append({ 

252 "profile": profile_name, 

253 "project": f"(org '{profile.org}' not found)", 

254 }) 

255 log.error("org_not_found", profile=profile_name, org=profile.org) 

256 except Exception as e: 

257 rows.append({"profile": profile_name, "project": f"(error: {e})"}) 

258 log.error("profile_api_error", profile=profile_name, error=str(e)) 

259 

260 columns = [ 

261 Column("Profile", "profile", style="cyan"), 

262 Column("Project", "project"), 

263 ] 

264 

265 render(rows, format, columns=columns) 

266 

267 

268@config_app.command("validate") 

269def validate( 

270 format: Annotated[ 

271 OutputFormat, 

272 typer.Option("--format", "-f", help="Output format"), 

273 ] = OutputFormat.table, 

274) -> None: 

275 """Verify connectivity to all configured profiles by querying projects. 

276 

277 Reports project count and slugs per profile. Useful after initial setup. 

278 

279 Examples: 

280 sentry-tool config validate 

281 sentry-tool config validate --format json 

282 """ 

283 log = get_logger("config") 

284 console = Console() 

285 

286 try: 

287 app_config = load_config() 

288 except ConfigurationError as e: 

289 console.print(f"[red]Config error: {e}[/red]") 

290 raise typer.Exit(1) from e 

291 

292 if not app_config.profiles: 

293 console.print("No profiles configured.") 

294 return 

295 

296 rows: list[dict[str, str]] = [] 

297 for profile_name, profile in app_config.profiles.items(): 

298 if not profile.auth_token or not profile.auth_token.strip(): 

299 rows.append({ 

300 "profile": profile_name, 

301 "status": "FAIL", 

302 "projects": "No auth token configured", 

303 }) 

304 log.warning("profile_missing_token", profile=profile_name) 

305 continue 

306 

307 try: 

308 projects = api_call( 

309 f"/organizations/{profile.org}/projects/", 

310 token=profile.auth_token.strip(), 

311 base_url=profile.url, 

312 ) 

313 

314 slugs = [proj.get("slug", "unknown") for proj in projects] 

315 slugs_str = ", ".join(slugs) if slugs else "(none)" 

316 

317 rows.append({ 

318 "profile": profile_name, 

319 "status": "OK", 

320 "projects": f"{len(projects)} projects — {slugs_str}", 

321 }) 

322 

323 except NotFoundError: 

324 rows.append({ 

325 "profile": profile_name, 

326 "status": "FAIL", 

327 "projects": f"Organization '{profile.org}' not found", 

328 }) 

329 log.error("org_not_found", profile=profile_name, org=profile.org) 

330 except Exception as e: 

331 rows.append({ 

332 "profile": profile_name, 

333 "status": "FAIL", 

334 "projects": str(e), 

335 }) 

336 log.error("profile_api_error", profile=profile_name, error=str(e)) 

337 

338 columns = [ 

339 Column("Profile", "profile", style="cyan"), 

340 Column("Status", "status"), 

341 Column("Projects", "projects"), 

342 ] 

343 

344 render(rows, format, columns=columns)