pycmdcheck 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.
- pycmdcheck/__init__.py +104 -0
- pycmdcheck/__main__.py +6 -0
- pycmdcheck/ast_cache.py +45 -0
- pycmdcheck/checks/__init__.py +1 -0
- pycmdcheck/checks/base.py +143 -0
- pycmdcheck/checks/build.py +101 -0
- pycmdcheck/checks/changelog.py +101 -0
- pycmdcheck/checks/ci.py +87 -0
- pycmdcheck/checks/citation.py +102 -0
- pycmdcheck/checks/community.py +108 -0
- pycmdcheck/checks/dependencies.py +309 -0
- pycmdcheck/checks/docs.py +305 -0
- pycmdcheck/checks/doctests.py +129 -0
- pycmdcheck/checks/formatting.py +200 -0
- pycmdcheck/checks/imports.py +239 -0
- pycmdcheck/checks/license.py +304 -0
- pycmdcheck/checks/linting.py +304 -0
- pycmdcheck/checks/metadata.py +233 -0
- pycmdcheck/checks/py_typed.py +207 -0
- pycmdcheck/checks/python_versions.py +188 -0
- pycmdcheck/checks/structure.py +282 -0
- pycmdcheck/checks/tests.py +230 -0
- pycmdcheck/checks/typing.py +257 -0
- pycmdcheck/checks/urls.py +124 -0
- pycmdcheck/checks/version.py +184 -0
- pycmdcheck/cli.py +296 -0
- pycmdcheck/config.py +194 -0
- pycmdcheck/constants.py +29 -0
- pycmdcheck/discovery.py +127 -0
- pycmdcheck/package_layout.py +234 -0
- pycmdcheck/profiles.py +158 -0
- pycmdcheck/py.typed +0 -0
- pycmdcheck/pyproject_reader.py +226 -0
- pycmdcheck/results.py +320 -0
- pycmdcheck/runner.py +236 -0
- pycmdcheck/subprocess_runner.py +166 -0
- pycmdcheck-0.1.0.dist-info/METADATA +346 -0
- pycmdcheck-0.1.0.dist-info/RECORD +41 -0
- pycmdcheck-0.1.0.dist-info/WHEEL +4 -0
- pycmdcheck-0.1.0.dist-info/entry_points.txt +24 -0
- pycmdcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Documentation check.
|
|
2
|
+
|
|
3
|
+
This module provides the DocsCheck class which verifies that a package
|
|
4
|
+
has proper documentation including a README file and optionally checks
|
|
5
|
+
for docstrings in the code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pycmdcheck.ast_cache import parse_file
|
|
15
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
16
|
+
from pycmdcheck.constants import MAX_DOCSTRING_FILES, MIN_README_WORDS
|
|
17
|
+
from pycmdcheck.package_layout import PackageLayout
|
|
18
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DocsCheck(BaseCheck):
|
|
24
|
+
"""Check for documentation presence and quality.
|
|
25
|
+
|
|
26
|
+
Verifies that the package has a README file and optionally checks
|
|
27
|
+
that public modules, classes, and functions have docstrings.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
name: The check identifier ("docs").
|
|
31
|
+
description: Human-readable description of this check.
|
|
32
|
+
README_FILENAMES: List of recognized README file names.
|
|
33
|
+
|
|
34
|
+
Configuration Options:
|
|
35
|
+
require_readme (bool): Whether README is required. Defaults to True.
|
|
36
|
+
check_docstrings (bool): Whether to check for docstrings in code.
|
|
37
|
+
Defaults to False.
|
|
38
|
+
check_readme_sections (bool): Whether to check for common README
|
|
39
|
+
sections (Installation, Usage). Defaults to False.
|
|
40
|
+
min_docstring_coverage (float): Minimum docstring coverage percentage
|
|
41
|
+
(0.0-100.0). When > 0 and coverage is below threshold, an issue
|
|
42
|
+
is reported. Defaults to 0.0 (disabled).
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
Run the docs check with defaults:
|
|
46
|
+
|
|
47
|
+
>>> from pathlib import Path
|
|
48
|
+
>>> check = DocsCheck()
|
|
49
|
+
>>> result = check.run(Path("."), {"enabled": True})
|
|
50
|
+
>>> result.name
|
|
51
|
+
'docs'
|
|
52
|
+
|
|
53
|
+
Enable docstring checking:
|
|
54
|
+
|
|
55
|
+
>>> config = {"enabled": True, "check_docstrings": True}
|
|
56
|
+
>>> result = check.run(Path("."), config)
|
|
57
|
+
|
|
58
|
+
Make README optional:
|
|
59
|
+
|
|
60
|
+
>>> config = {"enabled": True, "require_readme": False}
|
|
61
|
+
>>> result = check.run(Path("."), config)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
name = "docs"
|
|
65
|
+
description = "Check documentation (README, docstrings)"
|
|
66
|
+
|
|
67
|
+
README_FILENAMES = [
|
|
68
|
+
"README.md",
|
|
69
|
+
"README.rst",
|
|
70
|
+
"README.txt",
|
|
71
|
+
"README",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
75
|
+
"""Check documentation presence and quality.
|
|
76
|
+
|
|
77
|
+
Looks for a README file and optionally scans code for missing
|
|
78
|
+
docstrings on public items.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
package_path: Path to the package directory to check.
|
|
82
|
+
config: Configuration dictionary with options:
|
|
83
|
+
|
|
84
|
+
- require_readme (bool): Require README (default: True)
|
|
85
|
+
- check_docstrings (bool): Check code docstrings (default: False)
|
|
86
|
+
- check_readme_sections (bool): Check for common README
|
|
87
|
+
sections (default: False)
|
|
88
|
+
- min_docstring_coverage (float): Minimum coverage %
|
|
89
|
+
(default: 0.0, disabled)
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
A CheckResult with:
|
|
93
|
+
|
|
94
|
+
- OK status if all documentation requirements met
|
|
95
|
+
- NOTE status if minor improvements suggested
|
|
96
|
+
- WARNING status if README missing (when required)
|
|
97
|
+
"""
|
|
98
|
+
details: list[str] = []
|
|
99
|
+
issues: list[str] = []
|
|
100
|
+
|
|
101
|
+
# Check for README
|
|
102
|
+
require_readme = config.get("require_readme", True)
|
|
103
|
+
readme_found = self._check_readme(package_path, details, issues, require_readme)
|
|
104
|
+
|
|
105
|
+
# Check README sections (opt-in)
|
|
106
|
+
if config.get("check_readme_sections", False) and readme_found:
|
|
107
|
+
self._check_readme_sections(package_path, details, issues)
|
|
108
|
+
|
|
109
|
+
# Check for docstrings (optional)
|
|
110
|
+
check_docstrings = config.get("check_docstrings", False)
|
|
111
|
+
if check_docstrings:
|
|
112
|
+
min_coverage = config.get("min_docstring_coverage", 0.0)
|
|
113
|
+
self._check_docstrings(package_path, details, issues, min_coverage)
|
|
114
|
+
|
|
115
|
+
# Determine status
|
|
116
|
+
if issues and require_readme and not readme_found:
|
|
117
|
+
return CheckResult(
|
|
118
|
+
name=self.name,
|
|
119
|
+
status=CheckStatus.WARNING,
|
|
120
|
+
message="Documentation incomplete",
|
|
121
|
+
details=details + issues,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if issues:
|
|
125
|
+
return CheckResult(
|
|
126
|
+
name=self.name,
|
|
127
|
+
status=CheckStatus.NOTE,
|
|
128
|
+
message="Some documentation improvements suggested",
|
|
129
|
+
details=details + issues,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return CheckResult(
|
|
133
|
+
name=self.name,
|
|
134
|
+
status=CheckStatus.OK,
|
|
135
|
+
message="Documentation present",
|
|
136
|
+
details=details,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def _check_readme(
|
|
140
|
+
self,
|
|
141
|
+
package_path: Path,
|
|
142
|
+
details: list[str],
|
|
143
|
+
issues: list[str],
|
|
144
|
+
require_readme: bool,
|
|
145
|
+
) -> bool:
|
|
146
|
+
"""Check for README file."""
|
|
147
|
+
for filename in self.README_FILENAMES:
|
|
148
|
+
readme_path = package_path / filename
|
|
149
|
+
if readme_path.exists():
|
|
150
|
+
details.append(f"Found README: {filename}")
|
|
151
|
+
|
|
152
|
+
# Check README is not too short
|
|
153
|
+
try:
|
|
154
|
+
content = readme_path.read_text(encoding="utf-8")
|
|
155
|
+
word_count = len(content.split())
|
|
156
|
+
if word_count < MIN_README_WORDS:
|
|
157
|
+
issues.append(
|
|
158
|
+
f"README is very short (< {MIN_README_WORDS} words)"
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
details.append(f"README has {word_count} words")
|
|
162
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
163
|
+
logger.debug("Could not read README %s: %s", readme_path, exc)
|
|
164
|
+
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
if require_readme:
|
|
168
|
+
issues.append("No README file found")
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
def _check_readme_sections(
|
|
172
|
+
self,
|
|
173
|
+
package_path: Path,
|
|
174
|
+
details: list[str],
|
|
175
|
+
issues: list[str],
|
|
176
|
+
) -> None:
|
|
177
|
+
"""Check that the README mentions common sections.
|
|
178
|
+
|
|
179
|
+
Searches the whole README body (case-insensitive) for Installation
|
|
180
|
+
and Usage keywords rather than only Markdown ``#`` headings, so that
|
|
181
|
+
RST/Setext/plain-text READMEs and synonyms (Quick Start, Getting
|
|
182
|
+
Started, Example, Tutorial) are recognized and not falsely flagged.
|
|
183
|
+
"""
|
|
184
|
+
readme_content = ""
|
|
185
|
+
for filename in self.README_FILENAMES:
|
|
186
|
+
readme_path = package_path / filename
|
|
187
|
+
if readme_path.exists():
|
|
188
|
+
try:
|
|
189
|
+
readme_content = readme_path.read_text(encoding="utf-8")
|
|
190
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
191
|
+
logger.debug("Could not read README %s: %s", readme_path, exc)
|
|
192
|
+
break
|
|
193
|
+
|
|
194
|
+
if not readme_content:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
text = readme_content.lower()
|
|
198
|
+
|
|
199
|
+
expected_sections = {
|
|
200
|
+
"Installation": r"install",
|
|
201
|
+
"Usage": (
|
|
202
|
+
r"usage|quick[\s-]?start|getting[\s-]?started"
|
|
203
|
+
r"|example|tutorial|how to use"
|
|
204
|
+
),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for section_name, pattern in expected_sections.items():
|
|
208
|
+
if not re.search(pattern, text):
|
|
209
|
+
issues.append(f"README missing section: {section_name}")
|
|
210
|
+
|
|
211
|
+
def _check_docstrings(
|
|
212
|
+
self,
|
|
213
|
+
package_path: Path,
|
|
214
|
+
details: list[str],
|
|
215
|
+
issues: list[str],
|
|
216
|
+
min_coverage: float = 0.0,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Check for docstrings in public modules and functions."""
|
|
219
|
+
# Find Python files
|
|
220
|
+
layout = PackageLayout(package_path)
|
|
221
|
+
py_files = layout.python_files()
|
|
222
|
+
|
|
223
|
+
# Exclude test files
|
|
224
|
+
py_files = [f for f in py_files if not f.name.startswith("test_")]
|
|
225
|
+
|
|
226
|
+
missing_docstrings: list[str] = []
|
|
227
|
+
total_public_items = 0
|
|
228
|
+
|
|
229
|
+
for py_file in py_files[:MAX_DOCSTRING_FILES]: # Limit to first N files
|
|
230
|
+
try:
|
|
231
|
+
total, missing = self._check_file_docstrings_counted(py_file)
|
|
232
|
+
total_public_items += total
|
|
233
|
+
missing_docstrings.extend(missing)
|
|
234
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
235
|
+
logger.debug("Could not parse %s: %s", py_file, exc)
|
|
236
|
+
|
|
237
|
+
if missing_docstrings:
|
|
238
|
+
issues.append(
|
|
239
|
+
f"Missing docstrings in {len(missing_docstrings)} public items"
|
|
240
|
+
)
|
|
241
|
+
for missing_item in missing_docstrings[:5]:
|
|
242
|
+
issues.append(f" - {missing_item}")
|
|
243
|
+
if len(missing_docstrings) > 5:
|
|
244
|
+
issues.append(f" ... and {len(missing_docstrings) - 5} more")
|
|
245
|
+
else:
|
|
246
|
+
details.append("All public items have docstrings")
|
|
247
|
+
|
|
248
|
+
# Report coverage percentage
|
|
249
|
+
documented = total_public_items - len(missing_docstrings)
|
|
250
|
+
if total_public_items > 0:
|
|
251
|
+
pct = documented / total_public_items * 100
|
|
252
|
+
details.append(
|
|
253
|
+
f"Docstring coverage: {documented}/{total_public_items}"
|
|
254
|
+
f" public items ({pct:.0f}%)"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Check minimum coverage threshold
|
|
258
|
+
if min_coverage > 0 and pct < min_coverage:
|
|
259
|
+
issues.append(
|
|
260
|
+
f"Docstring coverage {pct:.0f}% is below"
|
|
261
|
+
f" minimum {min_coverage:.0f}%"
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
details.append("Docstring coverage: 0/0 public items (100%)")
|
|
265
|
+
|
|
266
|
+
def _check_file_docstrings(self, py_file: Path) -> list[str]:
|
|
267
|
+
"""Check docstrings in a single Python file."""
|
|
268
|
+
_total, missing = self._check_file_docstrings_counted(py_file)
|
|
269
|
+
return missing
|
|
270
|
+
|
|
271
|
+
def _check_file_docstrings_counted(self, py_file: Path) -> tuple[int, list[str]]:
|
|
272
|
+
"""Check docstrings in a single Python file.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
A tuple of (total_public_items, missing_items) where
|
|
276
|
+
*total_public_items* is the count of public items inspected
|
|
277
|
+
and *missing_items* is the list of items without docstrings.
|
|
278
|
+
"""
|
|
279
|
+
tree = parse_file(py_file)
|
|
280
|
+
if tree is None:
|
|
281
|
+
return 0, []
|
|
282
|
+
missing: list[str] = []
|
|
283
|
+
total = 0
|
|
284
|
+
|
|
285
|
+
# Check module docstring
|
|
286
|
+
total += 1
|
|
287
|
+
if not ast.get_docstring(tree):
|
|
288
|
+
missing.append(f"{py_file.name}: module")
|
|
289
|
+
|
|
290
|
+
for node in ast.walk(tree):
|
|
291
|
+
# Check class docstrings
|
|
292
|
+
if isinstance(node, ast.ClassDef):
|
|
293
|
+
if not node.name.startswith("_"):
|
|
294
|
+
total += 1
|
|
295
|
+
if not ast.get_docstring(node):
|
|
296
|
+
missing.append(f"{py_file.name}: class {node.name}")
|
|
297
|
+
|
|
298
|
+
# Check function docstrings
|
|
299
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
300
|
+
if not node.name.startswith("_"):
|
|
301
|
+
total += 1
|
|
302
|
+
if not ast.get_docstring(node):
|
|
303
|
+
missing.append(f"{py_file.name}: function {node.name}")
|
|
304
|
+
|
|
305
|
+
return total, missing
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Doctest execution check.
|
|
2
|
+
|
|
3
|
+
Runs doctests embedded in module docstrings using pytest's
|
|
4
|
+
``--doctest-modules`` flag.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
12
|
+
from pycmdcheck.constants import DEFAULT_TIMEOUT, MAX_DETAIL_LINES
|
|
13
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
14
|
+
from pycmdcheck.subprocess_runner import run_tool, tool_available
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DoctestsCheck(BaseCheck):
|
|
20
|
+
"""Run doctests via pytest --doctest-modules.
|
|
21
|
+
|
|
22
|
+
Executes all doctests embedded in module docstrings and reports
|
|
23
|
+
failures.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
name: The check identifier ("doctests").
|
|
27
|
+
description: Human-readable description of this check.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name = "doctests"
|
|
31
|
+
description = "Run doctests in package modules"
|
|
32
|
+
|
|
33
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
34
|
+
"""Run doctests for the package.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
package_path: Path to the package directory to check.
|
|
38
|
+
config: Configuration dictionary for this check.
|
|
39
|
+
Supports ``args`` (list[str]) for additional pytest args
|
|
40
|
+
and ``timeout`` (int) to override default.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A CheckResult with:
|
|
44
|
+
|
|
45
|
+
- OK status if all doctests pass or no doctests found
|
|
46
|
+
- WARNING status if some doctests fail
|
|
47
|
+
- ERROR status if doctest runner failed to execute
|
|
48
|
+
- SKIPPED status if pytest is not installed
|
|
49
|
+
"""
|
|
50
|
+
if not tool_available("pytest"):
|
|
51
|
+
return CheckResult(
|
|
52
|
+
name=self.name,
|
|
53
|
+
status=CheckStatus.SKIPPED,
|
|
54
|
+
message="pytest not installed",
|
|
55
|
+
details=["Install pytest to run doctests"],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Find the package source directory
|
|
59
|
+
src_dir = package_path / "src"
|
|
60
|
+
target = src_dir if src_dir.is_dir() else package_path
|
|
61
|
+
|
|
62
|
+
timeout = config.get("timeout", DEFAULT_TIMEOUT)
|
|
63
|
+
extra_args = config.get("args", [])
|
|
64
|
+
|
|
65
|
+
cmd = [
|
|
66
|
+
"pytest",
|
|
67
|
+
"--doctest-modules",
|
|
68
|
+
"--tb=short",
|
|
69
|
+
"-q",
|
|
70
|
+
str(target),
|
|
71
|
+
*extra_args,
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
details = ["Running: pytest --doctest-modules"]
|
|
75
|
+
|
|
76
|
+
result = run_tool(cmd, cwd=package_path, timeout=timeout)
|
|
77
|
+
|
|
78
|
+
if result.error:
|
|
79
|
+
return CheckResult(
|
|
80
|
+
name=self.name,
|
|
81
|
+
status=CheckStatus.ERROR,
|
|
82
|
+
message="Failed to run pytest for doctests",
|
|
83
|
+
details=[f"Error: {result.error}"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if result.timed_out:
|
|
87
|
+
return CheckResult(
|
|
88
|
+
name=self.name,
|
|
89
|
+
status=CheckStatus.ERROR,
|
|
90
|
+
message="Doctest execution timed out",
|
|
91
|
+
details=[f"Timeout after {timeout}s"],
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
stdout = result.stdout or ""
|
|
95
|
+
lines = stdout.strip().splitlines()
|
|
96
|
+
|
|
97
|
+
# pytest exit code 5 means "no tests were collected" — for a package
|
|
98
|
+
# with no doctests this is success, not a failure.
|
|
99
|
+
if result.returncode == 5 or "no tests ran" in stdout.lower():
|
|
100
|
+
details.append("No doctests found in package modules")
|
|
101
|
+
return CheckResult(
|
|
102
|
+
name=self.name,
|
|
103
|
+
status=CheckStatus.OK,
|
|
104
|
+
message="No doctests found",
|
|
105
|
+
details=details,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if result.returncode == 0:
|
|
109
|
+
details.append("All doctests passed")
|
|
110
|
+
return CheckResult(
|
|
111
|
+
name=self.name,
|
|
112
|
+
status=CheckStatus.OK,
|
|
113
|
+
message="All doctests passed",
|
|
114
|
+
details=details,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Doctests failed — extract failure info
|
|
118
|
+
failed_lines = [ln for ln in lines if "FAILED" in ln or "ERROR" in ln]
|
|
119
|
+
details.extend(failed_lines[:MAX_DETAIL_LINES])
|
|
120
|
+
if len(failed_lines) > MAX_DETAIL_LINES:
|
|
121
|
+
details.append(f"... and {len(failed_lines) - MAX_DETAIL_LINES} more")
|
|
122
|
+
|
|
123
|
+
fail_count = len(failed_lines) if failed_lines else "some"
|
|
124
|
+
return CheckResult(
|
|
125
|
+
name=self.name,
|
|
126
|
+
status=CheckStatus.WARNING,
|
|
127
|
+
message=f"{fail_count} doctest(s) failed",
|
|
128
|
+
details=details,
|
|
129
|
+
)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Code formatting check."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
7
|
+
from pycmdcheck.constants import DEFAULT_TIMEOUT, MAX_DETAIL_LINES
|
|
8
|
+
from pycmdcheck.pyproject_reader import read_pyproject
|
|
9
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
10
|
+
from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FormattingCheck(BaseCheck):
|
|
14
|
+
"""Check code formatting without modifying files."""
|
|
15
|
+
|
|
16
|
+
name = "formatting"
|
|
17
|
+
description = "Check code formatting (ruff format/black)"
|
|
18
|
+
|
|
19
|
+
SUPPORTED_TOOLS = ["ruff", "black"]
|
|
20
|
+
|
|
21
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
22
|
+
"""Run formatting check.
|
|
23
|
+
|
|
24
|
+
The formatter defaults to ``"auto"``: the package's configured
|
|
25
|
+
formatter is detected from pyproject.toml / ``ruff.toml``. A package
|
|
26
|
+
that has not opted into a formatter is SKIPPED rather than being held
|
|
27
|
+
to one tool's default style (pyOpenSci does not mandate a specific
|
|
28
|
+
formatter).
|
|
29
|
+
"""
|
|
30
|
+
tool = config.get("tool", "auto")
|
|
31
|
+
|
|
32
|
+
if tool == "auto":
|
|
33
|
+
detected = self._detect_formatter(package_path)
|
|
34
|
+
if detected is None:
|
|
35
|
+
return CheckResult(
|
|
36
|
+
name=self.name,
|
|
37
|
+
status=CheckStatus.SKIPPED,
|
|
38
|
+
message="No formatter configured",
|
|
39
|
+
details=[
|
|
40
|
+
"No [tool.ruff]/[tool.black] config or ruff.toml found; "
|
|
41
|
+
"skipping formatting check"
|
|
42
|
+
],
|
|
43
|
+
)
|
|
44
|
+
tool = detected
|
|
45
|
+
|
|
46
|
+
if tool not in self.SUPPORTED_TOOLS:
|
|
47
|
+
return CheckResult(
|
|
48
|
+
name=self.name,
|
|
49
|
+
status=CheckStatus.ERROR,
|
|
50
|
+
message=f"Unsupported formatting tool: {tool}",
|
|
51
|
+
details=[f"Supported tools: {', '.join(self.SUPPORTED_TOOLS)}"],
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if tool == "ruff":
|
|
55
|
+
return self._run_ruff_format(package_path, config)
|
|
56
|
+
else:
|
|
57
|
+
return self._run_black(package_path, config)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _detect_formatter(package_path: Path) -> str | None:
|
|
61
|
+
"""Detect the formatter a package has opted into, or ``None``.
|
|
62
|
+
|
|
63
|
+
Returns ``"ruff"`` if ``[tool.ruff]`` / ``ruff.toml`` is present,
|
|
64
|
+
``"black"`` if ``[tool.black]`` is present, otherwise ``None``.
|
|
65
|
+
"""
|
|
66
|
+
if (package_path / "ruff.toml").exists() or (
|
|
67
|
+
package_path / ".ruff.toml"
|
|
68
|
+
).exists():
|
|
69
|
+
return "ruff"
|
|
70
|
+
data = read_pyproject(package_path)
|
|
71
|
+
tool_table = data.get("tool", {}) if data else {}
|
|
72
|
+
if "ruff" in tool_table:
|
|
73
|
+
return "ruff"
|
|
74
|
+
if "black" in tool_table:
|
|
75
|
+
return "black"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
def _run_ruff_format(
|
|
79
|
+
self, package_path: Path, config: dict[str, Any]
|
|
80
|
+
) -> CheckResult:
|
|
81
|
+
"""Run ruff format --check."""
|
|
82
|
+
if not tool_available("ruff"):
|
|
83
|
+
return CheckResult(
|
|
84
|
+
name=self.name,
|
|
85
|
+
status=CheckStatus.SKIPPED,
|
|
86
|
+
message="ruff not installed",
|
|
87
|
+
details=["Install ruff: pip install ruff"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
args = sanitize_args(config.get("args", []))
|
|
91
|
+
result = run_tool(
|
|
92
|
+
["ruff", "format", "--check", ".", *args],
|
|
93
|
+
cwd=package_path,
|
|
94
|
+
timeout=DEFAULT_TIMEOUT,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if result.timed_out:
|
|
98
|
+
return CheckResult(
|
|
99
|
+
name=self.name,
|
|
100
|
+
status=CheckStatus.ERROR,
|
|
101
|
+
message="Formatting check timed out",
|
|
102
|
+
)
|
|
103
|
+
if result.error is not None:
|
|
104
|
+
return CheckResult(
|
|
105
|
+
name=self.name,
|
|
106
|
+
status=CheckStatus.ERROR,
|
|
107
|
+
message="Failed to run ruff format",
|
|
108
|
+
details=[result.error],
|
|
109
|
+
)
|
|
110
|
+
if result.success:
|
|
111
|
+
return CheckResult(
|
|
112
|
+
name=self.name,
|
|
113
|
+
status=CheckStatus.OK,
|
|
114
|
+
message="Code is properly formatted",
|
|
115
|
+
details=["Using formatter: ruff"],
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Files need reformatting
|
|
119
|
+
lines = result.output_lines()
|
|
120
|
+
reformat_lines = [ln for ln in lines if "would reformat" in ln.lower()]
|
|
121
|
+
file_count = len(reformat_lines)
|
|
122
|
+
details = ["Using formatter: ruff"]
|
|
123
|
+
if reformat_lines:
|
|
124
|
+
details.extend(reformat_lines[:MAX_DETAIL_LINES])
|
|
125
|
+
if file_count > MAX_DETAIL_LINES:
|
|
126
|
+
details.append(f"... and {file_count - MAX_DETAIL_LINES} more files")
|
|
127
|
+
else:
|
|
128
|
+
# Tool failed but output doesn't list specific files
|
|
129
|
+
details.extend(lines[:MAX_DETAIL_LINES])
|
|
130
|
+
|
|
131
|
+
return CheckResult(
|
|
132
|
+
name=self.name,
|
|
133
|
+
status=CheckStatus.WARNING,
|
|
134
|
+
message=(
|
|
135
|
+
f"{file_count} file(s) need reformatting"
|
|
136
|
+
if file_count
|
|
137
|
+
else "Code formatting issues found"
|
|
138
|
+
),
|
|
139
|
+
details=details,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _run_black(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
143
|
+
"""Run black --check."""
|
|
144
|
+
if not tool_available("black"):
|
|
145
|
+
return CheckResult(
|
|
146
|
+
name=self.name,
|
|
147
|
+
status=CheckStatus.SKIPPED,
|
|
148
|
+
message="black not installed",
|
|
149
|
+
details=["Install black: pip install black"],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
args = sanitize_args(config.get("args", []))
|
|
153
|
+
result = run_tool(
|
|
154
|
+
["black", "--check", ".", *args],
|
|
155
|
+
cwd=package_path,
|
|
156
|
+
timeout=DEFAULT_TIMEOUT,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if result.timed_out:
|
|
160
|
+
return CheckResult(
|
|
161
|
+
name=self.name,
|
|
162
|
+
status=CheckStatus.ERROR,
|
|
163
|
+
message="Formatting check timed out",
|
|
164
|
+
)
|
|
165
|
+
if result.error is not None:
|
|
166
|
+
return CheckResult(
|
|
167
|
+
name=self.name,
|
|
168
|
+
status=CheckStatus.ERROR,
|
|
169
|
+
message="Failed to run black",
|
|
170
|
+
details=[result.error],
|
|
171
|
+
)
|
|
172
|
+
if result.success:
|
|
173
|
+
return CheckResult(
|
|
174
|
+
name=self.name,
|
|
175
|
+
status=CheckStatus.OK,
|
|
176
|
+
message="Code is properly formatted",
|
|
177
|
+
details=["Using formatter: black"],
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
lines = result.output_lines()
|
|
181
|
+
reformat_lines = [ln for ln in lines if "would reformat" in ln.lower()]
|
|
182
|
+
file_count = len(reformat_lines)
|
|
183
|
+
details = ["Using formatter: black"]
|
|
184
|
+
if reformat_lines:
|
|
185
|
+
details.extend(reformat_lines[:MAX_DETAIL_LINES])
|
|
186
|
+
if file_count > MAX_DETAIL_LINES:
|
|
187
|
+
details.append(f"... and {file_count - MAX_DETAIL_LINES} more files")
|
|
188
|
+
else:
|
|
189
|
+
details.extend(lines[:MAX_DETAIL_LINES])
|
|
190
|
+
|
|
191
|
+
return CheckResult(
|
|
192
|
+
name=self.name,
|
|
193
|
+
status=CheckStatus.WARNING,
|
|
194
|
+
message=(
|
|
195
|
+
f"{file_count} file(s) need reformatting"
|
|
196
|
+
if file_count
|
|
197
|
+
else "Code formatting issues found"
|
|
198
|
+
),
|
|
199
|
+
details=details,
|
|
200
|
+
)
|