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
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-23 14:54 +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_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.
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_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 )
54 model_config = ConfigDict(frozen=True, extra="forbid")
57class DependencyConfig(BaseModel):
58 """Dependency management configuration.
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.
66 """
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 )
95 model_config = ConfigDict(frozen=True, extra="forbid")
98class LoggingConfig(BaseModel):
99 """Logging configuration options.
101 Attributes:
102 level: Log level.
103 format: Log format type.
104 enable_structured: Use structured logging (JSON).
106 """
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 )
121 model_config = ConfigDict(frozen=True, extra="forbid")
124class StackConfig(BaseModel):
125 """Main Stack configuration with full validation.
127 This is the primary configuration model that validates all Stack
128 settings at runtime, preventing configuration errors and catching
129 AI hallucinations early.
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.
142 Example:
143 >>> config = StackConfig(
144 ... project_name="my_project",
145 ... python_version="3.12",
146 ... )
147 >>> config.project_name
148 'my_project'
150 """
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 )
191 model_config = ConfigDict(
192 frozen=True,
193 extra="forbid",
194 validate_default=True,
195 )
197 @field_validator("project_name")
198 @classmethod
199 def validate_project_name(cls, value: str) -> str:
200 """Validate that project name is safe.
202 Args:
203 value: The project name to validate.
205 Returns:
206 The validated project name.
208 Raises:
209 ValueError: If project name contains invalid characters.
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
222 @field_validator("python_version")
223 @classmethod
224 def validate_python_version(cls, value: str) -> str:
225 """Validate Python version format.
227 Args:
228 value: The Python version string.
230 Returns:
231 The validated Python version.
233 Raises:
234 ValueError: If version format is invalid.
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)
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)
255 return value
257 @field_validator("project_dir")
258 @classmethod
259 def validate_project_dir(cls, value: Path) -> Path:
260 """Validate project directory is safe.
262 Args:
263 value: The project directory path.
265 Returns:
266 The validated and resolved path.
268 Raises:
269 ValueError: If path is unsafe or contains traversal.
271 """
272 resolved = value.resolve()
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)
279 return resolved
281 @model_validator(mode="after")
282 def validate_config_consistency(self) -> "StackConfig":
283 """Validate configuration consistency.
285 Returns:
286 The validated configuration.
288 Raises:
289 ValueError: If configuration is inconsistent.
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)
305 return self
307 def to_target_version(self) -> str:
308 """Get Python version in Ruff target format.
310 Returns:
311 Version string like 'py312'.
313 """
314 return f"py{self.python_version.replace('.', '')}"