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
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-28 19:20 -0500
1"""Configuration management commands."""
3import json
4import os
5from typing import Annotated, Any
7import typer
8from rich.console import Console
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
17config_app = typer.Typer(help="Configuration management commands")
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.
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
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 }
47 active_name = env["profile"] or app_config.default_profile
48 active_profile = app_config.profiles.get(active_name)
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)
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
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))
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()
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]")
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})
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 )
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")
128 if not app_config.profiles:
129 console.print("\nNo profiles configured")
130 return
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 ]
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 )
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.
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
180 if not app_config.profiles:
181 Console().print("No profiles configured.")
182 return
184 rows = [
185 {
186 "name": name,
187 "default": "*" if name == app_config.default_profile else "",
188 }
189 for name in app_config.profiles
190 ]
192 columns = [
193 Column("Name", "name", style="cyan"),
194 Column("Default", "default", justify="center"),
195 ]
197 render(rows, format, columns=columns, footer=f"{len(rows)} profiles")
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.
209 Profiles with missing auth tokens are skipped with error message.
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()
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
224 if not app_config.profiles:
225 console.print("No profiles configured.")
226 return
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
235 try:
236 projects = api_call(
237 f"/organizations/{profile.org}/projects/",
238 token=profile.auth_token.strip(),
239 base_url=profile.url,
240 )
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 )
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))
262 columns = [
263 Column("Profile", "profile", style="cyan"),
264 Column("Project", "project"),
265 ]
267 render(rows, format, columns=columns)
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.
279 Reports project count and slugs per profile. Useful after initial setup.
281 Examples:
282 sentry-tool config validate
283 sentry-tool config validate --format json
284 """
285 log = get_logger("config")
286 console = Console()
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
294 if not app_config.profiles:
295 console.print("No profiles configured.")
296 return
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
311 try:
312 projects = api_call(
313 f"/organizations/{profile.org}/projects/",
314 token=profile.auth_token.strip(),
315 base_url=profile.url,
316 )
318 slugs = [proj.get("slug", "unknown") for proj in projects]
319 slugs_str = ", ".join(slugs) if slugs else "(none)"
321 rows.append(
322 {
323 "profile": profile_name,
324 "status": "OK",
325 "projects": f"{len(projects)} projects — {slugs_str}",
326 }
327 )
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))
348 columns = [
349 Column("Profile", "profile", style="cyan"),
350 Column("Status", "status"),
351 Column("Projects", "projects"),
352 ]
354 render(rows, format, columns=columns)