Skip to content

Commit d0973d2

Browse files
author
Really Him
committed
feat(config): validate CLI overrides
1 parent 003314d commit d0973d2

12 files changed

+698
-136
lines changed

src/antipasta/cli/config_generate.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,46 @@
88
import click
99
from pydantic import ValidationError
1010

11+
from antipasta.cli.validation_utils import get_metric_constraints
1112
from antipasta.core.config import AntipastaConfig
13+
from antipasta.core.metric_models import MetricThresholds
1214

1315

14-
def validate_positive_int(value: str, min_val: int = 1, max_val: int | None = None) -> int:
15-
"""Validate a positive integer within optional range."""
16-
try:
17-
num = int(value)
18-
if num < min_val:
19-
raise click.BadParameter(f"Value must be at least {min_val}")
20-
if max_val is not None and num > max_val:
21-
raise click.BadParameter(f"Value must be at most {max_val}")
22-
return num
23-
except ValueError as e:
24-
raise click.BadParameter("Must be a valid integer") from e
16+
def validate_with_pydantic(metric_type: str, value: str) -> float:
17+
"""Validate a metric value using Pydantic model.
2518
19+
Args:
20+
metric_type: The metric type being validated
21+
value: String value to validate
2622
27-
def validate_positive_float(
28-
value: str, min_val: float = 0.0, max_val: float | None = None
29-
) -> float:
30-
"""Validate a positive float within optional range."""
23+
Returns:
24+
Validated numeric value
25+
26+
Raises:
27+
click.BadParameter: If validation fails
28+
"""
3129
try:
3230
num = float(value)
33-
if num < min_val:
34-
raise click.BadParameter(f"Value must be at least {min_val}")
35-
if max_val is not None and num > max_val:
36-
raise click.BadParameter(f"Value must be at most {max_val}")
31+
# Use Pydantic validation
32+
MetricThresholds(**{metric_type: num})
3733
return num
38-
except ValueError as e:
39-
raise click.BadParameter("Must be a valid number") from e
34+
except ValidationError as e:
35+
# Extract first error message
36+
if e.errors():
37+
err = e.errors()[0]
38+
err_type = err.get('type', '')
39+
ctx = err.get('ctx', {})
40+
41+
if 'greater_than_equal' in err_type:
42+
raise click.BadParameter(f"Value must be >= {ctx.get('ge', 0)}")
43+
elif 'less_than_equal' in err_type:
44+
raise click.BadParameter(f"Value must be <= {ctx.get('le', 'max')}")
45+
elif err_type == 'int_type':
46+
raise click.BadParameter(f"Must be an integer")
47+
48+
raise click.BadParameter(str(e))
49+
except ValueError:
50+
raise click.BadParameter("Must be a valid number")
4051

4152

