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,184 @@
|
|
|
1
|
+
"""Version consistency check."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pycmdcheck.ast_cache import parse_file
|
|
8
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
9
|
+
from pycmdcheck.package_layout import PackageLayout
|
|
10
|
+
from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
|
|
11
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class VersionCheck(BaseCheck):
|
|
15
|
+
"""Check that __version__ in code matches pyproject.toml version."""
|
|
16
|
+
|
|
17
|
+
name = "version"
|
|
18
|
+
description = "Verify version consistency"
|
|
19
|
+
|
|
20
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
21
|
+
"""Compare __version__ in code with pyproject.toml version."""
|
|
22
|
+
# Distinguish "no pyproject.toml" from "pyproject present but no
|
|
23
|
+
# [project] table" (e.g. legacy Poetry / setup.py metadata).
|
|
24
|
+
if read_pyproject(package_path) is None:
|
|
25
|
+
return CheckResult(
|
|
26
|
+
name=self.name,
|
|
27
|
+
status=CheckStatus.NOTE,
|
|
28
|
+
message="No pyproject.toml found",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
project = get_effective_project_table(package_path)
|
|
32
|
+
if not project:
|
|
33
|
+
return CheckResult(
|
|
34
|
+
name=self.name,
|
|
35
|
+
status=CheckStatus.NOTE,
|
|
36
|
+
message="No [project] table (legacy/setup.py metadata)",
|
|
37
|
+
details=["Version consistency not checked"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Check for dynamic version
|
|
41
|
+
dynamic = project.get("dynamic", [])
|
|
42
|
+
if "version" in dynamic:
|
|
43
|
+
return CheckResult(
|
|
44
|
+
name=self.name,
|
|
45
|
+
status=CheckStatus.SKIPPED,
|
|
46
|
+
message="Version is dynamic (set at build time)",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
pyproject_version = project.get("version")
|
|
50
|
+
if not pyproject_version:
|
|
51
|
+
return CheckResult(
|
|
52
|
+
name=self.name,
|
|
53
|
+
status=CheckStatus.WARNING,
|
|
54
|
+
message="No version in pyproject.toml [project] table",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Find __version__ in code (package __init__.py or single-module file)
|
|
58
|
+
raw_name = project.get("name")
|
|
59
|
+
fs_name = raw_name.replace("-", "_") if raw_name else None
|
|
60
|
+
layout = PackageLayout(package_path, package_name=fs_name)
|
|
61
|
+
version_source = self._find_version_source(layout, fs_name)
|
|
62
|
+
if version_source is None:
|
|
63
|
+
return CheckResult(
|
|
64
|
+
name=self.name,
|
|
65
|
+
status=CheckStatus.WARNING,
|
|
66
|
+
message="No __init__.py or module found to check __version__",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
code_version = self._extract_version(version_source)
|
|
70
|
+
if code_version is None:
|
|
71
|
+
return CheckResult(
|
|
72
|
+
name=self.name,
|
|
73
|
+
status=CheckStatus.WARNING,
|
|
74
|
+
message="No __version__ defined in code",
|
|
75
|
+
details=[
|
|
76
|
+
f"pyproject.toml version: {pyproject_version}",
|
|
77
|
+
f'Consider adding __version__ = "{pyproject_version}"'
|
|
78
|
+
f" to {version_source.name}",
|
|
79
|
+
],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if code_version == self._DYNAMIC_VERSION:
|
|
83
|
+
return CheckResult(
|
|
84
|
+
name=self.name,
|
|
85
|
+
status=CheckStatus.OK,
|
|
86
|
+
message="Version set dynamically (e.g. importlib.metadata)",
|
|
87
|
+
details=[
|
|
88
|
+
f"pyproject.toml version: {pyproject_version}",
|
|
89
|
+
"__version__ is set via function call at runtime",
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if code_version == pyproject_version:
|
|
94
|
+
return CheckResult(
|
|
95
|
+
name=self.name,
|
|
96
|
+
status=CheckStatus.OK,
|
|
97
|
+
message=f"Version {pyproject_version} is consistent",
|
|
98
|
+
details=[
|
|
99
|
+
f"pyproject.toml: {pyproject_version}",
|
|
100
|
+
f"__version__: {code_version}",
|
|
101
|
+
],
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return CheckResult(
|
|
105
|
+
name=self.name,
|
|
106
|
+
status=CheckStatus.ERROR,
|
|
107
|
+
message="Version mismatch",
|
|
108
|
+
details=[
|
|
109
|
+
f"pyproject.toml: {pyproject_version}",
|
|
110
|
+
f"__version__: {code_version}",
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# Sentinel returned when __version__ is set via a function call
|
|
115
|
+
_DYNAMIC_VERSION = "<dynamic>"
|
|
116
|
+
|
|
117
|
+
def _find_version_source(
|
|
118
|
+
self, layout: PackageLayout, fs_name: str | None
|
|
119
|
+
) -> Path | None:
|
|
120
|
+
"""Return the file that should declare ``__version__``.
|
|
121
|
+
|
|
122
|
+
Prefers the primary package's ``__init__.py``; falls back to a
|
|
123
|
+
single-module package (``foo.py`` at the import root) when there is no
|
|
124
|
+
package directory.
|
|
125
|
+
"""
|
|
126
|
+
pkg_dir = layout.primary_package
|
|
127
|
+
if pkg_dir is not None:
|
|
128
|
+
init_file = pkg_dir / "__init__.py"
|
|
129
|
+
if init_file.exists():
|
|
130
|
+
return init_file
|
|
131
|
+
|
|
132
|
+
modules = layout.top_level_modules()
|
|
133
|
+
if fs_name:
|
|
134
|
+
for module in modules:
|
|
135
|
+
if module.stem == fs_name:
|
|
136
|
+
return module
|
|
137
|
+
if len(modules) == 1:
|
|
138
|
+
return modules[0]
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _extract_version(self, init_file: Path) -> str | None:
|
|
142
|
+
"""Extract __version__ from a Python file using AST.
|
|
143
|
+
|
|
144
|
+
Returns the literal version string, the sentinel ``_DYNAMIC_VERSION``
|
|
145
|
+
if the version is set via a function call (e.g.
|
|
146
|
+
``importlib.metadata.version()``), or ``None`` if no ``__version__``
|
|
147
|
+
assignment is found.
|
|
148
|
+
"""
|
|
149
|
+
tree = parse_file(init_file)
|
|
150
|
+
if tree is None:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
for node in ast.walk(tree):
|
|
154
|
+
# ``from ._version import __version__`` re-exports the version from
|
|
155
|
+
# another module — treat it like a runtime/dynamic source rather
|
|
156
|
+
# than "missing", since it cannot be compared statically here.
|
|
157
|
+
if isinstance(node, ast.ImportFrom):
|
|
158
|
+
for alias in node.names:
|
|
159
|
+
if "__version__" in (alias.name, alias.asname):
|
|
160
|
+
return self._DYNAMIC_VERSION
|
|
161
|
+
if isinstance(node, ast.Assign):
|
|
162
|
+
for target in node.targets:
|
|
163
|
+
if isinstance(target, ast.Name) and target.id == "__version__":
|
|
164
|
+
if isinstance(node.value, ast.Constant) and isinstance(
|
|
165
|
+
node.value.value, str
|
|
166
|
+
):
|
|
167
|
+
return node.value.value
|
|
168
|
+
if isinstance(node.value, ast.Call):
|
|
169
|
+
return self._DYNAMIC_VERSION
|
|
170
|
+
elif isinstance(node, ast.AnnAssign):
|
|
171
|
+
if (
|
|
172
|
+
isinstance(node.target, ast.Name)
|
|
173
|
+
and node.target.id == "__version__"
|
|
174
|
+
):
|
|
175
|
+
if (
|
|
176
|
+
node.value
|
|
177
|
+
and isinstance(node.value, ast.Constant)
|
|
178
|
+
and isinstance(node.value.value, str)
|
|
179
|
+
):
|
|
180
|
+
return node.value.value
|
|
181
|
+
if node.value and isinstance(node.value, ast.Call):
|
|
182
|
+
return self._DYNAMIC_VERSION
|
|
183
|
+
|
|
184
|
+
return None
|
pycmdcheck/cli.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Command-line interface for pycmdcheck."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from pycmdcheck.config import load_config
|
|
14
|
+
from pycmdcheck.discovery import list_available_checks
|
|
15
|
+
from pycmdcheck.profiles import PROFILES, get_profile, list_profiles
|
|
16
|
+
from pycmdcheck.results import CheckStatus, Report
|
|
17
|
+
from pycmdcheck.runner import run_checks
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.command()
|
|
23
|
+
@click.argument("path", default=".", type=click.Path(exists=True))
|
|
24
|
+
@click.option(
|
|
25
|
+
"-c",
|
|
26
|
+
"--check",
|
|
27
|
+
multiple=True,
|
|
28
|
+
help="Run only specific check(s). Can be specified multiple times.",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"-s",
|
|
32
|
+
"--skip",
|
|
33
|
+
multiple=True,
|
|
34
|
+
help="Skip specific check(s). Can be specified multiple times.",
|
|
35
|
+
)
|
|
36
|
+
@click.option(
|
|
37
|
+
"--fail-on",
|
|
38
|
+
multiple=True,
|
|
39
|
+
type=click.Choice(["error", "warning", "note"]),
|
|
40
|
+
default=["error"],
|
|
41
|
+
help="Exit with non-zero code on these statuses. Default: error",
|
|
42
|
+
)
|
|
43
|
+
@click.option(
|
|
44
|
+
"--list",
|
|
45
|
+
"list_checks",
|
|
46
|
+
is_flag=True,
|
|
47
|
+
help="List available checks and exit.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"-v",
|
|
51
|
+
"--verbose",
|
|
52
|
+
is_flag=True,
|
|
53
|
+
help="Show detailed output for each check.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--json",
|
|
57
|
+
"json_output",
|
|
58
|
+
is_flag=True,
|
|
59
|
+
help="Output results as JSON.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--no-parallel",
|
|
63
|
+
is_flag=True,
|
|
64
|
+
help="Run checks sequentially instead of in parallel.",
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"-x",
|
|
68
|
+
"--fail-fast",
|
|
69
|
+
"fail_fast",
|
|
70
|
+
is_flag=True,
|
|
71
|
+
help="Stop after first check failure.",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--profile",
|
|
75
|
+
type=click.Choice(list(PROFILES)),
|
|
76
|
+
default=None,
|
|
77
|
+
help="Use a predefined check profile.",
|
|
78
|
+
)
|
|
79
|
+
@click.option(
|
|
80
|
+
"--list-profiles",
|
|
81
|
+
"list_profiles_flag",
|
|
82
|
+
is_flag=True,
|
|
83
|
+
help="List available profiles and exit.",
|
|
84
|
+
)
|
|
85
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging output.")
|
|
86
|
+
@click.version_option(package_name="pycmdcheck")
|
|
87
|
+
def main(
|
|
88
|
+
path: str,
|
|
89
|
+
check: tuple[str, ...],
|
|
90
|
+
skip: tuple[str, ...],
|
|
91
|
+
fail_on: tuple[str, ...],
|
|
92
|
+
list_checks: bool,
|
|
93
|
+
verbose: bool,
|
|
94
|
+
json_output: bool,
|
|
95
|
+
no_parallel: bool,
|
|
96
|
+
fail_fast: bool,
|
|
97
|
+
profile: str | None,
|
|
98
|
+
list_profiles_flag: bool,
|
|
99
|
+
debug: bool,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Check Python package quality.
|
|
102
|
+
|
|
103
|
+
PATH is the directory to check (default: current directory).
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
|
|
107
|
+
pycmdcheck # Check current directory
|
|
108
|
+
|
|
109
|
+
pycmdcheck /path/to/package # Check specific directory
|
|
110
|
+
|
|
111
|
+
pycmdcheck -c tests -c linting # Run only tests and linting
|
|
112
|
+
|
|
113
|
+
pycmdcheck -s typing # Skip type checking
|
|
114
|
+
|
|
115
|
+
pycmdcheck --fail-on error --fail-on warning # Fail on warnings too
|
|
116
|
+
"""
|
|
117
|
+
if debug:
|
|
118
|
+
logging.basicConfig(
|
|
119
|
+
level=logging.DEBUG,
|
|
120
|
+
format="%(name)s: %(message)s",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if list_profiles_flag:
|
|
124
|
+
_show_profiles()
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if list_checks:
|
|
128
|
+
_show_available_checks()
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Resolve profile
|
|
132
|
+
profile_config: dict[str, dict[str, object]] = {}
|
|
133
|
+
if profile:
|
|
134
|
+
prof = get_profile(profile)
|
|
135
|
+
if prof:
|
|
136
|
+
profile_config = prof.config_overrides
|
|
137
|
+
if not check:
|
|
138
|
+
# Use profile's check set (converted to tuple for consistency)
|
|
139
|
+
check = tuple(sorted(prof.checks))
|
|
140
|
+
|
|
141
|
+
# Validate check names
|
|
142
|
+
if check:
|
|
143
|
+
from pycmdcheck.discovery import discover_checks
|
|
144
|
+
|
|
145
|
+
available = set(discover_checks().keys())
|
|
146
|
+
invalid = set(check) - available
|
|
147
|
+
if invalid:
|
|
148
|
+
console.print(
|
|
149
|
+
f"[bold red]Unknown check(s): {', '.join(sorted(invalid))}[/bold red]"
|
|
150
|
+
)
|
|
151
|
+
console.print(f"Available checks: {', '.join(sorted(available))}")
|
|
152
|
+
sys.exit(2)
|
|
153
|
+
|
|
154
|
+
package_path = Path(path).resolve()
|
|
155
|
+
|
|
156
|
+
if not json_output:
|
|
157
|
+
profile_label = f" (profile: {profile})" if profile else ""
|
|
158
|
+
console.print(
|
|
159
|
+
f"\n[bold]Checking package:[/bold] {package_path}{profile_label}\n"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Load config from pyproject.toml and merge profile overrides
|
|
163
|
+
merged_config = load_config(package_path)
|
|
164
|
+
if profile_config:
|
|
165
|
+
checks_section = merged_config.setdefault("checks", {})
|
|
166
|
+
for check_name, overrides in profile_config.items():
|
|
167
|
+
existing = checks_section.get(check_name, {})
|
|
168
|
+
if isinstance(existing, bool):
|
|
169
|
+
existing = {"enabled": existing}
|
|
170
|
+
existing.update(overrides)
|
|
171
|
+
checks_section[check_name] = existing
|
|
172
|
+
|
|
173
|
+
# Run checks
|
|
174
|
+
if not json_output and console.is_terminal:
|
|
175
|
+
with console.status("[bold]Running checks...[/bold]"):
|
|
176
|
+
report = run_checks(
|
|
177
|
+
package_path=package_path,
|
|
178
|
+
checks=list(check) if check else None,
|
|
179
|
+
skip=list(skip),
|
|
180
|
+
config=merged_config,
|
|
181
|
+
parallel=not no_parallel,
|
|
182
|
+
fail_fast=fail_fast,
|
|
183
|
+
)
|
|
184
|
+
else:
|
|
185
|
+
report = run_checks(
|
|
186
|
+
package_path=package_path,
|
|
187
|
+
checks=list(check) if check else None,
|
|
188
|
+
skip=list(skip),
|
|
189
|
+
config=merged_config,
|
|
190
|
+
parallel=not no_parallel,
|
|
191
|
+
fail_fast=fail_fast,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Output results
|
|
195
|
+
if json_output:
|
|
196
|
+
_output_json(report)
|
|
197
|
+
else:
|
|
198
|
+
_output_rich(report, verbose)
|
|
199
|
+
|
|
200
|
+
# Determine exit code
|
|
201
|
+
if report.failed_on(list(fail_on)):
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _show_profiles(con: Console | None = None) -> None:
|
|
206
|
+
"""Display list of available profiles."""
|
|
207
|
+
con = con or console
|
|
208
|
+
profiles = list_profiles()
|
|
209
|
+
|
|
210
|
+
table = Table(title="Available Profiles")
|
|
211
|
+
table.add_column("Name", style="cyan")
|
|
212
|
+
table.add_column("Description")
|
|
213
|
+
|
|
214
|
+
for name, description in profiles:
|
|
215
|
+
table.add_row(name, description)
|
|
216
|
+
|
|
217
|
+
con.print(table)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _show_available_checks(con: Console | None = None) -> None:
|
|
221
|
+
"""Display list of available checks."""
|
|
222
|
+
con = con or console
|
|
223
|
+
checks = list_available_checks()
|
|
224
|
+
|
|
225
|
+
table = Table(title="Available Checks")
|
|
226
|
+
table.add_column("Name", style="cyan")
|
|
227
|
+
table.add_column("Description")
|
|
228
|
+
|
|
229
|
+
for name, description in checks:
|
|
230
|
+
table.add_row(name, description)
|
|
231
|
+
|
|
232
|
+
con.print(table)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _output_json(report: Report) -> None:
|
|
236
|
+
"""Output report as JSON."""
|
|
237
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _output_rich(report: Report, verbose: bool, con: Console | None = None) -> None:
|
|
241
|
+
"""Output report with rich formatting."""
|
|
242
|
+
con = con or console
|
|
243
|
+
if not report.results:
|
|
244
|
+
con.print("[yellow]No checks were run.[/yellow]")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
# Create results table
|
|
248
|
+
table = Table(show_header=True, header_style="bold")
|
|
249
|
+
table.add_column("Status", width=3, justify="center")
|
|
250
|
+
table.add_column("Check", style="cyan")
|
|
251
|
+
table.add_column("Message")
|
|
252
|
+
if verbose:
|
|
253
|
+
table.add_column("Time", justify="right")
|
|
254
|
+
|
|
255
|
+
for result in report.results:
|
|
256
|
+
status_text = Text(result.status.symbol)
|
|
257
|
+
status_text.stylize(result.status.color)
|
|
258
|
+
|
|
259
|
+
if verbose:
|
|
260
|
+
table.add_row(
|
|
261
|
+
status_text, result.name, result.message, f"{result.duration:.2f}s"
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
table.add_row(status_text, result.name, result.message)
|
|
265
|
+
|
|
266
|
+
con.print(table)
|
|
267
|
+
|
|
268
|
+
# Show details if verbose
|
|
269
|
+
if verbose:
|
|
270
|
+
for result in report.results:
|
|
271
|
+
if result.details:
|
|
272
|
+
con.print(f"\n[bold]{result.name}[/bold] details:")
|
|
273
|
+
for detail in result.details:
|
|
274
|
+
con.print(f" {detail}")
|
|
275
|
+
|
|
276
|
+
# Summary
|
|
277
|
+
con.print()
|
|
278
|
+
counts = report.count_by_status()
|
|
279
|
+
summary_parts: list[str] = []
|
|
280
|
+
|
|
281
|
+
for status in CheckStatus:
|
|
282
|
+
count = counts[status]
|
|
283
|
+
if count > 0:
|
|
284
|
+
colored = f"[{status.color}]{count} {status.value}[/{status.color}]"
|
|
285
|
+
summary_parts.append(colored)
|
|
286
|
+
|
|
287
|
+
con.print(" | ".join(summary_parts))
|
|
288
|
+
|
|
289
|
+
if report.passed:
|
|
290
|
+
con.print("\n[bold green]All checks passed![/bold green]")
|
|
291
|
+
else:
|
|
292
|
+
con.print("\n[bold red]Some checks failed.[/bold red]")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
if __name__ == "__main__":
|
|
296
|
+
main()
|
pycmdcheck/config.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Configuration loading for pycmdcheck."""
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pycmdcheck.pyproject_reader import get_tool_table
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
VALID_FAIL_ON = {"error", "warning", "note"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
16
|
+
"fail_on": ["error"],
|
|
17
|
+
"checks": {
|
|
18
|
+
"metadata": True,
|
|
19
|
+
"structure": True,
|
|
20
|
+
"tests": {"enabled": True, "runner": "pytest"},
|
|
21
|
+
"linting": {"enabled": True, "tool": "ruff"},
|
|
22
|
+
"typing": {"enabled": True, "tool": "mypy"},
|
|
23
|
+
"imports": True,
|
|
24
|
+
"license": True,
|
|
25
|
+
"docs": True,
|
|
26
|
+
"dependencies": True,
|
|
27
|
+
"build": True,
|
|
28
|
+
"formatting": {"enabled": True, "tool": "auto"},
|
|
29
|
+
"version": True,
|
|
30
|
+
"py_typed": True,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
"""Default configuration applied when no pyproject.toml is found.
|
|
34
|
+
|
|
35
|
+
This configuration enables all built-in checks with sensible defaults:
|
|
36
|
+
- tests: Uses pytest as the test runner
|
|
37
|
+
- linting: Uses ruff as the linter
|
|
38
|
+
- typing: Uses mypy as the type checker
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def load_config(package_path: Path) -> dict[str, Any]:
|
|
43
|
+
"""Load pycmdcheck configuration from pyproject.toml.
|
|
44
|
+
|
|
45
|
+
Reads the [tool.pycmdcheck] section from the package's pyproject.toml
|
|
46
|
+
and merges it with default configuration. If no pyproject.toml exists
|
|
47
|
+
or it doesn't contain pycmdcheck configuration, returns defaults.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
package_path: Path to the package directory containing pyproject.toml.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Configuration dictionary with the following structure:
|
|
54
|
+
- fail_on: List of statuses to fail on (default: ["error"])
|
|
55
|
+
- checks: Dictionary of check configurations
|
|
56
|
+
|
|
57
|
+
Examples:
|
|
58
|
+
>>> from pathlib import Path
|
|
59
|
+
>>> config = load_config(Path("."))
|
|
60
|
+
>>> config["fail_on"]
|
|
61
|
+
['error']
|
|
62
|
+
>>> config["checks"]["tests"]["runner"]
|
|
63
|
+
'pytest'
|
|
64
|
+
"""
|
|
65
|
+
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
66
|
+
tool_config = get_tool_table(package_path, "pycmdcheck")
|
|
67
|
+
if tool_config:
|
|
68
|
+
config = _merge_config(config, tool_config)
|
|
69
|
+
config_warnings = validate_config(config)
|
|
70
|
+
for w in config_warnings:
|
|
71
|
+
logger.warning("Config: %s", w)
|
|
72
|
+
logger.debug("Loaded config from %s", package_path)
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_check_config(config: dict[str, Any], check_name: str) -> dict[str, Any]:
|
|
77
|
+
"""Get configuration for a specific check.
|
|
78
|
+
|
|
79
|
+
Extracts the configuration for a named check from the full configuration.
|
|
80
|
+
Handles both boolean shorthand (e.g., `metadata = true`) and dictionary
|
|
81
|
+
configuration (e.g., `tests = { enabled = true, runner = "pytest" }`).
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
config: Full pycmdcheck configuration dictionary, as returned by
|
|
85
|
+
load_config().
|
|
86
|
+
check_name: Name of the check to get configuration for (e.g.,
|
|
87
|
+
"metadata", "tests", "linting").
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Configuration dictionary for the check, always containing an
|
|
91
|
+
"enabled" key. Additional keys depend on the check type.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
Boolean config is converted to dict:
|
|
95
|
+
|
|
96
|
+
>>> config = {"checks": {"metadata": True}}
|
|
97
|
+
>>> get_check_config(config, "metadata")
|
|
98
|
+
{'enabled': True}
|
|
99
|
+
|
|
100
|
+
Dict config is passed through with enabled defaulting to True:
|
|
101
|
+
|
|
102
|
+
>>> config = {"checks": {"tests": {"runner": "pytest"}}}
|
|
103
|
+
>>> get_check_config(config, "tests")
|
|
104
|
+
{'runner': 'pytest', 'enabled': True}
|
|
105
|
+
|
|
106
|
+
Unknown checks default to enabled:
|
|
107
|
+
|
|
108
|
+
>>> config = {"checks": {}}
|
|
109
|
+
>>> get_check_config(config, "unknown")
|
|
110
|
+
{'enabled': True}
|
|
111
|
+
"""
|
|
112
|
+
checks_config = config.get("checks", {})
|
|
113
|
+
check_config = checks_config.get(check_name, True)
|
|
114
|
+
|
|
115
|
+
# Handle boolean shorthand
|
|
116
|
+
if isinstance(check_config, bool):
|
|
117
|
+
return {"enabled": check_config}
|
|
118
|
+
|
|
119
|
+
# Handle dict config (ensure 'enabled' key exists)
|
|
120
|
+
if isinstance(check_config, dict):
|
|
121
|
+
result = dict(check_config)
|
|
122
|
+
result.setdefault("enabled", True)
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
# Default to enabled
|
|
126
|
+
return {"enabled": True}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_check_enabled(config: dict[str, Any], check_name: str) -> bool:
|
|
130
|
+
"""Check if a specific check is enabled.
|
|
131
|
+
|
|
132
|
+
Convenience function to determine if a check should run based on
|
|
133
|
+
the configuration.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
config: Full pycmdcheck configuration dictionary.
|
|
137
|
+
check_name: Name of the check to query.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if the check is enabled, False otherwise.
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
>>> config = {"checks": {"metadata": True, "linting": False}}
|
|
144
|
+
>>> is_check_enabled(config, "metadata")
|
|
145
|
+
True
|
|
146
|
+
>>> is_check_enabled(config, "linting")
|
|
147
|
+
False
|
|
148
|
+
>>> is_check_enabled(config, "unknown") # Defaults to enabled
|
|
149
|
+
True
|
|
150
|
+
"""
|
|
151
|
+
check_config = get_check_config(config, check_name)
|
|
152
|
+
return bool(check_config.get("enabled", True))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_config(config: dict[str, Any]) -> list[str]:
|
|
156
|
+
"""Validate config values and return a list of warning messages."""
|
|
157
|
+
warnings: list[str] = []
|
|
158
|
+
|
|
159
|
+
fail_on = config.get("fail_on", [])
|
|
160
|
+
if not isinstance(fail_on, list):
|
|
161
|
+
warnings.append(f"'fail_on' should be a list, got {type(fail_on).__name__}")
|
|
162
|
+
else:
|
|
163
|
+
for value in fail_on:
|
|
164
|
+
if value not in VALID_FAIL_ON:
|
|
165
|
+
warnings.append(
|
|
166
|
+
f"Invalid fail_on value '{value}'. "
|
|
167
|
+
f"Valid values: {', '.join(sorted(VALID_FAIL_ON))}"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return warnings
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _merge_config(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
174
|
+
"""Recursively merge override into base configuration.
|
|
175
|
+
|
|
176
|
+
Values in override take precedence. Nested dictionaries are merged
|
|
177
|
+
recursively rather than replaced.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
base: Base configuration dictionary.
|
|
181
|
+
override: Override configuration dictionary.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Merged configuration dictionary.
|
|
185
|
+
"""
|
|
186
|
+
result = copy.deepcopy(base)
|
|
187
|
+
|
|
188
|
+
for key, value in override.items():
|
|
189
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
190
|
+
result[key] = _merge_config(result[key], value)
|
|
191
|
+
else:
|
|
192
|
+
result[key] = value
|
|
193
|
+
|
|
194
|
+
return result
|
pycmdcheck/constants.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Named constants used across pycmdcheck."""
|
|
2
|
+
|
|
3
|
+
# Subprocess execution
|
|
4
|
+
DEFAULT_TIMEOUT: int = 120
|
|
5
|
+
LONG_TIMEOUT: int = 300
|
|
6
|
+
|
|
7
|
+
# Output limits
|
|
8
|
+
MAX_DETAIL_LINES: int = 10
|
|
9
|
+
MAX_WORKERS: int = 4
|
|
10
|
+
|
|
11
|
+
# Documentation thresholds
|
|
12
|
+
MIN_README_WORDS: int = 50
|
|
13
|
+
MIN_LICENSE_LENGTH: int = 50
|
|
14
|
+
MAX_DOCSTRING_FILES: int = 20
|
|
15
|
+
MAX_TYPE_SCAN_FILES: int = 20
|
|
16
|
+
|
|
17
|
+
# URL validation
|
|
18
|
+
URL_TIMEOUT: int = 10
|
|
19
|
+
MAX_URL_CHECKS: int = 20
|
|
20
|
+
|
|
21
|
+
# Changelog validation
|
|
22
|
+
MIN_CHANGELOG_LENGTH: int = 50
|
|
23
|
+
|
|
24
|
+
# Python version EOL dates (year, month) — versions whose EOL has passed
|
|
25
|
+
# Source: https://devguide.python.org/versions/
|
|
26
|
+
PYTHON_EOL_VERSIONS: dict[str, tuple[int, int]] = {
|
|
27
|
+
"3.8": (2024, 10),
|
|
28
|
+
"3.9": (2025, 10),
|
|
29
|
+
}
|