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
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
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m pycmdcheck."""
2
+
3
+ from pycmdcheck.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )
@@ -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")