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,230 @@
|
|
|
1
|
+
"""Test runner check.
|
|
2
|
+
|
|
3
|
+
This module provides the TestsCheck class which runs package tests
|
|
4
|
+
using pytest or unittest and reports the results.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.constants import LONG_TIMEOUT, MAX_DETAIL_LINES
|
|
12
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
13
|
+
from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestsCheck(BaseCheck):
|
|
17
|
+
"""Run package tests and report results.
|
|
18
|
+
|
|
19
|
+
Executes the package's test suite using the configured test runner
|
|
20
|
+
(pytest by default) and reports pass/fail status. Supports pytest
|
|
21
|
+
and unittest runners.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
name: The check identifier ("tests").
|
|
25
|
+
description: Human-readable description of this check.
|
|
26
|
+
|
|
27
|
+
Configuration Options:
|
|
28
|
+
runner (str): Test runner to use ("pytest" or "unittest").
|
|
29
|
+
Defaults to "pytest".
|
|
30
|
+
args (list[str]): Additional arguments to pass to the test runner.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
Run tests with default settings (pytest):
|
|
34
|
+
|
|
35
|
+
>>> from pathlib import Path
|
|
36
|
+
>>> check = TestsCheck()
|
|
37
|
+
>>> result = check.run(Path("."), {"enabled": True})
|
|
38
|
+
>>> result.name
|
|
39
|
+
'tests'
|
|
40
|
+
|
|
41
|
+
Configure to use unittest:
|
|
42
|
+
|
|
43
|
+
>>> config = {"enabled": True, "runner": "unittest"}
|
|
44
|
+
>>> result = check.run(Path("."), config)
|
|
45
|
+
|
|
46
|
+
Pass additional pytest arguments:
|
|
47
|
+
|
|
48
|
+
>>> config = {"enabled": True, "args": ["-x", "--cov"]}
|
|
49
|
+
>>> result = check.run(Path("."), config)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name = "tests"
|
|
53
|
+
description = "Run package tests (pytest/unittest)"
|
|
54
|
+
|
|
55
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
56
|
+
"""Run tests and return results.
|
|
57
|
+
|
|
58
|
+
Executes the test suite in the package's tests/ or test/ directory.
|
|
59
|
+
The test runner is determined by the ``runner`` config option.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
package_path: Path to the package directory containing tests.
|
|
63
|
+
config: Configuration dictionary with options:
|
|
64
|
+
|
|
65
|
+
- runner (str): "pytest" or "unittest" (default: "pytest")
|
|
66
|
+
- args (list[str]): Additional arguments for the runner
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A CheckResult with:
|
|
70
|
+
|
|
71
|
+
- OK status if all tests pass
|
|
72
|
+
- WARNING status if no tests found
|
|
73
|
+
- ERROR status if tests fail or runner not installed
|
|
74
|
+
- SKIPPED status if the test runner is not installed
|
|
75
|
+
"""
|
|
76
|
+
runner = config.get("runner", "pytest")
|
|
77
|
+
|
|
78
|
+
# Check if test directory exists
|
|
79
|
+
test_dirs = ["tests", "test"]
|
|
80
|
+
test_dir_found = None
|
|
81
|
+
for test_dir in test_dirs:
|
|
82
|
+
if (package_path / test_dir).is_dir():
|
|
83
|
+
test_dir_found = test_dir
|
|
84
|
+
break
|
|
85
|
+
|
|
86
|
+
# Also check for test files at root
|
|
87
|
+
test_files = list(package_path.glob("test_*.py"))
|
|
88
|
+
|
|
89
|
+
if not test_dir_found and not test_files:
|
|
90
|
+
return CheckResult(
|
|
91
|
+
name=self.name,
|
|
92
|
+
status=CheckStatus.WARNING,
|
|
93
|
+
message="No tests found",
|
|
94
|
+
details=["Consider adding tests in a 'tests/' directory"],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
preamble = [f"Using test runner: {runner}"]
|
|
98
|
+
|
|
99
|
+
if runner == "pytest":
|
|
100
|
+
return self._run_pytest(package_path, config, preamble)
|
|
101
|
+
elif runner == "unittest":
|
|
102
|
+
return self._run_unittest(package_path, config, preamble)
|
|
103
|
+
else:
|
|
104
|
+
return CheckResult(
|
|
105
|
+
name=self.name,
|
|
106
|
+
status=CheckStatus.ERROR,
|
|
107
|
+
message=f"Unknown test runner: {runner}",
|
|
108
|
+
details=["Supported runners: pytest, unittest"],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _run_pytest(
|
|
112
|
+
self, package_path: Path, config: dict[str, Any], preamble: list[str]
|
|
113
|
+
) -> CheckResult:
|
|
114
|
+
"""Run pytest."""
|
|
115
|
+
if not tool_available("pytest"):
|
|
116
|
+
return CheckResult(
|
|
117
|
+
name=self.name,
|
|
118
|
+
status=CheckStatus.SKIPPED,
|
|
119
|
+
message="pytest not installed",
|
|
120
|
+
details=["Install pytest: pip install pytest"],
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
args = sanitize_args(config.get("args", []))
|
|
124
|
+
cmd = ["pytest", "-v", "--tb=short", *args]
|
|
125
|
+
|
|
126
|
+
result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
|
|
127
|
+
|
|
128
|
+
if result.timed_out:
|
|
129
|
+
return CheckResult(
|
|
130
|
+
name=self.name,
|
|
131
|
+
status=CheckStatus.ERROR,
|
|
132
|
+
message="Tests timed out",
|
|
133
|
+
details=[f"Tests took longer than {LONG_TIMEOUT} seconds"],
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
if result.error is not None:
|
|
137
|
+
return CheckResult(
|
|
138
|
+
name=self.name,
|
|
139
|
+
status=CheckStatus.ERROR,
|
|
140
|
+
message="Failed to run tests",
|
|
141
|
+
details=[result.error],
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if result.success:
|
|
145
|
+
# Parse pytest output for summary
|
|
146
|
+
details = list(preamble)
|
|
147
|
+
for line in result.stdout.split("\n"):
|
|
148
|
+
if "passed" in line or "skipped" in line:
|
|
149
|
+
details.append(line.strip())
|
|
150
|
+
break
|
|
151
|
+
|
|
152
|
+
return CheckResult(
|
|
153
|
+
name=self.name,
|
|
154
|
+
status=CheckStatus.OK,
|
|
155
|
+
message="All tests passed",
|
|
156
|
+
details=details,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Tests failed
|
|
160
|
+
output = result.stdout + result.stderr
|
|
161
|
+
failure_lines: list[str] = []
|
|
162
|
+
for line in output.split("\n"):
|
|
163
|
+
if "FAILED" in line or "ERROR" in line:
|
|
164
|
+
failure_lines.append(line.strip())
|
|
165
|
+
|
|
166
|
+
details = [*preamble, *failure_lines[:MAX_DETAIL_LINES]]
|
|
167
|
+
if len(failure_lines) > MAX_DETAIL_LINES:
|
|
168
|
+
details.append("... (more failures omitted)")
|
|
169
|
+
|
|
170
|
+
return CheckResult(
|
|
171
|
+
name=self.name,
|
|
172
|
+
status=CheckStatus.ERROR,
|
|
173
|
+
message="Tests failed",
|
|
174
|
+
details=details,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _run_unittest(
|
|
178
|
+
self, package_path: Path, config: dict[str, Any], preamble: list[str]
|
|
179
|
+
) -> CheckResult:
|
|
180
|
+
"""Run unittest discovery."""
|
|
181
|
+
args = sanitize_args(config.get("args", []))
|
|
182
|
+
cmd = ["python", "-m", "unittest", "discover", "-v", *args]
|
|
183
|
+
|
|
184
|
+
result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
|
|
185
|
+
|
|
186
|
+
if result.timed_out:
|
|
187
|
+
return CheckResult(
|
|
188
|
+
name=self.name,
|
|
189
|
+
status=CheckStatus.ERROR,
|
|
190
|
+
message="Tests timed out",
|
|
191
|
+
details=[f"Tests took longer than {LONG_TIMEOUT} seconds"],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if result.error is not None:
|
|
195
|
+
return CheckResult(
|
|
196
|
+
name=self.name,
|
|
197
|
+
status=CheckStatus.ERROR,
|
|
198
|
+
message="Failed to run tests",
|
|
199
|
+
details=[result.error],
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if result.success:
|
|
203
|
+
# Parse output for test count (unittest outputs to stderr)
|
|
204
|
+
details = list(preamble)
|
|
205
|
+
for line in result.stderr.split("\n"):
|
|
206
|
+
if "Ran" in line:
|
|
207
|
+
details.append(line.strip())
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
return CheckResult(
|
|
211
|
+
name=self.name,
|
|
212
|
+
status=CheckStatus.OK,
|
|
213
|
+
message="All tests passed",
|
|
214
|
+
details=details,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Parse failures from stderr
|
|
218
|
+
failure_lines: list[str] = []
|
|
219
|
+
for line in result.stderr.split("\n"):
|
|
220
|
+
if "FAIL:" in line or "ERROR:" in line:
|
|
221
|
+
failure_lines.append(line.strip())
|
|
222
|
+
|
|
223
|
+
details = [*preamble, *failure_lines[:MAX_DETAIL_LINES]]
|
|
224
|
+
|
|
225
|
+
return CheckResult(
|
|
226
|
+
name=self.name,
|
|
227
|
+
status=CheckStatus.ERROR,
|
|
228
|
+
message="Tests failed",
|
|
229
|
+
details=details,
|
|
230
|
+
)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Type checking with configurable backends.
|
|
2
|
+
|
|
3
|
+
This module provides the TypingCheck class which runs static type
|
|
4
|
+
checking tools (mypy or pyright) on the package and reports any errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.constants import LONG_TIMEOUT, MAX_DETAIL_LINES
|
|
12
|
+
from pycmdcheck.package_layout import PackageLayout
|
|
13
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
14
|
+
from pycmdcheck.subprocess_runner import run_tool, sanitize_args, tool_available
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TypingCheck(BaseCheck):
|
|
18
|
+
"""Run type checking tools on the package.
|
|
19
|
+
|
|
20
|
+
Executes a configurable static type checker to verify type
|
|
21
|
+
annotations. Supports mypy (default) and pyright.
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
name: The check identifier ("typing").
|
|
25
|
+
description: Human-readable description of this check.
|
|
26
|
+
SUPPORTED_TOOLS: List of supported type checking tools.
|
|
27
|
+
|
|
28
|
+
Configuration Options:
|
|
29
|
+
tool (str): Type checker to use ("mypy" or "pyright").
|
|
30
|
+
Defaults to "mypy".
|
|
31
|
+
strict (bool): Whether to enable strict mode. Defaults to False.
|
|
32
|
+
args (list[str]): Additional arguments to pass to the type checker.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
Run type checking with default settings (mypy):
|
|
36
|
+
|
|
37
|
+
>>> from pathlib import Path
|
|
38
|
+
>>> check = TypingCheck()
|
|
39
|
+
>>> result = check.run(Path("."), {"enabled": True})
|
|
40
|
+
>>> result.name
|
|
41
|
+
'typing'
|
|
42
|
+
|
|
43
|
+
Configure to use pyright:
|
|
44
|
+
|
|
45
|
+
>>> config = {"enabled": True, "tool": "pyright"}
|
|
46
|
+
>>> result = check.run(Path("."), config)
|
|
47
|
+
|
|
48
|
+
Enable strict mode:
|
|
49
|
+
|
|
50
|
+
>>> config = {"enabled": True, "strict": True}
|
|
51
|
+
>>> result = check.run(Path("."), config)
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name = "typing"
|
|
55
|
+
description = "Run type checking (mypy/pyright)"
|
|
56
|
+
|
|
57
|
+
SUPPORTED_TOOLS = ["mypy", "pyright"]
|
|
58
|
+
|
|
59
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
60
|
+
"""Run type checking on the package.
|
|
61
|
+
|
|
62
|
+
Executes the configured type checker and collects any errors found.
|
|
63
|
+
Errors are reported in the result details (limited to first 10).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
package_path: Path to the package directory to check.
|
|
67
|
+
config: Configuration dictionary with options:
|
|
68
|
+
|
|
69
|
+
- tool (str): "mypy" or "pyright" (default: "mypy")
|
|
70
|
+
- strict (bool): Enable strict mode (default: False)
|
|
71
|
+
- args (list[str]): Additional arguments for the checker
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A CheckResult with:
|
|
75
|
+
|
|
76
|
+
- OK status if no type errors found
|
|
77
|
+
- ERROR status if type errors found or checker fails
|
|
78
|
+
- SKIPPED status if the type checker is not installed
|
|
79
|
+
"""
|
|
80
|
+
tool = config.get("tool", "mypy")
|
|
81
|
+
|
|
82
|
+
if tool not in self.SUPPORTED_TOOLS:
|
|
83
|
+
return CheckResult(
|
|
84
|
+
name=self.name,
|
|
85
|
+
status=CheckStatus.ERROR,
|
|
86
|
+
message=f"Unsupported type checker: {tool}",
|
|
87
|
+
details=[f"Supported tools: {', '.join(self.SUPPORTED_TOOLS)}"],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
preamble = [f"Using type checker: {tool}"]
|
|
91
|
+
|
|
92
|
+
if tool == "mypy":
|
|
93
|
+
return self._run_mypy(package_path, config, preamble)
|
|
94
|
+
else: # pyright
|
|
95
|
+
return self._run_pyright(package_path, config, preamble)
|
|
96
|
+
|
|
97
|
+
def _run_mypy(
|
|
98
|
+
self, package_path: Path, config: dict[str, Any], preamble: list[str]
|
|
99
|
+
) -> CheckResult:
|
|
100
|
+
"""Run mypy type checker."""
|
|
101
|
+
if not tool_available("mypy"):
|
|
102
|
+
return CheckResult(
|
|
103
|
+
name=self.name,
|
|
104
|
+
status=CheckStatus.SKIPPED,
|
|
105
|
+
message="mypy not installed",
|
|
106
|
+
details=["Install mypy: pip install mypy"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Target the discovered package(s)/module(s) rather than ".", which on
|
|
110
|
+
# flat/single-module layouts makes mypy scan tests/build/etc. and emit
|
|
111
|
+
# spurious "Duplicate module" discovery failures. mypy auto-detects the
|
|
112
|
+
# base directory by walking up past __init__.py, so module names stay
|
|
113
|
+
# correct (we deliberately do NOT pass --explicit-package-bases, which
|
|
114
|
+
# would mis-root packages relative to the cwd).
|
|
115
|
+
layout = PackageLayout(package_path)
|
|
116
|
+
targets = [str(p) for p in layout.package_dirs]
|
|
117
|
+
targets += [str(m) for m in layout.top_level_modules()]
|
|
118
|
+
if not targets:
|
|
119
|
+
src_dir = package_path / "src"
|
|
120
|
+
targets = [str(src_dir) if src_dir.is_dir() else "."]
|
|
121
|
+
|
|
122
|
+
args = sanitize_args(config.get("args", []))
|
|
123
|
+
strict = config.get("strict", False)
|
|
124
|
+
|
|
125
|
+
cmd = ["mypy", "--namespace-packages", *targets]
|
|
126
|
+
if strict:
|
|
127
|
+
cmd.append("--strict")
|
|
128
|
+
cmd.extend(args)
|
|
129
|
+
|
|
130
|
+
result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
|
|
131
|
+
|
|
132
|
+
if result.timed_out:
|
|
133
|
+
return CheckResult(
|
|
134
|
+
name=self.name,
|
|
135
|
+
status=CheckStatus.ERROR,
|
|
136
|
+
message="Type checking timed out",
|
|
137
|
+
details=[f"Type checking took longer than {LONG_TIMEOUT} seconds"],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if result.error is not None:
|
|
141
|
+
return CheckResult(
|
|
142
|
+
name=self.name,
|
|
143
|
+
status=CheckStatus.ERROR,
|
|
144
|
+
message="Failed to run mypy",
|
|
145
|
+
details=[result.error],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
stdout = result.stdout.strip()
|
|
149
|
+
|
|
150
|
+
if result.success:
|
|
151
|
+
# Check for "Success" message
|
|
152
|
+
if "Success" in stdout or not stdout:
|
|
153
|
+
return CheckResult(
|
|
154
|
+
name=self.name,
|
|
155
|
+
status=CheckStatus.OK,
|
|
156
|
+
message="No type errors found",
|
|
157
|
+
details=list(preamble),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# mypy exit code 2 is a fatal/discovery/config error (e.g. duplicate
|
|
161
|
+
# modules, missing config) — not a type-error finding.
|
|
162
|
+
if result.returncode == 2:
|
|
163
|
+
detail_lines = (result.output or "").strip().splitlines()
|
|
164
|
+
return CheckResult(
|
|
165
|
+
name=self.name,
|
|
166
|
+
status=CheckStatus.WARNING,
|
|
167
|
+
message="mypy could not analyze the package (configuration/layout)",
|
|
168
|
+
details=[*preamble, *detail_lines[:MAX_DETAIL_LINES]],
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Parse mypy output for errors
|
|
172
|
+
errors = []
|
|
173
|
+
for line in stdout.split("\n"):
|
|
174
|
+
if ": error:" in line:
|
|
175
|
+
errors.append(line.strip())
|
|
176
|
+
|
|
177
|
+
if not errors and "error:" in stdout:
|
|
178
|
+
errors = [line.strip() for line in stdout.split("\n") if line.strip()]
|
|
179
|
+
|
|
180
|
+
details = [*preamble, *errors[:MAX_DETAIL_LINES]]
|
|
181
|
+
|
|
182
|
+
if len(errors) > MAX_DETAIL_LINES:
|
|
183
|
+
details.append(f"... and {len(errors) - MAX_DETAIL_LINES} more errors")
|
|
184
|
+
|
|
185
|
+
error_count = len(errors) if errors else "some"
|
|
186
|
+
return CheckResult(
|
|
187
|
+
name=self.name,
|
|
188
|
+
status=CheckStatus.ERROR,
|
|
189
|
+
message=f"Found {error_count} type error(s)",
|
|
190
|
+
details=details,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _run_pyright(
|
|
194
|
+
self, package_path: Path, config: dict[str, Any], preamble: list[str]
|
|
195
|
+
) -> CheckResult:
|
|
196
|
+
"""Run pyright type checker."""
|
|
197
|
+
if not tool_available("pyright"):
|
|
198
|
+
return CheckResult(
|
|
199
|
+
name=self.name,
|
|
200
|
+
status=CheckStatus.SKIPPED,
|
|
201
|
+
message="pyright not installed",
|
|
202
|
+
details=["Install pyright: pip install pyright"],
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
args = sanitize_args(config.get("args", []))
|
|
206
|
+
strict = config.get("strict", False)
|
|
207
|
+
|
|
208
|
+
cmd = ["pyright"]
|
|
209
|
+
if strict:
|
|
210
|
+
cmd.append("--strict")
|
|
211
|
+
cmd.extend(args)
|
|
212
|
+
|
|
213
|
+
result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
|
|
214
|
+
|
|
215
|
+
if result.timed_out:
|
|
216
|
+
return CheckResult(
|
|
217
|
+
name=self.name,
|
|
218
|
+
status=CheckStatus.ERROR,
|
|
219
|
+
message="Type checking timed out",
|
|
220
|
+
details=[f"Type checking took longer than {LONG_TIMEOUT} seconds"],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if result.error is not None:
|
|
224
|
+
return CheckResult(
|
|
225
|
+
name=self.name,
|
|
226
|
+
status=CheckStatus.ERROR,
|
|
227
|
+
message="Failed to run pyright",
|
|
228
|
+
details=[result.error],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if result.success:
|
|
232
|
+
return CheckResult(
|
|
233
|
+
name=self.name,
|
|
234
|
+
status=CheckStatus.OK,
|
|
235
|
+
message="No type errors found",
|
|
236
|
+
details=list(preamble),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Parse pyright output
|
|
240
|
+
stdout = result.stdout.strip()
|
|
241
|
+
errors = []
|
|
242
|
+
for line in stdout.split("\n"):
|
|
243
|
+
if " - error:" in line or " - warning:" in line:
|
|
244
|
+
errors.append(line.strip())
|
|
245
|
+
|
|
246
|
+
details = [*preamble, *errors[:MAX_DETAIL_LINES]]
|
|
247
|
+
|
|
248
|
+
if len(errors) > MAX_DETAIL_LINES:
|
|
249
|
+
details.append(f"... and {len(errors) - MAX_DETAIL_LINES} more errors")
|
|
250
|
+
|
|
251
|
+
error_count = len(errors) if errors else "some"
|
|
252
|
+
return CheckResult(
|
|
253
|
+
name=self.name,
|
|
254
|
+
status=CheckStatus.ERROR,
|
|
255
|
+
message=f"Found {error_count} type error(s)",
|
|
256
|
+
details=details,
|
|
257
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""URL reachability check.
|
|
2
|
+
|
|
3
|
+
Validates that URLs declared in package metadata are reachable.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
13
|
+
from pycmdcheck.constants import MAX_DETAIL_LINES, MAX_URL_CHECKS, URL_TIMEOUT
|
|
14
|
+
from pycmdcheck.pyproject_reader import read_pyproject
|
|
15
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class URLsCheck(BaseCheck):
|
|
21
|
+
"""Check that project URLs in metadata are reachable.
|
|
22
|
+
|
|
23
|
+
Extracts URLs from ``[project.urls]`` in pyproject.toml and
|
|
24
|
+
validates each with an HTTP HEAD request.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
name: The check identifier ("urls").
|
|
28
|
+
description: Human-readable description of this check.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name = "urls"
|
|
32
|
+
description = "Validate project URLs are reachable"
|
|
33
|
+
|
|
34
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
35
|
+
"""Check URL reachability for project metadata URLs.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
package_path: Path to the package directory to check.
|
|
39
|
+
config: Configuration dictionary for this check.
|
|
40
|
+
Supports ``timeout`` (int) to override URL_TIMEOUT.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A CheckResult with:
|
|
44
|
+
|
|
45
|
+
- OK status if all URLs are reachable
|
|
46
|
+
- WARNING status if some URLs are unreachable
|
|
47
|
+
- NOTE status if no URLs found in metadata
|
|
48
|
+
"""
|
|
49
|
+
details: list[str] = []
|
|
50
|
+
timeout = config.get("timeout", URL_TIMEOUT)
|
|
51
|
+
|
|
52
|
+
pyproject = read_pyproject(package_path)
|
|
53
|
+
if pyproject is None:
|
|
54
|
+
return CheckResult(
|
|
55
|
+
name=self.name,
|
|
56
|
+
status=CheckStatus.NOTE,
|
|
57
|
+
message="No pyproject.toml found",
|
|
58
|
+
details=["Cannot check URLs without pyproject.toml"],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
project = pyproject.get("project", {})
|
|
62
|
+
urls = project.get("urls", {})
|
|
63
|
+
|
|
64
|
+
if not urls:
|
|
65
|
+
return CheckResult(
|
|
66
|
+
name=self.name,
|
|
67
|
+
status=CheckStatus.NOTE,
|
|
68
|
+
message="No project URLs defined",
|
|
69
|
+
details=[
|
|
70
|
+
"Add [project.urls] to pyproject.toml",
|
|
71
|
+
'Example: Homepage = "https://github.com/user/project"',
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
unreachable: list[str] = []
|
|
76
|
+
checked = 0
|
|
77
|
+
|
|
78
|
+
for label, url in list(urls.items())[:MAX_URL_CHECKS]:
|
|
79
|
+
checked += 1
|
|
80
|
+
reachable, error = self._check_url(url, timeout)
|
|
81
|
+
if reachable:
|
|
82
|
+
details.append(f"{label}: {url} (OK)")
|
|
83
|
+
else:
|
|
84
|
+
unreachable.append(label)
|
|
85
|
+
details.append(f"{label}: {url} (FAILED: {error})")
|
|
86
|
+
|
|
87
|
+
if unreachable:
|
|
88
|
+
return CheckResult(
|
|
89
|
+
name=self.name,
|
|
90
|
+
status=CheckStatus.WARNING,
|
|
91
|
+
message=(f"{len(unreachable)} of {checked} URL(s) unreachable"),
|
|
92
|
+
details=details[:MAX_DETAIL_LINES],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return CheckResult(
|
|
96
|
+
name=self.name,
|
|
97
|
+
status=CheckStatus.OK,
|
|
98
|
+
message=f"All {checked} project URL(s) reachable",
|
|
99
|
+
details=details[:MAX_DETAIL_LINES],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _check_url(self, url: str, timeout: int) -> tuple[bool, str | None]:
|
|
103
|
+
"""Check if a URL is reachable via HTTP HEAD then GET.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A tuple of (reachable, error_message).
|
|
107
|
+
"""
|
|
108
|
+
for method in ("HEAD", "GET"):
|
|
109
|
+
try:
|
|
110
|
+
req = urllib.request.Request(url, method=method)
|
|
111
|
+
req.add_header("User-Agent", "pycmdcheck/0.1")
|
|
112
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
113
|
+
if resp.status < 400:
|
|
114
|
+
return True, None
|
|
115
|
+
except urllib.error.HTTPError as e:
|
|
116
|
+
if method == "HEAD" and e.code == 405:
|
|
117
|
+
continue # Try GET instead
|
|
118
|
+
return False, f"HTTP {e.code}"
|
|
119
|
+
except urllib.error.URLError as e:
|
|
120
|
+
return False, str(e.reason)
|
|
121
|
+
except (OSError, ValueError) as e:
|
|
122
|
+
return False, str(e)
|
|
123
|
+
|
|
124
|
+
return False, "All methods failed"
|