Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/rsmetacheck/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
from __future__ import annotations

from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Optional, Set, Union

import tomllib


DEFAULT_CONFIG_FILENAMES = (".rsmetacheck.toml", "rsmetacheck.toml")


@dataclass
class AnalysisConfig:
ignored_checks: Set[str] = field(default_factory=set)
exclude_files: list[str] = field(default_factory=list)
check_parameters: Dict[str, Dict[str, Any]] = field(default_factory=dict)
profile: Optional[str] = None
source_path: Optional[Path] = None

@classmethod
def empty(cls) -> "AnalysisConfig":
return cls()

def is_ignored(self, check_code: str) -> bool:
return _normalize_check_code(check_code) in self.ignored_checks

def get_parameters(self, check_code: str) -> Dict[str, Any]:
return self.check_parameters.get(_normalize_check_code(check_code), {})


def _normalize_check_code(value: str) -> str:
return str(value).strip().upper()


def _normalize_check_codes(codes: Any) -> Set[str]:
if not isinstance(codes, list):
return set()
normalized = set()
for code in codes:
if isinstance(code, str) and code.strip():
normalized.add(_normalize_check_code(code))
return normalized


def _normalize_exclude_files(values: Any) -> list[str]:
if not isinstance(values, list):
return []
return [str(v).strip() for v in values if isinstance(v, str) and str(v).strip()]


def _normalize_parameters(parameters: Any) -> Dict[str, Dict[str, Any]]:
if not isinstance(parameters, dict):
return {}

normalized: Dict[str, Dict[str, Any]] = {}
for check_code, check_params in parameters.items():
if not isinstance(check_code, str):
continue
if not isinstance(check_params, dict):
continue
normalized[_normalize_check_code(check_code)] = dict(check_params)
return normalized


def _merge_parameters(
base: Dict[str, Dict[str, Any]],
override: Dict[str, Dict[str, Any]],
) -> Dict[str, Dict[str, Any]]:
merged: Dict[str, Dict[str, Any]] = {k: dict(v) for k, v in base.items()}
for check_code, params in override.items():
merged.setdefault(check_code, {})
merged[check_code].update(params)
return merged


def _resolve_config_path(
config_path: Optional[Union[str, Path]],
cwd: Optional[Union[str, Path]] = None,
) -> Optional[Path]:
if config_path:
candidate = Path(config_path)
if not candidate.exists():
raise FileNotFoundError(f"Config file not found: {candidate}")
return candidate

search_root = Path(cwd) if cwd else Path.cwd()
for filename in DEFAULT_CONFIG_FILENAMES:
candidate = search_root / filename
if candidate.exists():
return candidate

return None


def load_analysis_config(
config_path: Optional[Union[str, Path]] = None,
profile: Optional[str] = None,
cwd: Optional[Union[str, Path]] = None,
) -> AnalysisConfig:
"""
Load analysis configuration from TOML.

Supported structure:

ignore = ["P001", "W002"]
exclude_files = ["**/codemeta.json"]

[parameters.P001]
ahead_significant_diff = 2

[profiles.unstable]
ignore = ["W002"]
exclude_files = ["**/README.md"]

[profiles.unstable.parameters.P001]
ahead_significant_diff = 10
"""
resolved_path = _resolve_config_path(config_path, cwd=cwd)
if not resolved_path:
return AnalysisConfig.empty()

with resolved_path.open("rb") as f:
raw = tomllib.load(f)

selected_profile = profile or raw.get("active_profile")

base_ignore = _normalize_check_codes(raw.get("ignore", []))
base_exclude_files = _normalize_exclude_files(raw.get("exclude_files", []))
base_parameters = _normalize_parameters(raw.get("parameters", {}))

profile_ignore: Set[str] = set()
profile_exclude_files: list[str] = []
profile_parameters: Dict[str, Dict[str, Any]] = {}

profiles = raw.get("profiles", {})
if selected_profile:
if not isinstance(profiles, dict) or selected_profile not in profiles:
raise ValueError(f"Profile '{selected_profile}' was not found in {resolved_path}")

selected = profiles[selected_profile]
if not isinstance(selected, dict):
raise ValueError(f"Profile '{selected_profile}' must be a TOML table")

profile_ignore = _normalize_check_codes(selected.get("ignore", []))
profile_exclude_files = _normalize_exclude_files(selected.get("exclude_files", []))
profile_parameters = _normalize_parameters(selected.get("parameters", {}))

merged_parameters = _merge_parameters(base_parameters, profile_parameters)

return AnalysisConfig(
ignored_checks=base_ignore | profile_ignore,
exclude_files=base_exclude_files + profile_exclude_files,
check_parameters=merged_parameters,
profile=selected_profile,
source_path=resolved_path,
)
61 changes: 61 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from rsmetacheck.config import load_analysis_config


def test_load_analysis_config_auto_detects_default_file(tmp_path):
config_file = tmp_path / ".rsmetacheck.toml"
config_file.write_text(
"""
ignore = ["p001", "W002"]
exclude_files = ["codemeta.json"]

[parameters.P001]
ahead_significant_diff = 10
""".strip()
)

config = load_analysis_config(cwd=tmp_path)

assert config.source_path == config_file
assert config.is_ignored("P001")
assert config.is_ignored("w002")
assert config.exclude_files == ["codemeta.json"]
assert config.get_parameters("P001")["ahead_significant_diff"] == 10


def test_load_analysis_config_merges_profile_overrides(tmp_path):
config_file = tmp_path / ".rsmetacheck.toml"
config_file.write_text(
"""
ignore = ["W002"]
exclude_files = ["base.txt"]

[parameters.P001]
ahead_significant_diff = 2

[profiles.unstable]
ignore = ["P017"]
exclude_files = ["dev.txt"]

[profiles.unstable.parameters.P001]
ahead_significant_diff = 10
""".strip()
)

config = load_analysis_config(cwd=tmp_path, profile="unstable")

assert config.profile == "unstable"
assert config.is_ignored("W002")
assert config.is_ignored("P017")
assert config.exclude_files == ["base.txt", "dev.txt"]
assert config.get_parameters("P001")["ahead_significant_diff"] == 10


def test_load_analysis_config_missing_profile_raises(tmp_path):
config_file = tmp_path / ".rsmetacheck.toml"
config_file.write_text('[profiles.unstable]\nignore = ["W002"]\n')

try:
load_analysis_config(cwd=tmp_path, profile="prerelease")
assert False, "Expected ValueError"
except ValueError:
pass