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