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,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()