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,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
|
+
}
|