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,239 @@
1
+ """Import validation check.
2
+
3
+ This module provides the ImportsCheck class which validates that all
4
+ imports in the package can be resolved, helping catch missing dependencies.
5
+ """
6
+
7
+ import ast
8
+ import importlib.util
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from pycmdcheck.ast_cache import parse_file
14
+ from pycmdcheck.checks.base import BaseCheck
15
+ from pycmdcheck.checks.dependencies import (
16
+ _resolve_import_name,
17
+ _strip_version_specifier,
18
+ )
19
+ from pycmdcheck.package_layout import PackageLayout
20
+ from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
21
+ from pycmdcheck.results import CheckResult, CheckStatus
22
+
23
+
24
+ class ImportsCheck(BaseCheck):
25
+ """Check that all imports in the package can be resolved.
26
+
27
+ Scans all Python files in the package and attempts to resolve
28
+ each import statement. Reports any imports that cannot be found,
29
+ which may indicate missing dependencies.
30
+
31
+ Attributes:
32
+ name: The check identifier ("imports").
33
+ description: Human-readable description of this check.
34
+
35
+ Note:
36
+ This check excludes standard library modules and local package
37
+ imports from validation. It only flags third-party imports that
38
+ cannot be resolved in the current environment. Only top-level
39
+ module names (before the first dot) are validated; for example,
40
+ ``from package.sub import item`` only checks that ``package``
41
+ is resolvable. Relative imports are ignored entirely.
42
+
43
+ Examples:
44
+ Run the imports check:
45
+
46
+ >>> from pathlib import Path
47
+ >>> check = ImportsCheck()
48
+ >>> result = check.run(Path("."), {"enabled": True})
49
+ >>> result.name
50
+ 'imports'
51
+
52
+ The check reports unresolvable imports:
53
+
54
+ >>> # If a file imports 'nonexistent_package'
55
+ >>> # result.status == CheckStatus.WARNING
56
+ >>> # "Cannot import 'nonexistent_package'" in result.details
57
+ """
58
+
59
+ name = "imports"
60
+ description = "Validate package imports"
61
+
62
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
63
+ """Check imports in all Python files.
64
+
65
+ Parses each Python file to extract import statements, then
66
+ attempts to import each module to verify it exists.
67
+
68
+ Args:
69
+ package_path: Path to the package directory to check.
70
+ config: Configuration dictionary for this check. Currently
71
+ no check-specific options are used.
72
+
73
+ Returns:
74
+ A CheckResult with:
75
+
76
+ - OK status if all imports can be resolved
77
+ - NOTE status if no Python files found
78
+ - WARNING status if some imports cannot be resolved
79
+ """
80
+ details: list[str] = []
81
+
82
+ # Find all Python files
83
+ layout = PackageLayout(package_path)
84
+ py_files = layout.python_files()
85
+
86
+ if not py_files:
87
+ return CheckResult(
88
+ name=self.name,
89
+ status=CheckStatus.NOTE,
90
+ message="No Python files found",
91
+ details=details,
92
+ )
93
+
94
+ details.append(f"Checking {len(py_files)} Python files")
95
+
96
+ # Collect all imports
97
+ all_imports: list[tuple[Path, str]] = []
98
+
99
+ for py_file in py_files:
100
+ imports = self._extract_imports(py_file)
101
+ for imp in imports:
102
+ all_imports.append((py_file, imp))
103
+
104
+ # Check for import issues
105
+ issues = self._check_imports(all_imports, package_path, layout)
106
+
107
+ if not issues:
108
+ details.append(f"All {len(all_imports)} imports validated")
109
+ return CheckResult(
110
+ name=self.name,
111
+ status=CheckStatus.OK,
112
+ message="All imports are valid",
113
+ details=details,
114
+ )
115
+
116
+ for issue in issues[:10]:
117
+ details.append(issue)
118
+
119
+ if len(issues) > 10:
120
+ details.append(f"... and {len(issues) - 10} more issues")
121
+
122
+ return CheckResult(
123
+ name=self.name,
124
+ status=CheckStatus.WARNING,
125
+ message=f"Found {len(issues)} import issue(s)",
126
+ details=details,
127
+ )
128
+
129
+ def _extract_imports(self, py_file: Path) -> list[str]:
130
+ """Extract import names from a Python file."""
131
+ tree = parse_file(py_file)
132
+ if tree is None:
133
+ return []
134
+ imports: list[str] = []
135
+
136
+ for node in ast.walk(tree):
137
+ if isinstance(node, ast.Import):
138
+ for alias in node.names:
139
+ imports.append(alias.name.split(".")[0])
140
+ elif isinstance(node, ast.ImportFrom):
141
+ if node.module and node.level == 0:
142
+ imports.append(node.module.split(".")[0])
143
+
144
+ return imports
145
+
146
+ def _check_imports(
147
+ self,
148
+ imports: list[tuple[Path, str]],
149
+ package_path: Path,
150
+ layout: PackageLayout,
151
+ ) -> list[str]:
152
+ """Check if imports can be resolved."""
153
+ issues: list[str] = []
154
+
155
+ # Get stdlib modules
156
+ stdlib_modules = self._get_stdlib_modules()
157
+
158
+ # Get package name(s) from src/ or root
159
+ local_packages = layout.local_package_names()
160
+
161
+ # Import names that map to a declared dependency. These must not be
162
+ # flagged just because the dependency is not installed in the current
163
+ # environment (e.g. a no-install CI run) — that is an environment
164
+ # condition, not a missing import.
165
+ declared_imports = self._declared_import_names(package_path)
166
+
167
+ # Track which modules we've already checked
168
+ checked: set[str] = set()
169
+ unresolvable: set[str] = set()
170
+
171
+ for py_file, module_name in imports:
172
+ if module_name in checked:
173
+ continue
174
+ checked.add(module_name)
175
+
176
+ # Skip stdlib
177
+ if module_name in stdlib_modules:
178
+ continue
179
+
180
+ # Skip local packages
181
+ if module_name in local_packages:
182
+ continue
183
+
184
+ # Skip declared dependencies (may simply not be installed here)
185
+ if module_name in declared_imports:
186
+ continue
187
+
188
+ # Probe without executing the module
189
+ try:
190
+ spec = importlib.util.find_spec(module_name)
191
+ except (ModuleNotFoundError, ValueError):
192
+ spec = None
193
+
194
+ if spec is None:
195
+ if module_name not in unresolvable:
196
+ unresolvable.add(module_name)
197
+ issues.append(
198
+ f"Cannot import '{module_name}' (used in {py_file.name})"
199
+ )
200
+
201
+ return issues
202
+
203
+ def _declared_import_names(self, package_path: Path) -> set[str]:
204
+ """Return import names for all declared dependencies.
205
+
206
+ Includes ``[project].dependencies``, every extra in
207
+ ``optional-dependencies``, PEP 735 ``[dependency-groups]``, and legacy
208
+ Poetry dependencies (via the effective project table). Each PyPI name
209
+ is mapped to its import name (e.g. ``PyYAML`` -> ``yaml``).
210
+ """
211
+ project = get_effective_project_table(package_path)
212
+ raw: list[str] = []
213
+ if project:
214
+ deps = project.get("dependencies", [])
215
+ if isinstance(deps, list):
216
+ raw.extend(d for d in deps if isinstance(d, str))
217
+ optional = project.get("optional-dependencies", {})
218
+ if isinstance(optional, dict):
219
+ for group in optional.values():
220
+ if isinstance(group, list):
221
+ raw.extend(d for d in group if isinstance(d, str))
222
+
223
+ data = read_pyproject(package_path) or {}
224
+ groups = data.get("dependency-groups", {})
225
+ if isinstance(groups, dict):
226
+ for group in groups.values():
227
+ if isinstance(group, list):
228
+ raw.extend(d for d in group if isinstance(d, str))
229
+
230
+ names: set[str] = set()
231
+ for dep in raw:
232
+ pypi_name = _strip_version_specifier(dep)
233
+ if pypi_name:
234
+ names.add(_resolve_import_name(pypi_name))
235
+ return names
236
+
237
+ def _get_stdlib_modules(self) -> frozenset[str]:
238
+ """Get set of standard library module names."""
239
+ return sys.stdlib_module_names
@@ -0,0 +1,304 @@
1
+ """License file check.
2
+
3
+ This module provides the LicenseCheck class which verifies that a
4
+ package has a proper license file and optionally identifies the license type.
5
+ """
6
+
7
+ import re
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from pycmdcheck.checks.base import BaseCheck
12
+ from pycmdcheck.constants import MIN_LICENSE_LENGTH
13
+ from pycmdcheck.pyproject_reader import get_project_table
14
+ from pycmdcheck.results import CheckResult, CheckStatus
15
+
16
+
17
+ class LicenseCheck(BaseCheck):
18
+ """Check that the package has a license file.
19
+
20
+ Looks for common license file names (LICENSE, COPYING, etc.) and
21
+ attempts to identify the license type (MIT, Apache, GPL, etc.).
22
+
23
+ Attributes:
24
+ name: The check identifier ("license").
25
+ description: Human-readable description of this check.
26
+ LICENSE_FILENAMES: List of recognized license file names.
27
+ KNOWN_LICENSES: Dictionary mapping license identifiers to
28
+ text markers used for identification.
29
+
30
+ Examples:
31
+ Run the license check:
32
+
33
+ >>> from pathlib import Path
34
+ >>> check = LicenseCheck()
35
+ >>> result = check.run(Path("."), {"enabled": True})
36
+ >>> result.name
37
+ 'license'
38
+
39
+ The check identifies common licenses:
40
+
41
+ >>> # For a package with MIT license
42
+ >>> # result.status == CheckStatus.OK
43
+ >>> # "License type: MIT" in result.details
44
+ """
45
+
46
+ name = "license"
47
+ description = "Check for license file"
48
+
49
+ OSI_APPROVED_SPDX: set[str] = {
50
+ "MIT",
51
+ "Apache-2.0",
52
+ "GPL-2.0-only",
53
+ "GPL-2.0-or-later",
54
+ "GPL-3.0-only",
55
+ "GPL-3.0-or-later",
56
+ "BSD-2-Clause",
57
+ "BSD-3-Clause",
58
+ "ISC",
59
+ "MPL-2.0",
60
+ "LGPL-2.1-only",
61
+ "LGPL-2.1-or-later",
62
+ "LGPL-3.0-only",
63
+ "LGPL-3.0-or-later",
64
+ "EUPL-1.2",
65
+ "Artistic-2.0",
66
+ "Zlib",
67
+ "PSF-2.0",
68
+ "BSL-1.0",
69
+ "Unlicense",
70
+ "0BSD",
71
+ "AGPL-3.0-only",
72
+ "AGPL-3.0-or-later",
73
+ "EPL-1.0",
74
+ "EPL-2.0",
75
+ "CDDL-1.0",
76
+ "MPL-1.1",
77
+ "Python-2.0",
78
+ "PostgreSQL",
79
+ "NCSA",
80
+ "MIT-0",
81
+ # Common short forms also accepted
82
+ "GPL-2.0",
83
+ "GPL-3.0",
84
+ "LGPL-2.1",
85
+ "LGPL-3.0",
86
+ "AGPL-3.0",
87
+ }
88
+
89
+ # Matches LICENSE / LICENCE / COPYING with optional suffix, e.g.
90
+ # LICENSE-MIT, LICENSE.txt, COPYING.LESSER, LICENCE.md.
91
+ LICENSE_FILE_RE = re.compile(r"(?i)^(licen[cs]e|copying)([._-].*)?$")
92
+
93
+ LICENSE_FILENAMES = [
94
+ "LICENSE",
95
+ "LICENSE.txt",
96
+ "LICENSE.md",
97
+ "LICENSE.rst",
98
+ "LICENCE",
99
+ "LICENCE.txt",
100
+ "LICENCE.md",
101
+ "COPYING",
102
+ "COPYING.txt",
103
+ ]
104
+
105
+ KNOWN_LICENSES = {
106
+ "MIT": ["MIT License", "Permission is hereby granted, free of charge"],
107
+ "Apache-2.0": ["Apache License", "Version 2.0"],
108
+ "GPL-3.0": ["GNU GENERAL PUBLIC LICENSE", "Version 3"],
109
+ "GPL-2.0": ["GNU GENERAL PUBLIC LICENSE", "Version 2"],
110
+ "BSD-3-Clause": ["BSD 3-Clause License", "Redistribution and use"],
111
+ "BSD-2-Clause": ["BSD 2-Clause License", "Simplified"],
112
+ "ISC": ["ISC License", "Permission to use, copy, modify"],
113
+ "MPL-2.0": ["Mozilla Public License", "Version 2.0"],
114
+ "LGPL-3.0": ["GNU LESSER GENERAL PUBLIC LICENSE", "Version 3"],
115
+ }
116
+
117
+ def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
118
+ """Check for license file presence and validity.
119
+
120
+ Searches for a license file using common naming conventions,
121
+ verifies it's not empty, and attempts to identify the license type.
122
+
123
+ Args:
124
+ package_path: Path to the package directory to check.
125
+ config: Configuration dictionary for this check. Currently
126
+ no check-specific options are used.
127
+
128
+ Returns:
129
+ A CheckResult with:
130
+
131
+ - OK status if a valid license file is found
132
+ - WARNING status if no license file found or file is empty
133
+ """
134
+ details: list[str] = []
135
+
136
+ # Look for a license file (canonical names, then suffixed/dual-license
137
+ # names like LICENSE-MIT, then a file referenced from pyproject.toml).
138
+ license_file = self._find_license_file(package_path)
139
+
140
+ if not license_file:
141
+ return CheckResult(
142
+ name=self.name,
143
+ status=CheckStatus.WARNING,
144
+ message="No license file found",
145
+ details=[
146
+ f"Consider adding one of: {', '.join(self.LICENSE_FILENAMES[:3])}"
147
+ ],
148
+ )
149
+
150
+ details.append(f"Found license file: {license_file.name}")
151
+
152
+ # Try to identify the license type
153
+ try:
154
+ content = license_file.read_text(encoding="utf-8")
155
+ license_type = self._identify_license(content)
156
+
157
+ if license_type:
158
+ details.append(f"License type: {license_type}")
159
+ else:
160
+ details.append("License type: Unknown/Custom")
161
+
162
+ # Check if license file is not empty
163
+ if len(content.strip()) < MIN_LICENSE_LENGTH:
164
+ return CheckResult(
165
+ name=self.name,
166
+ status=CheckStatus.WARNING,
167
+ message="License file appears empty or incomplete",
168
+ details=details,
169
+ )
170
+
171
+ except (OSError, UnicodeDecodeError) as e:
172
+ details.append(f"Could not read license file: {e}")
173
+ return CheckResult(
174
+ name=self.name,
175
+ status=CheckStatus.WARNING,
176
+ message="Could not read license file",
177
+ details=details,
178
+ )
179
+
180
+ # OSI-approved license validation
181
+ self._check_osi_status(package_path, license_type, details)
182
+
183
+ return CheckResult(
184
+ name=self.name,
185
+ status=CheckStatus.OK,
186
+ message="License file present",
187
+ details=details,
188
+ )
189
+
190
+ def _find_license_file(self, package_path: Path) -> Path | None:
191
+ """Locate a license file.
192
+
193
+ Tries canonical names first (preserving prior selection order), then
194
+ any suffixed/dual-license filename (``LICENSE-MIT``, ``COPYING.LESSER``),
195
+ then a file referenced via PEP 621 ``license = {file = ...}`` / PEP 639
196
+ ``license-files`` in pyproject.toml.
197
+ """
198
+ # 1) Canonical names (LICENSE, LICENSE.txt, COPYING, …).
199
+ for filename in self.LICENSE_FILENAMES:
200
+ candidate = package_path / filename
201
+ if candidate.exists():
202
+ return candidate
203
+
204
+ # 2) Suffixed / dual-license filenames (LICENSE-MIT, LICENSE-APACHE, …).
205
+ try:
206
+ entries = sorted(package_path.iterdir())
207
+ except OSError:
208
+ entries = []
209
+ for entry in entries:
210
+ if entry.is_file() and self.LICENSE_FILE_RE.match(entry.name):
211
+ return entry
212
+
213
+ # 3) A file referenced from pyproject.toml metadata.
214
+ return self._license_file_from_metadata(package_path)
215
+
216
+ def _license_file_from_metadata(self, package_path: Path) -> Path | None:
217
+ """Return a license file referenced from pyproject.toml, if it exists."""
218
+ project = get_project_table(package_path)
219
+
220
+ # PEP 639 license-files: list of glob patterns.
221
+ license_files = project.get("license-files")
222
+ if isinstance(license_files, list):
223
+ for pattern in license_files:
224
+ if isinstance(pattern, str):
225
+ for match in sorted(package_path.glob(pattern)):
226
+ if match.is_file():
227
+ return match
228
+
229
+ # PEP 621 license = {file = "..."}.
230
+ lic = project.get("license")
231
+ if isinstance(lic, dict):
232
+ file_ref = lic.get("file")
233
+ if isinstance(file_ref, str):
234
+ candidate = package_path / file_ref
235
+ if candidate.is_file():
236
+ return candidate
237
+
238
+ return None
239
+
240
+ def _identify_license(self, content: str) -> str | None:
241
+ """Try to identify the license type from content."""
242
+ content_upper = content.upper()
243
+
244
+ for license_name, markers in self.KNOWN_LICENSES.items():
245
+ if all(marker.upper() in content_upper for marker in markers):
246
+ return license_name
247
+
248
+ return None
249
+
250
+ def _check_osi_status(
251
+ self,
252
+ package_path: Path,
253
+ license_type: str | None,
254
+ details: list[str],
255
+ ) -> None:
256
+ """Add OSI-approved status details based on pyproject.toml and content scan.
257
+
258
+ This checks both the SPDX identifier declared in ``pyproject.toml``
259
+ and the license type identified from the file content against the
260
+ :attr:`OSI_APPROVED_SPDX` set. Results are appended to *details*
261
+ without changing the overall check status.
262
+ """
263
+ # Check the content-scanned license type against OSI set
264
+ if license_type and license_type in self.OSI_APPROVED_SPDX:
265
+ details.append(f"License is OSI-approved ({license_type})")
266
+ return
267
+
268
+ # Check the SPDX identifier from pyproject.toml
269
+ project = get_project_table(package_path)
270
+ spdx = project.get("license")
271
+
272
+ if spdx is None:
273
+ return
274
+
275
+ # The license field can be a string (SPDX expression) or a table. Only
276
+ # {text = "..."} carries an SPDX value; {file = "..."} points at a file
277
+ # and must NOT be treated as an SPDX identifier.
278
+ if isinstance(spdx, dict):
279
+ spdx = spdx.get("text")
280
+
281
+ if not isinstance(spdx, str) or not spdx.strip():
282
+ return
283
+
284
+ if self._spdx_is_osi(spdx):
285
+ details.append(f"License is OSI-approved ({spdx})")
286
+ else:
287
+ details.append(
288
+ f"NOTE: License '{spdx}' is not in pycmdcheck's known "
289
+ "OSI-approved list (it may still be OSI-approved)"
290
+ )
291
+
292
+ def _spdx_is_osi(self, expression: str) -> bool:
293
+ """Return whether an SPDX expression contains an OSI-approved license.
294
+
295
+ Handles SPDX expressions (``MIT OR Apache-2.0``, ``GPL-3.0-only WITH
296
+ ...``, parenthesised groups). Returns ``True`` if any licensed
297
+ sub-identifier is in :attr:`OSI_APPROVED_SPDX` — lenient by design, so
298
+ valid-but-unlisted licenses are not falsely flagged as non-OSI.
299
+ """
300
+ tokens = re.split(
301
+ r"\s+(?:OR|AND|WITH)\s+|[()]", expression, flags=re.IGNORECASE
302
+ )
303
+ identifiers = [t.strip().rstrip("+") for t in tokens if t.strip()]
304
+ return any(ident in self.OSI_APPROVED_SPDX for ident in identifiers)