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

75 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-23 14:54 +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 pattern = r"^[a-zA-Z][a-zA-Z0-9_-]*\Z" 

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

214 msg = ( 

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

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

217 "underscore, or hyphen characters." 

218 ) 

219 raise ValueError(msg) 

220 return value 

221 

222 @field_validator("python_version") 

223 @classmethod 

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

225 """Validate Python version format. 

226 

227 Args: 

228 value: The Python version string. 

229 

230 Returns: 

231 The validated Python version. 

232 

233 Raises: 

234 ValueError: If version format is invalid. 

235 

236 """ 

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

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

239 msg = ( 

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

241 ) 

242 raise ValueError(msg) 

243 

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

245 is_old_python = major < PYTHON_MAJOR_VERSION or ( 

246 major == PYTHON_MAJOR_VERSION and minor < MIN_PYTHON_MINOR_VERSION 

247 ) 

248 if is_old_python: 

249 msg = ( 

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

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

252 ) 

253 raise ValueError(msg) 

254 

255 return value 

256 

257 @field_validator("project_dir") 

258 @classmethod 

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

260 """Validate project directory is safe. 

261 

262 Args: 

263 value: The project directory path. 

264 

265 Returns: 

266 The validated and resolved path. 

267 

268 Raises: 

269 ValueError: If path is unsafe or contains traversal. 

270 

271 """ 

272 resolved = value.resolve() 

273 

274 # Check for path traversal attempts 

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

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

277 raise ValueError(msg) 

278 

279 return resolved 

280 

281 @model_validator(mode="after") 

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

283 """Validate configuration consistency. 

284 

285 Returns: 

286 The validated configuration. 

287 

288 Raises: 

289 ValueError: If configuration is inconsistent. 

290 

291 """ 

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

293 all_tools_enabled = all( 

294 [ 

295 self.security.enable_bandit, 

296 self.security.enable_safety, 

297 self.security.enable_semgrep, 

298 self.security.enable_detect_secrets, 

299 ] 

300 ) 

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

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

303 raise ValueError(msg) 

304 

305 return self 

306 

307 def to_target_version(self) -> str: 

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

309 

310 Returns: 

311 Version string like 'py312'. 

312 

313 """ 

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