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,127 @@
1
+ """Plugin discovery via entry points.
2
+
3
+ This module provides functionality to discover and load check plugins
4
+ registered via Python entry points. Third-party packages can register
5
+ custom checks by adding entries to the ``pycmdcheck.checks`` entry point
6
+ group in their pyproject.toml.
7
+
8
+ Example entry point registration in pyproject.toml:
9
+
10
+ [project.entry-points."pycmdcheck.checks"]
11
+ my_check = "my_package.checks:MyCheck"
12
+ """
13
+
14
+ import functools
15
+ import logging
16
+ from importlib.metadata import entry_points
17
+ from typing import TYPE_CHECKING
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ if TYPE_CHECKING:
22
+ from pycmdcheck.checks.base import Check
23
+
24
+ ENTRY_POINT_GROUP = "pycmdcheck.checks"
25
+ """Entry point group name for check plugins."""
26
+
27
+
28
+ @functools.lru_cache(maxsize=1)
29
+ def discover_checks() -> dict[str, type["Check"]]:
30
+ """Discover all registered checks via entry points.
31
+
32
+ Scans the ``pycmdcheck.checks`` entry point group and loads all
33
+ registered check classes. Built-in checks are registered in the
34
+ package's pyproject.toml, and third-party packages can register
35
+ additional checks.
36
+
37
+ If a check fails to load (e.g., due to an import error), a
38
+ UserWarning is issued but discovery continues with other checks.
39
+
40
+ Returns:
41
+ A dictionary mapping check names (strings) to check classes.
42
+ The check classes implement the Check protocol from
43
+ pycmdcheck.checks.base.
44
+
45
+ Examples:
46
+ Discover all available checks:
47
+
48
+ >>> checks = discover_checks()
49
+ >>> "metadata" in checks
50
+ True
51
+ >>> "tests" in checks
52
+ True
53
+
54
+ Get a specific check class:
55
+
56
+ >>> checks = discover_checks()
57
+ >>> MetadataCheck = checks["metadata"]
58
+ >>> instance = MetadataCheck()
59
+ >>> instance.name
60
+ 'metadata'
61
+ """
62
+ checks: dict[str, type[Check]] = {}
63
+
64
+ eps = entry_points(group=ENTRY_POINT_GROUP)
65
+ for ep in eps:
66
+ try:
67
+ check_class = ep.load()
68
+ checks[ep.name] = check_class
69
+ except (ImportError, AttributeError, TypeError) as e:
70
+ # Log warning but continue with other checks
71
+ logger.warning(
72
+ "Failed to load check '%s' from %s: %s",
73
+ ep.name,
74
+ ep.value,
75
+ e,
76
+ )
77
+
78
+ logger.debug("Discovered %d checks: %s", len(checks), ", ".join(sorted(checks)))
79
+ return checks
80
+
81
+
82
+ def list_available_checks() -> list[tuple[str, str]]:
83
+ """List all available checks with their descriptions.
84
+
85
+ Discovers all registered checks and returns their names and
86
+ descriptions in a format suitable for display (e.g., in CLI help).
87
+ Results are sorted alphabetically by check name.
88
+
89
+ Returns:
90
+ A list of (name, description) tuples, sorted alphabetically
91
+ by name. Each tuple contains:
92
+
93
+ - name: The check's unique identifier (str)
94
+ - description: Human-readable description of what the check does (str)
95
+
96
+ If a check's description cannot be retrieved, "No description
97
+ available" is used as a fallback.
98
+
99
+ Examples:
100
+ List all checks for display:
101
+
102
+ >>> checks = list_available_checks()
103
+ >>> len(checks) > 0
104
+ True
105
+ >>> name, description = checks[0]
106
+ >>> isinstance(name, str) and isinstance(description, str)
107
+ True
108
+
109
+ Display in CLI:
110
+
111
+ >>> for name, desc in list_available_checks():
112
+ ... print(f" {name}: {desc}") # doctest: +SKIP
113
+ docs: Check documentation presence
114
+ imports: Check that package imports work
115
+ ...
116
+ """
117
+ checks = discover_checks()
118
+ result: list[tuple[str, str]] = []
119
+
120
+ for name, check_class in sorted(checks.items()):
121
+ try:
122
+ description = getattr(check_class, "description", "No description")
123
+ result.append((name, description))
124
+ except (AttributeError, TypeError):
125
+ result.append((name, "No description available"))
126
+
127
+ return result
@@ -0,0 +1,234 @@
1
+ """Shared utility for discovering Python package layout and files.
2
+
3
+ This module provides the :class:`PackageLayout` class, which consolidates
4
+ all package/directory discovery logic into a single, reusable component.
5
+ It replaces duplicated ``_find_package_dir``, ``_get_local_packages``, and
6
+ hardcoded exclusion sets that were previously scattered across check modules.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # ── Shared exclusion sets ─────────────────────────────────────────────
17
+
18
+ EXCLUDED_DIRS: frozenset[str] = frozenset(
19
+ {
20
+ "venv",
21
+ ".venv",
22
+ "env",
23
+ ".env",
24
+ "build",
25
+ "dist",
26
+ ".git",
27
+ "__pycache__",
28
+ ".tox",
29
+ ".nox",
30
+ ".mypy_cache",
31
+ ".pytest_cache",
32
+ ".ruff_cache",
33
+ "node_modules",
34
+ ".eggs",
35
+ }
36
+ )
37
+ """Directories that should always be skipped when walking a project tree."""
38
+
39
+ NON_PACKAGE_DIRS: frozenset[str] = frozenset(
40
+ {
41
+ "tests",
42
+ "test",
43
+ "docs",
44
+ "doc",
45
+ "build",
46
+ "dist",
47
+ "venv",
48
+ ".venv",
49
+ }
50
+ )
51
+ """Top-level directories that are never real packages in a flat layout."""
52
+
53
+ NON_MODULE_FILES: frozenset[str] = frozenset(
54
+ {
55
+ "setup.py",
56
+ "conftest.py",
57
+ "noxfile.py",
58
+ "tasks.py",
59
+ "manage.py",
60
+ "versioneer.py",
61
+ "__init__.py",
62
+ "__main__.py",
63
+ }
64
+ )
65
+ """Top-level ``.py`` files that are tooling/scripts, not the package module."""
66
+
67
+
68
+ class PackageLayout:
69
+ """Discover and expose the layout of a Python project.
70
+
71
+ Supports both *src/* layout and flat layout. Filesystem scanning is
72
+ deferred until the first property access (lazy discovery).
73
+
74
+ Args:
75
+ project_root: Path to the project root (the directory containing
76
+ ``pyproject.toml`` or equivalent).
77
+ package_name: Optional hint for the primary package name. When
78
+ given, it is used to select :attr:`primary_package` from the
79
+ discovered packages.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ project_root: Path,
85
+ *,
86
+ package_name: str | None = None,
87
+ ) -> None:
88
+ self._root = project_root
89
+ self._package_name = package_name
90
+
91
+ # Lazily populated by ``_discover()``
92
+ self._is_src_layout: bool | None = None
93
+ self._package_dirs: list[Path] | None = None
94
+
95
+ # ── Public properties ─────────────────────────────────────────────
96
+
97
+ @property
98
+ def is_src_layout(self) -> bool:
99
+ """Return ``True`` if the project uses a ``src/`` layout."""
100
+ if self._is_src_layout is None:
101
+ self._discover()
102
+ assert self._is_src_layout is not None
103
+ return self._is_src_layout
104
+
105
+ @property
106
+ def package_dirs(self) -> list[Path]:
107
+ """Return all package directories that contain ``__init__.py``."""
108
+ if self._package_dirs is None:
109
+ self._discover()
110
+ assert self._package_dirs is not None
111
+ return list(self._package_dirs)
112
+
113
+ @property
114
+ def primary_package(self) -> Path | None:
115
+ """Return the main package directory.
116
+
117
+ If *package_name* was given at construction time and a matching
118
+ package exists, that package is returned. Otherwise the first
119
+ discovered package is returned (or ``None`` if no packages exist).
120
+ """
121
+ dirs = self.package_dirs
122
+ if not dirs:
123
+ return None
124
+
125
+ if self._package_name:
126
+ for d in dirs:
127
+ if d.name == self._package_name:
128
+ return d
129
+
130
+ return dirs[0]
131
+
132
+ # ── Public methods ────────────────────────────────────────────────
133
+
134
+ def python_files(self, *, include_tests: bool = False) -> list[Path]:
135
+ """Return all ``.py`` files inside discovered packages.
136
+
137
+ Args:
138
+ include_tests: When ``False`` (the default), files under
139
+ directories named ``tests`` or ``test`` are excluded.
140
+
141
+ Returns:
142
+ Sorted list of ``.py`` file paths.
143
+ """
144
+ result: list[Path] = []
145
+ for pkg_dir in self.package_dirs:
146
+ for py_file in pkg_dir.rglob("*.py"):
147
+ parts = set(py_file.relative_to(pkg_dir).parts)
148
+ # Always skip globally excluded dirs
149
+ if parts & EXCLUDED_DIRS:
150
+ continue
151
+ if not include_tests and parts & {"tests", "test"}:
152
+ continue
153
+ result.append(py_file)
154
+
155
+ # Also filter based on the full path parts (catches excluded dirs
156
+ # that sit *above* the package dir, e.g. a .venv ancestor).
157
+ result = [
158
+ f for f in result if not any(part in EXCLUDED_DIRS for part in f.parts)
159
+ ]
160
+ return sorted(result)
161
+
162
+ def local_package_names(self) -> set[str]:
163
+ """Return the names of all discovered local packages."""
164
+ return {d.name for d in self.package_dirs}
165
+
166
+ @property
167
+ def import_root(self) -> Path:
168
+ """Return the directory imports resolve from (``src/`` or the root)."""
169
+ if self._is_src_layout is None:
170
+ self._discover()
171
+ src_dir = self._root / "src"
172
+ return src_dir if (self._is_src_layout and src_dir.is_dir()) else self._root
173
+
174
+ def top_level_modules(self) -> list[Path]:
175
+ """Return single-file top-level modules at the import root.
176
+
177
+ Finds ``.py`` files directly under the import root (``src/`` for a
178
+ src-layout, otherwise the project root) that represent the package
179
+ itself — e.g. a single-module package ``foo.py`` — excluding common
180
+ tooling scripts (``setup.py``, ``conftest.py``, …). Used to support
181
+ single-module packages that have no package directory / ``__init__.py``.
182
+ """
183
+ base = self.import_root
184
+ if not base.is_dir():
185
+ return []
186
+ modules: list[Path] = []
187
+ for item in sorted(base.iterdir()):
188
+ if (
189
+ item.is_file()
190
+ and item.suffix == ".py"
191
+ and item.name not in NON_MODULE_FILES
192
+ and not item.name.startswith(".")
193
+ ):
194
+ modules.append(item)
195
+ return modules
196
+
197
+ # ── Internal discovery ────────────────────────────────────────────
198
+
199
+ def _discover(self) -> None:
200
+ """Scan the filesystem and populate internal caches."""
201
+ logger.debug("Discovering package layout under %s", self._root)
202
+
203
+ dirs: list[Path] = []
204
+ src_dir = self._root / "src"
205
+
206
+ if src_dir.is_dir():
207
+ self._is_src_layout = True
208
+ logger.debug("Detected src/ layout")
209
+ for item in sorted(src_dir.iterdir()):
210
+ if (
211
+ item.is_dir()
212
+ and not item.name.startswith((".", "_"))
213
+ and (item / "__init__.py").exists()
214
+ ):
215
+ logger.debug(" Found src-layout package: %s", item.name)
216
+ dirs.append(item)
217
+ else:
218
+ self._is_src_layout = False
219
+ logger.debug("No src/ directory; checking flat layout")
220
+ for item in sorted(self._root.iterdir()):
221
+ if (
222
+ item.is_dir()
223
+ and item.name not in NON_PACKAGE_DIRS
224
+ and not item.name.startswith(".")
225
+ and (item / "__init__.py").exists()
226
+ ):
227
+ logger.debug(" Found flat-layout package: %s", item.name)
228
+ dirs.append(item)
229
+
230
+ self._package_dirs = dirs
231
+ logger.debug(
232
+ "Discovery complete: %d package(s) found",
233
+ len(self._package_dirs),
234
+ )
pycmdcheck/profiles.py ADDED
@@ -0,0 +1,158 @@
1
+ """Check profiles for pycmdcheck.
2
+
3
+ Profiles define named sets of checks and configuration overrides
4
+ for common use cases like pyOpenSci onboarding or strict quality gates.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ # All checks that ship with pycmdcheck
13
+ ALL_CHECKS: frozenset[str] = frozenset(
14
+ {
15
+ "metadata",
16
+ "structure",
17
+ "tests",
18
+ "linting",
19
+ "typing",
20
+ "imports",
21
+ "license",
22
+ "docs",
23
+ "dependencies",
24
+ "build",
25
+ "formatting",
26
+ "version",
27
+ "py_typed",
28
+ # New checks
29
+ "community",
30
+ "ci",
31
+ "changelog",
32
+ "citation",
33
+ "python_versions",
34
+ "urls",
35
+ "doctests",
36
+ }
37
+ )
38
+
39
+ # Original 13 checks (pre-pyOpenSci)
40
+ ORIGINAL_CHECKS: frozenset[str] = frozenset(
41
+ {
42
+ "metadata",
43
+ "structure",
44
+ "tests",
45
+ "linting",
46
+ "typing",
47
+ "imports",
48
+ "license",
49
+ "docs",
50
+ "dependencies",
51
+ "build",
52
+ "formatting",
53
+ "version",
54
+ "py_typed",
55
+ }
56
+ )
57
+
58
+ # Checks that require installing the package, running external tools
59
+ # (pytest/ruff/mypy), or network access. These produce false results when run
60
+ # against an arbitrary repository that has NOT been installed (e.g. a CI triage
61
+ # bot), so they are excluded from the static-only "triage" profile.
62
+ DYNAMIC_CHECKS: frozenset[str] = frozenset(
63
+ {
64
+ "tests", # runs pytest
65
+ "imports", # resolves imports against the running interpreter
66
+ "dependencies", # resolves imports against the installed environment
67
+ "build", # builds the package
68
+ "linting", # runs ruff/flake8
69
+ "typing", # runs mypy/pyright
70
+ "formatting", # runs ruff/black
71
+ "doctests", # runs pytest --doctest
72
+ "urls", # validates links over the network
73
+ }
74
+ )
75
+
76
+ # Static, no-install, no-network checks — safe to run against an arbitrary
77
+ # submitted repository without building or installing it.
78
+ STATIC_CHECKS: frozenset[str] = ALL_CHECKS - DYNAMIC_CHECKS
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class Profile:
83
+ """A named profile defining which checks to run and configuration."""
84
+
85
+ name: str
86
+ description: str
87
+ checks: frozenset[str]
88
+ config_overrides: dict[str, dict[str, Any]] = field(default_factory=dict)
89
+
90
+
91
+ PROFILES: dict[str, Profile] = {
92
+ "minimal": Profile(
93
+ name="minimal",
94
+ description="Quick sanity checks (metadata, structure, license)",
95
+ checks=frozenset({"metadata", "structure", "license"}),
96
+ ),
97
+ "triage": Profile(
98
+ name="triage",
99
+ description=(
100
+ "Static packaging/docs checks only — no install, external tools, "
101
+ "or network (for CI triage of arbitrary repositories)"
102
+ ),
103
+ checks=STATIC_CHECKS,
104
+ config_overrides={
105
+ "docs": {
106
+ "require_readme": True,
107
+ "check_readme_sections": True,
108
+ },
109
+ },
110
+ ),
111
+ "default": Profile(
112
+ name="default",
113
+ description="Standard checks (original 13 built-in checks)",
114
+ checks=ORIGINAL_CHECKS,
115
+ ),
116
+ "pyopensci": Profile(
117
+ name="pyopensci",
118
+ description="pyOpenSci onboarding requirements",
119
+ checks=ALL_CHECKS,
120
+ config_overrides={
121
+ "docs": {
122
+ "require_readme": True,
123
+ "check_docstrings": True,
124
+ "check_readme_sections": True,
125
+ },
126
+ },
127
+ ),
128
+ "strict": Profile(
129
+ name="strict",
130
+ description="All checks at maximum strictness",
131
+ checks=ALL_CHECKS,
132
+ config_overrides={
133
+ "docs": {
134
+ "require_readme": True,
135
+ "check_docstrings": True,
136
+ "check_readme_sections": True,
137
+ },
138
+ "typing": {"strict": True},
139
+ },
140
+ ),
141
+ }
142
+
143
+
144
+ def get_profile(name: str) -> Profile | None:
145
+ """Look up a profile by name.
146
+
147
+ Args:
148
+ name: Profile name (minimal, default, pyopensci, strict).
149
+
150
+ Returns:
151
+ The Profile, or None if not found.
152
+ """
153
+ return PROFILES.get(name)
154
+
155
+
156
+ def list_profiles() -> list[tuple[str, str]]:
157
+ """Return all profiles as (name, description) pairs."""
158
+ return [(p.name, p.description) for p in PROFILES.values()]
pycmdcheck/py.typed ADDED
File without changes