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,226 @@
1
+ """Cached pyproject.toml reader utility.
2
+
3
+ Provides a single, cached entry point for reading ``pyproject.toml`` files.
4
+ This replaces the duplicated TOML-parsing logic that previously lived in
5
+ ``config.py``, ``metadata.py``, and ``structure.py``. The
6
+ :func:`read_pyproject` function is decorated with :func:`functools.lru_cache`
7
+ so each file is read from disk at most once per ``package_path`` per run.
8
+
9
+ Examples:
10
+ >>> from pathlib import Path
11
+ >>> from pycmdcheck.pyproject_reader import read_pyproject, get_project_table
12
+ >>> data = read_pyproject(Path("."))
13
+ >>> project = get_project_table(Path("."))
14
+ >>> project.get("name")
15
+ 'pycmdcheck'
16
+ """
17
+
18
+ import re
19
+ import sys
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ if sys.version_info >= (3, 11):
25
+ import tomllib
26
+ else:
27
+ import tomli as tomllib
28
+
29
+
30
+ @lru_cache(maxsize=8)
31
+ def read_pyproject(package_path: Path) -> dict[str, Any] | None:
32
+ """Read and parse ``pyproject.toml`` from *package_path*.
33
+
34
+ The result is cached (by ``package_path``) so repeated calls for the
35
+ same path return the identical dict object without re-reading the file.
36
+
37
+ Args:
38
+ package_path: Directory that contains ``pyproject.toml``.
39
+
40
+ Returns:
41
+ Parsed dictionary, or ``None`` if the file does not exist.
42
+
43
+ Raises:
44
+ tomllib.TOMLDecodeError: If the file exists but contains invalid TOML.
45
+ """
46
+ pyproject_path = package_path / "pyproject.toml"
47
+
48
+ if not pyproject_path.exists():
49
+ return None
50
+
51
+ with open(pyproject_path, "rb") as f:
52
+ return tomllib.load(f)
53
+
54
+
55
+ def get_project_table(package_path: Path) -> dict[str, Any]:
56
+ """Return the ``[project]`` table from ``pyproject.toml``.
57
+
58
+ Args:
59
+ package_path: Directory that contains ``pyproject.toml``.
60
+
61
+ Returns:
62
+ The ``[project]`` table as a dict, or an empty dict if the file
63
+ is missing or the table is absent.
64
+ """
65
+ data = read_pyproject(package_path)
66
+ if data is None:
67
+ return {}
68
+ project = data.get("project", {})
69
+ return project if isinstance(project, dict) else {}
70
+
71
+
72
+ def get_tool_table(package_path: Path, tool_name: str) -> dict[str, Any]:
73
+ """Return the ``[tool.<tool_name>]`` table from ``pyproject.toml``.
74
+
75
+ Args:
76
+ package_path: Directory that contains ``pyproject.toml``.
77
+ tool_name: Name of the tool whose configuration to retrieve
78
+ (e.g. ``"pycmdcheck"``, ``"ruff"``).
79
+
80
+ Returns:
81
+ The ``[tool.<tool_name>]`` table as a dict, or an empty dict if
82
+ the file is missing or the table is absent.
83
+ """
84
+ data = read_pyproject(package_path)
85
+ if data is None:
86
+ return {}
87
+ tool = data.get("tool", {})
88
+ if not isinstance(tool, dict):
89
+ return {}
90
+ table = tool.get(tool_name, {})
91
+ return table if isinstance(table, dict) else {}
92
+
93
+
94
+ _AUTHOR_RE = re.compile(r"^\s*(?P<name>.*?)\s*(?:<(?P<email>[^>]+)>)?\s*$")
95
+
96
+
97
+ def _parse_poetry_authors(authors: Any) -> list[dict[str, str]]:
98
+ """Convert Poetry ``authors`` (``"Name <email>"`` strings) to PEP 621 form."""
99
+ if not isinstance(authors, list):
100
+ return []
101
+ result: list[dict[str, str]] = []
102
+ for entry in authors:
103
+ if not isinstance(entry, str):
104
+ continue
105
+ m = _AUTHOR_RE.match(entry)
106
+ if not m:
107
+ continue
108
+ person: dict[str, str] = {}
109
+ name = (m.group("name") or "").strip()
110
+ email = (m.group("email") or "").strip()
111
+ if name:
112
+ person["name"] = name
113
+ if email:
114
+ person["email"] = email
115
+ if person:
116
+ result.append(person)
117
+ return result
118
+
119
+
120
+ def poetry_python_to_pep440(constraint: str) -> str | None:
121
+ """Translate a Poetry ``python`` constraint to a PEP 440 specifier.
122
+
123
+ Poetry uses caret (``^3.9``) and tilde (``~3.9``) operators that PEP 440
124
+ does not. This produces a best-effort PEP 440 specifier preserving the
125
+ lower bound (which is what the EOL check cares about). PEP 440 operators
126
+ (``>=``, ``<``, ``==``, ``!=``, comma-separated) and bare versions are
127
+ handled too. Returns ``None`` for an unusable value.
128
+ """
129
+ if not isinstance(constraint, str):
130
+ return None
131
+ text = constraint.strip()
132
+ if not text:
133
+ return None
134
+ # Caret/tilde: keep the lower bound (e.g. ^3.9 / ~3.9 -> >=3.9).
135
+ m = re.match(r"^[\^~]\s*(\d+(?:\.\d+)*)$", text)
136
+ if m:
137
+ return f">={m.group(1)}"
138
+ # Already a PEP 440 operator (or comma-separated set): pass through.
139
+ if re.search(r"[<>=!~]", text):
140
+ return text
141
+ # Bare version (e.g. "3.9"): treat as a lower bound.
142
+ if re.match(r"^\d+(?:\.\d+)*$", text):
143
+ return f">={text}"
144
+ return None
145
+
146
+
147
+ def _synthesize_from_poetry(poetry: dict[str, Any]) -> dict[str, Any]:
148
+ """Build a PEP 621-shaped ``[project]`` dict from a ``[tool.poetry]`` table."""
149
+ project: dict[str, Any] = {}
150
+
151
+ for key in ("name", "version", "description", "readme", "license", "keywords"):
152
+ if key in poetry:
153
+ project[key] = poetry[key]
154
+
155
+ if isinstance(poetry.get("classifiers"), list):
156
+ project["classifiers"] = poetry["classifiers"]
157
+
158
+ authors = _parse_poetry_authors(poetry.get("authors"))
159
+ if authors:
160
+ project["authors"] = authors
161
+ maintainers = _parse_poetry_authors(poetry.get("maintainers"))
162
+ if maintainers:
163
+ project["maintainers"] = maintainers
164
+
165
+ # requires-python lives under [tool.poetry.dependencies].python in Poetry.
166
+ deps = poetry.get("dependencies", {})
167
+ if isinstance(deps, dict):
168
+ py = deps.get("python")
169
+ if isinstance(py, str):
170
+ translated = poetry_python_to_pep440(py)
171
+ if translated:
172
+ project["requires-python"] = translated
173
+ dep_names = [name for name in deps if name != "python"]
174
+ if dep_names:
175
+ project["dependencies"] = dep_names
176
+
177
+ # urls: dedicated keys plus an optional [tool.poetry.urls] table.
178
+ urls: dict[str, str] = {}
179
+ for label, key in (
180
+ ("Homepage", "homepage"),
181
+ ("Repository", "repository"),
182
+ ("Documentation", "documentation"),
183
+ ):
184
+ value = poetry.get(key)
185
+ if isinstance(value, str):
186
+ urls[label] = value
187
+ extra_urls = poetry.get("urls")
188
+ if isinstance(extra_urls, dict):
189
+ for label, value in extra_urls.items():
190
+ if isinstance(value, str):
191
+ urls[label] = value
192
+ if urls:
193
+ project["urls"] = urls
194
+
195
+ return project
196
+
197
+
198
+ def get_effective_project_table(package_path: Path) -> dict[str, Any]:
199
+ """Return a PEP 621-shaped ``[project]`` table, accounting for legacy Poetry.
200
+
201
+ If a PEP 621 ``[project]`` table is present (even minimally), it is returned
202
+ unchanged. Otherwise, if a legacy ``[tool.poetry]`` table is present (the
203
+ metadata format used by Poetry projects before Poetry 2.0), its fields are
204
+ normalized into a PEP 621-shaped dict so the checks can treat both layouts
205
+ uniformly. Returns an empty dict if neither is present.
206
+ """
207
+ data = read_pyproject(package_path)
208
+ if data is None:
209
+ return {}
210
+ project = data.get("project")
211
+ if isinstance(project, dict) and project:
212
+ return project
213
+ tool = data.get("tool", {})
214
+ poetry = tool.get("poetry") if isinstance(tool, dict) else None
215
+ if isinstance(poetry, dict) and poetry:
216
+ return _synthesize_from_poetry(poetry)
217
+ return {}
218
+
219
+
220
+ def clear_cache() -> None:
221
+ """Clear the :func:`read_pyproject` LRU cache.
222
+
223
+ Call this between test runs or when the underlying ``pyproject.toml``
224
+ may have changed on disk.
225
+ """
226
+ read_pyproject.cache_clear()
pycmdcheck/results.py ADDED
@@ -0,0 +1,320 @@
1
+ """Result types for pycmdcheck."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import Any
6
+
7
+
8
+ class CheckStatus(Enum):
9
+ """Status of a check result.
10
+
11
+ Each check returns one of these statuses to indicate
12
+ whether the check passed, failed, or encountered issues.
13
+
14
+ Attributes:
15
+ OK: Check passed successfully.
16
+ NOTE: Informational message, not a problem.
17
+ WARNING: Potential issue that should be reviewed.
18
+ ERROR: Check failed.
19
+ SKIPPED: Check was skipped (e.g., required tool not installed).
20
+
21
+ Examples:
22
+ >>> status = CheckStatus.OK
23
+ >>> status.symbol
24
+ '✓'
25
+ >>> status.color
26
+ 'green'
27
+ >>> str(status)
28
+ 'ok'
29
+ """
30
+
31
+ OK = "ok"
32
+ NOTE = "note"
33
+ WARNING = "warning"
34
+ ERROR = "error"
35
+ SKIPPED = "skipped"
36
+
37
+ def __str__(self) -> str:
38
+ """Return the string value of the status.
39
+
40
+ Returns:
41
+ The status value as a lowercase string.
42
+ """
43
+ return self.value
44
+
45
+ @property
46
+ def symbol(self) -> str:
47
+ """Return a visual symbol for the status.
48
+
49
+ Returns:
50
+ A unicode symbol representing the status:
51
+ ✓ for OK, ℹ for NOTE, ⚠ for WARNING, ✗ for ERROR, ○ for SKIPPED.
52
+
53
+ Examples:
54
+ >>> CheckStatus.OK.symbol
55
+ '✓'
56
+ >>> CheckStatus.ERROR.symbol
57
+ '✗'
58
+ """
59
+ symbols = {
60
+ CheckStatus.OK: "✓",
61
+ CheckStatus.NOTE: "ℹ",
62
+ CheckStatus.WARNING: "⚠",
63
+ CheckStatus.ERROR: "✗",
64
+ CheckStatus.SKIPPED: "○",
65
+ }
66
+ return symbols[self]
67
+
68
+ @property
69
+ def color(self) -> str:
70
+ """Return a Rich library color name for the status.
71
+
72
+ Returns:
73
+ A color name compatible with the Rich library:
74
+ green, blue, yellow, red, or dim.
75
+
76
+ Examples:
77
+ >>> CheckStatus.OK.color
78
+ 'green'
79
+ >>> CheckStatus.ERROR.color
80
+ 'red'
81
+ """
82
+ colors = {
83
+ CheckStatus.OK: "green",
84
+ CheckStatus.NOTE: "blue",
85
+ CheckStatus.WARNING: "yellow",
86
+ CheckStatus.ERROR: "red",
87
+ CheckStatus.SKIPPED: "dim",
88
+ }
89
+ return colors[self]
90
+
91
+
92
+ @dataclass
93
+ class CheckResult:
94
+ """Result of a single check.
95
+
96
+ Contains the outcome of running a check, including the status,
97
+ a human-readable message, and optional details.
98
+
99
+ Attributes:
100
+ name: Name of the check that produced this result (e.g., "metadata").
101
+ status: The status of the check (OK, WARNING, ERROR, etc.).
102
+ message: Human-readable message describing the result.
103
+ details: Optional list of detailed messages providing more context.
104
+ duration: Time taken to run the check in seconds.
105
+
106
+ Examples:
107
+ Create a successful check result:
108
+
109
+ >>> result = CheckResult(
110
+ ... name="metadata",
111
+ ... status=CheckStatus.OK,
112
+ ... message="Package metadata is valid",
113
+ ... )
114
+ >>> result.status
115
+ <CheckStatus.OK: 'ok'>
116
+
117
+ Create a result with details:
118
+
119
+ >>> result = CheckResult(
120
+ ... name="linting",
121
+ ... status=CheckStatus.WARNING,
122
+ ... message="Found 3 linting issues",
123
+ ... details=["Line 10: E501 line too long", "Line 20: W503 line break"],
124
+ ... )
125
+ >>> print(result)
126
+ ⚠ linting: Found 3 linting issues
127
+ """
128
+
129
+ name: str
130
+ status: CheckStatus
131
+ message: str
132
+ details: list[str] = field(default_factory=list)
133
+ duration: float = 0.0
134
+
135
+ def __str__(self) -> str:
136
+ """Return a formatted string representation.
137
+
138
+ Returns:
139
+ A string in the format "symbol name: message".
140
+
141
+ Examples:
142
+ >>> result = CheckResult("test", CheckStatus.OK, "All tests passed")
143
+ >>> str(result)
144
+ '✓ test: All tests passed'
145
+ """
146
+ return f"{self.status.symbol} {self.name}: {self.message}"
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ """Convert to dictionary for JSON serialization.
150
+
151
+ Returns:
152
+ A dictionary with keys: name, status, message, details, duration.
153
+ The status is converted to its string value.
154
+
155
+ Examples:
156
+ >>> result = CheckResult("test", CheckStatus.OK, "Passed")
157
+ >>> result.to_dict() # doctest: +NORMALIZE_WHITESPACE
158
+ {'name': 'test', 'status': 'ok', 'message': 'Passed',
159
+ 'details': [], 'duration': 0.0}
160
+ """
161
+ return {
162
+ "name": self.name,
163
+ "status": self.status.value,
164
+ "message": self.message,
165
+ "details": self.details,
166
+ "duration": self.duration,
167
+ }
168
+
169
+
170
+ @dataclass
171
+ class Report:
172
+ """Collection of check results for a package.
173
+
174
+ A Report aggregates multiple CheckResult objects and provides
175
+ methods for querying the overall status and summarizing results.
176
+
177
+ Attributes:
178
+ results: List of CheckResult objects from running checks.
179
+ package_path: Path to the package that was checked.
180
+
181
+ Examples:
182
+ Create a report and add results:
183
+
184
+ >>> report = Report(package_path="/path/to/package")
185
+ >>> report.add(CheckResult("metadata", CheckStatus.OK, "Valid"))
186
+ >>> report.add(CheckResult("tests", CheckStatus.OK, "Passed"))
187
+ >>> report.passed
188
+ True
189
+
190
+ Check for specific failure conditions:
191
+
192
+ >>> report.failed_on(["error"])
193
+ False
194
+ >>> report.failed_on(["error", "warning"])
195
+ False
196
+ """
197
+
198
+ results: list[CheckResult] = field(default_factory=list)
199
+ package_path: str = ""
200
+
201
+ def add(self, result: CheckResult) -> None:
202
+ """Add a check result to the report.
203
+
204
+ Args:
205
+ result: The CheckResult to add.
206
+
207
+ Examples:
208
+ >>> report = Report()
209
+ >>> report.add(CheckResult("test", CheckStatus.OK, "Passed"))
210
+ >>> len(report.results)
211
+ 1
212
+ """
213
+ self.results.append(result)
214
+
215
+ @property
216
+ def passed(self) -> bool:
217
+ """Check if all checks passed (no errors).
218
+
219
+ Returns:
220
+ True if no check has ERROR status, False otherwise.
221
+
222
+ Examples:
223
+ >>> report = Report()
224
+ >>> report.add(CheckResult("test", CheckStatus.OK, "Passed"))
225
+ >>> report.passed
226
+ True
227
+ >>> report.add(CheckResult("lint", CheckStatus.ERROR, "Failed"))
228
+ >>> report.passed
229
+ False
230
+ """
231
+ return not any(r.status == CheckStatus.ERROR for r in self.results)
232
+
233
+ @property
234
+ def has_warnings(self) -> bool:
235
+ """Check if any check has warnings.
236
+
237
+ Returns:
238
+ True if any check has WARNING status, False otherwise.
239
+
240
+ Examples:
241
+ >>> report = Report()
242
+ >>> report.add(CheckResult("test", CheckStatus.OK, "Passed"))
243
+ >>> report.has_warnings
244
+ False
245
+ >>> report.add(CheckResult("lint", CheckStatus.WARNING, "Issues"))
246
+ >>> report.has_warnings
247
+ True
248
+ """
249
+ return any(r.status == CheckStatus.WARNING for r in self.results)
250
+
251
+ def count_by_status(self) -> dict[CheckStatus, int]:
252
+ """Count results by status.
253
+
254
+ Returns:
255
+ A dictionary mapping each CheckStatus to the count of results
256
+ with that status.
257
+
258
+ Examples:
259
+ >>> report = Report()
260
+ >>> report.add(CheckResult("a", CheckStatus.OK, "OK"))
261
+ >>> report.add(CheckResult("b", CheckStatus.OK, "OK"))
262
+ >>> report.add(CheckResult("c", CheckStatus.WARNING, "Warn"))
263
+ >>> counts = report.count_by_status()
264
+ >>> counts[CheckStatus.OK]
265
+ 2
266
+ >>> counts[CheckStatus.WARNING]
267
+ 1
268
+ """
269
+ counts: dict[CheckStatus, int] = dict.fromkeys(CheckStatus, 0)
270
+ for result in self.results:
271
+ counts[result.status] += 1
272
+ return counts
273
+
274
+ def failed_on(self, fail_on: list[str]) -> bool:
275
+ """Check if any result matches the fail_on criteria.
276
+
277
+ Args:
278
+ fail_on: List of status values to consider as failures
279
+ (e.g., ["error", "warning"]).
280
+
281
+ Returns:
282
+ True if any result has a status in the fail_on list.
283
+
284
+ Examples:
285
+ >>> report = Report()
286
+ >>> report.add(CheckResult("test", CheckStatus.WARNING, "Warn"))
287
+ >>> report.failed_on(["error"])
288
+ False
289
+ >>> report.failed_on(["error", "warning"])
290
+ True
291
+ """
292
+ fail_statuses = {CheckStatus(s) for s in fail_on}
293
+ return any(r.status in fail_statuses for r in self.results)
294
+
295
+ def to_dict(self) -> dict[str, Any]:
296
+ """Convert to dictionary for JSON serialization.
297
+
298
+ Returns:
299
+ A dictionary containing:
300
+ - package_path: The path that was checked
301
+ - passed: Whether all checks passed
302
+ - summary: Count of each status
303
+ - results: List of result dictionaries
304
+
305
+ Examples:
306
+ >>> report = Report(package_path="/pkg")
307
+ >>> report.add(CheckResult("test", CheckStatus.OK, "OK"))
308
+ >>> data = report.to_dict()
309
+ >>> data["passed"]
310
+ True
311
+ >>> data["summary"]["ok"]
312
+ 1
313
+ """
314
+ counts = self.count_by_status()
315
+ return {
316
+ "package_path": self.package_path,
317
+ "passed": self.passed,
318
+ "summary": {status.value: count for status, count in counts.items()},
319
+ "results": [r.to_dict() for r in self.results],
320
+ }