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