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

82 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-10-13 20:52 +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 

26def _check_python_version_format(value: str) -> None: 

27 """Validate Python version matches the X.Y format.""" 

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

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

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

31 raise ValueError(msg) 

32 

33 

34def _check_python_minimum_version(major: int, minor: int, value: str) -> None: 

35 """Validate Python version meets the minimum supported requirement.""" 

36 is_old_python = major < PYTHON_MAJOR_VERSION or ( 

37 major == PYTHON_MAJOR_VERSION and minor < MIN_PYTHON_MINOR_VERSION 

38 ) 

39 if is_old_python: 

40 msg = ( 

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

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

43 ) 

44 raise ValueError(msg) 

45 

46 

47class SecurityConfig(BaseModel): 

48 """Security-related configuration options. 

49 

50 Attributes: 

51 level: Security strictness level. 

52 enable_bandit: Enable Bandit SAST scanner. 

53 enable_pip_audit: Enable pip-audit dependency checker. 

54 enable_semgrep: Enable Semgrep analysis. 

55 enable_detect_secrets: Enable secret detection. 

56 bandit_severity: Minimum severity level for Bandit. 

57 

58 """ 

59 

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

61 default="strict", 

62 description="Security strictness level", 

63 ) 

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

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

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

67 enable_detect_secrets: bool = Field( 

68 default=True, 

69 description="Enable secret detection", 

70 ) 

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

72 default="low", 

73 description="Minimum Bandit severity", 

74 ) 

75 

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

77 

78 

79class DependencyConfig(BaseModel): 

80 """Dependency management configuration. 

81 

82 Attributes: 

83 install_runtime_deps: Install pydantic, orjson, uvloop. 

84 install_dev_deps: Install development dependencies. 

85 dev_dependencies: List of dev dependencies to install. 

86 runtime_dependencies: List of runtime dependencies to install. 

87 

88 """ 

89 

90 install_runtime_deps: bool = Field( 

91 default=False, 

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

93 ) 

94 install_dev_deps: bool = Field( 

95 default=True, 

96 description="Install development dependencies", 

97 ) 

98 dev_dependencies: list[str] = Field( 

99 default_factory=lambda: [ 

100 "ruff", 

101 "mypy", 

102 "bandit", 

103 "pip-audit", 

104 "pre-commit", 

105 "pytest", 

106 "pytest-cov", 

107 "py-spy", 

108 "semgrep", 

109 ], 

110 description="Development dependencies to install", 

111 ) 

112 runtime_dependencies: list[str] = Field( 

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

114 description="Runtime dependencies to install", 

115 ) 

116 

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

118 

119 

120class LoggingConfig(BaseModel): 

121 """Logging configuration options. 

122 

123 Attributes: 

124 level: Log level. 

125 format: Log format type. 

126 enable_structured: Use structured logging (JSON). 

127 

128 """ 

129 

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

131 default="INFO", 

132 description="Logging level", 

133 ) 

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

135 default="detailed", 

136 description="Log output format", 

137 ) 

138 enable_structured: bool = Field( 

139 default=False, 

140 description="Enable JSON structured logging", 

141 ) 

142 

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

144 

145 

146class StackConfig(BaseModel): 

147 """Main TaipanStack configuration with full validation. 

148 

149 This is the primary configuration model that validates all TaipanStack 

150 settings at runtime, preventing configuration errors and catching 

151 AI hallucinations early. 

152 

153 Attributes: 

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

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

156 project_dir: Directory to initialize project in. 

157 dry_run: Simulate execution without changes. 

158 force: Overwrite existing files without backup. 

159 verbose: Enable verbose logging. 

160 security: Security configuration. 

161 dependencies: Dependency configuration. 

162 logging: Logging configuration. 

163 

164 Example: 

165 >>> config = StackConfig( 

166 ... project_name="my_project", 

167 ... python_version="3.12", 

168 ... ) 

169 >>> config.project_name 

170 'my_project' 

171 

172 """ 

173 

174 project_name: str = Field( 

175 default="my_project", 

176 min_length=1, 

177 max_length=100, 

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

179 ) 

180 python_version: str = Field( 

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

182 description="Target Python version", 

183 ) 

184 project_dir: Path = Field( 

185 default_factory=Path.cwd, 

186 description="Project directory", 

187 ) 

188 dry_run: bool = Field( 

189 default=False, 

190 description="Simulate execution without making changes", 

191 ) 

192 force: bool = Field( 

193 default=False, 

194 description="Overwrite existing files without backup", 

195 ) 

196 verbose: bool = Field( 

197 default=False, 

198 description="Enable verbose output", 

199 ) 

200 security: SecurityConfig = Field( 

201 default_factory=SecurityConfig, 

202 description="Security configuration", 

203 ) 

204 dependencies: DependencyConfig = Field( 

205 default_factory=DependencyConfig, 

206 description="Dependency configuration", 

207 ) 

208 logging: LoggingConfig = Field( 

209 default_factory=LoggingConfig, 

210 description="Logging configuration", 

211 ) 

212 

213 model_config = ConfigDict( 

214 frozen=True, 

215 extra="forbid", 

216 validate_default=True, 

217 ) 

218 

219 @field_validator("project_name") 

220 @classmethod 

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

222 """Validate that project name is safe. 

223 

224 Args: 

225 value: The project name to validate. 

226 

227 Returns: 

228 The validated project name. 

229 

230 Raises: 

231 ValueError: If project name contains invalid characters. 

232 

233 """ 

234 _ = cls 

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

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

237 msg = ( 

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

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

240 "underscore, or hyphen characters." 

241 ) 

242 raise ValueError(msg) 

243 return value 

244 

245 @field_validator("python_version") 

246 @classmethod 

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

248 """Validate Python version format. 

249 

250 Args: 

251 value: The Python version string. 

252 

253 Returns: 

254 The validated Python version. 

255 

256 Raises: 

257 ValueError: If version format is invalid. 

258 

259 """ 

260 _ = cls 

261 _check_python_version_format(value) 

262 

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

264 _check_python_minimum_version(major, minor, value) 

265 

266 return value 

267 

268 @field_validator("project_dir") 

269 @classmethod 

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

271 """Validate project directory is safe. 

272 

273 Args: 

274 value: The project directory path. 

275 

276 Returns: 

277 The validated and resolved path. 

278 

279 Raises: 

280 ValueError: If path is unsafe or contains traversal. 

281 

282 """ 

283 _ = cls 

284 resolved = value.resolve() 

285 

286 # Check for path traversal attempts 

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

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

289 raise ValueError(msg) 

290 

291 return resolved 

292 

293 @model_validator(mode="after") 

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

295 """Validate configuration consistency. 

296 

297 Returns: 

298 The validated configuration. 

299 

300 Raises: 

301 ValueError: If configuration is inconsistent. 

302 

303 """ 

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

305 all_tools_enabled = all( 

306 [ 

307 self.security.enable_bandit, 

308 self.security.enable_pip_audit, 

309 self.security.enable_semgrep, 

310 self.security.enable_detect_secrets, 

311 ], 

312 ) 

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

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

315 raise ValueError(msg) 

316 

317 return self 

318 

319 def to_target_version(self) -> str: 

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

321 

322 Returns: 

323 Version string like 'py312'. 

324 

325 """ 

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