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
« prev ^ index » next coverage.py v7.15.1, created at 2026-10-13 20:52 +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
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)
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)
47class SecurityConfig(BaseModel):
48 """Security-related configuration options.
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.
58 """
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 )
76 model_config = ConfigDict(frozen=True, extra="forbid")
79class DependencyConfig(BaseModel):
80 """Dependency management configuration.
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.
88 """
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 )
117 model_config = ConfigDict(frozen=True, extra="forbid")
120class LoggingConfig(BaseModel):
121 """Logging configuration options.
123 Attributes:
124 level: Log level.
125 format: Log format type.
126 enable_structured: Use structured logging (JSON).
128 """
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 )
143 model_config = ConfigDict(frozen=True, extra="forbid")
146class StackConfig(BaseModel):
147 """Main TaipanStack configuration with full validation.
149 This is the primary configuration model that validates all TaipanStack
150 settings at runtime, preventing configuration errors and catching
151 AI hallucinations early.
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.
164 Example:
165 >>> config = StackConfig(
166 ... project_name="my_project",
167 ... python_version="3.12",
168 ... )
169 >>> config.project_name
170 'my_project'
172 """
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 )
213 model_config = ConfigDict(
214 frozen=True,
215 extra="forbid",
216 validate_default=True,
217 )
219 @field_validator("project_name")
220 @classmethod
221 def validate_project_name(cls, value: str) -> str:
222 """Validate that project name is safe.
224 Args:
225 value: The project name to validate.
227 Returns:
228 The validated project name.
230 Raises:
231 ValueError: If project name contains invalid characters.
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
245 @field_validator("python_version")
246 @classmethod
247 def validate_python_version(cls, value: str) -> str:
248 """Validate Python version format.
250 Args:
251 value: The Python version string.
253 Returns:
254 The validated Python version.
256 Raises:
257 ValueError: If version format is invalid.
259 """
260 _ = cls
261 _check_python_version_format(value)
263 major, minor = map(int, value.split("."))
264 _check_python_minimum_version(major, minor, value)
266 return value
268 @field_validator("project_dir")
269 @classmethod
270 def validate_project_dir(cls, value: Path) -> Path:
271 """Validate project directory is safe.
273 Args:
274 value: The project directory path.
276 Returns:
277 The validated and resolved path.
279 Raises:
280 ValueError: If path is unsafe or contains traversal.
282 """
283 _ = cls
284 resolved = value.resolve()
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)
291 return resolved
293 @model_validator(mode="after")
294 def validate_config_consistency(self) -> "StackConfig":
295 """Validate configuration consistency.
297 Returns:
298 The validated configuration.
300 Raises:
301 ValueError: If configuration is inconsistent.
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)
317 return self
319 def to_target_version(self) -> str:
320 """Get Python version in Ruff target format.
322 Returns:
323 Version string like 'py312'.
325 """
326 return f"py{self.python_version.replace('.', '')}"