Skip to content

Commit 076dcbf

Browse files
Merge pull request #90 from SoftwareUnderstanding/config
Add root config file support for analysis tuning and improve SoMEF failure handling
2 parents c87bd73 + a990899 commit 076dcbf

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

src/rsmetacheck/config.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass, field
4+
from pathlib import Path
5+
from typing import Any, Dict, Optional, Set, Union
6+
7+
import tomllib
8+
9+
10+
DEFAULT_CONFIG_FILENAMES = (".rsmetacheck.toml", "rsmetacheck.toml")
11+
12+
13+
@dataclass
14+
class AnalysisConfig:
15+
ignored_checks: Set[str] = field(default_factory=set)
16+
exclude_files: list[str] = field(default_factory=list)
17+
check_parameters: Dict[str, Dict[str, Any]] = field(default_factory=dict)
18+
profile: Optional[str] = None
19+
source_path: Optional[Path] = None
20+
21+
@classmethod
22+
def empty(cls) -> "AnalysisConfig":
23+
return cls()
24+
25+
def is_ignored(self, check_code: str) -> bool:
26+
return _normalize_check_code(check_code) in self.ignored_checks
27+
28+
def get_parameters(self, check_code: str) -> Dict[str, Any]:
29+
return self.check_parameters.get(_normalize_check_code(check_code), {})
30+
31+
32+
def _normalize_check_code(value: str) -> str:
33+
return str(value).strip().upper()
34+
35+
36+
def _normalize_check_codes(codes: Any) -> Set[str]:
37+
if not isinstance(codes, list):
38+
return set()
39+
normalized = set()
40+
for code in codes:
41+
if isinstance(code, str) and code.strip():
42+
normalized.add(_normalize_check_code(code))
43+
return normalized
44+
45+
46+
def _normalize_exclude_files(values: Any) -> list[str]:
47+
if not isinstance(values, list):
48+
return []
49+
return [str(v).strip() for v in values if isinstance(v, str) and str(v).strip()]
50+
51+
52+
def _normalize_parameters(parameters: Any) -> Dict[str, Dict[str, Any]]:
53+
if not isinstance(parameters, dict):
54+
return {}
55+
56+
normalized: Dict[str, Dict[str, Any]] = {}
57+
for check_code, check_params in parameters.items():
58+
if not isinstance(check_code, str):
59+
continue
60+
if not isinstance(check_params, dict):
61+
continue
62+
normalized[_normalize_check_code(check_code)] = dict(check_params)
63+
return normalized
64+
65+
66+
def _merge_parameters(
67+
base: Dict[str, Dict[str, Any]],
68+
override: Dict[str, Dict[str, Any]],
69+
) -> Dict[str, Dict[str, Any]]:
70+
merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in base.items()}
71+
for check_code, params in override.items():
72+
merged.setdefault(check_code, {})
73+
merged[check_code].update(params)
74+
return merged
75+
76+
77+
def _resolve_config_path(
78+
config_path: Optional[Union[str, Path]],
79+
cwd: Optional[Union[str, Path]] = None,
80+
) -> Optional[Path]:
81+
if config_path:
82+
candidate = Path(config_path)
83+
if not candidate.exists():
84+
raise FileNotFoundError(f"Config file not found: {candidate}")
85+
return candidate
86+
87+
search_root = Path(cwd) if cwd else Path.cwd()
88+
for filename in DEFAULT_CONFIG_FILENAMES:
89+
candidate = search_root / filename
90+
if candidate.exists():
91+
return candidate
92+
93+
return None
94+
95+
96+
def load_analysis_config(
97+
config_path: Optional[Union[str, Path]] = None,
98+
profile: Optional[str] = None,
99+
cwd: Optional[Union[str, Path]] = None,
100+
) -> AnalysisConfig:
101+
"""
102+
Load analysis configuration from TOML.
103+
104+
Supported structure:
105+
106+
ignore = ["P001", "W002"]
107+
exclude_files = ["**/codemeta.json"]
108+
109+
[parameters.P001]
110+
ahead_significant_diff = 2
111+
112+
[profiles.unstable]
113+
ignore = ["W002"]
114+
exclude_files = ["**/README.md"]
115+
116+
[profiles.unstable.parameters.P001]
117+
ahead_significant_diff = 10
118+
"""
119+
resolved_path = _resolve_config_path(config_path, cwd=cwd)
120+
if not resolved_path:
121+
return AnalysisConfig.empty()
122+
123+
with resolved_path.open("rb") as f:
124+
raw = tomllib.load(f)
125+
126+
selected_profile = profile or raw.get("active_profile")
127+
128+
base_ignore = _normalize_check_codes(raw.get("ignore", []))
129+
base_exclude_files = _normalize_exclude_files(raw.get("exclude_files", []))
130+
base_parameters = _normalize_parameters(raw.get("parameters", {}))
131+
132+
profile_ignore: Set[str] = set()
133+
profile_exclude_files: list[str] = []
134+
profile_parameters: Dict[str, Dict[str, Any]] = {}
135+
136+
profiles = raw.get("profiles", {})
137+
if selected_profile:
138+
if not isinstance(profiles, dict) or selected_profile not in profiles:
139+
raise ValueError(f"Profile '{selected_profile}' was not found in {resolved_path}")
140+
141+
selected = profiles[selected_profile]
142+
if not isinstance(selected, dict):
143+
raise ValueError(f"Profile '{selected_profile}' must be a TOML table")
144+
145+
profile_ignore = _normalize_check_codes(selected.get("ignore", []))
146+
profile_exclude_files = _normalize_exclude_files(selected.get("exclude_files", []))
147+
profile_parameters = _normalize_parameters(selected.get("parameters", {}))
148+
149+
merged_parameters = _merge_parameters(base_parameters, profile_parameters)
150+
151+
return AnalysisConfig(
152+
ignored_checks=base_ignore | profile_ignore,
153+
exclude_files=base_exclude_files + profile_exclude_files,
154+
check_parameters=merged_parameters,
155+
profile=selected_profile,
156+
source_path=resolved_path,
157+
)

