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.
Files changed (41) hide show
  1. pycmdcheck/__init__.py +104 -0
  2. pycmdcheck/__main__.py +6 -0
  3. pycmdcheck/ast_cache.py +45 -0
  4. pycmdcheck/checks/__init__.py +1 -0
  5. pycmdcheck/checks/base.py +143 -0
  6. pycmdcheck/checks/build.py +101 -0
  7. pycmdcheck/checks/changelog.py +101 -0
  8. pycmdcheck/checks/ci.py +87 -0
  9. pycmdcheck/checks/citation.py +102 -0
  10. pycmdcheck/checks/community.py +108 -0
  11. pycmdcheck/checks/dependencies.py +309 -0
  12. pycmdcheck/checks/docs.py +305 -0
  13. pycmdcheck/checks/doctests.py +129 -0
  14. pycmdcheck/checks/formatting.py +200 -0
  15. pycmdcheck/checks/imports.py +239 -0
  16. pycmdcheck/checks/license.py +304 -0
  17. pycmdcheck/checks/linting.py +304 -0
  18. pycmdcheck/checks/metadata.py +233 -0
  19. pycmdcheck/checks/py_typed.py +207 -0
  20. pycmdcheck/checks/python_versions.py +188 -0
  21. pycmdcheck/checks/structure.py +282 -0
  22. pycmdcheck/checks/tests.py +230 -0
  23. pycmdcheck/checks/typing.py +257 -0
  24. pycmdcheck/checks/urls.py +124 -0
  25. pycmdcheck/checks/version.py +184 -0
  26. pycmdcheck/cli.py +296 -0
  27. pycmdcheck/config.py +194 -0
  28. pycmdcheck/constants.py +29 -0
  29. pycmdcheck/discovery.py +127 -0
  30. pycmdcheck/package_layout.py +234 -0
  31. pycmdcheck/profiles.py +158 -0
  32. pycmdcheck/py.typed +0 -0
  33. pycmdcheck/pyproject_reader.py +226 -0
  34. pycmdcheck/results.py +320 -0
  35. pycmdcheck/runner.py +236 -0
  36. pycmdcheck/subprocess_runner.py +166 -0
  37. pycmdcheck-0.1.0.dist-info/METADATA +346 -0
  38. pycmdcheck-0.1.0.dist-info/RECORD +41 -0
  39. pycmdcheck-0.1.0.dist-info/WHEEL +4 -0
  40. pycmdcheck-0.1.0.dist-info/entry_points.txt +24 -0
  41. 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)