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
@@ -0,0 +1,304 @@
1
+ """Linting check with configurable backends.
2
+
3
+ This module provides the LintingCheck class which runs code linting
4
+ tools (ruff, flake8, or pylint) on the package and reports any issues.
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 DEFAULT_TIMEOUT, 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 LintingCheck(BaseCheck):
17
+ """Run linting tools on the package.
18
+
19
+ Executes a configurable linting tool to check code quality and
20
+ style. Supports ruff (default), flake8, and pylint.
21
+
22
+ Attributes:
23
+ name: The check identifier ("linting").
24
+ description: Human-readable description of this check.
25
+ SUPPORTED_TOOLS: List of supported linting tools.
26
+
27
+ Configuration Options:
28
+ tool (str): Linter to use ("ruff", "flake8", or "pylint").
29
+ Defaults to "ruff".
30
+ args (list[str]): Additional arguments to pass to the linter.
31
+
32
+ Examples:
33
+ Run linting with default settings (ruff):
34
+
35
+ >>> from pathlib import Path
36
+ >>> check = LintingCheck()
37
+ >>> result = check.run(Path("."), {"enabled": True})
38
+ >>> result.name
39
+ 'linting'
40
+
41
+ Configure to use flake8:
42
+
43
+ >>> config = {"enabled": True, "tool": "flake8"}
44
+ >>> result = check.run(Path("."), config)
45
+
46
+ Pass additional ruff arguments:
47
+
48
+ >>> config = {"enabled": True, "args": ["--select", "E,W"]}
49
+ >>> result = check.run(Path("."), config)
50
+ """
51
+
52
+ name = "linting"
53
+ description = "Run code linting (ruff/flake8/pylint)"
54
+
55
+ SUPPORTED_TOOLS = ["ruff", "flake8", "pylint"]
56
+
57
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
58
+ """Run linting check on the package.
59
+
60
+ Executes the configured linting tool and collects any issues found.
61
+ Issues are reported in the result details (limited to first 10).
62
+
63
+ Args:
64
+ package_path: Path to the package directory to lint.
65
+ config: Configuration dictionary with options:
66
+
67
+ - tool (str): "ruff", "flake8", or "pylint" (default: "ruff")
68
+ - args (list[str]): Additional arguments for the linter
69
+
70
+ Returns:
71
+ A CheckResult with:
72
+
73
+ - OK status if no linting issues found
74
+ - WARNING status if linting issues found
75
+ - ERROR status if linter fails or unsupported tool specified
76
+ - SKIPPED status if the linting tool is not installed
77
+ """
78
+ tool = config.get("tool", "ruff")
79
+
80
+ if tool not in self.SUPPORTED_TOOLS:
81
+ return CheckResult(
82
+ name=self.name,
83
+ status=CheckStatus.ERROR,
84
+ message=f"Unsupported linting tool: {tool}",
85
+ details=[f"Supported tools: {', '.join(self.SUPPORTED_TOOLS)}"],
86
+ )
87
+
88
+ preamble = [f"Using linter: {tool}"]
89
+
90
+ if tool == "ruff":
91
+ return self._run_ruff(package_path, config, preamble)
92
+ elif tool == "flake8":
93
+ return self._run_flake8(package_path, config, preamble)
94
+ else: # pylint
95
+ return self._run_pylint(package_path, config, preamble)
96
+
97
+ def _run_ruff(
98
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
99
+ ) -> CheckResult:
100
+ """Run ruff linter."""
101
+ if not tool_available("ruff"):
102
+ return CheckResult(
103
+ name=self.name,
104
+ status=CheckStatus.SKIPPED,
105
+ message="ruff not installed",
106
+ details=["Install ruff: pip install ruff"],
107
+ )
108
+
109
+ args = sanitize_args(config.get("args", []))
110
+ cmd = ["ruff", "check", ".", *args]
111
+
112
+ result = run_tool(cmd, cwd=package_path, timeout=DEFAULT_TIMEOUT)
113
+
114
+ if result.timed_out:
115
+ return CheckResult(
116
+ name=self.name,
117
+ status=CheckStatus.ERROR,
118
+ message="Linting timed out",
119
+ details=[f"Linting took longer than {DEFAULT_TIMEOUT} seconds"],
120
+ )
121
+
122
+ if result.error is not None:
123
+ return CheckResult(
124
+ name=self.name,
125
+ status=CheckStatus.ERROR,
126
+ message="Failed to run ruff",
127
+ details=[result.error],
128
+ )
129
+
130
+ if result.success:
131
+ return CheckResult(
132
+ name=self.name,
133
+ status=CheckStatus.OK,
134
+ message="No linting issues found",
135
+ details=list(preamble),
136
+ )
137
+
138
+ issues = result.output_lines()
139
+ issue_count = len(issues)
140
+
141
+ # A tool/config/usage failure (ruff exit code >= 2, or a non-zero exit
142
+ # with no parsable issues on stdout) is NOT a code-quality finding —
143
+ # surface the real error instead of "Found 0 linting issue(s)".
144
+ tool_error = self._tool_failure(result, "ruff", issue_count)
145
+ if tool_error is not None:
146
+ return tool_error
147
+
148
+ details = [*preamble, *issues[:MAX_DETAIL_LINES]]
149
+
150
+ if issue_count > MAX_DETAIL_LINES:
151
+ details.append(f"... and {issue_count - MAX_DETAIL_LINES} more issues")
152
+
153
+ return CheckResult(
154
+ name=self.name,
155
+ status=CheckStatus.WARNING,
156
+ message=f"Found {issue_count} linting issue(s)",
157
+ details=details,
158
+ )
159
+
160
+ def _tool_failure(
161
+ self, result: Any, tool: str, issue_count: int
162
+ ) -> CheckResult | None:
163
+ """Return an ERROR result if the linter failed to run, else ``None``.
164
+
165
+ Treats an exit code >= 2 (ruff/flake8 configuration or usage error) or
166
+ a non-zero exit that produced no parsable issues but did write to stderr
167
+ as a tool failure rather than a lint finding.
168
+ """
169
+ is_config_error = result.returncode >= 2
170
+ stderr = (result.stderr or "").strip()
171
+ no_issues_but_stderr = issue_count == 0 and bool(stderr)
172
+ if not (is_config_error or no_issues_but_stderr):
173
+ return None
174
+ detail_lines = (result.output or "").strip().splitlines()
175
+ return CheckResult(
176
+ name=self.name,
177
+ status=CheckStatus.ERROR,
178
+ message=f"{tool} failed to run (configuration or usage error)",
179
+ details=[f"Using linter: {tool}", *detail_lines[:MAX_DETAIL_LINES]],
180
+ )
181
+
182
+ def _run_flake8(
183
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
184
+ ) -> CheckResult:
185
+ """Run flake8 linter."""
186
+ if not tool_available("flake8"):
187
+ return CheckResult(
188
+ name=self.name,
189
+ status=CheckStatus.SKIPPED,
190
+ message="flake8 not installed",
191
+ details=["Install flake8: pip install flake8"],
192
+ )
193
+
194
+ args = sanitize_args(config.get("args", []))
195
+ cmd = ["flake8", ".", *args]
196
+
197
+ result = run_tool(cmd, cwd=package_path, timeout=DEFAULT_TIMEOUT)
198
+
199
+ if result.timed_out:
200
+ return CheckResult(
201
+ name=self.name,
202
+ status=CheckStatus.ERROR,
203
+ message="Linting timed out",
204
+ details=[f"Linting took longer than {DEFAULT_TIMEOUT} seconds"],
205
+ )
206
+
207
+ if result.error is not None:
208
+ return CheckResult(
209
+ name=self.name,
210
+ status=CheckStatus.ERROR,
211
+ message="Failed to run flake8",
212
+ details=[result.error],
213
+ )
214
+
215
+ if result.success:
216
+ return CheckResult(
217
+ name=self.name,
218
+ status=CheckStatus.OK,
219
+ message="No linting issues found",
220
+ details=list(preamble),
221
+ )
222
+
223
+ issues = result.output_lines()
224
+ issue_count = len(issues)
225
+
226
+ tool_error = self._tool_failure(result, "flake8", issue_count)
227
+ if tool_error is not None:
228
+ return tool_error
229
+
230
+ details = [*preamble, *issues[:MAX_DETAIL_LINES]]
231
+
232
+ if issue_count > MAX_DETAIL_LINES:
233
+ details.append(f"... and {issue_count - MAX_DETAIL_LINES} more issues")
234
+
235
+ return CheckResult(
236
+ name=self.name,
237
+ status=CheckStatus.WARNING,
238
+ message=f"Found {issue_count} linting issue(s)",
239
+ details=details,
240
+ )
241
+
242
+ def _run_pylint(
243
+ self, package_path: Path, config: dict[str, Any], preamble: list[str]
244
+ ) -> CheckResult:
245
+ """Run pylint linter."""
246
+ if not tool_available("pylint"):
247
+ return CheckResult(
248
+ name=self.name,
249
+ status=CheckStatus.SKIPPED,
250
+ message="pylint not installed",
251
+ details=["Install pylint: pip install pylint"],
252
+ )
253
+
254
+ # Find Python files to lint
255
+ src_dir = package_path / "src"
256
+ if src_dir.is_dir():
257
+ target = str(src_dir)
258
+ else:
259
+ target = "."
260
+
261
+ args = sanitize_args(config.get("args", []))
262
+ cmd = ["pylint", target, "--output-format=text", *args]
263
+
264
+ result = run_tool(cmd, cwd=package_path, timeout=LONG_TIMEOUT)
265
+
266
+ if result.timed_out:
267
+ return CheckResult(
268
+ name=self.name,
269
+ status=CheckStatus.ERROR,
270
+ message="Linting timed out",
271
+ details=[f"Linting took longer than {LONG_TIMEOUT} seconds"],
272
+ )
273
+
274
+ if result.error is not None:
275
+ return CheckResult(
276
+ name=self.name,
277
+ status=CheckStatus.ERROR,
278
+ message="Failed to run pylint",
279
+ details=[result.error],
280
+ )
281
+
282
+ if result.success:
283
+ return CheckResult(
284
+ name=self.name,
285
+ status=CheckStatus.OK,
286
+ message="No linting issues found",
287
+ details=list(preamble),
288
+ )
289
+
290
+ # Parse output
291
+ issues = result.output_lines()
292
+ issue_lines = [i for i in issues if ": " in i and i[0] != "*"]
293
+
294
+ details = [*preamble, *issue_lines[:MAX_DETAIL_LINES]]
295
+
296
+ if len(issue_lines) > MAX_DETAIL_LINES:
297
+ details.append(f"... and {len(issue_lines) - MAX_DETAIL_LINES} more issues")
298
+
299
+ return CheckResult(
300
+ name=self.name,
301
+ status=CheckStatus.WARNING,
302
+ message=f"Found {len(issue_lines)} linting issue(s)",
303
+ details=details,
304
+ )
@@ -0,0 +1,233 @@
1
+ """Package metadata validation check.
2
+
3
+ This module provides the MetadataCheck class which validates that a
4
+ Python package has proper metadata defined in pyproject.toml (preferred)
5
+ or legacy setup.py/setup.cfg files.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pycmdcheck.checks.base import BaseCheck
12
+ from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
13
+ from pycmdcheck.results import CheckResult, CheckStatus
14
+
15
+
16
+ class MetadataCheck(BaseCheck):
17
+ """Check that package metadata is valid and complete.
18
+
19
+ Validates package metadata according to PEP 621 (pyproject.toml).
20
+ Checks for required fields (name, version) and recommends additional
21
+ fields (description, readme, license, requires-python).
22
+
23
+ Attributes:
24
+ name: The check identifier ("metadata").
25
+ description: Human-readable description of this check.
26
+ REQUIRED_FIELDS: List of fields that must be present in [project].
27
+ RECOMMENDED_FIELDS: List of fields that should be present.
28
+ EXTENDED_FIELDS: List of extended fields checked for completeness.
29
+
30
+ Examples:
31
+ Run the metadata check on a package:
32
+
33
+ >>> from pathlib import Path
34
+ >>> check = MetadataCheck()
35
+ >>> result = check.run(Path("."), {"enabled": True})
36
+ >>> result.status in [CheckStatus.OK, CheckStatus.NOTE, CheckStatus.ERROR]
37
+ True
38
+
39
+ The check validates pyproject.toml structure:
40
+
41
+ >>> check.REQUIRED_FIELDS
42
+ ['name', 'version']
43
+ """
44
+
45
+ name = "metadata"
46
+ description = "Validate package metadata (pyproject.toml)"
47
+
48
+ REQUIRED_FIELDS = ["name", "version"]
49
+ RECOMMENDED_FIELDS = ["description", "readme", "license", "requires-python"]
50
+ EXTENDED_FIELDS = ["authors", "urls", "classifiers"]
51
+
52
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
53
+ """Check package metadata for validity and completeness.
54
+
55
+ Looks for pyproject.toml first (preferred), falling back to
56
+ setup.py/setup.cfg (legacy). Validates required fields and
57
+ suggests recommended fields.
58
+
59
+ Args:
60
+ package_path: Path to the package directory containing
61
+ pyproject.toml or setup files.
62
+ config: Configuration dictionary for this check. Currently
63
+ no check-specific options are used.
64
+
65
+ Returns:
66
+ A CheckResult with:
67
+
68
+ - OK status if all required and recommended fields present
69
+ - NOTE status if required fields present but some recommended missing
70
+ - WARNING status if using legacy setup.py/setup.cfg
71
+ - ERROR status if pyproject.toml is invalid or missing required fields
72
+ """
73
+ pyproject_path = package_path / "pyproject.toml"
74
+ setup_py_path = package_path / "setup.py"
75
+ setup_cfg_path = package_path / "setup.cfg"
76
+
77
+ details: list[str] = []
78
+
79
+ # Check for pyproject.toml (preferred)
80
+ if pyproject_path.exists():
81
+ return self._check_pyproject(pyproject_path, details)
82
+
83
+ # Check for setup.py or setup.cfg
84
+ if setup_py_path.exists() or setup_cfg_path.exists():
85
+ details.append("Using legacy setup.py/setup.cfg instead of pyproject.toml")
86
+ return CheckResult(
87
+ name=self.name,
88
+ status=CheckStatus.WARNING,
89
+ message="Consider migrating to pyproject.toml",
90
+ details=details,
91
+ )
92
+
93
+ return CheckResult(
94
+ name=self.name,
95
+ status=CheckStatus.ERROR,
96
+ message="No package metadata found",
97
+ details=["Missing pyproject.toml, setup.py, or setup.cfg"],
98
+ )
99
+
100
+ def _check_pyproject(self, pyproject_path: Path, details: list[str]) -> CheckResult:
101
+ """Validate pyproject.toml contents."""
102
+ try:
103
+ pyproject = read_pyproject(pyproject_path.parent)
104
+ except (ValueError, OSError) as e:
105
+ return CheckResult(
106
+ name=self.name,
107
+ status=CheckStatus.ERROR,
108
+ message="Invalid pyproject.toml",
109
+ details=[f"TOML parse error: {e}"],
110
+ )
111
+
112
+ if pyproject is None:
113
+ return CheckResult(
114
+ name=self.name,
115
+ status=CheckStatus.ERROR,
116
+ message="No package metadata found",
117
+ details=["Missing pyproject.toml"],
118
+ )
119
+
120
+ # Use the effective table so legacy Poetry ([tool.poetry], no [project])
121
+ # is treated like a PEP 621 [project] table rather than reported empty.
122
+ project = get_effective_project_table(pyproject_path.parent)
123
+ missing_required: list[str] = []
124
+ missing_recommended: list[str] = []
125
+
126
+ # Check required fields
127
+ for field in self.REQUIRED_FIELDS:
128
+ if not self._is_declared(project, field):
129
+ missing_required.append(field)
130
+
131
+ # Check recommended fields
132
+ for field in self.RECOMMENDED_FIELDS:
133
+ if not self._is_declared(project, field):
134
+ missing_recommended.append(field)
135
+
136
+ # Build result
137
+ if missing_required:
138
+ details.append(f"Missing required fields: {', '.join(missing_required)}")
139
+ return CheckResult(
140
+ name=self.name,
141
+ status=CheckStatus.ERROR,
142
+ message="Missing required metadata fields",
143
+ details=details,
144
+ )
145
+
146
+ if missing_recommended:
147
+ details.append(
148
+ f"Missing recommended fields: {', '.join(missing_recommended)}"
149
+ )
150
+ self._check_extended_fields(project, pyproject, details)
151
+ return CheckResult(
152
+ name=self.name,
153
+ status=CheckStatus.NOTE,
154
+ message="Some recommended metadata fields are missing",
155
+ details=details,
156
+ )
157
+
158
+ self._check_extended_fields(project, pyproject, details)
159
+
160
+ if any("missing extended" in d.lower() for d in details):
161
+ return CheckResult(
162
+ name=self.name,
163
+ status=CheckStatus.NOTE,
164
+ message="Some extended metadata fields are missing",
165
+ details=details,
166
+ )
167
+
168
+ details.append("All metadata fields present")
169
+ return CheckResult(
170
+ name=self.name,
171
+ status=CheckStatus.OK,
172
+ message="Package metadata is valid",
173
+ details=details,
174
+ )
175
+
176
+ @staticmethod
177
+ def _is_declared(project: dict[str, Any], field: str) -> bool:
178
+ """Return whether a ``[project]`` field is declared.
179
+
180
+ A field counts as declared if it is statically present in the
181
+ ``[project]`` table OR listed in PEP 621 ``dynamic`` (in which case
182
+ the build backend supplies its value at build time, e.g. via
183
+ setuptools_scm, hatch-vcs, or uv-dynamic-versioning). Fields named
184
+ in ``dynamic`` must not be reported as missing.
185
+ """
186
+ if field in project:
187
+ return True
188
+ dynamic = project.get("dynamic", [])
189
+ return isinstance(dynamic, list) and field in dynamic
190
+
191
+ def _check_extended_fields(
192
+ self,
193
+ project: dict[str, Any],
194
+ pyproject: dict[str, Any],
195
+ details: list[str],
196
+ ) -> None:
197
+ """Check extended metadata fields and append notes to details.
198
+
199
+ Validates that ``authors``, ``classifiers``, and ``[project.urls]``
200
+ are present and non-empty. Missing or empty fields are reported as
201
+ a single NOTE-level detail line. Fields declared in PEP 621
202
+ ``dynamic`` are considered present (the build backend supplies them).
203
+
204
+ Args:
205
+ project: The ``[project]`` table from pyproject.toml.
206
+ pyproject: The full parsed pyproject.toml dictionary.
207
+ details: Mutable list to which detail strings are appended.
208
+ """
209
+ missing: list[str] = []
210
+
211
+ # authors: must be a non-empty list (or declared dynamic)
212
+ authors = project.get("authors")
213
+ if not self._is_declared(project, "authors") and (
214
+ not isinstance(authors, list) or len(authors) == 0
215
+ ):
216
+ missing.append("authors")
217
+
218
+ # urls: the [project.urls] table must exist with >=1 entry (or dynamic)
219
+ urls = project.get("urls")
220
+ if not self._is_declared(project, "urls") and (
221
+ not isinstance(urls, dict) or len(urls) == 0
222
+ ):
223
+ missing.append("urls")
224
+
225
+ # classifiers: must be a non-empty list (or declared dynamic)
226
+ classifiers = project.get("classifiers")
227
+ if not self._is_declared(project, "classifiers") and (
228
+ not isinstance(classifiers, list) or len(classifiers) == 0
229
+ ):
230
+ missing.append("classifiers")
231
+
232
+ if missing:
233
+ details.append(f"Missing extended metadata fields: {', '.join(missing)}")