Coverage for src / sentry_tool / config.py: 100.00%
47 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-15 10:53 -0500
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-15 10:53 -0500
1"""Configuration management with profile support.
3Loads configuration from multiple sources with layered precedence:
41. CLI flags (--profile)
52. Environment variables (SENTRY_PROFILE, SENTRY_URL, etc.)
63. Config file (~/.config/sentry-tool/config.toml)
74. Default values (lowest priority)
8"""
10import os
11import tomllib
12from dataclasses import dataclass, field
13from pathlib import Path
14from typing import Any
16from pydantic import BaseModel, Field
18from sentry_tool.exceptions import ConfigurationError
21class SentryProfile(BaseModel):
22 """All fields optional in profile, required after resolution with env vars."""
24 url: str = Field(
25 default="https://sentry.io",
26 description="Base URL for Sentry instance",
27 )
28 org: str = Field(
29 default="sentry",
30 description="Sentry organization slug",
31 )
32 project: str = Field(
33 default="otel-collector",
34 description="Sentry project slug",
35 )
36 auth_token: str | None = Field(
37 default=None,
38 description="Sentry API authentication token",
39 )
42class AppConfig(BaseModel):
43 """Application-wide configuration with profile management."""
45 default_profile: str = Field(default="default")
46 profiles: dict[str, SentryProfile] = Field(default_factory=lambda: {"default": SentryProfile()})
49def load_config(config_path: Path | None = None) -> AppConfig:
50 """Search order: explicit path > ~/.config/sentry-tool/config.toml > defaults.
52 Returns default AppConfig if no file found.
53 """
54 search_paths = [
55 Path.home() / ".config" / "sentry-tool" / "config.toml",
56 ]
58 if config_path:
59 search_paths.insert(0, config_path)
61 for path in search_paths:
62 if path.exists():
63 with path.open("rb") as f:
64 config_data = tomllib.load(f)
65 return AppConfig(**config_data)
67 # No config file found, return defaults
68 return AppConfig()
71def get_profile(config: AppConfig, profile: str | None = None) -> SentryProfile:
72 """Resolution order: explicit profile > SENTRY_PROFILE env > config default.
74 Raises ConfigurationError if profile name not found in config.
75 """
76 profile_name = profile or os.environ.get("SENTRY_PROFILE") or config.default_profile
78 if profile_name not in config.profiles:
79 available = ", ".join(sorted(config.profiles.keys()))
80 raise ConfigurationError(
81 f"Profile '{profile_name}' not found. Available profiles: {available}"
82 )
84 return config.profiles[profile_name]
87@dataclass
88class EnvOverrides:
89 """Environment variable and CLI flag overrides for config resolution."""
91 cli_project: str | None = field(default=None)
92 url: str | None = field(default=None)
93 org: str | None = field(default=None)
94 project: str | None = field(default=None)
95 auth_token: str | None = field(default=None)
98def resolve_sentry_config(
99 config: SentryProfile,
100 overrides: EnvOverrides | None = None,
101 **kwargs: str | None,
102) -> dict[str, Any]:
103 """Precedence: CLI flags > environment variables > profile > defaults.
105 Raises ConfigurationError if final auth_token is empty or missing.
107 Accepts either an EnvOverrides object or keyword arguments for backwards compatibility:
108 cli_project, env_url, env_org, env_project, env_auth_token
109 """
110 if overrides is None:
111 overrides = EnvOverrides(
112 cli_project=kwargs.get("cli_project"),
113 url=kwargs.get("env_url"),
114 org=kwargs.get("env_org"),
115 project=kwargs.get("env_project"),
116 auth_token=kwargs.get("env_auth_token"),
117 )
119 auth_token = overrides.auth_token if overrides.auth_token is not None else config.auth_token
121 if not auth_token or not auth_token.strip():
122 msg = "SENTRY_AUTH_TOKEN not set in profile or environment"
123 raise ConfigurationError(msg)
125 # Project precedence: CLI flag > env var > profile
126 project = (
127 overrides.cli_project
128 if overrides.cli_project is not None
129 else (overrides.project if overrides.project is not None else config.project)
130 )
132 return {
133 "url": overrides.url if overrides.url is not None else config.url,
134 "org": overrides.org if overrides.org is not None else config.org,
135 "project": project,
136 "auth_token": auth_token.strip(),
137 }