Coverage for src/taipanstack/config/models.py: 100%

78 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 15:01 +0000

1""" 

2Configuration models with Pydantic validation. 

3 

4This module provides type-safe configuration models that validate 

5all inputs at runtime, preventing errors and AI hallucinations. 

6""" 

7 

8import re 

9import sys 

10from pathlib import Path 

11from typing import Literal 

12 

13from pydantic import ( 

14 BaseModel, 

15 ConfigDict, 

16 Field, 

17 field_validator, 

18 model_validator, 

19) 

20 

21# Constants to avoid magic values (PLR2004) 

22PYTHON_MAJOR_VERSION = 3 

23MIN_PYTHON_MINOR_VERSION = 10 

24 

25 

26class SecurityConfig(BaseModel): 

27 """Security-related configuration options. 

28 

29 Attributes: 

30 level: Security strictness level. 

31 enable_bandit: Enable Bandit SAST scanner. 

32 enable_pip_audit: Enable pip-audit dependency checker. 

33 enable_semgrep: Enable Semgrep analysis. 

34 enable_detect_secrets: Enable secret detection. 

35 bandit_severity: Minimum severity level for Bandit. 

36 

37 """ 

38 

39 level: Literal["standard", "strict", "paranoid"] = Field( 

40 default="strict", 

41 description="Security strictness level", 

42 ) 

43 enable_bandit: bool = Field(default=True, description="Enable Bandit SAST") 

44 enable_pip_audit: bool = Field(default=True, description="Enable pip-audit SCA") 

45 enable_semgrep: bool = Field(default=True, description="Enable Semgrep") 

46 enable_detect_secrets: bool = Field( 

47 default=True, 

48 description="Enable secret detection", 

49 ) 

50 bandit_severity: Literal["low", "medium", "high"] = Field( 

51 default="low", 

52 description="Minimum Bandit severity", 

53 ) 

54 

55 model_config = ConfigDict(frozen=True, extra="forbid") 

56 

57 

58class DependencyConfig(BaseModel): 

59 """Dependency management configuration. 

60 

61 Attributes: 

62 install_runtime_deps: Install pydantic, orjson, uvloop. 

63 install_dev_deps: Install development dependencies. 

64 dev_dependencies: List of dev dependencies to install. 

65 runtime_dependencies: List of runtime dependencies to install. 

66 

67 """ 

68 

69 install_runtime_deps: bool = Field( 

70 default=False, 

71 description="Install runtime dependencies (pydantic, orjson, uvloop)", 

72 ) 

73 install_dev_deps: bool = Field( 

74 default=True, 

75 description="Install development dependencies", 

76 ) 

77 dev_dependencies: list[str] = Field( 

78 default_factory=lambda: [ 

79 "ruff", 

80 "mypy", 

81 "bandit", 

82 "pip-audit", 

83 "pre-commit", 

84 "pytest", 

85 "pytest-cov", 

86 "py-spy", 

87 "semgrep", 

88 ], 

89 description="Development dependencies to install", 

90 ) 

91 runtime_dependencies: list[str] = Field( 

92 default_factory=lambda: ["pydantic>=2.0", "orjson"], 

93 description="Runtime dependencies to install", 

94 ) 

95 

96 model_config = ConfigDict(frozen=True, extra="forbid") 

97 

98 

99class LoggingConfig(BaseModel): 

100 """Logging configuration options. 

101 

102 Attributes: 

103 level: Log level. 

104 format: Log format type. 

105 enable_structured: Use structured logging (JSON). 

106 

107 """ 

108 

109 level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field( 

110 default="INFO", 

111 description="Logging level", 

112 ) 

113 format: Literal["simple", "detailed", "json"] = Field( 

114 default="detailed", 

115 description="Log output format", 

116 ) 

117 enable_structured: bool = Field( 

118 default=False, 

119 description="Enable JSON structured logging", 

120 ) 

121 

122 model_config = ConfigDict(frozen=True, extra="forbid") 

123 

124 

125class StackConfig(BaseModel): 

126 """Main Stack configuration with full validation. 

127 

128 This is the primary configuration model that validates all Stack 

129 settings at runtime, preventing configuration errors and catching 

130 AI hallucinations early. 

131 

132 Attributes: 

133 project_name: Name of the project (alphanumeric, _, -). 

134 python_version: Target Python version (e.g., "3.12"). 

135 project_dir: Directory to initialize project in. 

136 dry_run: Simulate execution without changes. 

137 force: Overwrite existing files without backup. 

138 verbose: Enable verbose logging. 

139 security: Security configuration. 

140 dependencies: Dependency configuration. 

141 logging: Logging configuration. 

142 

143 Example: 

144 >>> config = StackConfig( 

145 ... project_name="my_project", 

146 ... python_version="3.12", 

147 ... ) 

148 >>> config.project_name 

149 'my_project' 

150 

151 """ 

152 

