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

133 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-28 19:20 -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 (active_profile.auth_token if active_profile else None) 

69 output = { 

70 "default_profile": app_config.default_profile, 

71 "active_profile": active_name, 

72 "effective": { 

73 "url": _effective("url"), 

74 "org": _effective("org"), 

75 "project": _effective("project"), 

76 "auth_token": mask_token(effective_token), 

77 }, 

78 "profiles": { 

79 name: { 

80 "url": profile.url, 

81 "org": profile.org, 

82 "project": profile.project, 

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

84 } 

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

86 }, 

87 } 

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

89 

90 

91def _print_show_tables( 

92 app_config: AppConfig, 

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

94 active_name: str | None, 

95 active_profile: Any, 

96) -> None: 

97 console = Console() 

98 

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

100 if env["profile"]: 

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

102 

103 if active_profile: 

104 rows = [] 

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

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

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

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

109 

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

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

112 rows.append( 

113 { 

114 "setting": "auth_token", 

115 "value": mask_token(auth_token), 

116 "source": source, 

117 } 

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 { 

253 "profile": profile_name, 

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

255 } 

256 ) 

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

258 except Exception as e: 

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

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

261 

262 columns = [ 

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

264 Column("Project", "project"), 

265 ] 

266 

267 render(rows, format, columns=columns) 

268 

269 

270@config_app.command("validate") 

271def validate( 

272 format: Annotated[ 

273 OutputFormat, 

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

275 ] = OutputFormat.table, 

276) -> None: 

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

278 

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

280 

281 Examples: 

282 sentry-tool config validate 

283 sentry-tool config validate --format json 

284 """ 

285 log = get_logger("config") 

286 console = Console() 

287 

288 try: 

289 app_config = load_config() 

290 except ConfigurationError as e: 

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

292 raise typer.Exit(1) from e 

293 

294 if not app_config.profiles: 

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

296 return 

297 

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

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

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

301 rows.append( 

302 { 

303 "profile": profile_name, 

304 "status": "FAIL", 

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

306 } 

307 ) 

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

309 continue 

310 

311 try: 

312 projects = api_call( 

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

314 token=profile.auth_token.strip(), 

315 base_url=profile.url, 

316 ) 

317 

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

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

320 

321 rows.append( 

322 { 

323 "profile": profile_name, 

324 "status": "OK", 

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

326 } 

327 ) 

328 

329 except NotFoundError: 

330 rows.append( 

331 { 

332 "profile": profile_name, 

333 "status": "FAIL", 

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

335 } 

336 ) 

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

338 except Exception as e: 

339 rows.append( 

340 { 

341 "profile": profile_name, 

342 "status": "FAIL", 

343 "projects": str(e), 

344 } 

345 ) 

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

347 

348 columns = [ 

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

350 Column("Status", "status"), 

351 Column("Projects", "projects"), 

352 ] 

353 

354 render(rows, format, columns=columns)