tests/test_config.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from rsmetacheck.config import load_analysis_config
2+
3+
4+
def test_load_analysis_config_auto_detects_default_file(tmp_path):
5+
config_file = tmp_path / ".rsmetacheck.toml"
6+
config_file.write_text(
7+
"""
8+
ignore = ["p001", "W002"]
9+
exclude_files = ["codemeta.json"]
10+
11+
[parameters.P001]
12+
ahead_significant_diff = 10
13+
""".strip()
14+
)
15+
16+
config = load_analysis_config(cwd=tmp_path)
17+
18+
assert config.source_path == config_file
19+
assert config.is_ignored("P001")
20+
assert config.is_ignored("w002")
21+
assert config.exclude_files == ["codemeta.json"]
22+
assert config.get_parameters("P001")["ahead_significant_diff"] == 10
23+
24+
25+
def test_load_analysis_config_merges_profile_overrides(tmp_path):
26+
config_file = tmp_path / ".rsmetacheck.toml"
27+
config_file.write_text(
28+
"""
29+
ignore = ["W002"]
30+
exclude_files = ["base.txt"]
31+
32+
[parameters.P001]
33+
ahead_significant_diff = 2
34+
35+
[profiles.unstable]
36+
ignore = ["P017"]
37+
exclude_files = ["dev.txt"]
38+
39+
[profiles.unstable.parameters.P001]
40+
ahead_significant_diff = 10
41+
""".strip()
42+
)
43+
44+
config = load_analysis_config(cwd=tmp_path, profile="unstable")
45+
46+
assert config.profile == "unstable"
47+
assert config.is_ignored("W002")
48+
assert config.is_ignored("P017")
49+
assert config.exclude_files == ["base.txt", "dev.txt"]
50+
assert config.get_parameters("P001")["ahead_significant_diff"] == 10
51+
52+
53+
def test_load_analysis_config_missing_profile_raises(tmp_path):
54+
config_file = tmp_path / ".rsmetacheck.toml"
55+
config_file.write_text('[profiles.unstable]\nignore = ["W002"]\n')
56+
57+
try:
58+
load_analysis_config(cwd=tmp_path, profile="prerelease")
59+
assert False, "Expected ValueError"
60+
except ValueError:
61+
pass

0 commit comments

Comments
 (0)