4253
def prompt_with_validation(
@@ -98,25 +109,29 @@ def generate(output: Path, non_interactive: bool) -> None:
98109
click.echo("\nLet's set up your code quality thresholds:")
99110
click.echo("-" * 40)
100111

112+
# Get constraints from Pydantic model
113+
cc_min, cc_max = get_metric_constraints('cyclomatic_complexity')
101114
max_cyclomatic = prompt_with_validation(
102115
"Maximum cyclomatic complexity per function",
103116
default=10,
104-
validator=lambda v: validate_positive_int(v, min_val=1, max_val=50),
105-
help_text="ℹ️ Range: 1-50 (lower is stricter). Recommended: 10",
117+
validator=lambda v: validate_with_pydantic('cyclomatic_complexity', v),
118+
help_text=f"ℹ️ Range: {cc_min}-{cc_max} (lower is stricter). Recommended: 10",
106119
)
107120

121+
cog_min, cog_max = get_metric_constraints('cognitive_complexity')
108122
max_cognitive = prompt_with_validation(
109123
"Maximum cognitive complexity per function",
110124
default=15,
111-
validator=lambda v: validate_positive_int(v, min_val=1, max_val=100),
112-
help_text="ℹ️ Range: 1-100 (lower is stricter). Recommended: 15",
125+
validator=lambda v: validate_with_pydantic('cognitive_complexity', v),
126+
help_text=f"ℹ️ Range: {cog_min}-{cog_max} (lower is stricter). Recommended: 15",
113127
)
114128

129+
mi_min, mi_max = get_metric_constraints('maintainability_index')
115130
min_maintainability = prompt_with_validation(
116131
"Minimum maintainability index",
117132
default=50,
118-
validator=lambda v: validate_positive_int(v, min_val=0, max_val=100),
119-
help_text="ℹ️ Range: 0-100 (higher is stricter). Recommended: 50",
133+
validator=lambda v: validate_with_pydantic('maintainability_index', v),
134+
help_text=f"ℹ️ Range: {mi_min}-{mi_max} (higher is stricter). Recommended: 50",
120135
)
121136

122137
# Ask about advanced metrics
@@ -135,25 +150,28 @@ def generate(output: Path, non_interactive: bool) -> None:
135150
click.echo("\nAdvanced Halstead metrics:")
136151
click.echo("-" * 40)
137152

153+
hv_min, hv_max = get_metric_constraints('halstead_volume')
138154
defaults_dict["max_halstead_volume"] = prompt_with_validation(
139155
"Maximum Halstead volume",
140156
default=1000,
141-
validator=lambda v: validate_positive_float(v, min_val=1, max_val=100000),
142-
help_text="ℹ️ Range: 1-100000. Measures program size. Recommended: 1000",
157+
validator=lambda v: validate_with_pydantic('halstead_volume', v),
158+
help_text=f"ℹ️ Range: {hv_min}-{hv_max}. Measures program size. Recommended: 1000",
143159
)
144160

161+
hd_min, hd_max = get_metric_constraints('halstead_difficulty')
145162
defaults_dict["max_halstead_difficulty"] = prompt_with_validation(
146163
"Maximum Halstead difficulty",
147164
default=10,
148-
validator=lambda v: validate_positive_float(v, min_val=0.1, max_val=100),
149-
help_text="ℹ️ Range: 0.1-100. Measures error proneness. Recommended: 10",
165+
validator=lambda v: validate_with_pydantic('halstead_difficulty', v),
166+
help_text=f"ℹ️ Range: {hd_min}-{hd_max}. Measures error proneness. Recommended: 10",
150167
)
151168

169+
he_min, he_max = get_metric_constraints('halstead_effort')
152170
defaults_dict["max_halstead_effort"] = prompt_with_validation(
153171
"Maximum Halstead effort",
154172
default=10000,
155-
validator=lambda v: validate_positive_float(v, min_val=1, max_val=1000000),
156-
help_text="ℹ️ Range: 1-1000000. Measures implementation time. Recommended: 10000",
173+
validator=lambda v: validate_with_pydantic('halstead_effort', v),
174+
help_text=f"ℹ️ Range: {he_min}-{he_max}. Measures implementation time. Recommended: 10000",
157175
)
158176
else:
159177
# Use defaults for advanced metrics

src/antipasta/cli/metrics.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import click
99

10+
from antipasta.cli.validation_utils import format_validation_error_for_cli, get_metric_help_text
1011
from antipasta.core.aggregator import MetricAggregator
1112
from antipasta.core.config import AntipastaConfig
1213
from antipasta.core.config_override import ConfigOverride
@@ -112,7 +113,15 @@ def metrics(
112113
try:
113114
override.parse_threshold_string(threshold_str)
114115
except ValueError as e:
115-
click.echo(f"Error parsing threshold override: {e}", err=True)
116+
click.echo(f"❌ Error: {format_validation_error_for_cli(e)}", err=True)
117+
118+
# If it's a range error, show the valid range
119+
if '=' in threshold_str:
120+
metric_type = threshold_str.split('=')[0].strip()
121+
help_text = get_metric_help_text(metric_type)
122+
if help_text and metric_type in help_text:
123+
click.echo(f" ℹ️ {help_text}", err=True)
124+
116125
sys.exit(1)
117126

118127
# Apply overrides to configuration
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""Utilities for extracting validation info from Pydantic schemas.
2+
3+
This module provides helper functions to extract constraint information
4+
from Pydantic models and format them for CLI help text and error messages.
5+
"""
6+
7+
from typing import Optional, Tuple
8+
9+
from antipasta.core.metric_models import MetricThresholds
10+
11+
12+
def get_metric_constraints(metric_type: str) -> Tuple[Optional[float], Optional[float]]:
13+
"""Get min/max constraints for a metric from Pydantic schema.
14+
15+
Args:
16+
metric_type: The metric type to get constraints for
17+
18+
Returns:
19+
Tuple of (min_value, max_value), either can be None
20+
"""
21+
schema = MetricThresholds.model_json_schema()
22+
properties = schema.get('properties', {})
23+
24+
if metric_type in properties:
25+
prop_schema = properties[metric_type]
26+
27+
# Handle anyOf schemas (used for Optional fields)
28+
if 'anyOf' in prop_schema:
29+
# Find the non-null schema
30+
for sub_schema in prop_schema['anyOf']:
31+
if sub_schema.get('type') != 'null':
32+
prop_schema = sub_schema
33+
break
34+
35+
min_val = prop_schema.get('minimum')
36+
max_val = prop_schema.get('maximum')
37+
38+
# Also check for exclusive bounds
39+
if min_val is None:
40+
min_val = prop_schema.get('exclusiveMinimum')
41+
if max_val is None:
42+
max_val = prop_schema.get('exclusiveMaximum')
43+
44+
return (min_val, max_val)
45+
46+
return (None, None)
47+
48+
49+
def get_metric_help_text(metric_type: str) -> str:
50+
"""Get help text for a metric including its valid range.
51+
52+
Args:
53+
metric_type: The metric type to get help text for
54+
55+
Returns:
56+
Help text string with description and valid range
57+
"""
58+
schema = MetricThresholds.model_json_schema()
59+
properties = schema.get('properties', {})
60+
61+
if metric_type in properties:
62+
prop_schema = properties[metric_type]
63+
64+
# Handle anyOf schemas
65+
if 'anyOf' in prop_schema:
66+
for sub_schema in prop_schema['anyOf']:
67+
if sub_schema.get('type') != 'null':
68+
prop_schema = sub_schema
69+
break
70+
71+
description = prop_schema.get('description', '')
72+
73+
# Extract range from schema
74+
min_val, max_val = get_metric_constraints(metric_type)
75+
76+
range_parts = []
77+
if min_val is not None:
78+
range_parts.append(f">= {min_val}")
79+
if max_val is not None:
80+
range_parts.append(f"<= {max_val}")
81+
82+
if range_parts:
83+
range_text = " and ".join(range_parts)
84+
if description:
85+
# Extract just the description part without existing range
86+
if '(' in description:
87+
description = description.split('(')[0].strip()
88+
return f"{description} (valid: {range_text})"
89+
else:
90+
return f"Valid range: {range_text}"
91+
92+
return description if description else f"Metric: {metric_type}"
93+
94+
return f"Metric: {metric_type}"
95+
96+
97+
def format_validation_error_for_cli(e: Exception) -> str:
98+
"""Format validation errors for CLI display.
99+
100+
Args:
101+
e: The exception to format
102+
103+
Returns:
104+
User-friendly error message
105+
"""
106+
error_msg = str(e)
107+
108+
# Make error messages more user-friendly
109+
if "Invalid metric type" in error_msg:
110+
# The error already lists valid types
111+
return error_msg
112+
elif "must be" in error_msg:
113+
# Range errors are already clear
114+
return error_msg
115+
else:
116+
return f"Validation error: {error_msg}"

src/antipasta/core/config.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@
1111
from typing import TYPE_CHECKING
1212

1313
import yaml
14-
from pydantic import BaseModel, Field, field_validator, model_validator
15-
14+
from pydantic import BaseModel, Field, field_validator
15+
16+
from antipasta.core.metric_models import (
17+
CognitiveComplexity,
18+
CyclomaticComplexity,
19+
HalsteadDifficulty,
20+
HalsteadEffort,
21+
HalsteadVolume,
22+
MaintainabilityIndex,
23+
)
1624
from antipasta.core.metrics import MetricType
1725

1826
if TYPE_CHECKING:
@@ -65,22 +73,18 @@ def validate_extensions(cls, v: list[str]) -> list[str]:
6573

6674

6775
class DefaultsConfig(BaseModel):
68-
"""Default configuration values."""
69-
70-
max_cyclomatic_complexity: float = 10
71-
min_maintainability_index: float = 50
72-
max_halstead_volume: float = 1000
73-
max_halstead_difficulty: float = 10
74-
max_halstead_effort: float = 10000
75-
max_cognitive_complexity: float = 15
76-
77-
@model_validator(mode="after")
78-
def validate_defaults(self) -> DefaultsConfig:
79-
"""Ensure all values are positive."""
80-
for field_name, value in self.model_dump().items():
81-
if value < 0:
82-
raise ValueError(f"{field_name} must be non-negative")
83-
return self
76+
"""Default configuration values with automatic validation.
77+
78+
All validation is handled by Pydantic Field constraints,
79+
no custom validators needed.
80+
"""
81+
82+
max_cyclomatic_complexity: CyclomaticComplexity = 10
83+
min_maintainability_index: MaintainabilityIndex = 50
84+
max_halstead_volume: HalsteadVolume = 1000
85+
max_halstead_difficulty: HalsteadDifficulty = 10
86+
max_halstead_effort: HalsteadEffort = 10000
87+
max_cognitive_complexity: CognitiveComplexity = 15
8488

8589

8690
class AntipastaConfig(BaseModel):

0 commit comments

Comments
 (0)