Skip to content

Commit d83cf58

Browse files
jeremyederclaude
andauthored
feat: Phase 2 Task 4 - Replace manual config validation with Pydantic (#134)
Replaces 85 lines of manual YAML validation code with declarative Pydantic models for automatic type checking and validation. Changes: - Add pydantic>=2.0.0 dependency to pyproject.toml - Refactor Config model (models/config.py) from dataclass to Pydantic BaseModel - Add field validators for weights, language_overrides, custom_theme - Add model validator for weights sum constraint - Add from_yaml_dict() classmethod for YAML loading - Preserve backwards-compatible API (to_dict, get_weight, is_excluded) - Security: Still uses validate_path() from security utils - Use modern ConfigDict (Pydantic V2) instead of deprecated class Config - Simplify load_config() in cli/main.py (-67 lines of manual validation) - Simplify _load_config() in cli/assess_batch.py (-63 lines) - Remove validate_config_dict import (no longer needed in CLI) Benefits: - Automatic type checking and validation - Better error messages with field locations - JSON schema generation capability (for future use) - Centralized validation logic in model - Reduced code duplication (2 load_config implementations → 1 model method) LOC Impact: - config.py: +101 lines (79 → 180) - Pydantic validators - cli/main.py: -28 lines (384 → 356) - Removed manual validation - cli/assess_batch.py: -23 lines (532 → 509) - Removed manual validation - Net: +50 LOC (vs. -100 target, but gains outweigh LOC metric) Tests: All config model tests pass (3 tests, no warnings) Security: Path validation still uses centralized security utils Phase 2 Task 4 of 6 complete. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent f242b58 commit d83cf58

File tree

4 files changed

+205
-157
lines changed

4 files changed

+205
-157
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"anthropic>=0.74.0",
2626
"jsonschema>=4.17.0",
2727
"requests>=2.31.0",
28+
"pydantic>=2.0.0",
2829
]
2930

3031
[project.optional-dependencies]

src/agentready/cli/assess_batch.py

Lines changed: 35 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from ..reporters.html import HTMLReporter
1313
from ..reporters.markdown import MarkdownReporter
1414
from ..services.batch_scanner import BatchScanner
15-
from ..utils.security import validate_config_dict, validate_path
15+
from pydantic import ValidationError
1616

1717

1818
def _get_agentready_version() -> str:
@@ -29,67 +29,44 @@ def _get_agentready_version() -> str:
2929

3030

3131
def _load_config(config_path: Path) -> Config:
32-
"""Load configuration from YAML file with validation.
32+
"""Load configuration from YAML file with Pydantic validation.
3333
34-
Uses centralized security utilities from utils.security module.
35-
"""
36-
import yaml
34+
Uses Pydantic for automatic validation, replacing duplicated manual
35+
validation code with the Config.from_yaml_dict() classmethod.
3736
38-
with open(config_path, "r", encoding="utf-8") as f:
39-
data = yaml.safe_load(f)
40-
41-
# Define config schema for validation
42-
schema = {
43-
"weights": {str: (int, float)},
44-
"excluded_attributes": [str],
45-
"language_overrides": {str: list},
46-
"output_dir": str,
47-
"report_theme": str,
48-
"custom_theme": dict,
49-
}
50-
51-
# Validate config structure using centralized utility
52-
validated = validate_config_dict(data, schema)
53-
54-
# Additional nested validations for complex types
55-
if "language_overrides" in validated:
56-
for lang, patterns in validated["language_overrides"].items():
57-
if not isinstance(patterns, list):
58-
raise ValueError(
59-
f"'language_overrides' values must be lists, got {type(patterns).__name__}"
60-
)
61-
for pattern in patterns:
62-
if not isinstance(pattern, str):
63-
raise ValueError(
64-
f"'language_overrides' patterns must be strings, got {type(pattern).__name__}"
65-
)
66-
67-
if "custom_theme" in validated:
68-
for key, value in validated["custom_theme"].items():
69-
if not isinstance(key, str):
70-
raise ValueError(
71-
f"'custom_theme' keys must be strings, got {type(key).__name__}"
72-
)
73-
if not isinstance(value, str):
74-
raise ValueError(
75-
f"'custom_theme' values must be strings, got {type(value).__name__}"
76-
)
37+
Args:
38+
config_path: Path to YAML configuration file
7739
78-
# Validate and sanitize output_dir path
79-
output_dir = None
80-
if "output_dir" in validated:
81-
output_dir = validate_path(
82-
validated["output_dir"], allow_system_dirs=False, must_exist=False
83-
)
40+
Returns:
41+
Validated Config instance
8442
85-
return Config(
86-
weights=validated.get("weights", {}),
87-
excluded_attributes=validated.get("excluded_attributes", []),
88-
language_overrides=validated.get("language_overrides", {}),
89-
output_dir=output_dir,
90-
report_theme=validated.get("report_theme", "default"),
91-
custom_theme=validated.get("custom_theme"),
92-
)
43+
Raises:
44+
ValidationError: If YAML data doesn't match expected schema
45+
FileNotFoundError: If config file doesn't exist
46+
yaml.YAMLError: If YAML parsing fails
47+
"""
48+
import sys
49+
50+
import yaml
51+
52+
try:
53+
with open(config_path, "r", encoding="utf-8") as f:
54+
data = yaml.safe_load(f)
55+
56+
# Pydantic handles all validation automatically
57+
return Config.from_yaml_dict(data)
58+
except ValidationError as e:
59+
# Convert Pydantic validation errors to user-friendly messages
60+
errors = []
61+
for error in e.errors():
62+
field = " → ".join(str(x) for x in error["loc"])
63+
msg = error["msg"]
64+
errors.append(f" - {field}: {msg}")
65+
66+
click.echo("Configuration validation failed:", err=True)
67+
for error in errors:
68+
click.echo(error, err=True)
69+
sys.exit(1)
9370

