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,108 @@
|
|
|
1
|
+
"""Community files check.
|
|
2
|
+
|
|
3
|
+
Verifies that a package has community health files such as
|
|
4
|
+
CONTRIBUTING.md and CODE_OF_CONDUCT.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommunityCheck(BaseCheck):
|
|
15
|
+
"""Check for community health files.
|
|
16
|
+
|
|
17
|
+
Verifies that CONTRIBUTING.md and CODE_OF_CONDUCT.md exist in
|
|
18
|
+
the package directory.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
name: The check identifier ("community").
|
|
22
|
+
description: Human-readable description of this check.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "community"
|
|
26
|
+
description = "Check for community health files"
|
|
27
|
+
|
|
28
|
+
CONTRIBUTING_FILENAMES = [
|
|
29
|
+
"CONTRIBUTING.md",
|
|
30
|
+
"CONTRIBUTING.rst",
|
|
31
|
+
"CONTRIBUTING.txt",
|
|
32
|
+
"CONTRIBUTING",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
CODE_OF_CONDUCT_FILENAMES = [
|
|
36
|
+
"CODE_OF_CONDUCT.md",
|
|
37
|
+
"CODE_OF_CONDUCT.rst",
|
|
38
|
+
"CODE_OF_CONDUCT.txt",
|
|
39
|
+
"CODE_OF_CONDUCT",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
43
|
+
"""Check for community health files.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
package_path: Path to the package directory to check.
|
|
47
|
+
config: Configuration dictionary for this check.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
A CheckResult with:
|
|
51
|
+
|
|
52
|
+
- OK status if both files are present
|
|
53
|
+
- NOTE status if one or both files are missing
|
|
54
|
+
"""
|
|
55
|
+
details: list[str] = []
|
|
56
|
+
missing: list[str] = []
|
|
57
|
+
|
|
58
|
+
# Check for CONTRIBUTING
|
|
59
|
+
contributing = self._find_file(package_path, self.CONTRIBUTING_FILENAMES)
|
|
60
|
+
if contributing:
|
|
61
|
+
details.append(f"Found: {self._rel(package_path, contributing)}")
|
|
62
|
+
else:
|
|
63
|
+
missing.append("CONTRIBUTING.md")
|
|
64
|
+
|
|
65
|
+
# Check for CODE_OF_CONDUCT
|
|
66
|
+
coc = self._find_file(package_path, self.CODE_OF_CONDUCT_FILENAMES)
|
|
67
|
+
if coc:
|
|
68
|
+
details.append(f"Found: {self._rel(package_path, coc)}")
|
|
69
|
+
else:
|
|
70
|
+
missing.append("CODE_OF_CONDUCT.md")
|
|
71
|
+
|
|
72
|
+
if not missing:
|
|
73
|
+
return CheckResult(
|
|
74
|
+
name=self.name,
|
|
75
|
+
status=CheckStatus.OK,
|
|
76
|
+
message="Community health files present",
|
|
77
|
+
details=details,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
details.append(f"Missing: {', '.join(missing)}")
|
|
81
|
+
return CheckResult(
|
|
82
|
+
name=self.name,
|
|
83
|
+
status=CheckStatus.NOTE,
|
|
84
|
+
message=f"Missing {len(missing)} community file(s)",
|
|
85
|
+
details=details,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# GitHub resolves community-health files from the repo root, .github/, and
|
|
89
|
+
# docs/ (in that order). Mirror that so files in those locations count.
|
|
90
|
+
SEARCH_DIRS = ("", ".github", "docs")
|
|
91
|
+
|
|
92
|
+
def _find_file(self, package_path: Path, filenames: list[str]) -> Path | None:
|
|
93
|
+
"""Find a community file in the root, ``.github/``, or ``docs/``."""
|
|
94
|
+
for subdir in self.SEARCH_DIRS:
|
|
95
|
+
base = package_path / subdir if subdir else package_path
|
|
96
|
+
for filename in filenames:
|
|
97
|
+
candidate = base / filename
|
|
98
|
+
if candidate.exists():
|
|
99
|
+
return candidate
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _rel(package_path: Path, path: Path) -> str:
|
|
104
|
+
"""Return *path* relative to *package_path* for display."""
|
|
105
|
+
try:
|
|
106
|
+
return str(path.relative_to(package_path))
|
|
107
|
+
except ValueError:
|
|
108
|
+
return path.name
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Dependency audit check.
|
|
2
|
+
|
|
3
|
+
Cross-references declared dependencies in pyproject.toml with actual
|
|
4
|
+
imports found in source files.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pycmdcheck.ast_cache import parse_file
|
|
15
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
16
|
+
from pycmdcheck.package_layout import EXCLUDED_DIRS, PackageLayout
|
|
17
|
+
from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
|
|
18
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
19
|
+
|
|
20
|
+
# Common PyPI package name -> import name mappings
|
|
21
|
+
PYPI_TO_IMPORT: dict[str, str] = {
|
|
22
|
+
"pillow": "PIL",
|
|
23
|
+
"scikit-learn": "sklearn",
|
|
24
|
+
"scikit-image": "skimage",
|
|
25
|
+
"python-dateutil": "dateutil",
|
|
26
|
+
"pyyaml": "yaml",
|
|
27
|
+
"beautifulsoup4": "bs4",
|
|
28
|
+
"opencv-python": "cv2",
|
|
29
|
+
"opencv-python-headless": "cv2",
|
|
30
|
+
"typing-extensions": "typing_extensions",
|
|
31
|
+
"pyjwt": "jwt",
|
|
32
|
+
"pyzmq": "zmq",
|
|
33
|
+
"python-dotenv": "dotenv",
|
|
34
|
+
"attrs": "attr",
|
|
35
|
+
"python-multipart": "multipart",
|
|
36
|
+
"msgpack-python": "msgpack",
|
|
37
|
+
"ruamel-yaml": "ruamel",
|
|
38
|
+
"google-cloud-storage": "google",
|
|
39
|
+
"protobuf": "google",
|
|
40
|
+
"python-magic": "magic",
|
|
41
|
+
"charset-normalizer": "charset_normalizer",
|
|
42
|
+
"importlib-metadata": "importlib_metadata",
|
|
43
|
+
"importlib-resources": "importlib_resources",
|
|
44
|
+
"setuptools": "setuptools",
|
|
45
|
+
"pycryptodome": "Crypto",
|
|
46
|
+
"pymysql": "pymysql",
|
|
47
|
+
"psycopg2-binary": "psycopg2",
|
|
48
|
+
"psycopg2": "psycopg2",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_import_name(pypi_name: str) -> str:
|
|
53
|
+
"""Resolve a PyPI package name to its Python import name.
|
|
54
|
+
|
|
55
|
+
Uses importlib.metadata to check installed package metadata first,
|
|
56
|
+
falls back to the static PYPI_TO_IMPORT mapping, then to simple
|
|
57
|
+
name normalization.
|
|
58
|
+
"""
|
|
59
|
+
normalized = _normalize_pypi_name(pypi_name)
|
|
60
|
+
|
|
61
|
+
# Try importlib.metadata first (most accurate for installed packages)
|
|
62
|
+
try:
|
|
63
|
+
dist = importlib.metadata.distribution(pypi_name)
|
|
64
|
+
# Check top_level.txt
|
|
65
|
+
try:
|
|
66
|
+
top_level = dist.read_text("top_level.txt")
|
|
67
|
+
except (FileNotFoundError, OSError):
|
|
68
|
+
top_level = None
|
|
69
|
+
if top_level:
|
|
70
|
+
names = [n.strip() for n in top_level.strip().splitlines() if n.strip()]
|
|
71
|
+
if names:
|
|
72
|
+
# Prefer public names (not starting with _)
|
|
73
|
+
public = [n for n in names if not n.startswith("_")]
|
|
74
|
+
return public[0] if public else names[0]
|
|
75
|
+
# Check packages from RECORD
|
|
76
|
+
if dist.files:
|
|
77
|
+
for f in dist.files:
|
|
78
|
+
parts = f.parts
|
|
79
|
+
if len(parts) > 1 and parts[-1] == "__init__.py":
|
|
80
|
+
return str(parts[0])
|
|
81
|
+
# Single-file module (e.g. "foo.py" at top level)
|
|
82
|
+
if len(parts) == 1 and str(parts[0]).endswith(".py"):
|
|
83
|
+
name = str(parts[0]).removesuffix(".py")
|
|
84
|
+
if not name.startswith("_"):
|
|
85
|
+
return name
|
|
86
|
+
except importlib.metadata.PackageNotFoundError:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
# Fallback to static mapping
|
|
90
|
+
if normalized in PYPI_TO_IMPORT:
|
|
91
|
+
return PYPI_TO_IMPORT[normalized]
|
|
92
|
+
|
|
93
|
+
# Default normalization
|
|
94
|
+
return normalized.replace("-", "_")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DependenciesCheck(BaseCheck):
|
|
98
|
+
"""Check that declared dependencies match actual imports."""
|
|
99
|
+
|
|
100
|
+
name = "dependencies"
|
|
101
|
+
description = "Audit declared vs. actual dependencies"
|
|
102
|
+
|
|
103
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
104
|
+
"""Cross-reference pyproject.toml dependencies with imports."""
|
|
105
|
+
if read_pyproject(package_path) is None:
|
|
106
|
+
return CheckResult(
|
|
107
|
+
name=self.name,
|
|
108
|
+
status=CheckStatus.NOTE,
|
|
109
|
+
message="No pyproject.toml found, skipping dependency audit",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
project = get_effective_project_table(package_path)
|
|
113
|
+
if not project:
|
|
114
|
+
return CheckResult(
|
|
115
|
+
name=self.name,
|
|
116
|
+
status=CheckStatus.NOTE,
|
|
117
|
+
message=(
|
|
118
|
+
"No [project] table (e.g. legacy Poetry); skipping dependency audit"
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
raw_deps = project.get("dependencies", [])
|
|
123
|
+
if not raw_deps:
|
|
124
|
+
return CheckResult(
|
|
125
|
+
name=self.name,
|
|
126
|
+
status=CheckStatus.OK,
|
|
127
|
+
message="No dependencies declared",
|
|
128
|
+
details=["Package has no declared dependencies"],
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Parse declared dependency names
|
|
132
|
+
declared = {} # normalized_pypi_name -> import_name
|
|
133
|
+
for dep in raw_deps:
|
|
134
|
+
pypi_name = _strip_version_specifier(dep)
|
|
135
|
+
normalized = _normalize_pypi_name(pypi_name)
|
|
136
|
+
import_name = _resolve_import_name(pypi_name)
|
|
137
|
+
declared[normalized] = import_name
|
|
138
|
+
|
|
139
|
+
# Extract all imports from source
|
|
140
|
+
layout = PackageLayout(package_path)
|
|
141
|
+
source_imports, scanned_files = self._collect_imports(layout)
|
|
142
|
+
|
|
143
|
+
# If no source could be located (e.g. a layout we cannot scan), skip the
|
|
144
|
+
# audit honestly rather than reporting every dependency as "unused".
|
|
145
|
+
if scanned_files == 0:
|
|
146
|
+
return CheckResult(
|
|
147
|
+
name=self.name,
|
|
148
|
+
status=CheckStatus.NOTE,
|
|
149
|
+
message="Could not locate package source; skipping dependency audit",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
details: list[str] = []
|
|
153
|
+
|
|
154
|
+
# Check for undeclared imports
|
|
155
|
+
declared_import_names = set(declared.values())
|
|
156
|
+
# Extras / optional-deps / PEP 735 groups satisfy imports too, but are
|
|
157
|
+
# NOT counted toward the "unused" audit below.
|
|
158
|
+
extra_import_names = self._extra_import_names(project, package_path)
|
|
159
|
+
stdlib = sys.stdlib_module_names
|
|
160
|
+
local_packages = layout.local_package_names()
|
|
161
|
+
|
|
162
|
+
undeclared = []
|
|
163
|
+
for imp in source_imports:
|
|
164
|
+
if (
|
|
165
|
+
imp in stdlib
|
|
166
|
+
or imp in local_packages
|
|
167
|
+
or imp in declared_import_names
|
|
168
|
+
or imp in extra_import_names
|
|
169
|
+
):
|
|
170
|
+
continue
|
|
171
|
+
undeclared.append(imp)
|
|
172
|
+
|
|
173
|
+
# Check for unused declared dependencies
|
|
174
|
+
unused = []
|
|
175
|
+
for pypi_name, import_name in declared.items():
|
|
176
|
+
if import_name not in source_imports:
|
|
177
|
+
unused.append(pypi_name)
|
|
178
|
+
|
|
179
|
+
if undeclared:
|
|
180
|
+
details.append(f"Undeclared imports: {', '.join(sorted(undeclared)[:10])}")
|
|
181
|
+
if unused:
|
|
182
|
+
details.append(f"Unused dependencies: {', '.join(sorted(unused)[:10])}")
|
|
183
|
+
|
|
184
|
+
# Check for unbounded version specifiers (informational only)
|
|
185
|
+
self._check_version_bounds(raw_deps, details)
|
|
186
|
+
|
|
187
|
+
if undeclared:
|
|
188
|
+
return CheckResult(
|
|
189
|
+
name=self.name,
|
|
190
|
+
status=CheckStatus.WARNING,
|
|
191
|
+
message=f"Found {len(undeclared)} undeclared import(s)",
|
|
192
|
+
details=details,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if unused:
|
|
196
|
+
return CheckResult(
|
|
197
|
+
name=self.name,
|
|
198
|
+
status=CheckStatus.NOTE,
|
|
199
|
+
message=f"Found {len(unused)} unused declared dependency(ies)",
|
|
200
|
+
details=details,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
details.append(f"All {len(declared)} dependencies verified")
|
|
204
|
+
return CheckResult(
|
|
205
|
+
name=self.name,
|
|
206
|
+
status=CheckStatus.OK,
|
|
207
|
+
message="All dependencies match imports",
|
|
208
|
+
details=details,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
def _extra_import_names(
|
|
212
|
+
self, project: dict[str, Any], package_path: Path
|
|
213
|
+
) -> set[str]:
|
|
214
|
+
"""Return import names declared as extras or PEP 735 dependency-groups.
|
|
215
|
+
|
|
216
|
+
These are used only to suppress false "undeclared import" findings —
|
|
217
|
+
not counted toward the "unused dependency" audit.
|
|
218
|
+
"""
|
|
219
|
+
raw: list[str] = []
|
|
220
|
+
optional = project.get("optional-dependencies", {})
|
|
221
|
+
if isinstance(optional, dict):
|
|
222
|
+
for group in optional.values():
|
|
223
|
+
if isinstance(group, list):
|
|
224
|
+
raw.extend(d for d in group if isinstance(d, str))
|
|
225
|
+
|
|
226
|
+
data = read_pyproject(package_path) or {}
|
|
227
|
+
groups = data.get("dependency-groups", {})
|
|
228
|
+
if isinstance(groups, dict):
|
|
229
|
+
for group in groups.values():
|
|
230
|
+
if isinstance(group, list):
|
|
231
|
+
raw.extend(d for d in group if isinstance(d, str))
|
|
232
|
+
|
|
233
|
+
names: set[str] = set()
|
|
234
|
+
for dep in raw:
|
|
235
|
+
pypi_name = _strip_version_specifier(dep)
|
|
236
|
+
if pypi_name:
|
|
237
|
+
names.add(_resolve_import_name(pypi_name))
|
|
238
|
+
return names
|
|
239
|
+
|
|
240
|
+
def _collect_imports(self, layout: PackageLayout) -> tuple[set[str], int]:
|
|
241
|
+
"""Extract top-level import names from source files.
|
|
242
|
+
|
|
243
|
+
Returns a ``(imports, file_count)`` tuple. Scans discovered packages
|
|
244
|
+
plus single-file top-level modules, and — for single-module / PEP 420
|
|
245
|
+
namespace layouts with no package directory — the import root tree, so
|
|
246
|
+
those layouts are not treated as having zero source.
|
|
247
|
+
"""
|
|
248
|
+
py_files: set[Path] = set(layout.python_files())
|
|
249
|
+
py_files.update(layout.top_level_modules())
|
|
250
|
+
|
|
251
|
+
# Single-module / namespace layouts have no package dir; scan the
|
|
252
|
+
# import-root tree directly for Python sources.
|
|
253
|
+
if not layout.package_dirs:
|
|
254
|
+
base = layout.import_root
|
|
255
|
+
if base.is_dir():
|
|
256
|
+
for candidate in base.rglob("*.py"):
|
|
257
|
+
parts = set(candidate.parts)
|
|
258
|
+
if parts & EXCLUDED_DIRS or parts & {"tests", "test"}:
|
|
259
|
+
continue
|
|
260
|
+
py_files.add(candidate)
|
|
261
|
+
|
|
262
|
+
imports: set[str] = set()
|
|
263
|
+
scanned = 0
|
|
264
|
+
for py_file in sorted(py_files):
|
|
265
|
+
tree = parse_file(py_file)
|
|
266
|
+
if tree is None:
|
|
267
|
+
continue
|
|
268
|
+
scanned += 1
|
|
269
|
+
for node in ast.walk(tree):
|
|
270
|
+
if isinstance(node, ast.Import):
|
|
271
|
+
for alias in node.names:
|
|
272
|
+
imports.add(alias.name.split(".")[0])
|
|
273
|
+
elif isinstance(node, ast.ImportFrom):
|
|
274
|
+
if node.module and node.level == 0:
|
|
275
|
+
imports.add(node.module.split(".")[0])
|
|
276
|
+
|
|
277
|
+
return imports, scanned
|
|
278
|
+
|
|
279
|
+
def _check_version_bounds(self, raw_deps: list[str], details: list[str]) -> None:
|
|
280
|
+
"""Append a NOTE-level detail if any dependencies lack version bounds.
|
|
281
|
+
|
|
282
|
+
A dependency is considered unbounded if its raw string contains no
|
|
283
|
+
version specifier characters (>=, <=, ==, !=, ~=, <, >).
|
|
284
|
+
"""
|
|
285
|
+
version_pattern = re.compile(r"[><=!~]=?")
|
|
286
|
+
unbounded: list[str] = []
|
|
287
|
+
for dep in raw_deps:
|
|
288
|
+
# Strip environment markers before checking
|
|
289
|
+
dep_no_markers = dep.split(";")[0].strip()
|
|
290
|
+
if not version_pattern.search(dep_no_markers):
|
|
291
|
+
# Extract bare package name (strip extras like [extra])
|
|
292
|
+
name = re.split(r"[\[\s]", dep_no_markers)[0].strip()
|
|
293
|
+
unbounded.append(name)
|
|
294
|
+
if unbounded:
|
|
295
|
+
details.append(
|
|
296
|
+
f"NOTE: Unbounded dependencies: {', '.join(sorted(unbounded))}"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _strip_version_specifier(dep: str) -> str:
|
|
301
|
+
"""Strip version specifiers and extras from a PEP 508 dependency string."""
|
|
302
|
+
dep = dep.split(";")[0].strip() # Remove environment markers
|
|
303
|
+
dep = re.split(r"[><=!~\[]", dep)[0].strip() # Remove version/extras
|
|
304
|
+
return dep
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _normalize_pypi_name(name: str) -> str:
|
|
308
|
+
"""Normalize a PyPI package name per PEP 503."""
|
|
309
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|