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 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
@@ -0,0 +1,6 @@
1
+ """Allow preen to be run as a module."""
2
+
3
+ from .cli import run
4
+
5
+ if __name__ == "__main__":
6
+ run()
@@ -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
+ )
@@ -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
+ )