9471

9572
def _generate_multi_reports(batch_assessment, output_path: Path, verbose: bool) -> None:

src/agentready/cli/main.py

Lines changed: 36 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from ..reporters.markdown import MarkdownReporter
1919
from ..services.research_loader import ResearchLoader
2020
from ..services.scanner import Scanner
21-
from ..utils.security import validate_config_dict, validate_path
21+
from pydantic import ValidationError
2222
from ..utils.subprocess_utils import safe_subprocess_run
2323
from .align import align
2424
from .assess_batch import assess_batch
@@ -242,73 +242,45 @@ def run_assessment(repository_path, verbose, output_dir, config_path):
242242

243243

244244
def load_config(config_path: Path) -> Config:
245-
"""Load configuration from YAML file with validation.
245+
"""Load configuration from YAML file with Pydantic validation.
246246
247-
Security: Validates YAML structure to prevent injection attacks
248-
and malformed data from causing crashes or unexpected behavior.
249-
Uses centralized security utilities from utils.security module.
247+
Uses Pydantic for automatic validation, replacing 67 lines of manual
248+
validation code with declarative field validators.
249+
250+
Security: Uses yaml.safe_load() for safe YAML parsing and Pydantic
251+
validators for type checking and path sanitization.
252+
253+
Args:
254+
config_path: Path to YAML configuration file
255+
256+
Returns:
257+
Validated Config instance
258+
259+
Raises:
260+
ValidationError: If YAML data doesn't match expected schema
261+
FileNotFoundError: If config file doesn't exist
262+
yaml.YAMLError: If YAML parsing fails
250263
"""
251264
import yaml
252265

253-
with open(config_path, "r", encoding="utf-8") as f:
254-
data = yaml.safe_load(f)
255-
256-
# Define config schema for validation
257-
schema = {
258-
"weights": {str: (int, float)}, # dict[str, int|float]
259-
"excluded_attributes": [str], # list[str]
260-
"language_overrides": {
261-
str: list
262-
}, # dict[str, list] (nested list validated separately)
263-
"output_dir": str,
264-
"report_theme": str,
265-
"custom_theme": dict, # dict (nested types validated separately)
266-
}
267-
268-
# Validate config structure using centralized utility
269-
validated = validate_config_dict(data, schema)
270-
271-
# Additional nested validations for complex types
272-
if "language_overrides" in validated:
273-
lang_overrides = validated["language_overrides"]
274-
for lang, patterns in lang_overrides.items():
275-
if not isinstance(patterns, list):
276-
raise ValueError(
277-
f"'language_overrides' values must be lists, got {type(patterns).__name__}"
278-
)
279-
for pattern in patterns:
280-
if not isinstance(pattern, str):
281-
raise ValueError(
282-
f"'language_overrides' patterns must be strings, got {type(pattern).__name__}"
283-
)
284-
285-
if "custom_theme" in validated:
286-
custom_theme = validated["custom_theme"]
287-
for key, value in custom_theme.items():
288-
if not isinstance(key, str):
289-
raise ValueError(
290-
f"'custom_theme' keys must be strings, got {type(key).__name__}"
291-
)
292-
if not isinstance(value, str):
293-
raise ValueError(
294-
f"'custom_theme' values must be strings, got {type(value).__name__}"
295-
)
296-
297-
# Validate and sanitize output_dir path
298-
output_dir = None
299-
if "output_dir" in validated:
300-
output_dir = validate_path(
301-
validated["output_dir"], allow_system_dirs=False, must_exist=False
302-
)
303-
304-
return Config(
305-
weights=validated.get("weights", {}),
306-
excluded_attributes=validated.get("excluded_attributes", []),
307-
language_overrides=validated.get("language_overrides", {}),
308-
output_dir=output_dir,
309-
report_theme=validated.get("report_theme", "default"),
310-
custom_theme=validated.get("custom_theme"),
311-
)
266+
try:
267+
with open(config_path, "r", encoding="utf-8") as f:
268+
data = yaml.safe_load(f)
269+
270+
# Pydantic handles all validation automatically
271+
return Config.from_yaml_dict(data)
272+
except ValidationError as e:
273+
# Convert Pydantic validation errors to user-friendly messages
274+
errors = []
275+
for error in e.errors():
276+
field = " → ".join(str(x) for x in error["loc"])
277+
msg = error["msg"]
278+
errors.append(f" - {field}: {msg}")
279+
280+
click.echo("Configuration validation failed:", err=True)
281+
for error in errors:
282+
click.echo(error, err=True)
283+
sys.exit(1)
312284

313285

314286
@cli.command()

0 commit comments

Comments
 (0)