preen 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- preen/__init__.py +25 -0
- preen/__main__.py +6 -0
- preen/checks/__init__.py +10 -0
- preen/checks/base.py +148 -0
- preen/checks/ci_matrix.py +154 -0
- preen/checks/citation.py +107 -0
- preen/checks/codespell.py +216 -0
- preen/checks/deps.py +114 -0
- preen/checks/deptree.py +200 -0
- preen/checks/links.py +202 -0
- preen/checks/pydoclint.py +163 -0
- preen/checks/pyright.py +177 -0
- preen/checks/ruff.py +137 -0
- preen/checks/runner.py +50 -0
- preen/checks/structure.py +223 -0
- preen/checks/tests.py +75 -0
- preen/checks/version.py +205 -0
- preen/cli.py +444 -0
- preen/commands/__init__.py +1 -0
- preen/commands/bump.py +243 -0
- preen/commands/fix.py +133 -0
- preen/commands/init.py +286 -0
- preen/commands/release.py +194 -0
- preen/config.py +117 -0
- preen/interactive.py +182 -0
- preen/syncer.py +561 -0
- preen/templates/__init__.py +5 -0
- preen/templates/manager.py +51 -0
- preen-0.1.0.dist-info/METADATA +303 -0
- preen-0.1.0.dist-info/RECORD +34 -0
- preen-0.1.0.dist-info/WHEEL +5 -0
- preen-0.1.0.dist-info/entry_points.txt +2 -0
- preen-0.1.0.dist-info/licenses/LICENSE +21 -0
- preen-0.1.0.dist-info/top_level.txt +1 -0
preen/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Top-level package for preen.
|
|
2
|
+
|
|
3
|
+
This module exposes a small public API for programmatic use. At this stage the
|
|
4
|
+
implementation is minimal, providing only the `sync` function used by the
|
|
5
|
+
command‑line interface. Future versions will add additional helpers for
|
|
6
|
+
checking and releasing packages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version as _get_version # type: ignore
|
|
10
|
+
|
|
11
|
+
from .syncer import sync_project
|
|
12
|
+
|
|
13
|
+
__all__ = ["__version__", "sync_project"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name: str):
|
|
17
|
+
"""Lazily expose the package version.
|
|
18
|
+
|
|
19
|
+
The version is looked up using importlib.metadata when accessed. This
|
|
20
|
+
avoids importing pkg_resources at runtime and follows the recommended
|
|
21
|
+
pattern for modern packaging.
|
|
22
|
+
"""
|
|
23
|
+
if name == "__version__":
|
|
24
|
+
return _get_version(__name__)
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
preen/__main__.py
ADDED
preen/checks/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Check framework for preen.
|
|
2
|
+
|
|
3
|
+
This module provides the base infrastructure for running checks and
|
|
4
|
+
managing issues/fixes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import Check, CheckResult, Issue, Fix, Severity
|
|
8
|
+
from .runner import run_checks
|
|
9
|
+
|
|
10
|
+
__all__ = ["Check", "CheckResult", "Issue", "Fix", "Severity", "run_checks"]
|
preen/checks/base.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Base classes for the check framework."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Callable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Severity(Enum):
|
|
13
|
+
"""Severity levels for issues."""
|
|
14
|
+
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
WARNING = "warning"
|
|
17
|
+
INFO = "info"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Impact(Enum):
|
|
21
|
+
"""Impact classification for release workflow decision making."""
|
|
22
|
+
|
|
23
|
+
CRITICAL = "critical" # Must fix - blocks release (security, broken builds)
|
|
24
|
+
IMPORTANT = "important" # Should fix but can override (style, deprecations)
|
|
25
|
+
INFORMATIONAL = "info" # Nice to fix (suggestions, optimizations)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Fix:
|
|
30
|
+
"""Represents a proposed fix for an issue."""
|
|
31
|
+
|
|
32
|
+
description: str
|
|
33
|
+
diff: str
|
|
34
|
+
apply: Callable[[], None]
|
|
35
|
+
|
|
36
|
+
def preview(self) -> str:
|
|
37
|
+
"""Return a preview of the fix as a diff."""
|
|
38
|
+
return self.diff
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Issue:
|
|
43
|
+
"""Represents an issue found by a check."""
|
|
44
|
+
|
|
45
|
+
check: str
|
|
46
|
+
severity: Severity
|
|
47
|
+
description: str
|
|
48
|
+
file: Path | None = None
|
|
49
|
+
line: int | None = None
|
|
50
|
+
proposed_fix: Fix | None = None
|
|
51
|
+
impact: Impact = Impact.IMPORTANT # Default to important (can override)
|
|
52
|
+
explanation: str = "" # Why this issue matters
|
|
53
|
+
override_question: str = "" # Custom question for override prompt
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
location = ""
|
|
57
|
+
if self.file:
|
|
58
|
+
location = f" in {self.file}"
|
|
59
|
+
if self.line:
|
|
60
|
+
location += f":{self.line}"
|
|
61
|
+
return f"[{self.severity.value}] {self.check}: {self.description}{location}"
|
|
62
|
+
|
|
63
|
+
def get_impact_symbol(self) -> str:
|
|
64
|
+
"""Get emoji symbol for impact level."""
|
|
65
|
+
symbols = {
|
|
66
|
+
Impact.CRITICAL: "đźš«",
|
|
67
|
+
Impact.IMPORTANT: "⚠️",
|
|
68
|
+
Impact.INFORMATIONAL: "ℹ️",
|
|
69
|
+
}
|
|
70
|
+
return symbols[self.impact]
|
|
71
|
+
|
|
72
|
+
def is_blocking(self) -> bool:
|
|
73
|
+
"""Return True if this issue should block release by default."""
|
|
74
|
+
return self.impact == Impact.CRITICAL
|
|
75
|
+
|
|
76
|
+
def can_override(self) -> bool:
|
|
77
|
+
"""Return True if this issue can be overridden in interactive mode."""
|
|
78
|
+
return self.impact in [Impact.IMPORTANT, Impact.INFORMATIONAL]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class CheckResult:
|
|
83
|
+
"""Result of running a check."""
|
|
84
|
+
|
|
85
|
+
check: str
|
|
86
|
+
passed: bool
|
|
87
|
+
issues: list[Issue] = field(default_factory=list)
|
|
88
|
+
duration: float = 0.0
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_errors(self) -> bool:
|
|
92
|
+
"""Return True if any issues are errors."""
|
|
93
|
+
return any(issue.severity == Severity.ERROR for issue in self.issues)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def has_warnings(self) -> bool:
|
|
97
|
+
"""Return True if any issues are warnings."""
|
|
98
|
+
return any(issue.severity == Severity.WARNING for issue in self.issues)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def has_blocking_issues(self) -> bool:
|
|
102
|
+
"""Return True if any issues are blocking (critical impact)."""
|
|
103
|
+
return any(issue.is_blocking() for issue in self.issues)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def has_overridable_issues(self) -> bool:
|
|
107
|
+
"""Return True if any issues can be overridden."""
|
|
108
|
+
return any(issue.can_override() for issue in self.issues)
|
|
109
|
+
|
|
110
|
+
def get_issues_by_impact(self, impact: Impact) -> list[Issue]:
|
|
111
|
+
"""Get all issues with a specific impact level."""
|
|
112
|
+
return [issue for issue in self.issues if issue.impact == impact]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Check(ABC):
|
|
116
|
+
"""Abstract base class for all checks."""
|
|
117
|
+
|
|
118
|
+
def __init__(self, project_dir: Path):
|
|
119
|
+
"""Initialize the check.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
project_dir: Path to the project directory.
|
|
123
|
+
"""
|
|
124
|
+
self.project_dir = project_dir
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
@abstractmethod
|
|
128
|
+
def name(self) -> str:
|
|
129
|
+
"""Return the name of this check."""
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def description(self) -> str:
|
|
134
|
+
"""Return a description of what this check does."""
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
@abstractmethod
|
|
138
|
+
def run(self) -> CheckResult:
|
|
139
|
+
"""Run the check and return the result.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
CheckResult containing any issues found.
|
|
143
|
+
"""
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
def can_fix(self) -> bool:
|
|
147
|
+
"""Return True if this check can automatically fix issues."""
|
|
148
|
+
return False
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""CI matrix validation check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..config import PreenConfig
|
|
9
|
+
from ..syncer import _read_pyproject
|
|
10
|
+
from .base import Check, CheckResult, Issue, Fix, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CIMatrixCheck(Check):
|
|
14
|
+
"""Check if CI matrix covers all declared Python versions."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
return "ci-matrix"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def description(self) -> str:
|
|
22
|
+
return "Check if CI matrix tests all declared Python versions"
|
|
23
|
+
|
|
24
|
+
def run(self) -> CheckResult:
|
|
25
|
+
"""Check CI matrix against declared Python versions."""
|
|
26
|
+
issues = []
|
|
27
|
+
|
|
28
|
+
# Load project configuration
|
|
29
|
+
try:
|
|
30
|
+
config = PreenConfig.from_pyproject(self.project_dir)
|
|
31
|
+
pyproject = _read_pyproject(self.project_dir / "pyproject.toml")
|
|
32
|
+
declared_versions = config.get_ci_python_versions(pyproject)
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return CheckResult(
|
|
35
|
+
check=self.name,
|
|
36
|
+
passed=False,
|
|
37
|
+
issues=[
|
|
38
|
+
Issue(
|
|
39
|
+
check=self.name,
|
|
40
|
+
severity=Severity.ERROR,
|
|
41
|
+
description=f"Failed to read project metadata: {e}",
|
|
42
|
+
)
|
|
43
|
+
],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Check if CI workflow exists
|
|
47
|
+
ci_path = self.project_dir / ".github" / "workflows" / "ci.yml"
|
|
48
|
+
if not ci_path.exists():
|
|
49
|
+
issues.append(
|
|
50
|
+
Issue(
|
|
51
|
+
check=self.name,
|
|
52
|
+
severity=Severity.WARNING,
|
|
53
|
+
description="No CI workflow found at .github/workflows/ci.yml",
|
|
54
|
+
proposed_fix=Fix(
|
|
55
|
+
description="Generate CI workflow from pyproject.toml",
|
|
56
|
+
diff="Run: preen sync --only ci",
|
|
57
|
+
apply=self._fix_ci_workflow,
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return CheckResult(
|
|
62
|
+
check=self.name,
|
|
63
|
+
passed=False,
|
|
64
|
+
issues=issues,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Parse CI workflow
|
|
68
|
+
try:
|
|
69
|
+
with ci_path.open("r", encoding="utf-8") as f:
|
|
70
|
+
ci_content = yaml.safe_load(f)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
issues.append(
|
|
73
|
+
Issue(
|
|
74
|
+
check=self.name,
|
|
75
|
+
severity=Severity.ERROR,
|
|
76
|
+
description=f"Failed to parse CI workflow: {e}",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return CheckResult(
|
|
80
|
+
check=self.name,
|
|
81
|
+
passed=False,
|
|
82
|
+
issues=issues,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Extract Python versions from CI matrix
|
|
86
|
+
ci_versions = set()
|
|
87
|
+
try:
|
|
88
|
+
jobs = ci_content.get("jobs", {})
|
|
89
|
+
test_job = jobs.get("test", {})
|
|
90
|
+
strategy = test_job.get("strategy", {})
|
|
91
|
+
matrix = strategy.get("matrix", {})
|
|
92
|
+
python_versions = matrix.get("python-version", [])
|
|
93
|
+
|
|
94
|
+
# Handle both list format and string format
|
|
95
|
+
if isinstance(python_versions, list):
|
|
96
|
+
ci_versions.update(python_versions)
|
|
97
|
+
elif isinstance(python_versions, str):
|
|
98
|
+
ci_versions.add(python_versions)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# Compare versions
|
|
103
|
+
declared_set = set(declared_versions)
|
|
104
|
+
missing_in_ci = declared_set - ci_versions
|
|
105
|
+
extra_in_ci = ci_versions - declared_set
|
|
106
|
+
|
|
107
|
+
if missing_in_ci:
|
|
108
|
+
missing_versions = ", ".join(sorted(missing_in_ci))
|
|
109
|
+
issues.append(
|
|
110
|
+
Issue(
|
|
111
|
+
check=self.name,
|
|
112
|
+
severity=Severity.WARNING,
|
|
113
|
+
description=f"CI matrix missing Python versions: {missing_versions}",
|
|
114
|
+
file=Path(".github/workflows/ci.yml"),
|
|
115
|
+
proposed_fix=Fix(
|
|
116
|
+
description="Update CI matrix to include all declared Python versions",
|
|
117
|
+
diff=f"Add Python versions to CI matrix: {missing_versions}",
|
|
118
|
+
apply=self._fix_ci_workflow,
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if extra_in_ci and not missing_in_ci:
|
|
124
|
+
# Only warn about extra versions if we're not missing any
|
|
125
|
+
extra_versions = ", ".join(sorted(extra_in_ci))
|
|
126
|
+
issues.append(
|
|
127
|
+
Issue(
|
|
128
|
+
check=self.name,
|
|
129
|
+
severity=Severity.INFO,
|
|
130
|
+
description=f"CI matrix has extra Python versions not declared in classifiers: {extra_versions}",
|
|
131
|
+
file=Path(".github/workflows/ci.yml"),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return CheckResult(
|
|
136
|
+
check=self.name,
|
|
137
|
+
passed=len([issue for issue in issues if issue.severity != Severity.INFO])
|
|
138
|
+
== 0,
|
|
139
|
+
issues=issues,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def can_fix(self) -> bool:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def _fix_ci_workflow(self) -> None:
|
|
146
|
+
"""Regenerate CI workflow to fix matrix issues."""
|
|
147
|
+
from ..syncer import sync_project
|
|
148
|
+
|
|
149
|
+
sync_project(
|
|
150
|
+
self.project_dir,
|
|
151
|
+
quiet=True,
|
|
152
|
+
check=False,
|
|
153
|
+
targets={"ci"},
|
|
154
|
+
)
|
preen/checks/citation.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Citation file check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..syncer import sync_project
|
|
8
|
+
from .base import Check, CheckResult, Issue, Fix, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CitationCheck(Check):
|
|
12
|
+
"""Check if CITATION.cff is in sync with pyproject.toml."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def name(self) -> str:
|
|
16
|
+
return "citation"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def description(self) -> str:
|
|
20
|
+
return "Check if CITATION.cff is synced with pyproject.toml"
|
|
21
|
+
|
|
22
|
+
def run(self) -> CheckResult:
|
|
23
|
+
"""Check if citation file needs updating."""
|
|
24
|
+
issues = []
|
|
25
|
+
|
|
26
|
+
# Run sync in check mode for citation only
|
|
27
|
+
try:
|
|
28
|
+
result = sync_project(
|
|
29
|
+
self.project_dir,
|
|
30
|
+
quiet=True,
|
|
31
|
+
check=True,
|
|
32
|
+
targets={"citation"},
|
|
33
|
+
)
|
|
34
|
+
except SystemExit:
|
|
35
|
+
# sync_project exits with 1 if files would change
|
|
36
|
+
result = sync_project(
|
|
37
|
+
self.project_dir,
|
|
38
|
+
quiet=True,
|
|
39
|
+
check=False,
|
|
40
|
+
targets={"citation"},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# Get the diff
|
|
44
|
+
citation_path = self.project_dir / "CITATION.cff"
|
|
45
|
+
old_content = ""
|
|
46
|
+
if citation_path.exists():
|
|
47
|
+
old_content = citation_path.read_text()
|
|
48
|
+
|
|
49
|
+
new_content = result.get("updated", {}).get("CITATION.cff", "")
|
|
50
|
+
|
|
51
|
+
def apply_fix():
|
|
52
|
+
sync_project(
|
|
53
|
+
self.project_dir,
|
|
54
|
+
quiet=True,
|
|
55
|
+
check=False,
|
|
56
|
+
targets={"citation"},
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
issues.append(
|
|
60
|
+
Issue(
|
|
61
|
+
check=self.name,
|
|
62
|
+
severity=Severity.WARNING,
|
|
63
|
+
description="CITATION.cff is out of sync with pyproject.toml",
|
|
64
|
+
file=Path("CITATION.cff"),
|
|
65
|
+
proposed_fix=Fix(
|
|
66
|
+
description="Regenerate CITATION.cff from pyproject.toml",
|
|
67
|
+
diff=self._generate_diff(old_content, new_content),
|
|
68
|
+
apply=apply_fix,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return CheckResult(
|
|
74
|
+
check=self.name,
|
|
75
|
+
passed=len(issues) == 0,
|
|
76
|
+
issues=issues,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def can_fix(self) -> bool:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
def _generate_diff(self, old: str, new: str) -> str:
|
|
83
|
+
"""Generate a simple diff between old and new content."""
|
|
84
|
+
if not old:
|
|
85
|
+
return f"Create new file:\n{new}"
|
|
86
|
+
|
|
87
|
+
old_lines = old.split("\n")
|
|
88
|
+
new_lines = new.split("\n")
|
|
89
|
+
|
|
90
|
+
diff_lines = []
|
|
91
|
+
for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines)):
|
|
92
|
+
if old_line != new_line:
|
|
93
|
+
diff_lines.append(f"@@ Line {i + 1} @@")
|
|
94
|
+
diff_lines.append(f"- {old_line}")
|
|
95
|
+
diff_lines.append(f"+ {new_line}")
|
|
96
|
+
|
|
97
|
+
# Handle different lengths
|
|
98
|
+
if len(new_lines) > len(old_lines):
|
|
99
|
+
diff_lines.append("@@ Added lines @@")
|
|
100
|
+
for line in new_lines[len(old_lines) :]:
|
|
101
|
+
diff_lines.append(f"+ {line}")
|
|
102
|
+
elif len(old_lines) > len(new_lines):
|
|
103
|
+
diff_lines.append("@@ Removed lines @@")
|
|
104
|
+
for line in old_lines[len(new_lines) :]:
|
|
105
|
+
diff_lines.append(f"- {line}")
|
|
106
|
+
|
|
107
|
+
return "\n".join(diff_lines) if diff_lines else "No changes"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Codespell spell checking check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
# from typing import List # No longer needed with Python 3.12+
|
|
9
|
+
|
|
10
|
+
from .base import Check, CheckResult, Issue, Fix, Severity, Impact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodespellCheck(Check):
|
|
14
|
+
"""Check for spelling errors in documentation and comments using codespell."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def name(self) -> str:
|
|
18
|
+
return "codespell"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def description(self) -> str:
|
|
22
|
+
return "Check spelling in documentation and comments with codespell"
|
|
23
|
+
|
|
24
|
+
def _parse_codespell_output(self, output: str) -> list[Issue]:
|
|
25
|
+
"""Parse codespell output and convert to Issue objects."""
|
|
26
|
+
issues = []
|
|
27
|
+
|
|
28
|
+
# Pattern to match codespell output format:
|
|
29
|
+
# path/to/file.py:line: word ==> suggestion
|
|
30
|
+
pattern = r'^(.+?):(\d+): (.+) ==> (.+)$'
|
|
31
|
+
|
|
32
|
+
for line in output.strip().split('\n'):
|
|
33
|
+
if not line.strip():
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
match = re.match(pattern, line)
|
|
37
|
+
if match:
|
|
38
|
+
file_path, line_num, misspelled, suggestion = match.groups()
|
|
39
|
+
|
|
40
|
+
# Convert absolute path to relative
|
|
41
|
+
try:
|
|
42
|
+
rel_path = Path(file_path).relative_to(self.project_dir)
|
|
43
|
+
except ValueError:
|
|
44
|
+
# If path is not under project_dir, use as-is
|
|
45
|
+
rel_path = Path(file_path)
|
|
46
|
+
|
|
47
|
+
# Determine impact based on file location
|
|
48
|
+
impact = self._get_impact_for_file(rel_path)
|
|
49
|
+
|
|
50
|
+
issues.append(Issue(
|
|
51
|
+
check=self.name,
|
|
52
|
+
severity=Severity.WARNING, # Spelling errors are usually warnings
|
|
53
|
+
description=f"'{misspelled}' should be '{suggestion}'",
|
|
54
|
+
file=rel_path,
|
|
55
|
+
line=int(line_num),
|
|
56
|
+
impact=impact,
|
|
57
|
+
explanation=f"Spelling error detected. '{misspelled}' should be spelled '{suggestion}'. Good spelling improves documentation quality and professionalism.",
|
|
58
|
+
))
|
|
59
|
+
|
|
60
|
+
return issues
|
|
61
|
+
|
|
62
|
+
def _get_impact_for_file(self, file_path: Path) -> Impact:
|
|
63
|
+
"""Determine impact level based on file location."""
|
|
64
|
+
file_str = str(file_path).lower()
|
|
65
|
+
|
|
66
|
+
# Critical for user-facing documentation
|
|
67
|
+
if any(critical in file_str for critical in ['readme', 'changelog', 'license', 'contributing']):
|
|
68
|
+
return Impact.CRITICAL
|
|
69
|
+
|
|
70
|
+
# Important for documentation and code
|
|
71
|
+
if file_path.suffix in ['.md', '.rst', '.py', '.txt'] or 'docs/' in file_str:
|
|
72
|
+
return Impact.IMPORTANT
|
|
73
|
+
|
|
74
|
+
# Informational for other files
|
|
75
|
+
return Impact.INFORMATIONAL
|
|
76
|
+
|
|
77
|
+
def _get_codespell_command(self) -> list[str]:
|
|
78
|
+
"""Build codespell command with appropriate options."""
|
|
79
|
+
cmd = ["codespell"]
|
|
80
|
+
|
|
81
|
+
# Add skip patterns for common directories/files that don't need spell checking
|
|
82
|
+
skip_patterns = [
|
|
83
|
+
".git",
|
|
84
|
+
"__pycache__",
|
|
85
|
+
"*.pyc",
|
|
86
|
+
"*.egg-info",
|
|
87
|
+
".pytest_cache",
|
|
88
|
+
"node_modules",
|
|
89
|
+
".mypy_cache",
|
|
90
|
+
"dist",
|
|
91
|
+
"build",
|
|
92
|
+
".venv",
|
|
93
|
+
"venv"
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
cmd.extend(["--skip", ",".join(skip_patterns)])
|
|
97
|
+
|
|
98
|
+
# Focus on text files and code
|
|
99
|
+
# Don't spell check binary files, images, etc.
|
|
100
|
+
cmd.extend([
|
|
101
|
+
"--check-filenames", # Also check file names
|
|
102
|
+
"--quiet-level", "2", # Suppress binary file warnings
|
|
103
|
+
])
|
|
104
|
+
|
|
105
|
+
# Add project directory as target
|
|
106
|
+
cmd.append(str(self.project_dir))
|
|
107
|
+
|
|
108
|
+
return cmd
|
|
109
|
+
|
|
110
|
+
def run(self) -> CheckResult:
|
|
111
|
+
"""Run codespell check."""
|
|
112
|
+
issues = []
|
|
113
|
+
|
|
114
|
+
# Check if codespell is available
|
|
115
|
+
try:
|
|
116
|
+
subprocess.run(
|
|
117
|
+
["codespell", "--version"],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
check=True,
|
|
120
|
+
cwd=self.project_dir,
|
|
121
|
+
)
|
|
122
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
123
|
+
return CheckResult(
|
|
124
|
+
check=self.name,
|
|
125
|
+
passed=False,
|
|
126
|
+
issues=[
|
|
127
|
+
Issue(
|
|
128
|
+
check=self.name,
|
|
129
|
+
severity=Severity.ERROR,
|
|
130
|
+
description="codespell is not installed. Install with: pip install codespell",
|
|
131
|
+
impact=Impact.CRITICAL,
|
|
132
|
+
explanation="codespell is required for spell checking documentation and comments",
|
|
133
|
+
)
|
|
134
|
+
],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Run codespell
|
|
138
|
+
cmd = self._get_codespell_command()
|
|
139
|
+
result = subprocess.run(
|
|
140
|
+
cmd,
|
|
141
|
+
capture_output=True,
|
|
142
|
+
text=True,
|
|
143
|
+
cwd=self.project_dir,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# codespell returns:
|
|
147
|
+
# 0: No misspellings found
|
|
148
|
+
# >0: Misspellings found (exit code represents number of files with issues)
|
|
149
|
+
|
|
150
|
+
match result.returncode:
|
|
151
|
+
case 0:
|
|
152
|
+
# No misspellings found - issues list stays empty
|
|
153
|
+
pass
|
|
154
|
+
case code if code > 0 and (result.stdout or result.stderr):
|
|
155
|
+
# Misspellings found - check both stdout and stderr
|
|
156
|
+
output = result.stdout if result.stdout else result.stderr
|
|
157
|
+
if output:
|
|
158
|
+
issues = self._parse_codespell_output(output)
|
|
159
|
+
|
|
160
|
+
# Add a single fix for all spelling issues if any exist
|
|
161
|
+
if issues:
|
|
162
|
+
fix = self._get_fix_for_issues(issues)
|
|
163
|
+
# Attach the fix to the first issue (consolidated approach)
|
|
164
|
+
issues[0].proposed_fix = fix
|
|
165
|
+
case code if code > 0:
|
|
166
|
+
# Non-zero exit code but no output - likely an error
|
|
167
|
+
error_msg = result.stderr.strip() or f"codespell exited with code {result.returncode}"
|
|
168
|
+
issues.append(Issue(
|
|
169
|
+
check=self.name,
|
|
170
|
+
severity=Severity.WARNING,
|
|
171
|
+
description=f"codespell error: {error_msg}",
|
|
172
|
+
impact=Impact.INFORMATIONAL,
|
|
173
|
+
explanation="codespell had trouble analyzing some files",
|
|
174
|
+
))
|
|
175
|
+
|
|
176
|
+
return CheckResult(
|
|
177
|
+
check=self.name,
|
|
178
|
+
passed=len(issues) == 0,
|
|
179
|
+
issues=issues,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def can_fix(self) -> bool:
|
|
183
|
+
return True # Codespell can auto-fix spelling errors
|
|
184
|
+
|
|
185
|
+
def _get_fix_for_issues(self, issues: list[Issue]) -> Fix:
|
|
186
|
+
"""Create a Fix object for codespell auto-correction."""
|
|
187
|
+
def apply_codespell_fix():
|
|
188
|
+
"""Apply codespell automatic fixes."""
|
|
189
|
+
cmd = self._get_codespell_command()
|
|
190
|
+
# Add write flag to actually apply fixes
|
|
191
|
+
cmd.insert(-1, "--write-changes") # Insert before directory argument
|
|
192
|
+
|
|
193
|
+
subprocess.run(
|
|
194
|
+
cmd,
|
|
195
|
+
cwd=self.project_dir,
|
|
196
|
+
check=False, # Don't raise on exit code 1 (fixes applied)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Get diff preview by running with --diff flag
|
|
200
|
+
cmd = self._get_codespell_command()
|
|
201
|
+
cmd.insert(-1, "--diff") # Insert before directory argument
|
|
202
|
+
|
|
203
|
+
diff_result = subprocess.run(
|
|
204
|
+
cmd,
|
|
205
|
+
capture_output=True,
|
|
206
|
+
text=True,
|
|
207
|
+
cwd=self.project_dir,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
diff_output = diff_result.stdout if diff_result.stdout else "No diff available"
|
|
211
|
+
|
|
212
|
+
return Fix(
|
|
213
|
+
description=f"Apply codespell automatic fixes for {len(issues)} spelling error(s)",
|
|
214
|
+
diff=diff_output,
|
|
215
|
+
apply=apply_codespell_fix,
|
|
216
|
+
)
|