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
pycmdcheck/__init__.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""pycmdcheck - Python package quality checker.
|
|
2
|
+
|
|
3
|
+
A Python equivalent of R's `R CMD check` for validating Python packages.
|
|
4
|
+
|
|
5
|
+
Example usage:
|
|
6
|
+
|
|
7
|
+
from pycmdcheck import check
|
|
8
|
+
|
|
9
|
+
# Check current directory
|
|
10
|
+
report = check(".")
|
|
11
|
+
|
|
12
|
+
# Check specific package
|
|
13
|
+
report = check("/path/to/package")
|
|
14
|
+
|
|
15
|
+
# Check with options
|
|
16
|
+
report = check(
|
|
17
|
+
".",
|
|
18
|
+
checks=["metadata", "tests"],
|
|
19
|
+
skip=["linting"],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Check results
|
|
23
|
+
if report.passed:
|
|
24
|
+
print("All checks passed!")
|
|
25
|
+
else:
|
|
26
|
+
for result in report.results:
|
|
27
|
+
print(f"{result.status}: {result.name} - {result.message}")
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from importlib.metadata import PackageNotFoundError
|
|
31
|
+
from importlib.metadata import version as _get_version
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from pycmdcheck.results import CheckResult, CheckStatus, Report
|
|
35
|
+
from pycmdcheck.runner import run_checks
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
__version__ = _get_version("pycmdcheck")
|
|
39
|
+
except PackageNotFoundError:
|
|
40
|
+
__version__ = "0.0.0"
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"check",
|
|
44
|
+
"run_checks",
|
|
45
|
+
"CheckResult",
|
|
46
|
+
"CheckStatus",
|
|
47
|
+
"Report",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check(
|
|
53
|
+
path: str = ".",
|
|
54
|
+
*,
|
|
55
|
+
checks: list[str] | None = None,
|
|
56
|
+
skip: list[str] | None = None,
|
|
57
|
+
config: dict[str, Any] | None = None,
|
|
58
|
+
parallel: bool = True,
|
|
59
|
+
fail_fast: bool = False,
|
|
60
|
+
) -> Report:
|
|
61
|
+
"""Check a Python package for quality issues.
|
|
62
|
+
|
|
63
|
+
This is the main entry point for programmatic use of pycmdcheck.
|
|
64
|
+
It discovers available checks via entry points, loads configuration
|
|
65
|
+
from pyproject.toml, and runs the enabled checks.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
path: Path to the package directory. Defaults to current directory.
|
|
69
|
+
checks: List of check names to run. If None, runs all enabled checks
|
|
70
|
+
from configuration. Available checks: build, dependencies, docs,
|
|
71
|
+
formatting, imports, license, linting, metadata, py_typed,
|
|
72
|
+
structure, tests, typing, version.
|
|
73
|
+
skip: List of check names to skip, even if enabled in configuration.
|
|
74
|
+
config: Pre-loaded configuration dictionary. If None, configuration
|
|
75
|
+
is loaded from the package's pyproject.toml.
|
|
76
|
+
parallel: Whether to run checks in parallel. Defaults to True.
|
|
77
|
+
fail_fast: Whether to stop after the first ERROR. Defaults to False.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A Report object containing all check results. Use report.passed to
|
|
81
|
+
check if all checks passed, or iterate report.results for details.
|
|
82
|
+
|
|
83
|
+
Examples:
|
|
84
|
+
Basic usage - check current directory:
|
|
85
|
+
|
|
86
|
+
>>> from pycmdcheck import check # doctest: +SKIP
|
|
87
|
+
>>> report = check(".")
|
|
88
|
+
>>> report.passed
|
|
89
|
+
True
|
|
90
|
+
|
|
91
|
+
Run only specific checks:
|
|
92
|
+
|
|
93
|
+
>>> report = check(".", checks=["metadata", "tests"]) # doctest: +SKIP
|
|
94
|
+
"""
|
|
95
|
+
report = run_checks(
|
|
96
|
+
package_path=path,
|
|
97
|
+
checks=checks,
|
|
98
|
+
skip=skip,
|
|
99
|
+
config=config,
|
|
100
|
+
parallel=parallel,
|
|
101
|
+
fail_fast=fail_fast,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return report
|
pycmdcheck/__main__.py
ADDED
pycmdcheck/ast_cache.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Thread-safe AST parse cache for pycmdcheck.
|
|
2
|
+
|
|
3
|
+
Multiple checks parse the same Python files independently. This module
|
|
4
|
+
provides a shared cache so each file is parsed at most once per run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_cache: dict[Path, ast.Module | None] = {}
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_file(path: Path) -> ast.Module | None:
|
|
21
|
+
"""Parse a Python file, returning cached result if available.
|
|
22
|
+
|
|
23
|
+
Returns None if the file cannot be read or parsed.
|
|
24
|
+
"""
|
|
25
|
+
resolved = path.resolve()
|
|
26
|
+
with _lock:
|
|
27
|
+
if resolved in _cache:
|
|
28
|
+
return _cache[resolved]
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
with open(resolved, encoding="utf-8") as f:
|
|
32
|
+
tree = ast.parse(f.read(), filename=str(resolved))
|
|
33
|
+
except (SyntaxError, UnicodeDecodeError, OSError) as exc:
|
|
34
|
+
logger.debug("Failed to parse %s: %s", resolved, exc)
|
|
35
|
+
tree = None
|
|
36
|
+
|
|
37
|
+
with _lock:
|
|
38
|
+
_cache[resolved] = tree
|
|
39
|
+
return tree
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def clear_cache() -> None:
|
|
43
|
+
"""Clear the AST cache (call between test runs)."""
|
|
44
|
+
with _lock:
|
|
45
|
+
_cache.clear()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in checks for pycmdcheck."""
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Base check protocol and abstract class."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from pycmdcheck.results import CheckResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Check(Protocol):
|
|
12
|
+
"""Protocol defining the interface for all checks.
|
|
13
|
+
|
|
14
|
+
This protocol defines what a check must implement to be usable
|
|
15
|
+
by pycmdcheck. Checks can be implemented by either:
|
|
16
|
+
|
|
17
|
+
1. Inheriting from BaseCheck (recommended for most cases)
|
|
18
|
+
2. Implementing this Protocol directly (for structural subtyping)
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
name: Unique identifier for the check (e.g., "metadata", "tests").
|
|
22
|
+
description: Human-readable description of what the check does.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
Implement a check using the protocol directly:
|
|
26
|
+
|
|
27
|
+
>>> class MyCheck:
|
|
28
|
+
... name = "my_check"
|
|
29
|
+
... description = "My custom check"
|
|
30
|
+
...
|
|
31
|
+
... def run(self, package_path, config):
|
|
32
|
+
... return CheckResult(
|
|
33
|
+
... name=self.name,
|
|
34
|
+
... status=CheckStatus.OK,
|
|
35
|
+
... message="Check passed",
|
|
36
|
+
... )
|
|
37
|
+
>>> isinstance(MyCheck(), Check)
|
|
38
|
+
True
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
description: str
|
|
43
|
+
|
|
44
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
45
|
+
"""Execute the check and return result.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
package_path: Path to the package directory to check.
|
|
49
|
+
config: Configuration dictionary for this check, loaded from
|
|
50
|
+
pyproject.toml [tool.pycmdcheck.checks.<name>] section.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A CheckResult containing the status, message, and optional details.
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BaseCheck(ABC):
|
|
59
|
+
"""Abstract base class for implementing checks.
|
|
60
|
+
|
|
61
|
+
This is the recommended way to implement custom checks. Subclasses
|
|
62
|
+
must override the `name`, `description` attributes and implement
|
|
63
|
+
the `run` method.
|
|
64
|
+
|
|
65
|
+
Attributes:
|
|
66
|
+
name: Unique identifier for the check. Override in subclass.
|
|
67
|
+
description: Human-readable description. Override in subclass.
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
Create a custom check:
|
|
71
|
+
|
|
72
|
+
>>> from pycmdcheck.checks.base import BaseCheck
|
|
73
|
+
>>> from pycmdcheck.results import CheckResult, CheckStatus
|
|
74
|
+
>>>
|
|
75
|
+
>>> class CopyrightCheck(BaseCheck):
|
|
76
|
+
... name = "copyright"
|
|
77
|
+
... description = "Check for copyright headers"
|
|
78
|
+
...
|
|
79
|
+
... def run(self, package_path, config):
|
|
80
|
+
... # Check logic here
|
|
81
|
+
... return CheckResult(
|
|
82
|
+
... name=self.name,
|
|
83
|
+
... status=CheckStatus.OK,
|
|
84
|
+
... message="All files have copyright headers",
|
|
85
|
+
... )
|
|
86
|
+
|
|
87
|
+
Register via entry points in pyproject.toml:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[project.entry-points."pycmdcheck.checks"]
|
|
91
|
+
copyright = "my_package.checks:CopyrightCheck"
|
|
92
|
+
```
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
name: str = "base"
|
|
96
|
+
description: str = "Base check"
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
100
|
+
"""Execute the check and return result.
|
|
101
|
+
|
|
102
|
+
Subclasses must implement this method to perform the actual check.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
package_path: Path to the package directory to check. This is
|
|
106
|
+
always an absolute, resolved path.
|
|
107
|
+
config: Configuration dictionary for this check. Contains any
|
|
108
|
+
options specified in pyproject.toml under
|
|
109
|
+
[tool.pycmdcheck.checks.<name>]. Common keys include
|
|
110
|
+
"enabled" (bool) and check-specific options.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
A CheckResult with:
|
|
114
|
+
- name: Should match self.name
|
|
115
|
+
- status: One of CheckStatus values (OK, WARNING, ERROR, etc.)
|
|
116
|
+
- message: Brief description of the result
|
|
117
|
+
- details: Optional list of specific issues found
|
|
118
|
+
|
|
119
|
+
Examples:
|
|
120
|
+
>>> def run(self, package_path, config):
|
|
121
|
+
... issues = self._find_issues(package_path)
|
|
122
|
+
... if issues:
|
|
123
|
+
... return CheckResult(
|
|
124
|
+
... name=self.name,
|
|
125
|
+
... status=CheckStatus.WARNING,
|
|
126
|
+
... message=f"Found {len(issues)} issues",
|
|
127
|
+
... details=issues,
|
|
128
|
+
... )
|
|
129
|
+
... return CheckResult(
|
|
130
|
+
... name=self.name,
|
|
131
|
+
... status=CheckStatus.OK,
|
|
132
|
+
... message="No issues found",
|
|
133
|
+
... )
|
|
134
|
+
"""
|
|
135
|
+
...
|
|
136
|
+
|
|
137
|
+
def __repr__(self) -> str:
|
|
138
|
+
"""Return string representation of the check.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
A string in the format "<ClassName: check_name>".
|
|
142
|
+
"""
|
|
143
|
+
return f"<{self.__class__.__name__}: {self.name}>"
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Build check — verifies the package can be built."""
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
9
|
+
from pycmdcheck.constants import DEFAULT_TIMEOUT, MAX_DETAIL_LINES
|
|
10
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
11
|
+
from pycmdcheck.subprocess_runner import run_tool, sanitize_args
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BuildCheck(BaseCheck):
|
|
15
|
+
"""Check that the package can be built into a wheel."""
|
|
16
|
+
|
|
17
|
+
name = "build"
|
|
18
|
+
description = "Verify package builds successfully"
|
|
19
|
+
|
|
20
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
21
|
+
"""Attempt to build the package."""
|
|
22
|
+
# Check if build module is available
|
|
23
|
+
if importlib.util.find_spec("build") is None:
|
|
24
|
+
return CheckResult(
|
|
25
|
+
name=self.name,
|
|
26
|
+
status=CheckStatus.SKIPPED,
|
|
27
|
+
message="'build' module not installed",
|
|
28
|
+
details=["Install it: pip install build"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Check pyproject.toml exists
|
|
32
|
+
if not (package_path / "pyproject.toml").exists():
|
|
33
|
+
return CheckResult(
|
|
34
|
+
name=self.name,
|
|
35
|
+
status=CheckStatus.ERROR,
|
|
36
|
+
message="No pyproject.toml found",
|
|
37
|
+
details=["Cannot build without pyproject.toml"],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
timeout = config.get("timeout", DEFAULT_TIMEOUT)
|
|
41
|
+
args = sanitize_args(config.get("args", []))
|
|
42
|
+
|
|
43
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
44
|
+
cmd = ["python", "-m", "build", "--outdir", tmpdir, *args]
|
|
45
|
+
result = run_tool(cmd, cwd=package_path, timeout=timeout)
|
|
46
|
+
|
|
47
|
+
if result.timed_out:
|
|
48
|
+
return CheckResult(
|
|
49
|
+
name=self.name,
|
|
50
|
+
status=CheckStatus.ERROR,
|
|
51
|
+
message="Build timed out",
|
|
52
|
+
details=[f"Build took longer than {timeout} seconds"],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if result.error is not None:
|
|
56
|
+
return CheckResult(
|
|
57
|
+
name=self.name,
|
|
58
|
+
status=CheckStatus.ERROR,
|
|
59
|
+
message="Failed to run build",
|
|
60
|
+
details=[result.error],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
if result.success:
|
|
64
|
+
# Verify wheel was created
|
|
65
|
+
wheels = list(Path(tmpdir).glob("*.whl"))
|
|
66
|
+
sdists = list(Path(tmpdir).glob("*.tar.gz"))
|
|
67
|
+
details = []
|
|
68
|
+
if wheels:
|
|
69
|
+
details.append(f"Built wheel: {wheels[0].name}")
|
|
70
|
+
if sdists:
|
|
71
|
+
details.append(f"Built sdist: {sdists[0].name}")
|
|
72
|
+
|
|
73
|
+
if wheels or sdists:
|
|
74
|
+
return CheckResult(
|
|
75
|
+
name=self.name,
|
|
76
|
+
status=CheckStatus.OK,
|
|
77
|
+
message="Package built successfully",
|
|
78
|
+
details=details,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Build command succeeded but produced no artifacts
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name=self.name,
|
|
84
|
+
status=CheckStatus.WARNING,
|
|
85
|
+
message="Build succeeded but no artifacts found",
|
|
86
|
+
details=["No .whl or .tar.gz files were produced"],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Build failed (non-zero exit code)
|
|
90
|
+
output_lines = result.output_lines()
|
|
91
|
+
details = (
|
|
92
|
+
output_lines[-MAX_DETAIL_LINES:]
|
|
93
|
+
if output_lines
|
|
94
|
+
else ["No output captured"]
|
|
95
|
+
)
|
|
96
|
+
return CheckResult(
|
|
97
|
+
name=self.name,
|
|
98
|
+
status=CheckStatus.ERROR,
|
|
99
|
+
message="Build failed",
|
|
100
|
+
details=details,
|
|
101
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Changelog file check.
|
|
2
|
+
|
|
3
|
+
Verifies that a package has a changelog or news file documenting
|
|
4
|
+
changes across releases.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.constants import MIN_CHANGELOG_LENGTH
|
|
12
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChangelogCheck(BaseCheck):
|
|
16
|
+
"""Check for changelog or news file.
|
|
17
|
+
|
|
18
|
+
Searches for common changelog file names and verifies
|
|
19
|
+
the file has meaningful content.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: The check identifier ("changelog").
|
|
23
|
+
description: Human-readable description of this check.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name = "changelog"
|
|
27
|
+
description = "Check for changelog or news file"
|
|
28
|
+
|
|
29
|
+
CHANGELOG_FILENAMES = [
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"CHANGELOG.rst",
|
|
32
|
+
"CHANGELOG.txt",
|
|
33
|
+
"CHANGELOG",
|
|
34
|
+
"CHANGES.md",
|
|
35
|
+
"CHANGES.rst",
|
|
36
|
+
"CHANGES.txt",
|
|
37
|
+
"CHANGES",
|
|
38
|
+
"NEWS.md",
|
|
39
|
+
"NEWS.rst",
|
|
40
|
+
"NEWS.txt",
|
|
41
|
+
"NEWS",
|
|
42
|
+
"HISTORY.md",
|
|
43
|
+
"HISTORY.rst",
|
|
44
|
+
"HISTORY.txt",
|
|
45
|
+
"HISTORY",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
49
|
+
"""Check for changelog file presence and content.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
package_path: Path to the package directory to check.
|
|
53
|
+
config: Configuration dictionary for this check.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
A CheckResult with:
|
|
57
|
+
|
|
58
|
+
- OK status if a changelog with content is found
|
|
59
|
+
- WARNING status if changelog exists but is too short
|
|
60
|
+
- NOTE status if no changelog found
|
|
61
|
+
"""
|
|
62
|
+
details: list[str] = []
|
|
63
|
+
|
|
64
|
+
for filename in self.CHANGELOG_FILENAMES:
|
|
65
|
+
candidate = package_path / filename
|
|
66
|
+
if candidate.exists():
|
|
67
|
+
details.append(f"Found: {candidate.name}")
|
|
68
|
+
try:
|
|
69
|
+
content = candidate.read_text(encoding="utf-8")
|
|
70
|
+
if len(content.strip()) < MIN_CHANGELOG_LENGTH:
|
|
71
|
+
details.append("Changelog appears empty or incomplete")
|
|
72
|
+
return CheckResult(
|
|
73
|
+
name=self.name,
|
|
74
|
+
status=CheckStatus.WARNING,
|
|
75
|
+
message="Changelog file is too short",
|
|
76
|
+
details=details,
|
|
77
|
+
)
|
|
78
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
79
|
+
details.append(f"Could not read changelog: {e}")
|
|
80
|
+
return CheckResult(
|
|
81
|
+
name=self.name,
|
|
82
|
+
status=CheckStatus.WARNING,
|
|
83
|
+
message="Could not read changelog file",
|
|
84
|
+
details=details,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return CheckResult(
|
|
88
|
+
name=self.name,
|
|
89
|
+
status=CheckStatus.OK,
|
|
90
|
+
message="Changelog present",
|
|
91
|
+
details=details,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return CheckResult(
|
|
95
|
+
name=self.name,
|
|
96
|
+
status=CheckStatus.NOTE,
|
|
97
|
+
message="No changelog found",
|
|
98
|
+
details=[
|
|
99
|
+
"Consider adding CHANGELOG.md to document changes across releases"
|
|
100
|
+
],
|
|
101
|
+
)
|
pycmdcheck/checks/ci.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Continuous integration configuration check.
|
|
2
|
+
|
|
3
|
+
Verifies that a package has CI configuration files for automated
|
|
4
|
+
testing and deployment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CICheck(BaseCheck):
|
|
15
|
+
"""Check for continuous integration configuration.
|
|
16
|
+
|
|
17
|
+
Searches for common CI configuration files and directories
|
|
18
|
+
such as GitHub Actions workflows, CircleCI, Travis CI, etc.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
name: The check identifier ("ci").
|
|
22
|
+
description: Human-readable description of this check.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "ci"
|
|
26
|
+
description = "Check for CI configuration"
|
|
27
|
+
|
|
28
|
+
# (path_relative_to_root, description)
|
|
29
|
+
CI_INDICATORS: list[tuple[str, str]] = [
|
|
30
|
+
(".github/workflows", "GitHub Actions"),
|
|
31
|
+
(".circleci", "CircleCI"),
|
|
32
|
+
(".travis.yml", "Travis CI"),
|
|
33
|
+
(".gitlab-ci.yml", "GitLab CI"),
|
|
34
|
+
("Jenkinsfile", "Jenkins"),
|
|
35
|
+
("azure-pipelines.yml", "Azure Pipelines"),
|
|
36
|
+
(".buildkite", "Buildkite"),
|
|
37
|
+
("appveyor.yml", "AppVeyor"),
|
|
38
|
+
(".appveyor.yml", "AppVeyor"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
42
|
+
"""Check for CI configuration presence.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
package_path: Path to the package directory to check.
|
|
46
|
+
config: Configuration dictionary for this check.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
A CheckResult with:
|
|
50
|
+
|
|
51
|
+
- OK status if CI configuration is found
|
|
52
|
+
- NOTE status if no CI configuration detected
|
|
53
|
+
"""
|
|
54
|
+
details: list[str] = []
|
|
55
|
+
found_ci: list[str] = []
|
|
56
|
+
|
|
57
|
+
for path_str, ci_name in self.CI_INDICATORS:
|
|
58
|
+
candidate = package_path / path_str
|
|
59
|
+
if candidate.exists():
|
|
60
|
+
# For directories, check they contain config files
|
|
61
|
+
if candidate.is_dir():
|
|
62
|
+
config_files = list(candidate.glob("*.yml")) + list(
|
|
63
|
+
candidate.glob("*.yaml")
|
|
64
|
+
)
|
|
65
|
+
if config_files:
|
|
66
|
+
found_ci.append(ci_name)
|
|
67
|
+
details.append(
|
|
68
|
+
f"Found {ci_name}: {len(config_files)} config file(s)"
|
|
69
|
+
)
|
|
70
|
+
else:
|
|
71
|
+
found_ci.append(ci_name)
|
|
72
|
+
details.append(f"Found {ci_name}: {candidate.name}")
|
|
73
|
+
|
|
74
|
+
if found_ci:
|
|
75
|
+
return CheckResult(
|
|
76
|
+
name=self.name,
|
|
77
|
+
status=CheckStatus.OK,
|
|
78
|
+
message=f"CI configured ({', '.join(found_ci)})",
|
|
79
|
+
details=details,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return CheckResult(
|
|
83
|
+
name=self.name,
|
|
84
|
+
status=CheckStatus.NOTE,
|
|
85
|
+
message="No CI configuration found",
|
|
86
|
+
details=["Consider adding GitHub Actions, CircleCI, or similar CI setup"],
|
|
87
|
+
)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Citation file check.
|
|
2
|
+
|
|
3
|
+
Verifies that a package has a citation file such as CITATION.cff
|
|
4
|
+
or CITATION.bib for proper academic attribution.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pycmdcheck.checks.base import BaseCheck
|
|
11
|
+
from pycmdcheck.results import CheckResult, CheckStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CitationCheck(BaseCheck):
|
|
15
|
+
"""Check for citation file.
|
|
16
|
+
|
|
17
|
+
Searches for CITATION.cff, CITATION.bib, or similar files
|
|
18
|
+
that enable proper academic citation of the package.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
name: The check identifier ("citation").
|
|
22
|
+
description: Human-readable description of this check.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name = "citation"
|
|
26
|
+
description = "Check for citation file"
|
|
27
|
+
|
|
28
|
+
CITATION_FILENAMES = [
|
|
29
|
+
"CITATION.cff",
|
|
30
|
+
"CITATION.bib",
|
|
31
|
+
"CITATION",
|
|
32
|
+
"CITATION.md",
|
|
33
|
+
"CITATION.txt",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Required keys in a valid CITATION.cff
|
|
37
|
+
CFF_REQUIRED_KEYS = ["title", "authors"]
|
|
38
|
+
|
|
39
|
+
def run(self, package_path: Path, config: dict[str, Any]) -> CheckResult:
|
|
40
|
+
"""Check for citation file presence.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
package_path: Path to the package directory to check.
|
|
44
|
+
config: Configuration dictionary for this check.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
A CheckResult with:
|
|
48
|
+
|
|
49
|
+
- OK status if a citation file is found
|
|
50
|
+
- NOTE status if no citation file found
|
|
51
|
+
"""
|
|
52
|
+
details: list[str] = []
|
|
53
|
+
|
|
54
|
+
for filename in self.CITATION_FILENAMES:
|
|
55
|
+
candidate = package_path / filename
|
|
56
|
+
if candidate.exists():
|
|
57
|
+
details.append(f"Found: {candidate.name}")
|
|
58
|
+
|
|
59
|
+
# Basic validation for CITATION.cff
|
|
60
|
+
if candidate.name == "CITATION.cff":
|
|
61
|
+
self._validate_cff(candidate, details)
|
|
62
|
+
|
|
63
|
+
return CheckResult(
|
|
64
|
+
name=self.name,
|
|
65
|
+
status=CheckStatus.OK,
|
|
66
|
+
message="Citation file present",
|
|
67
|
+
details=details,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return CheckResult(
|
|
71
|
+
name=self.name,
|
|
72
|
+
status=CheckStatus.NOTE,
|
|
73
|
+
message="No citation file found",
|
|
74
|
+
details=[
|
|
75
|
+
"Consider adding CITATION.cff for academic attribution",
|
|
76
|
+
"See https://citation-file-format.github.io/",
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _validate_cff(self, cff_path: Path, details: list[str]) -> None:
|
|
81
|
+
"""Basic validation of CITATION.cff content.
|
|
82
|
+
|
|
83
|
+
Checks that required keys (title, authors) are present
|
|
84
|
+
by scanning for key patterns in the YAML content.
|
|
85
|
+
Does not require a YAML parser — uses simple line scanning.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
content = cff_path.read_text(encoding="utf-8")
|
|
89
|
+
except (OSError, UnicodeDecodeError):
|
|
90
|
+
details.append("Could not read CITATION.cff")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
missing = []
|
|
94
|
+
for key in self.CFF_REQUIRED_KEYS:
|
|
95
|
+
# Check for top-level key (not indented)
|
|
96
|
+
if not any(line.startswith(f"{key}:") for line in content.splitlines()):
|
|
97
|
+
missing.append(key)
|
|
98
|
+
|
|
99
|
+
if missing:
|
|
100
|
+
details.append(f"CITATION.cff missing keys: {', '.join(missing)}")
|
|
101
|
+
else:
|
|
102
|
+
details.append("CITATION.cff has required keys")
|