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
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 15:01 +0000
1"""
2Configuration models with Pydantic validation.
4This module provides type-safe configuration models that validate
5all inputs at runtime, preventing errors and AI hallucinations.
6"""
8import re
9import sys
10from pathlib import Path
11from typing import Literal
13from pydantic import (
14 BaseModel,
15 ConfigDict,
16 Field,
17 field_validator,
18 model_validator,
19)
21# Constants to avoid magic values (PLR2004)
22PYTHON_MAJOR_VERSION = 3
23MIN_PYTHON_MINOR_VERSION = 10
26class SecurityConfig(BaseModel):
27 """Security-related configuration options.
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.
37 """
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 )
55 model_config = ConfigDict(frozen=True, extra="forbid")
58class DependencyConfig(BaseModel):
59 """Dependency management configuration.
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.
67 """
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 )
96 model_config = ConfigDict(frozen=True, extra="forbid")
99class LoggingConfig(BaseModel):
100 """Logging configuration options.
102 Attributes:
103 level: Log level.
104 format: Log format type.
105 enable_structured: Use structured logging (JSON).
107 """
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 )
122 model_config = ConfigDict(frozen=True, extra="forbid")
125class StackConfig(BaseModel):
126 """Main Stack configuration with full validation.
128 This is the primary configuration model that validates all Stack
129 settings at runtime, preventing configuration errors and catching
130 AI hallucinations early.
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.
143 Example:
144 >>> config = StackConfig(
145 ... project_name="my_project",
146 ... python_version="3.12",
147 ... )
148 >>> config.project_name
149 'my_project'
151 """
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 )
192 model_config = ConfigDict(
193 frozen=True,
194 extra="forbid",
195 validate_default=True,
196 )
198 @field_validator("project_name")
199 @classmethod
200 def validate_project_name(cls, value: str) -> str:
201 """Validate that project name is safe.
203 Args:
204 value: The project name to validate.
206 Returns:
207 The validated project name.
209 Raises:
210 ValueError: If project name contains invalid characters.
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
224 @field_validator("python_version")
225 @classmethod
226 def validate_python_version(cls, value: str) -> str:
227 """Validate Python version format.
229 Args:
230 value: The Python version string.
232 Returns:
233 The validated Python version.
235 Raises:
236 ValueError: If version format is invalid.
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)
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)
258 return value
260 @field_validator("project_dir")
261 @classmethod
262 def validate_project_dir(cls, value: Path) -> Path:
263 """Validate project directory is safe.
265 Args:
266 value: The project directory path.
268 Returns:
269 The validated and resolved path.
271 Raises:
272 ValueError: If path is unsafe or contains traversal.
274 """
275 _ = cls
276 resolved = value.resolve()
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)
283 return resolved
285 @model_validator(mode="after")
286 def validate_config_consistency(self) -> "StackConfig":
287 """Validate configuration consistency.
289 Returns:
290 The validated configuration.
292 Raises:
293 ValueError: If configuration is inconsistent.
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)
309 return self
311 def to_target_version(self) -> str:
312 """Get Python version in Ruff target format.
314 Returns:
315 Version string like 'py312'.
317 """
318 return f"py{self.python_version.replace('.', '')}"