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,207 @@
|
|
|
1
|
+
"""PEP 561 py.typed marker check.
|
|
2
|
+
|
|
3
|
+
This module provides the PyTypedCheck class which verifies that packages
|
|
4
|
+
using type annotations include a ``py.typed`` marker file as required by
|
|
5
|
+
PEP 561 for downstream type checkers to recognise the package as typed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import logging
|
|
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.constants import MAX_TYPE_SCAN_FILES
|
|
16
|
+
from pycmdcheck.package_layout import PackageLayout
|
|
17
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PyTypedCheck(BaseCheck):
|
|
23
|
+
"""Check for PEP 561 ``py.typed`` marker file.
|
|
24
|
+
|
|
25
|
+
Scans the package source for type annotation usage and verifies
|
|
26
|
+
that a ``py.typed`` marker file exists in the package directory.
|
|
27
|
+
If annotations are found but the marker is missing, downstream
|
|
28
|
+
type checkers (mypy, pyright) will not recognise the package as
|
|
29
|
+
typed.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
name: The check identifier (``"py_typed"``).
|
|
33
|
+
description: Human-readable description of this check.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
Run the py.typed check:
|
|
37
|
+
|
|
38
|
+
>>> from pathlib import Path
|
|
39
|
+
>>> check = PyTypedCheck()
|
|
40
|
+
>>> result = check.run(Path("."), {"enabled": True})
|
|
41
|
+
>>> result.name
|
|
42
|
+
'py_typed'
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name = "py_typed"
|
|
46
|
+
description = "Check for PEP 561 py.typed marker"
|
|
47
|
+
|
|
48
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
49
|
+
"""Check for py.typed marker in typed packages.
|
|
50
|
+
|
|
51
|
+
Locates the main package directory, determines whether type
|
|
52
|
+
annotations are used, and checks for the ``py.typed`` marker.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
package_path: Path to the package directory to check.
|
|
56
|
+
config: Configuration dictionary for this check. Currently
|
|
57
|
+
no check-specific options are used.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A CheckResult with:
|
|
61
|
+
|
|
62
|
+
- OK status if ``py.typed`` is present
|
|
63
|
+
- NOTE status if types are used but no ``py.typed`` marker
|
|
64
|
+
- OK status if no type annotations detected (marker not needed)
|
|
65
|
+
- NOTE status if no package directory found
|
|
66
|
+
"""
|
|
67
|
+
details: list[str] = []
|
|
68
|
+
|
|
69
|
+
# Locate the package directory (the one with __init__.py)
|
|
70
|
+
package_dir = PackageLayout(package_path).primary_package
|
|
71
|
+
if package_dir is None:
|
|
72
|
+
return CheckResult(
|
|
73
|
+
name=self.name,
|
|
74
|
+
status=CheckStatus.NOTE,
|
|
75
|
+
message="No package directory found",
|
|
76
|
+
details=["Could not locate a package with __init__.py"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
details.append(f"Package directory: {package_dir.relative_to(package_path)}")
|
|
80
|
+
|
|
81
|
+
py_typed_path = package_dir / "py.typed"
|
|
82
|
+
has_marker = py_typed_path.exists()
|
|
83
|
+
|
|
84
|
+
if has_marker:
|
|
85
|
+
details.append("py.typed marker file present")
|
|
86
|
+
|
|
87
|
+
# Scan for type annotation usage
|
|
88
|
+
uses_types = self._package_uses_types(package_dir)
|
|
89
|
+
|
|
90
|
+
if has_marker and uses_types:
|
|
91
|
+
details.append("Package uses type annotations")
|
|
92
|
+
return CheckResult(
|
|
93
|
+
name=self.name,
|
|
94
|
+
status=CheckStatus.OK,
|
|
95
|
+
message="py.typed marker present in typed package",
|
|
96
|
+
details=details,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if has_marker and not uses_types:
|
|
100
|
+
details.append("No type annotations detected (marker still valid)")
|
|
101
|
+
return CheckResult(
|
|
102
|
+
name=self.name,
|
|
103
|
+
status=CheckStatus.OK,
|
|
104
|
+
message="py.typed marker present",
|
|
105
|
+
details=details,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if uses_types and not has_marker:
|
|
109
|
+
details.append("Package uses type annotations but has no py.typed marker")
|
|
110
|
+
details.append(
|
|
111
|
+
f"Add an empty file: {py_typed_path.relative_to(package_path)}"
|
|
112
|
+
)
|
|
113
|
+
return CheckResult(
|
|
114
|
+
name=self.name,
|
|
115
|
+
status=CheckStatus.NOTE,
|
|
116
|
+
message="Missing py.typed marker for typed package",
|
|
117
|
+
details=details,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# No types used, no marker -- nothing to do
|
|
121
|
+
details.append("No type annotations detected; py.typed not required")
|
|
122
|
+
return CheckResult(
|
|
123
|
+
name=self.name,
|
|
124
|
+
status=CheckStatus.OK,
|
|
125
|
+
message="py.typed not needed (no type annotations found)",
|
|
126
|
+
details=details,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
# Private helpers
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def _package_uses_types(self, package_dir: Path) -> bool:
|
|
134
|
+
"""Determine whether the package uses type annotations.
|
|
135
|
+
|
|
136
|
+
Scans Python files for function/method parameter annotations,
|
|
137
|
+
return annotations, and variable annotations. Only the first
|
|
138
|
+
20 files are checked for performance.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
package_dir: Path to the package directory.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if any type annotation usage is detected.
|
|
145
|
+
"""
|
|
146
|
+
py_files = list(package_dir.rglob("*.py"))
|
|
147
|
+
# Exclude __pycache__
|
|
148
|
+
py_files = [f for f in py_files if "__pycache__" not in f.parts]
|
|
149
|
+
|
|
150
|
+
for py_file in py_files[:MAX_TYPE_SCAN_FILES]:
|
|
151
|
+
try:
|
|
152
|
+
if self._file_uses_types(py_file):
|
|
153
|
+
return True
|
|
154
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
155
|
+
logger.debug("Could not check %s for types: %s", py_file, exc)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def _file_uses_types(self, py_file: Path) -> bool:
|
|
161
|
+
"""Check if a single Python file uses type annotations.
|
|
162
|
+
|
|
163
|
+
Looks for:
|
|
164
|
+
- Function parameter annotations (``def f(x: int)``)
|
|
165
|
+
- Return annotations (``def f() -> str``)
|
|
166
|
+
- Variable annotations (``x: int = 1``)
|
|
167
|
+
- Imports from ``typing`` or ``typing_extensions``
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
py_file: Path to a ``.py`` file.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
True if any annotation usage is found.
|
|
174
|
+
"""
|
|
175
|
+
tree = parse_file(py_file)
|
|
176
|
+
if tree is None:
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
for node in ast.walk(tree):
|
|
180
|
+
# Check for function annotations
|
|
181
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
182
|
+
# Return annotation
|
|
183
|
+
if node.returns is not None:
|
|
184
|
+
return True
|
|
185
|
+
# Parameter annotations
|
|
186
|
+
all_args = node.args.posonlyargs + node.args.args + node.args.kwonlyargs
|
|
187
|
+
for arg in all_args:
|
|
188
|
+
if arg.annotation is not None:
|
|
189
|
+
return True
|
|
190
|
+
if node.args.vararg and node.args.vararg.annotation is not None:
|
|
191
|
+
return True
|
|
192
|
+
if node.args.kwarg and node.args.kwarg.annotation is not None:
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
# Check for variable annotations
|
|
196
|
+
elif isinstance(node, ast.AnnAssign):
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
# Check for typing imports
|
|
200
|
+
elif isinstance(node, ast.ImportFrom):
|
|
201
|
+
if node.module and node.module.split(".")[0] in (
|
|
202
|
+
"typing",
|
|
203
|
+
"typing_extensions",
|
|
204
|
+
):
|
|
205
|
+
return True
|
|
206
|
+
|
|
207
|
+
return False
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Python version support check.
|
|
2
|
+
|
|
3
|
+
Verifies that a package's ``requires-python`` specifier excludes
|
|
4
|
+
end-of-life Python versions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import configparser
|
|
8
|
+
import datetime
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
14
|
+
from pycmdcheck.constants import PYTHON_EOL_VERSIONS
|
|
15
|
+
from pycmdcheck.pyproject_reader import get_effective_project_table, read_pyproject
|
|
16
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PythonVersionsCheck(BaseCheck):
|
|
20
|
+
"""Check that requires-python excludes end-of-life Python versions.
|
|
21
|
+
|
|
22
|
+
Parses the ``requires-python`` field from pyproject.toml and checks
|
|
23
|
+
whether it allows any Python versions that have reached end-of-life.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
name: The check identifier ("python_versions").
|
|
27
|
+
description: Human-readable description of this check.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name = "python_versions"
|
|
31
|
+
description = "Check Python version support (EOL versions)"
|
|
32
|
+
|
|
33
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
34
|
+
"""Check requires-python against EOL versions.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
package_path: Path to the package directory to check.
|
|
38
|
+
config: Configuration dictionary for this check.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A CheckResult with:
|
|
42
|
+
|
|
43
|
+
- OK status if requires-python excludes all EOL versions
|
|
44
|
+
- NOTE status if requires-python allows EOL versions
|
|
45
|
+
- WARNING status if no requires-python specified
|
|
46
|
+
- NOTE status if no pyproject.toml found
|
|
47
|
+
"""
|
|
48
|
+
details: list[str] = []
|
|
49
|
+
|
|
50
|
+
# Resolve a Python constraint from any declared source: PEP 621
|
|
51
|
+
# [project], legacy Poetry [tool.poetry.dependencies].python, or
|
|
52
|
+
# setup.cfg [options] python_requires.
|
|
53
|
+
requires_python = self._get_requires_python(package_path)
|
|
54
|
+
|
|
55
|
+
if not requires_python:
|
|
56
|
+
no_pyproject = read_pyproject(package_path) is None
|
|
57
|
+
no_setup_cfg = not (package_path / "setup.cfg").exists()
|
|
58
|
+
if no_pyproject and no_setup_cfg:
|
|
59
|
+
return CheckResult(
|
|
60
|
+
name=self.name,
|
|
61
|
+
status=CheckStatus.NOTE,
|
|
62
|
+
message="No pyproject.toml found",
|
|
63
|
+
details=["Cannot check requires-python without pyproject.toml"],
|
|
64
|
+
)
|
|
65
|
+
return CheckResult(
|
|
66
|
+
name=self.name,
|
|
67
|
+
status=CheckStatus.WARNING,
|
|
68
|
+
message="No requires-python specified",
|
|
69
|
+
details=[
|
|
70
|
+
"Add requires-python to [project] in pyproject.toml",
|
|
71
|
+
'Example: requires-python = ">=3.10"',
|
|
72
|
+
],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
details.append(f"requires-python: {requires_python}")
|
|
76
|
+
|
|
77
|
+
# Determine which EOL versions are currently past EOL
|
|
78
|
+
now = datetime.datetime.now(tz=datetime.timezone.utc)
|
|
79
|
+
current_date = (now.year, now.month)
|
|
80
|
+
|
|
81
|
+
eol_allowed: list[str] = []
|
|
82
|
+
for version, eol_date in PYTHON_EOL_VERSIONS.items():
|
|
83
|
+
if eol_date <= current_date:
|
|
84
|
+
# This version is past EOL — check if requires-python allows it
|
|
85
|
+
if self._specifier_allows(requires_python, version):
|
|
86
|
+
eol_allowed.append(version)
|
|
87
|
+
details.append(f"Python {version} is EOL but allowed")
|
|
88
|
+
|
|
89
|
+
if eol_allowed:
|
|
90
|
+
return CheckResult(
|
|
91
|
+
name=self.name,
|
|
92
|
+
status=CheckStatus.NOTE,
|
|
93
|
+
message=(
|
|
94
|
+
f"requires-python allows EOL version(s): {', '.join(eol_allowed)}"
|
|
95
|
+
),
|
|
96
|
+
details=details,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
details.append("All allowed Python versions are supported")
|
|
100
|
+
return CheckResult(
|
|
101
|
+
name=self.name,
|
|
102
|
+
status=CheckStatus.OK,
|
|
103
|
+
message="Python version requirement is current",
|
|
104
|
+
details=details,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _get_requires_python(self, package_path: Path) -> str | None:
|
|
108
|
+
"""Return a declared Python constraint from any supported source."""
|
|
109
|
+
project = get_effective_project_table(package_path)
|
|
110
|
+
requires_python = project.get("requires-python")
|
|
111
|
+
if isinstance(requires_python, str) and requires_python.strip():
|
|
112
|
+
return requires_python
|
|
113
|
+
return self._setup_cfg_python_requires(package_path)
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def _setup_cfg_python_requires(package_path: Path) -> str | None:
|
|
117
|
+
"""Read ``python_requires`` from ``setup.cfg`` ``[options]`` if present."""
|
|
118
|
+
setup_cfg = package_path / "setup.cfg"
|
|
119
|
+
if not setup_cfg.exists():
|
|
120
|
+
return None
|
|
121
|
+
parser = configparser.ConfigParser()
|
|
122
|
+
try:
|
|
123
|
+
parser.read(setup_cfg, encoding="utf-8")
|
|
124
|
+
except (OSError, configparser.Error):
|
|
125
|
+
return None
|
|
126
|
+
if parser.has_option("options", "python_requires"):
|
|
127
|
+
value = parser.get("options", "python_requires").strip()
|
|
128
|
+
return value or None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def _specifier_allows(self, specifier: str, version: str) -> bool:
|
|
132
|
+
"""Check if a PEP 440 specifier allows the given version.
|
|
133
|
+
|
|
134
|
+
Prefers the accurate ``packaging`` library (a declared dependency).
|
|
135
|
+
Falls back to a conservative hand parser only if ``packaging`` is
|
|
136
|
+
somehow unavailable.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
return version in SpecifierSet(specifier, prereleases=True)
|
|
143
|
+
except InvalidSpecifier:
|
|
144
|
+
return False
|
|
145
|
+
except ImportError:
|
|
146
|
+
return self._fallback_allows(specifier, version)
|
|
147
|
+
|
|
148
|
+
def _fallback_allows(self, specifier: str, version: str) -> bool:
|
|
149
|
+
"""Conservative fallback used only when ``packaging`` is missing.
|
|
150
|
+
|
|
151
|
+
Handles comma-separated ``>=``, ``>``, ``<=``, ``<``, ``==`` (incl.
|
|
152
|
+
``==X.*``), ``!=`` and ``~=`` clauses. Returns ``False`` whenever the
|
|
153
|
+
specifier cannot be confidently parsed, so an EOL version is never
|
|
154
|
+
falsely reported as allowed.
|
|
155
|
+
"""
|
|
156
|
+
target = self._version_tuple(version)
|
|
157
|
+
clauses = [c.strip() for c in specifier.split(",") if c.strip()]
|
|
158
|
+
if not clauses:
|
|
159
|
+
return False
|
|
160
|
+
for clause in clauses:
|
|
161
|
+
match = re.match(r"(>=|<=|==|~=|!=|>|<)\s*(\d+(?:\.\d+)*)(\.\*)?$", clause)
|
|
162
|
+
if not match:
|
|
163
|
+
return False
|
|
164
|
+
op, num, wildcard = match.group(1), match.group(2), match.group(3)
|
|
165
|
+
bound = self._version_tuple(num)
|
|
166
|
+
if op == ">=" and not target >= bound:
|
|
167
|
+
return False
|
|
168
|
+
if op == ">" and not target > bound:
|
|
169
|
+
return False
|
|
170
|
+
if op == "<=" and not target <= bound:
|
|
171
|
+
return False
|
|
172
|
+
if op == "<" and not target < bound:
|
|
173
|
+
return False
|
|
174
|
+
if op == "~=" and not target >= bound:
|
|
175
|
+
return False
|
|
176
|
+
if op == "==":
|
|
177
|
+
if wildcard:
|
|
178
|
+
if target[: len(bound)] != bound:
|
|
179
|
+
return False
|
|
180
|
+
elif target != bound:
|
|
181
|
+
return False
|
|
182
|
+
if op == "!=" and target == bound:
|
|
183
|
+
return False
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
def _version_tuple(self, version: str) -> tuple[int, ...]:
|
|
187
|
+
"""Convert a version string to a comparable tuple."""
|
|
188
|
+
return tuple(int(x) for x in version.split("."))
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Package structure validation check.
|
|
2
|
+
|
|
3
|
+
This module provides the StructureCheck class which validates that a
|
|
4
|
+
Python package follows standard directory layouts (src layout or flat layout).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.package_layout import NON_PACKAGE_DIRS
|
|
12
|
+
from pycmdcheck.pyproject_reader import read_pyproject
|
|
13
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
14
|
+
|
|
15
|
+
# Build backends that produce native-extension packages which may have no
|
|
16
|
+
# Python source under src/ (or have it under a different directory).
|
|
17
|
+
NATIVE_BUILD_BACKENDS = (
|
|
18
|
+
"maturin",
|
|
19
|
+
"scikit_build",
|
|
20
|
+
"setuptools_rust",
|
|
21
|
+
"mesonpy",
|
|
22
|
+
"meson_python",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StructureCheck(BaseCheck):
|
|
27
|
+
"""Check that package has a valid directory structure.
|
|
28
|
+
|
|
29
|
+
Validates that the package follows one of the standard Python
|
|
30
|
+
package layouts:
|
|
31
|
+
|
|
32
|
+
- **src layout**: Package code in ``src/<package_name>/``
|
|
33
|
+
- **flat layout**: Package code directly in project root
|
|
34
|
+
|
|
35
|
+
The check verifies that package directories contain ``__init__.py``
|
|
36
|
+
files and are properly structured.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
name: The check identifier ("structure").
|
|
40
|
+
description: Human-readable description of this check.
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
Run the structure check:
|
|
44
|
+
|
|
45
|
+
>>> from pathlib import Path
|
|
46
|
+
>>> check = StructureCheck()
|
|
47
|
+
>>> result = check.run(Path("."), {"enabled": True})
|
|
48
|
+
>>> result.name
|
|
49
|
+
'structure'
|
|
50
|
+
|
|
51
|
+
Check a package with src layout:
|
|
52
|
+
|
|
53
|
+
>>> # For a package with src/mypackage/__init__.py
|
|
54
|
+
>>> # result.status == CheckStatus.OK
|
|
55
|
+
>>> # result.details contains "Using src layout"
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
name = "structure"
|
|
59
|
+
description = "Validate package directory structure"
|
|
60
|
+
|
|
61
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
62
|
+
"""Check package directory structure.
|
|
63
|
+
|
|
64
|
+
Determines the layout type (src or flat) and validates that
|
|
65
|
+
the package directories are properly formed with ``__init__.py``
|
|
66
|
+
files.
|
|
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 valid src or flat layout found
|
|
77
|
+
- WARNING status if packages missing ``__init__.py``
|
|
78
|
+
- ERROR status if no package or module found
|
|
79
|
+
"""
|
|
80
|
+
details: list[str] = []
|
|
81
|
+
|
|
82
|
+
# Check for src layout
|
|
83
|
+
src_path = package_path / "src"
|
|
84
|
+
if src_path.is_dir():
|
|
85
|
+
return self._check_src_layout(package_path, src_path, details)
|
|
86
|
+
|
|
87
|
+
# Check for flat layout
|
|
88
|
+
return self._check_flat_layout(package_path, details)
|
|
89
|
+
|
|
90
|
+
def _check_src_layout(
|
|
91
|
+
self,
|
|
92
|
+
package_path: Path,
|
|
93
|
+
src_path: Path,
|
|
94
|
+
details: list[str],
|
|
95
|
+
) -> CheckResult:
|
|
96
|
+
"""Validate src layout structure."""
|
|
97
|
+
details.append("Using src layout")
|
|
98
|
+
|
|
99
|
+
# Find candidate package directories in src/
|
|
100
|
+
package_dirs = [
|
|
101
|
+
d
|
|
102
|
+
for d in src_path.iterdir()
|
|
103
|
+
if d.is_dir() and not d.name.startswith((".", "_"))
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
if not package_dirs:
|
|
107
|
+
return self._handle_no_src_package(package_path, src_path, details)
|
|
108
|
+
|
|
109
|
+
# Classify each candidate: regular package, PEP 420 namespace package,
|
|
110
|
+
# or a directory genuinely missing __init__.py.
|
|
111
|
+
missing_init: list[str] = []
|
|
112
|
+
for pkg_dir in package_dirs:
|
|
113
|
+
if (pkg_dir / "__init__.py").exists():
|
|
114
|
+
details.append(f"Found package: {pkg_dir.name}")
|
|
115
|
+
elif self._is_namespace_package(pkg_dir):
|
|
116
|
+
details.append(f"Found namespace package: {pkg_dir.name}")
|
|
117
|
+
else:
|
|
118
|
+
missing_init.append(pkg_dir.name)
|
|
119
|
+
|
|
120
|
+
if missing_init:
|
|
121
|
+
details.append(f"Missing __init__.py in: {', '.join(missing_init)}")
|
|
122
|
+
return CheckResult(
|
|
123
|
+
name=self.name,
|
|
124
|
+
status=CheckStatus.WARNING,
|
|
125
|
+
message="Some packages missing __init__.py",
|
|
126
|
+
details=details,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return CheckResult(
|
|
130
|
+
name=self.name,
|
|
131
|
+
status=CheckStatus.OK,
|
|
132
|
+
message="Valid src layout structure",
|
|
133
|
+
details=details,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _handle_no_src_package(
|
|
137
|
+
self,
|
|
138
|
+
package_path: Path,
|
|
139
|
+
src_path: Path,
|
|
140
|
+
details: list[str],
|
|
141
|
+
) -> CheckResult:
|
|
142
|
+
"""Handle a ``src/`` directory with no candidate package subdirectory.
|
|
143
|
+
|
|
144
|
+
Covers single-module src layouts (``src/foo.py``) and native-extension
|
|
145
|
+
packages (maturin/scikit-build/setuptools-rust) whose ``src/`` holds no
|
|
146
|
+
Python at all — neither should be a hard ERROR.
|
|
147
|
+
"""
|
|
148
|
+
# Single-module src layout: src/foo.py
|
|
149
|
+
modules = [f for f in sorted(src_path.glob("*.py")) if f.name != "__init__.py"]
|
|
150
|
+
if modules:
|
|
151
|
+
details.append(
|
|
152
|
+
f"Found single-file modules: {', '.join(m.name for m in modules)}"
|
|
153
|
+
)
|
|
154
|
+
return CheckResult(
|
|
155
|
+
name=self.name,
|
|
156
|
+
status=CheckStatus.OK,
|
|
157
|
+
message="Valid single-file module structure",
|
|
158
|
+
details=details,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# Native-extension package (maturin/scikit-build/setuptools-rust): the
|
|
162
|
+
# Python lives elsewhere (or there is none), so a missing Python package
|
|
163
|
+
# under src/ is expected, not an error.
|
|
164
|
+
if self._is_native_backend(package_path):
|
|
165
|
+
details.append(
|
|
166
|
+
"No Python package under src/ (native-extension build backend detected)"
|
|
167
|
+
)
|
|
168
|
+
return CheckResult(
|
|
169
|
+
name=self.name,
|
|
170
|
+
status=CheckStatus.NOTE,
|
|
171
|
+
message="No Python package in src/ (native-extension layout)",
|
|
172
|
+
details=details,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return CheckResult(
|
|
176
|
+
name=self.name,
|
|
177
|
+
status=CheckStatus.ERROR,
|
|
178
|
+
message="No package directory found in src/",
|
|
179
|
+
details=details,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _check_flat_layout(
|
|
183
|
+
self,
|
|
184
|
+
package_path: Path,
|
|
185
|
+
details: list[str],
|
|
186
|
+
) -> CheckResult:
|
|
187
|
+
"""Validate flat layout structure."""
|
|
188
|
+
details.append("Using flat layout")
|
|
189
|
+
|
|
190
|
+
# Look for package directories (not src/)
|
|
191
|
+
package_dirs = [
|
|
192
|
+
d
|
|
193
|
+
for d in package_path.iterdir()
|
|
194
|
+
if d.is_dir()
|
|
195
|
+
and not d.name.startswith((".", "_"))
|
|
196
|
+
and d.name not in NON_PACKAGE_DIRS
|
|
197
|
+
and (d / "__init__.py").exists()
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
if not package_dirs:
|
|
201
|
+
# Check if there are any .py files at root (single-file module)
|
|
202
|
+
py_files = list(package_path.glob("*.py"))
|
|
203
|
+
py_files = [
|
|
204
|
+
f for f in py_files if f.name not in ("setup.py", "conftest.py")
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
if py_files:
|
|
208
|
+
details.append(
|
|
209
|
+
f"Found single-file modules: {', '.join(f.name for f in py_files)}"
|
|
210
|
+
)
|
|
211
|
+
return CheckResult(
|
|
212
|
+
name=self.name,
|
|
213
|
+
status=CheckStatus.OK,
|
|
214
|
+
message="Valid single-file module structure",
|
|
215
|
+
details=details,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# PEP 420 namespace package: a top-level dir without __init__.py
|
|
219
|
+
# that nonetheless contains a nested package/module.
|
|
220
|
+
namespace_dirs = [
|
|
221
|
+
d
|
|
222
|
+
for d in package_path.iterdir()
|
|
223
|
+
if d.is_dir()
|
|
224
|
+
and not d.name.startswith((".", "_"))
|
|
225
|
+
and d.name not in NON_PACKAGE_DIRS
|
|
226
|
+
and self._is_namespace_package(d)
|
|
227
|
+
]
|
|
228
|
+
if namespace_dirs:
|
|
229
|
+
details.append(
|
|
230
|
+
"Found namespace package(s): "
|
|
231
|
+
+ ", ".join(d.name for d in namespace_dirs)
|
|
232
|
+
)
|
|
233
|
+
return CheckResult(
|
|
234
|
+
name=self.name,
|
|
235
|
+
status=CheckStatus.OK,
|
|
236
|
+
message="Valid namespace package structure",
|
|
237
|
+
details=details,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return CheckResult(
|
|
241
|
+
name=self.name,
|
|
242
|
+
status=CheckStatus.ERROR,
|
|
243
|
+
message="No package or module found",
|
|
244
|
+
details=details + ["Consider using src/ layout or adding __init__.py"],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
for pkg_dir in package_dirs:
|
|
248
|
+
details.append(f"Found package: {pkg_dir.name}")
|
|
249
|
+
|
|
250
|
+
return CheckResult(
|
|
251
|
+
name=self.name,
|
|
252
|
+
status=CheckStatus.OK,
|
|
253
|
+
message="Valid flat layout structure",
|
|
254
|
+
details=details,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@staticmethod
|
|
258
|
+
def _is_namespace_package(directory: Path) -> bool:
|
|
259
|
+
"""Return whether *directory* is a PEP 420 implicit namespace package.
|
|
260
|
+
|
|
261
|
+
A namespace package has no ``__init__.py`` of its own but contains a
|
|
262
|
+
nested subpackage (a directory with ``__init__.py``) somewhere beneath
|
|
263
|
+
it. This distinguishes it from a regular package that simply forgot its
|
|
264
|
+
``__init__.py``.
|
|
265
|
+
"""
|
|
266
|
+
if (directory / "__init__.py").exists():
|
|
267
|
+
return False
|
|
268
|
+
for nested in directory.rglob("__init__.py"):
|
|
269
|
+
if nested.parent != directory:
|
|
270
|
+
return True
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
@staticmethod
|
|
274
|
+
def _is_native_backend(package_path: Path) -> bool:
|
|
275
|
+
"""Return whether pyproject.toml declares a native-extension backend."""
|
|
276
|
+
data = read_pyproject(package_path)
|
|
277
|
+
if not data:
|
|
278
|
+
return False
|
|
279
|
+
backend = data.get("build-system", {}).get("build-backend", "")
|
|
280
|
+
if not isinstance(backend, str):
|
|
281
|
+
return False
|
|
282
|
+
return any(native in backend for native in NATIVE_BUILD_BACKENDS)
|