Coverage for railway/core/settings.py: 88%

1"""Settings management for Railway Framework."""

2

3 from pathlib import Path

4 from typing import Any

5

6 import yaml

7 from pydantic import BaseModel, Field

8 from pydantic_settings import BaseSettings, SettingsConfigDict

9

10

11 class APISettings(BaseModel):

12 """API configuration."""

13

14 base_url: str = ""

15 timeout: int = 30

16 max_retries: int = 3

17

18

19 class DatabaseSettings(BaseModel):

20 """Database configuration."""

21

22 host: str = "localhost"

23 port: int = 5432

24 name: str = "db"

25 user: str | None = None

26 password: str | None = None

27

28

29 class RetryNodeSettings(BaseModel):

30 """Per-node retry settings."""

31

32 max_attempts: int = 3

33 min_wait: int = 2

34 max_wait: int = 10

35 multiplier: int = 1

36

37

38 class RetrySettings(BaseModel):

39 """Retry policy configuration."""

40

41 default: RetryNodeSettings = Field(default_factory=RetryNodeSettings)

42 nodes: dict[str, RetryNodeSettings] = Field(default_factory=dict)

43

44

45 class LoggingHandlerSettings(BaseModel):

46 """Logging handler configuration."""

47

48 type: str # file, console

49 level: str = "INFO"

50 path: str | None = None

51 rotation: str | None = None

52 retention: str | None = None

53

54

55 class LoggingSettings(BaseModel):

56 """Logging configuration."""

57

58 level: str = "INFO"

59 format: str = "{time:HH:mm:ss} | {level} | {message}"

60 handlers: list[LoggingHandlerSettings] = Field(default_factory=list)

61

62

63 def _load_yaml_config(config_dir: Path, env: str) -> dict[str, Any]:

64 """

65 Load YAML configuration file.

66

67 Args:

68 config_dir: Directory containing config files

69 env: Environment name (development, production, etc.)

70

71 Returns:

72 Configuration dictionary

73 """

74 config_file = config_dir / f"{env}.yaml"

75

76 if not config_file.exists():

77 return {}

78

79 with open(config_file, encoding="utf-8") as f:

80 return yaml.safe_load(f) or {}

81

82

83 def _parse_api_settings(config: dict[str, Any]) -> APISettings:

84 """Parse API settings from config dict."""

85 api_config = config.get("api", {})

86 return APISettings(**api_config) if api_config else APISettings()

87

88

89 def _parse_database_settings(config: dict[str, Any]) -> DatabaseSettings:

90 """Parse database settings from config dict."""

91 db_config = config.get("database", {})

92 return DatabaseSettings(**db_config) if db_config else DatabaseSettings()

93

94

95 def _parse_retry_settings(config: dict[str, Any]) -> RetrySettings:

96 """Parse retry settings from config dict."""

97 retry_config = config.get("retry", {})

98 if not retry_config:

99 return RetrySettings()

100

101 default_retry = RetryNodeSettings(**retry_config.get("default", {}))

102 nodes_retry = {

103 name: RetryNodeSettings(**settings)

104 for name, settings in retry_config.get("nodes", {}).items()

105 }

106 return RetrySettings(default=default_retry, nodes=nodes_retry)

107

108

109 def _parse_logging_settings(config: dict[str, Any]) -> LoggingSettings:

110 """Parse logging settings from config dict."""

111 log_config = config.get("logging", {})

112 if not log_config:

113 return LoggingSettings()

114

115 handlers = [

116 LoggingHandlerSettings(**h) for h in log_config.get("handlers", [])

117 ]

118 return LoggingSettings(

119 level=log_config.get("level", "INFO"),

120 format=log_config.get("format", "{time:HH:mm:ss} | {level} | {message}"),

121 handlers=handlers,

122 )

123

124

125 def _apply_env_overrides(

126 logging_settings: LoggingSettings,

127 log_level: str | None,

128) -> LoggingSettings:

129 """

130 Apply environment variable overrides to logging settings.

131

132 Returns new LoggingSettings with overrides applied.

133 """

134 if log_level:

135 return LoggingSettings(

136 level=log_level,

137 format=logging_settings.format,

138 handlers=logging_settings.handlers,

139 )

140 return logging_settings

141

142

143 class Settings(BaseSettings):

144 """

145 Application settings.

146

147 Loads configuration from:

148 1. Environment variables (.env file)

149 2. YAML config file (config/{env}.yaml)

150

151 Environment variables override YAML config values.

152 """

153

154 model_config = SettingsConfigDict(

155 env_file=".env",

156 env_file_encoding="utf-8",

157 case_sensitive=False,

158 extra="allow",

159 )

160

161 # Environment variables

162 railway_env: str = "development"

163 app_name: str = "railway_app"

164 log_level: str | None = None # Override from .env

165

166 # Configuration from YAML (will be populated after init)

167 api: APISettings = Field(default_factory=APISettings)

168 database: DatabaseSettings = Field(default_factory=DatabaseSettings)

169 retry: RetrySettings = Field(default_factory=RetrySettings)

170 logging: LoggingSettings = Field(default_factory=LoggingSettings)

171

172 def __init__(self, _config_dir: str | None = None, **kwargs: Any) -> None:

173 """Initialize settings and load YAML config."""

174 super().__init__(**kwargs)

175

176 # Determine config directory

177 config_dir = self._resolve_config_dir(_config_dir)

178

179 # Load and parse YAML configuration

180 config = _load_yaml_config(config_dir, self.railway_env)

181

182 # Parse individual sections (functional approach)

183 self.api = _parse_api_settings(config)

184 self.database = _parse_database_settings(config)

185 self.retry = _parse_retry_settings(config)

186 self.logging = _parse_logging_settings(config)

187

188 # Apply environment variable overrides

189 self.logging = _apply_env_overrides(self.logging, self.log_level)

190

191 def _resolve_config_dir(self, config_dir: str | None) -> Path:

192 """Resolve the configuration directory path."""

193 if config_dir: 193 ↛ 197line 193 didn't jump to line 197 because the condition on line 193 was always true

194 return Path(config_dir)

195

196 # Default: look for config/ in current directory or parent

197 cwd_config = Path.cwd() / "config"

198 if cwd_config.exists():

199 return cwd_config

200

201 return Path(__file__).parent.parent.parent / "config"

202

203 def get_retry_settings(self, node_name: str) -> RetryNodeSettings:

204 """

205 Get retry settings for a specific node.

206

207 Args:

208 node_name: Name of the node

209

210 Returns:

211 RetryNodeSettings for the node, or default if not specified

212 """

213 return self.retry.nodes.get(node_name, self.retry.default)

214

215

216# Global settings instance (lazy initialization)

217 _settings: Settings | None = None

218

219

220 def get_settings(_config_dir: str | None = None) -> Settings:

221 """

222 Get the global settings instance.

223

224 Creates a new instance on first call, returns cached instance thereafter.

225

226 Args:

227 _config_dir: Optional config directory (only used on first call)

228

229 Returns:

230 Settings instance

231 """

232 global _settings

233 if _settings is None:

234 _settings = Settings(_config_dir=_config_dir)

235 return _settings

236

237

238 def reset_settings() -> None:

239 """Reset the global settings instance (for testing)."""

240 global _settings

241 _settings = None