from __future__ import annotations
import os
import re
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import TypeGuard
SKIP_DIRS = frozenset(
{
".git",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".tmp_pycache",
".venv",
"target",
}
)
type ParsedObject = dict[str, object]
def _is_parsed_object(value: object) -> TypeGuard[ParsedObject]:
return isinstance(value, dict) and all(isinstance(key, str) for key in value)
def _require_parsed_object(value: object, context: str) -> ParsedObject:
if not _is_parsed_object(value):
msg = f"{context} is not a TOML object"
raise TypeError(msg)
return value
@dataclass(frozen=True, slots=True)
class PackageInfo:
name: str
version: str
@dataclass(frozen=True, slots=True)
class DependencySnippet:
path: Path
line: int
version: str
text: str
@dataclass(frozen=True, slots=True)
class VersionMismatch:
snippet: DependencySnippet
package: PackageInfo
def _read_cargo_package_info(cargo_toml: Path) -> PackageInfo:
data: object = tomllib.loads(cargo_toml.read_text(encoding="utf-8"))
cargo = _require_parsed_object(data, str(cargo_toml))
package = cargo.get("package")
if not _is_parsed_object(package):
msg = f"{cargo_toml} is missing a [package] table"
raise TypeError(msg)
name = package.get("name")
if not isinstance(name, str):
msg = f"{cargo_toml} is missing a string package.name"
raise TypeError(msg)
version = package.get("version")
if not isinstance(version, str):
msg = f"{cargo_toml} is missing a string package.version"
raise TypeError(msg)
return PackageInfo(name=name, version=version)
def _iter_markdown_files(root: Path) -> list[Path]:
markdown_files: list[Path] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [dirname for dirname in dirnames if not (set((Path(dirpath) / dirname).relative_to(root).parts) & SKIP_DIRS)]
markdown_files.extend(Path(dirpath) / filename for filename in filenames if filename.endswith(".md"))
return sorted(markdown_files)
def _dependency_regex(package_name: str) -> re.Pattern[str]:
escaped_name = re.escape(package_name)
return re.compile(rf'(?<![\w.-]){escaped_name}\s*=\s*(?:"(?P<plain>[^"]+)"|\{{[^}}]*version\s*=\s*"(?P<table>[^"]+)"[^}}]*\}})')
def _dependency_snippets(path: Path, package_name: str) -> list[DependencySnippet]:
dependency_re = _dependency_regex(package_name)
snippets: list[DependencySnippet] = []
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
for match in dependency_re.finditer(line):
version = match.group("plain") or match.group("table")
snippets.append(
DependencySnippet(
path=path,
line=line_number,
version=version,
text=line.strip(),
)
)
return snippets
def find_version_mismatches(root: Path) -> list[VersionMismatch]:
package = _read_cargo_package_info(root / "Cargo.toml")
mismatches: list[VersionMismatch] = []
for path in _iter_markdown_files(root):
for snippet in _dependency_snippets(path, package.name):
if snippet.version != package.version:
mismatches.append(VersionMismatch(snippet=snippet, package=package))
return mismatches
def main() -> int:
root = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
try:
mismatches = find_version_mismatches(root)
except (OSError, TypeError, tomllib.TOMLDecodeError) as error:
print(f"Could not check documentation dependency versions: {error}", file=sys.stderr)
return 1
if not mismatches:
return 0
print("Documentation dependency snippets are out of sync with Cargo.toml:", file=sys.stderr)
for mismatch in mismatches:
snippet = mismatch.snippet
rel_path = snippet.path.relative_to(root)
print(
f" {rel_path}:{snippet.line}: {mismatch.package.name} found {snippet.version}, expected {mismatch.package.version}: {snippet.text}",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
sys.exit(main())