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
pycmdcheck/runner.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Check runner that orchestrates check execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pycmdcheck.config import get_check_config, is_check_enabled, load_config
|
|
10
|
+
from pycmdcheck.constants import MAX_WORKERS
|
|
11
|
+
from pycmdcheck.discovery import discover_checks
|
|
12
|
+
from pycmdcheck.results import CheckResult, CheckStatus, Report
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run_checks(
|
|
18
|
+
package_path: str | Path,
|
|
19
|
+
checks: list[str] | None = None,
|
|
20
|
+
skip: list[str] | None = None,
|
|
21
|
+
config: dict[str, Any] | None = None,
|
|
22
|
+
parallel: bool = True,
|
|
23
|
+
fail_fast: bool = False,
|
|
24
|
+
) -> Report:
|
|
25
|
+
"""Run checks on a package.
|
|
26
|
+
|
|
27
|
+
This is the core function that orchestrates check discovery and execution.
|
|
28
|
+
It loads configuration, discovers available checks via entry points,
|
|
29
|
+
and runs them either in parallel or sequentially.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
package_path: Path to the package directory to check. Can be a string
|
|
33
|
+
or Path object. Will be resolved to an absolute path.
|
|
34
|
+
checks: List of specific check names to run. If None, runs all checks
|
|
35
|
+
that are enabled in the configuration. Available built-in checks:
|
|
36
|
+
build, dependencies, docs, formatting, imports, license, linting,
|
|
37
|
+
metadata, py_typed, structure, tests, typing, version.
|
|
38
|
+
skip: List of check names to skip, even if they would otherwise run.
|
|
39
|
+
config: Pre-loaded configuration dictionary. If None, configuration
|
|
40
|
+
is loaded from the package's pyproject.toml file.
|
|
41
|
+
parallel: Whether to run checks in parallel using threads. Defaults
|
|
42
|
+
to True. Set to False for deterministic ordering or debugging.
|
|
43
|
+
fail_fast: Whether to stop after the first ERROR result. Defaults
|
|
44
|
+
to False.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A Report object containing all check results. The report includes
|
|
48
|
+
the package path and a list of CheckResult objects.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
Run all enabled checks:
|
|
52
|
+
|
|
53
|
+
>>> report = run_checks("/path/to/package")
|
|
54
|
+
>>> report.passed
|
|
55
|
+
True
|
|
56
|
+
|
|
57
|
+
Run specific checks only:
|
|
58
|
+
|
|
59
|
+
>>> report = run_checks(".", checks=["metadata", "tests"])
|
|
60
|
+
>>> len(report.results)
|
|
61
|
+
2
|
|
62
|
+
|
|
63
|
+
Run sequentially for debugging:
|
|
64
|
+
|
|
65
|
+
>>> report = run_checks(".", parallel=False)
|
|
66
|
+
"""
|
|
67
|
+
package_path = Path(package_path).resolve()
|
|
68
|
+
skip = skip or []
|
|
69
|
+
|
|
70
|
+
# Load configuration
|
|
71
|
+
if config is None:
|
|
72
|
+
config = load_config(package_path)
|
|
73
|
+
|
|
74
|
+
# Discover available checks
|
|
75
|
+
available_checks = discover_checks()
|
|
76
|
+
|
|
77
|
+
# Determine which checks to run
|
|
78
|
+
checks_to_run: list[str] = []
|
|
79
|
+
if checks:
|
|
80
|
+
# Run only specified checks
|
|
81
|
+
for name in checks:
|
|
82
|
+
if name in available_checks:
|
|
83
|
+
checks_to_run.append(name)
|
|
84
|
+
else:
|
|
85
|
+
# Run all enabled checks
|
|
86
|
+
for name in available_checks:
|
|
87
|
+
if is_check_enabled(config, name):
|
|
88
|
+
checks_to_run.append(name)
|
|
89
|
+
|
|
90
|
+
# Remove skipped checks
|
|
91
|
+
checks_to_run = [c for c in checks_to_run if c not in skip]
|
|
92
|
+
|
|
93
|
+
# Create report
|
|
94
|
+
report = Report(package_path=str(package_path))
|
|
95
|
+
|
|
96
|
+
if not checks_to_run:
|
|
97
|
+
return report
|
|
98
|
+
|
|
99
|
+
# Run checks
|
|
100
|
+
if parallel and len(checks_to_run) > 1:
|
|
101
|
+
results = _run_parallel(
|
|
102
|
+
package_path, checks_to_run, available_checks, config, fail_fast=fail_fast
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
results = _run_sequential(
|
|
106
|
+
package_path,
|
|
107
|
+
checks_to_run,
|
|
108
|
+
available_checks,
|
|
109
|
+
config,
|
|
110
|
+
fail_fast=fail_fast,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
for result in results:
|
|
114
|
+
report.add(result)
|
|
115
|
+
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_sequential(
|
|
120
|
+
package_path: Path,
|
|
121
|
+
check_names: list[str],
|
|
122
|
+
available_checks: dict[str, type],
|
|
123
|
+
config: dict[str, Any],
|
|
124
|
+
fail_fast: bool = False,
|
|
125
|
+
) -> list[CheckResult]:
|
|
126
|
+
"""Run checks sequentially."""
|
|
127
|
+
results: list[CheckResult] = []
|
|
128
|
+
|
|
129
|
+
for name in check_names:
|
|
130
|
+
result = _run_single_check(package_path, name, available_checks, config)
|
|
131
|
+
results.append(result)
|
|
132
|
+
if fail_fast and result.status == CheckStatus.ERROR:
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _run_parallel(
|
|
139
|
+
package_path: Path,
|
|
140
|
+
check_names: list[str],
|
|
141
|
+
available_checks: dict[str, type],
|
|
142
|
+
config: dict[str, Any],
|
|
143
|
+
fail_fast: bool = False,
|
|
144
|
+
) -> list[CheckResult]:
|
|
145
|
+
"""Run checks in parallel using threads."""
|
|
146
|
+
results: list[CheckResult] = []
|
|
147
|
+
|
|
148
|
+
with ThreadPoolExecutor(max_workers=min(len(check_names), MAX_WORKERS)) as executor:
|
|
149
|
+
futures = {
|
|
150
|
+
executor.submit(
|
|
151
|
+
_run_single_check, package_path, name, available_checks, config
|
|
152
|
+
): name
|
|
153
|
+
for name in check_names
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for future in as_completed(futures):
|
|
157
|
+
try:
|
|
158
|
+
result = future.result()
|
|
159
|
+
results.append(result)
|
|
160
|
+
if fail_fast and result.status == CheckStatus.ERROR:
|
|
161
|
+
for f in futures:
|
|
162
|
+
f.cancel()
|
|
163
|
+
break
|
|
164
|
+
except Exception as e:
|
|
165
|
+
name = futures[future]
|
|
166
|
+
logger.exception("Check %s raised exception in thread", name)
|
|
167
|
+
results.append(
|
|
168
|
+
CheckResult(
|
|
169
|
+
name=name,
|
|
170
|
+
status=CheckStatus.ERROR,
|
|
171
|
+
message=f"Check failed with exception: {e}",
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
if fail_fast:
|
|
175
|
+
for f in futures:
|
|
176
|
+
f.cancel()
|
|
177
|
+
break
|
|
178
|
+
|
|
179
|
+
# Sort by original order
|
|
180
|
+
name_order = {name: i for i, name in enumerate(check_names)}
|
|
181
|
+
results.sort(key=lambda r: name_order.get(r.name, 999))
|
|
182
|
+
|
|
183
|
+
return results
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _run_single_check(
|
|
187
|
+
package_path: Path,
|
|
188
|
+
name: str,
|
|
189
|
+
available_checks: dict[str, type],
|
|
190
|
+
config: dict[str, Any],
|
|
191
|
+
) -> CheckResult:
|
|
192
|
+
"""Run a single check."""
|
|
193
|
+
check_class = available_checks.get(name)
|
|
194
|
+
if not check_class:
|
|
195
|
+
return CheckResult(
|
|
196
|
+
name=name,
|
|
197
|
+
status=CheckStatus.ERROR,
|
|
198
|
+
message=f"Check '{name}' not found",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
check_config = get_check_config(config, name)
|
|
202
|
+
|
|
203
|
+
logger.debug("Starting check: %s", name)
|
|
204
|
+
start_time = time.time()
|
|
205
|
+
try:
|
|
206
|
+
check_instance = check_class()
|
|
207
|
+
result: CheckResult = check_instance.run(package_path, check_config)
|
|
208
|
+
if result.name != name:
|
|
209
|
+
logger.warning(
|
|
210
|
+
"Check '%s' returned result with name='%s'; correcting",
|
|
211
|
+
name,
|
|
212
|
+
result.name,
|
|
213
|
+
)
|
|
214
|
+
result = CheckResult(
|
|
215
|
+
name=name,
|
|
216
|
+
status=result.status,
|
|
217
|
+
message=result.message,
|
|
218
|
+
details=result.details,
|
|
219
|
+
duration=result.duration,
|
|
220
|
+
)
|
|
221
|
+
result.duration = time.time() - start_time
|
|
222
|
+
logger.debug(
|
|
223
|
+
"Check %s completed in %.2fs: %s",
|
|
224
|
+
name,
|
|
225
|
+
result.duration,
|
|
226
|
+
result.status.value,
|
|
227
|
+
)
|
|
228
|
+
return result
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.exception("Check %s raised exception", name)
|
|
231
|
+
return CheckResult(
|
|
232
|
+
name=name,
|
|
233
|
+
status=CheckStatus.ERROR,
|
|
234
|
+
message=f"Check raised exception: {e}",
|
|
235
|
+
duration=time.time() - start_time,
|
|
236
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Shared subprocess runner utility for pycmdcheck.
|
|
2
|
+
|
|
3
|
+
This module provides a unified interface for running external tools
|
|
4
|
+
(pytest, ruff, mypy, pyright, etc.) as subprocesses. It replaces
|
|
5
|
+
duplicated subprocess-handling code across the check modules with a
|
|
6
|
+
single, well-tested implementation.
|
|
7
|
+
|
|
8
|
+
Typical usage::
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.subprocess_runner import run_tool, tool_available
|
|
11
|
+
|
|
12
|
+
if not tool_available("ruff"):
|
|
13
|
+
... # handle missing tool
|
|
14
|
+
|
|
15
|
+
result = run_tool(["ruff", "check", "."], cwd=package_path)
|
|
16
|
+
if result.success:
|
|
17
|
+
...
|
|
18
|
+
else:
|
|
19
|
+
for line in result.output_lines():
|
|
20
|
+
...
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from pycmdcheck.constants import LONG_TIMEOUT
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SubprocessResult:
|
|
38
|
+
"""Immutable result of a subprocess execution.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
returncode: Exit code of the process. Defaults to ``-1`` when
|
|
42
|
+
the process was never started.
|
|
43
|
+
stdout: Captured standard output.
|
|
44
|
+
stderr: Captured standard error.
|
|
45
|
+
timed_out: Whether the process exceeded the timeout.
|
|
46
|
+
error: Human-readable error message when the process could not
|
|
47
|
+
be started or an unexpected exception occurred.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
returncode: int = -1
|
|
51
|
+
stdout: str = ""
|
|
52
|
+
stderr: str = ""
|
|
53
|
+
timed_out: bool = False
|
|
54
|
+
error: str | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def success(self) -> bool:
|
|
58
|
+
"""Return ``True`` when the process exited cleanly with code 0.
|
|
59
|
+
|
|
60
|
+
A result is *not* considered successful if the process timed out
|
|
61
|
+
or an error prevented it from running, even if the return code
|
|
62
|
+
happens to be 0.
|
|
63
|
+
"""
|
|
64
|
+
return self.returncode == 0 and not self.timed_out and self.error is None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def output(self) -> str:
|
|
68
|
+
"""Return combined stdout and stderr."""
|
|
69
|
+
return self.stdout + self.stderr
|
|
70
|
+
|
|
71
|
+
def output_lines(self, *, strip_blank: bool = True) -> list[str]:
|
|
72
|
+
"""Split *stdout* into individual lines.
|
|
73
|
+
|
|
74
|
+
Parameters:
|
|
75
|
+
strip_blank: When ``True`` (the default), blank/empty lines
|
|
76
|
+
are removed from the result.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
A list of stdout lines. A trailing newline does not produce
|
|
80
|
+
a trailing empty string.
|
|
81
|
+
"""
|
|
82
|
+
if not self.stdout:
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
# splitlines() already handles the trailing-newline case nicely:
|
|
86
|
+
# "a\nb\n".splitlines() == ["a", "b"]
|
|
87
|
+
lines = self.stdout.splitlines()
|
|
88
|
+
|
|
89
|
+
if strip_blank:
|
|
90
|
+
lines = [line for line in lines if line]
|
|
91
|
+
|
|
92
|
+
return lines
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def tool_available(tool_name: str) -> bool:
|
|
96
|
+
"""Check whether an external tool is available on ``$PATH``.
|
|
97
|
+
|
|
98
|
+
This is a thin wrapper around :func:`shutil.which`.
|
|
99
|
+
|
|
100
|
+
Parameters:
|
|
101
|
+
tool_name: The command name to look up (e.g. ``"ruff"``).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
``True`` if the tool was found, ``False`` otherwise.
|
|
105
|
+
"""
|
|
106
|
+
return shutil.which(tool_name) is not None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def run_tool(
|
|
110
|
+
cmd: list[str],
|
|
111
|
+
*,
|
|
112
|
+
cwd: Path | str | None = None,
|
|
113
|
+
timeout: int = LONG_TIMEOUT,
|
|
114
|
+
) -> SubprocessResult:
|
|
115
|
+
"""Run an external tool as a subprocess and return a structured result.
|
|
116
|
+
|
|
117
|
+
Parameters:
|
|
118
|
+
cmd: The command and its arguments as a list of strings.
|
|
119
|
+
cwd: Working directory for the subprocess.
|
|
120
|
+
timeout: Maximum number of seconds to wait. Defaults to 300 (5
|
|
121
|
+
minutes).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A :class:`SubprocessResult` with captured stdout, stderr, and
|
|
125
|
+
metadata about the execution.
|
|
126
|
+
"""
|
|
127
|
+
try:
|
|
128
|
+
logger.debug("Running: %s (cwd=%s, timeout=%ds)", cmd, cwd, timeout)
|
|
129
|
+
proc = subprocess.run(
|
|
130
|
+
cmd,
|
|
131
|
+
cwd=cwd,
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
timeout=timeout,
|
|
135
|
+
)
|
|
136
|
+
logger.debug("Process exited with code %d", proc.returncode)
|
|
137
|
+
return SubprocessResult(
|
|
138
|
+
returncode=proc.returncode,
|
|
139
|
+
stdout=proc.stdout,
|
|
140
|
+
stderr=proc.stderr,
|
|
141
|
+
)
|
|
142
|
+
except subprocess.TimeoutExpired:
|
|
143
|
+
logger.warning("Process timed out after %ds: %s", timeout, cmd)
|
|
144
|
+
return SubprocessResult(timed_out=True)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.error("Failed to run %s: %s", cmd, exc)
|
|
147
|
+
return SubprocessResult(error=str(exc))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def sanitize_args(args: list[str]) -> list[str]:
|
|
151
|
+
"""Validate and return subprocess args from check configuration.
|
|
152
|
+
|
|
153
|
+
Ensures all arguments are strings. Both flags (``--select``) and
|
|
154
|
+
their values (``"E,F,W"``) are allowed, since ``subprocess.run``
|
|
155
|
+
is always called with ``shell=False`` which prevents injection.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
TypeError: If any element is not a string.
|
|
159
|
+
"""
|
|
160
|
+
for arg in args:
|
|
161
|
+
if not isinstance(arg, str):
|
|
162
|
+
raise TypeError(
|
|
163
|
+
f"Non-string argument in check config args: {arg!r}. "
|
|
164
|
+
f"All arguments must be strings."
|
|
165
|
+
)
|
|
166
|
+
return list(args)
|