153 project_name: str = Field( 

154 default="my_project", 

155 min_length=1, 

156 max_length=100, 

157 description="Project name (alphanumeric, underscore, hyphen only)", 

158 ) 

159 python_version: str = Field( 

160 default_factory=lambda: f"{sys.version_info.major}.{sys.version_info.minor}", 

161 description="Target Python version", 

162 ) 

163 project_dir: Path = Field( 

164 default_factory=Path.cwd, 

165 description="Project directory", 

166 ) 

167 dry_run: bool = Field( 

168 default=False, 

169 description="Simulate execution without making changes", 

170 ) 

171 force: bool = Field( 

172 default=False, 

173 description="Overwrite existing files without backup", 

174 ) 

175 verbose: bool = Field( 

176 default=False, 

177 description="Enable verbose output", 

178 ) 

179 security: SecurityConfig = Field( 

180 default_factory=SecurityConfig, 

181 description="Security configuration", 

182 ) 

183 dependencies: DependencyConfig = Field( 

184 default_factory=DependencyConfig, 

185 description="Dependency configuration", 

186 ) 

187 logging: LoggingConfig = Field( 

188 default_factory=LoggingConfig, 

189 description="Logging configuration", 

190 ) 

191 

192 model_config = ConfigDict( 

193 frozen=True, 

194 extra="forbid", 

195 validate_default=True, 

196 ) 

197 

198 @field_validator("project_name") 

199 @classmethod 

200 def validate_project_name(cls, value: str) -> str: 

201 """Validate that project name is safe. 

202 

203 Args: 

204 value: The project name to validate. 

205 

206 Returns: 

207 The validated project name. 

208 

209 Raises: 

210 ValueError: If project name contains invalid characters. 

211 

212 """ 

213 _ = cls 

214 pattern = r"^[a-zA-Z][a-zA-Z0-9_-]*\Z" 

215 if not re.match(pattern, value): 

216 msg = ( 

217 f"Project name '{value}' is invalid. " 

218 "Must start with a letter and contain only alphanumeric, " 

219 "underscore, or hyphen characters." 

220 ) 

221 raise ValueError(msg) 

222 return value 

223 

224 @field_validator("python_version") 

225 @classmethod 

226 def validate_python_version(cls, value: str) -> str: 

227 """Validate Python version format. 

228 

229 Args: 

230 value: The Python version string. 

231 

232 Returns: 

233 The validated Python version. 

234 

235 Raises: 

236 ValueError: If version format is invalid. 

237 

238 """ 

239 _ = cls 

240 pattern = r"^\d+\.\d+\Z" 

241 if not re.match(pattern, value): 

242 msg = ( 

243 f"Python version '{value}' is invalid. Use format 'X.Y' (e.g., '3.12')." 

244 ) 

245 raise ValueError(msg) 

246 

247 major, minor = map(int, value.split(".")) 

248 is_old_python = major < PYTHON_MAJOR_VERSION or ( 

249 major == PYTHON_MAJOR_VERSION and minor < MIN_PYTHON_MINOR_VERSION 

250 ) 

251 if is_old_python: 

252 msg = ( 

253 f"Python version {value} is not supported. " 

254 f"Minimum is {PYTHON_MAJOR_VERSION}.{MIN_PYTHON_MINOR_VERSION}." 

255 ) 

256 raise ValueError(msg) 

257 

258 return value 

259 

260 @field_validator("project_dir") 

261 @classmethod 

262 def validate_project_dir(cls, value: Path) -> Path: 

263 """Validate project directory is safe. 

264 

265 Args: 

266 value: The project directory path. 

267 

268 Returns: 

269 The validated and resolved path. 

270 

271 Raises: 

272 ValueError: If path is unsafe or contains traversal. 

273 

274 """ 

275 _ = cls 

276 resolved = value.resolve() 

277 

278 # Check for path traversal attempts 

279 if ".." in str(value): 

280 msg = f"Path traversal detected in project_dir: {value}" 

281 raise ValueError(msg) 

282 

283 return resolved 

284 

285 @model_validator(mode="after") 

286 def validate_config_consistency(self) -> "StackConfig": 

287 """Validate configuration consistency. 

288 

289 Returns: 

290 The validated configuration. 

291 

292 Raises: 

293 ValueError: If configuration is inconsistent. 

294 

295 """ 

296 # If paranoid security, ensure all security tools are enabled 

297 all_tools_enabled = all( 

298 [ 

299 self.security.enable_bandit, 

300 self.security.enable_pip_audit, 

301 self.security.enable_semgrep, 

302 self.security.enable_detect_secrets, 

303 ], 

304 ) 

305 if self.security.level == "paranoid" and not all_tools_enabled: 

306 msg = "Paranoid security level requires all security tools enabled." 

307 raise ValueError(msg) 

308 

309 return self 

310 

311 def to_target_version(self) -> str: 

312 """Get Python version in Ruff target format. 

313 

314 Returns: 

315 Version string like 'py312'. 

316 

317 """ 

318 return f"py{self.python_version.replace('.', '')}"