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,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)}")
|