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

78 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-12 21:18 +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_safety: Enable Safety 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_safety: bool = Field(default=True, description="Enable Safety SCA") 

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

46 enable_detect_secrets: bool = Field( 

47 default=True, description="Enable secret detection" 

48 ) 

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

50 default="low", 

51 description="Minimum Bandit severity", 

52 ) 

53 

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

55 

56 

57class DependencyConfig(BaseModel): 

58 """Dependency management configuration. 

59 

60 Attributes: 

61 install_runtime_deps: Install pydantic, orjson, uvloop. 

62 install_dev_deps: Install development dependencies. 

63 dev_dependencies: List of dev dependencies to install. 

64 runtime_dependencies: List of runtime dependencies to install. 

65 

66 """ 

67 

68 install_runtime_deps: bool = Field( 

69 default=False, 

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

71 ) 

72 install_dev_deps: bool = Field( 

73 default=True, 

74 description="Install development dependencies", 

75 ) 

76 dev_dependencies: list[str] = Field( 

77 default_factory=lambda: [ 

78 "ruff", 

79 "mypy", 

80 "bandit", 

81 "safety", 

82 "pre-commit", 

83 "pytest", 

84 "pytest-cov", 

85 "py-spy", 

86 "semgrep", 

87 ], 

88 description="Development dependencies to install", 

89 ) 

90 runtime_dependencies: list[str] = Field( 

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

92 description="Runtime dependencies to install", 

93 ) 

94 

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

96 

97 

98class LoggingConfig(BaseModel): 

99 """Logging configuration options. 

100 

101 Attributes: 

102 level: Log level. 

103 format: Log format type. 

104 enable_structured: Use structured logging (JSON). 

105 

106 """ 

107 

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

109 default="INFO", 

110 description="Logging level", 

111 ) 

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

113 default="detailed", 

114 description="Log output format", 

115 ) 

116 enable_structured: bool = Field( 

117 default=False, 

118 description="Enable JSON structured logging", 

119 ) 

120 

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

122 

123 

124class StackConfig(BaseModel): 

125 """Main Stack configuration with full validation. 

126 

127 This is the primary configuration model that validates all Stack 

128 settings at runtime, preventing configuration errors and catching 

129 AI hallucinations early. 

130 

131 Attributes: 

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

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

134 project_dir: Directory to initialize project in. 

135 dry_run: Simulate execution without changes. 

136 force: Overwrite existing files without backup. 

137 verbose: Enable verbose logging. 

138 security: Security configuration. 

139 dependencies: Dependency configuration. 

140 logging: Logging configuration. 

141 

142 Example: 

143 >>> config = StackConfig( 

144 ... project_name="my_project", 

145 ... python_version="3.12", 

146 ... ) 

147 >>> config.project_name 

148 'my_project' 

149 

150 """ 

151 

152 project_name: str = Field( 

153 default="my_project", 

154 min_length=1, 

155 max_length=100, 

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

157 ) 

158 python_version: str = Field( 

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

160 description="Target Python version", 

161 ) 

162 project_dir: Path = Field( 

163 default_factory=Path.cwd, 

164 description="Project directory", 

165 ) 

166 dry_run: bool = Field( 

167 default=False, 

168 description="Simulate execution without making changes", 

169 ) 

170 force: bool = Field( 

171 default=False, 

172 description="Overwrite existing files without backup", 

173 ) 

174 verbose: bool = Field( 

175 default=False, 

176 description="Enable verbose output", 

177 ) 

178 security: SecurityConfig = Field( 

179 default_factory=SecurityConfig, 

180 description="Security configuration", 

181 ) 

182 dependencies: DependencyConfig = Field( 

183 default_factory=DependencyConfig, 

184 description="Dependency configuration", 

185 ) 

186 logging: LoggingConfig = Field( 

187 default_factory=LoggingConfig, 

188 description="Logging configuration", 

189 ) 

190 

191 model_config = ConfigDict( 

192 frozen=True, 

193 extra="forbid", 

194 validate_default=True, 

195 ) 

196 

197 @field_validator("project_name") 

198 @classmethod 

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

200 """Validate that project name is safe. 

201 

202 Args: 

203 value: The project name to validate. 

204 

205 Returns: 

206 The validated project name. 

207 

208 Raises: 

209 ValueError: If project name contains invalid characters. 

210 

211 """ 

212 _ = cls 

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

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

215 msg = ( 

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

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

218 "underscore, or hyphen characters." 

219 ) 

220 raise ValueError(msg) 

221 return value 

222 

223 @field_validator("python_version") 

224 @classmethod 

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

226 """Validate Python version format. 

227 

228 Args: 

229 value: The Python version string. 

230 

231 Returns: 

232 The validated Python version. 

233 

234 Raises: 

235 ValueError: If version format is invalid. 

236 

237 """ 

238 _ = cls 

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

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

241 msg = ( 

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

243 ) 

244 raise ValueError(msg) 

245 

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

247 is_old_python = major < PYTHON_MAJOR_VERSION or ( 

248 major == PYTHON_MAJOR_VERSION and minor < MIN_PYTHON_MINOR_VERSION 

249 ) 

250 if is_old_python: 

251 msg = ( 

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

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

254 ) 

255 raise ValueError(msg) 

256 

257 return value 

258 

259 @field_validator("project_dir") 

260 @classmethod 

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

262 """Validate project directory is safe. 

263 

264 Args: 

265 value: The project directory path. 

266 

267 Returns: 

268 The validated and resolved path. 

269 

270 Raises: 

271 ValueError: If path is unsafe or contains traversal. 

272 

273 """ 

274 _ = cls 

275 resolved = value.resolve() 

276 

277 # Check for path traversal attempts 

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

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

280 raise ValueError(msg) 

281 

282 return resolved 

283 

284 @model_validator(mode="after") 

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

286 """Validate configuration consistency. 

287 

288 Returns: 

289 The validated configuration. 

290 

291 Raises: 

292 ValueError: If configuration is inconsistent. 

293 

294 """ 

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

296 all_tools_enabled = all( 

297 [ 

298 self.security.enable_bandit, 

299 self.security.enable_safety, 

300 self.security.enable_semgrep, 

301 self.security.enable_detect_secrets, 

302 ] 

303 ) 

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

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

306 raise ValueError(msg) 

307 

308 return self 

309 

310 def to_target_version(self) -> str: 

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

312 

313 Returns: 

314 Version string like 'py312'. 

315 

316 """ 

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