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
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-07 20:19 -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 (
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))
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()
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]")
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})
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 })
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 "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))
260 columns = [
261 Column("Profile", "profile", style="cyan"),
262 Column("Project", "project"),
263 ]
265 render(rows, format, columns=columns)
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.
277 Reports project count and slugs per profile. Useful after initial setup.
279 Examples:
280 sentry-tool config validate
281 sentry-tool config validate --format json
282 """
283 log = get_logger("config")
284 console = Console()
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
292 if not app_config.profiles:
293 console.print("No profiles configured.")
294 return
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
307 try:
308 projects = api_call(
309 f"/organizations/{profile.org}/projects/",
310 token=profile.auth_token.strip(),
311 base_url=profile.url,
312 )
314 slugs = [proj.get("slug", "unknown") for proj in projects]
315 slugs_str = ", ".join(slugs) if slugs else "(none)"
317 rows.append({
318 "profile": profile_name,
319 "status": "OK",
320 "projects": f"{len(projects)} projects — {slugs_str}",
321 })
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))
338 columns = [
339 Column("Profile", "profile", style="cyan"),
340 Column("Status", "status"),
341 Column("Projects", "projects"),
342 ]
344 render(rows, format, columns=columns)