Coverage for src / sentry_tool / config.py: 100.00%

48 statements  

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

1"""Configuration management with profile support. 

2 

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""" 

9 

10import os 

11import tomllib 

12from dataclasses import dataclass, field 

13from pathlib import Path 

14from typing import Any 

15 

16from pydantic import BaseModel, Field 

17 

18from sentry_tool.exceptions import ConfigurationError 

19 

20 

21class SentryProfile(BaseModel): 

22 """All fields optional in profile, required after resolution with env vars.""" 

23 

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 ) 

40 

41 

42class AppConfig(BaseModel): 

43 """Application-wide configuration with profile management.""" 

44 

45 default_profile: str = Field(default="default") 

46 profiles: dict[str, SentryProfile] = Field(default_factory=lambda: {"default": SentryProfile()}) 

47 sentry_dsn: str | None = Field( 

48 default=None, 

49 description="Sentry DSN override (env var > config > hardcoded default)", 

50 ) 

51 

52 

53def load_config(config_path: Path | None = None) -> AppConfig: 

54 """Search order: explicit path > ~/.config/sentry-tool/config.toml > defaults. 

55 

56 Returns default AppConfig if no file found. 

57 """ 

58 search_paths = [ 

59 Path.home() / ".config" / "sentry-tool" / "config.toml", 

60 ] 

61 

62 if config_path: 

63 search_paths.insert(0, config_path) 

64 

65 for path in search_paths: 

66 if path.exists(): 

67 with path.open("rb") as f: 

68 config_data = tomllib.load(f) 

69 return AppConfig(**config_data) 

70 

71 return AppConfig() 

72 

73 

74def get_profile(config: AppConfig, profile: str | None = None) -> SentryProfile: 

75 """Resolution order: explicit profile > SENTRY_PROFILE env > config default. 

76 

77 Raises ConfigurationError if profile name not found in config. 

78 """ 

79 profile_name = profile or os.environ.get("SENTRY_PROFILE") or config.default_profile 

80 

81 if profile_name not in config.profiles: 

82 available = ", ".join(sorted(config.profiles.keys())) 

83 raise ConfigurationError( 

84 f"Profile '{profile_name}' not found. Available profiles: {available}" 

85 ) 

86 

87 return config.profiles[profile_name] 

88 

89 

90@dataclass 

91class EnvOverrides: 

92 """Environment variable and CLI flag overrides for config resolution.""" 

93 

94 cli_project: str | None = field(default=None) 

95 url: str | None = field(default=None) 

96 org: str | None = field(default=None) 

97 project: str | None = field(default=None) 

98 auth_token: str | None = field(default=None) 

99 

100 

101def resolve_sentry_config( 

102 config: SentryProfile, 

103 overrides: EnvOverrides | None = None, 

104 **kwargs: str | None, 

105) -> dict[str, Any]: 

106 """Precedence: CLI flags > environment variables > profile > defaults. 

107 

108 Raises ConfigurationError if final auth_token is empty or missing. 

109 

110 Accepts either an EnvOverrides object or keyword arguments for backwards compatibility: 

111 cli_project, env_url, env_org, env_project, env_auth_token 

112 """ 

113 if overrides is None: 

114 overrides = EnvOverrides( 

115 cli_project=kwargs.get("cli_project"), 

116 url=kwargs.get("env_url"), 

117 org=kwargs.get("env_org"), 

118 project=kwargs.get("env_project"), 

119 auth_token=kwargs.get("env_auth_token"), 

120 ) 

121 

122 auth_token = overrides.auth_token if overrides.auth_token is not None else config.auth_token 

123 

124 if not auth_token or not auth_token.strip(): 

125 msg = "SENTRY_AUTH_TOKEN not set in profile or environment" 

126 raise ConfigurationError(msg) 

127 

128 # Project precedence: CLI flag > env var > profile 

129 project = ( 

130 overrides.cli_project 

131 if overrides.cli_project is not None 

132 else (overrides.project if overrides.project is not None else config.project) 

133 ) 

134 

135 return { 

136 "url": overrides.url if overrides.url is not None else config.url, 

137 "org": overrides.org if overrides.org is not None else config.org, 

138 "project": project, 

139 "auth_token": auth_token.strip(), 

140 }