211 lines
5.7 KiB
Python
Executable File
211 lines
5.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Vérifie que chaque fiche contient les champs prévus pour son modèle."""
|
|
|
|
import sys
|
|
import tomllib
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from _schema import (
|
|
CONTENT_SECTIONS,
|
|
LIST_FIELDS,
|
|
SECTION_FIELDS,
|
|
UPDATED_LINE_RE,
|
|
VOCABULARY_FIELDS,
|
|
)
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
CONTENT_DIR = ROOT_DIR / "src" / "content"
|
|
|
|
COMMON_FIELDS = {
|
|
"title": str,
|
|
"date": datetime,
|
|
"description": str,
|
|
"draft": bool,
|
|
}
|
|
|
|
|
|
|
|
class Validator:
|
|
def __init__(self) -> None:
|
|
self.errors = 0
|
|
|
|
def error(self, path: Path | None, message: str) -> None:
|
|
label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else ""
|
|
print(f"ERREUR : {label}{message}", file=sys.stderr)
|
|
self.errors += 1
|
|
|
|
def finish(self) -> int:
|
|
print(f"\nRésultat : {self.errors} erreur(s).")
|
|
return 1 if self.errors else 0
|
|
|
|
|
|
def read_front_matter(
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> tuple[dict[str, Any], str] | None:
|
|
try:
|
|
lines = path.read_text(encoding="utf-8").splitlines()
|
|
except (OSError, UnicodeError) as exc:
|
|
validator.error(path, f"lecture impossible ({exc})")
|
|
return None
|
|
|
|
if not lines or lines[0] != "+++":
|
|
validator.error(path, "front matter TOML initial absent")
|
|
return None
|
|
|
|
try:
|
|
closing_index = lines.index("+++", 1)
|
|
except ValueError:
|
|
validator.error(path, "délimiteur final +++ absent")
|
|
return None
|
|
|
|
source = "\n".join(lines[1:closing_index])
|
|
try:
|
|
return tomllib.loads(source), source
|
|
except tomllib.TOMLDecodeError as exc:
|
|
validator.error(path, f"front matter TOML invalide ({exc})")
|
|
return None
|
|
|
|
|
|
def check_type(
|
|
value: Any,
|
|
expected_type: type,
|
|
label: str,
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> bool:
|
|
if isinstance(value, expected_type):
|
|
return True
|
|
|
|
validator.error(path, f"{label} doit être de type {expected_type.__name__}")
|
|
return False
|
|
|
|
|
|
def check_string_list(
|
|
value: Any,
|
|
label: str,
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> bool:
|
|
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
return True
|
|
|
|
validator.error(path, f"{label} doit être un tableau de chaînes")
|
|
return False
|
|
|
|
|
|
def check_fields(
|
|
metadata: dict[str, Any],
|
|
table_name: str,
|
|
fields: tuple[str, ...],
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> None:
|
|
table = metadata.get(table_name)
|
|
if not isinstance(table, dict):
|
|
validator.error(path, f"{table_name} doit être une table TOML")
|
|
return
|
|
|
|
missing = [field for field in fields if field not in table]
|
|
if missing:
|
|
validator.error(path, f"champs {table_name} manquants : {', '.join(missing)}")
|
|
|
|
for field in fields:
|
|
if field not in table:
|
|
continue
|
|
|
|
label = f"{table_name}.{field}"
|
|
if field in LIST_FIELDS:
|
|
check_string_list(table[field], label, path, validator)
|
|
else:
|
|
check_type(table[field], str, label, path, validator)
|
|
|
|
|
|
def check_common_fields(
|
|
metadata: dict[str, Any],
|
|
front_matter: str,
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> None:
|
|
for field, expected_type in COMMON_FIELDS.items():
|
|
if field not in metadata:
|
|
validator.error(path, f"champ manquant : {field}")
|
|
else:
|
|
check_type(metadata[field], expected_type, field, path, validator)
|
|
|
|
updated_lines = len(UPDATED_LINE_RE.findall(front_matter))
|
|
if updated_lines != 1:
|
|
validator.error(path, "la fiche doit contenir exactement une ligne updated")
|
|
|
|
if "updated" in metadata:
|
|
check_type(metadata["updated"], datetime, "updated", path, validator)
|
|
|
|
|
|
def check_vocabulary(
|
|
metadata: dict[str, Any],
|
|
path: Path,
|
|
validator: Validator,
|
|
) -> None:
|
|
taxonomies = metadata.get("taxonomies")
|
|
if not isinstance(taxonomies, dict):
|
|
validator.error(path, "taxonomies doit être une table TOML")
|
|
return
|
|
|
|
if "natures" not in taxonomies:
|
|
validator.error(path, "champ taxonomies manquant : natures")
|
|
return
|
|
|
|
natures = taxonomies["natures"]
|
|
if not check_string_list(natures, "taxonomies.natures", path, validator):
|
|
return
|
|
|
|
if len(natures) != 1:
|
|
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
|
|
return
|
|
|
|
nature = natures[0]
|
|
if nature not in VOCABULARY_FIELDS:
|
|
validator.error(path, f"nature grammaticale inconnue : {nature!r}")
|
|
return
|
|
|
|
check_fields(metadata, "extra", VOCABULARY_FIELDS[nature], path, validator)
|
|
|
|
|
|
def main() -> int:
|
|
validator = Validator()
|
|
if not CONTENT_DIR.is_dir():
|
|
validator.error(None, f"répertoire introuvable : {CONTENT_DIR}")
|
|
return validator.finish()
|
|
|
|
print("Vérification des fiches…", flush=True)
|
|
for path in sorted(CONTENT_DIR.rglob("*.md")):
|
|
relative_path = path.relative_to(CONTENT_DIR)
|
|
if path.name == "_index.md" or len(relative_path.parts) == 1:
|
|
continue
|
|
|
|
section = relative_path.parts[0]
|
|
if section not in CONTENT_SECTIONS:
|
|
validator.error(path, f"section inconnue : {section}")
|
|
continue
|
|
|
|
result = read_front_matter(path, validator)
|
|
if result is None:
|
|
continue
|
|
metadata, front_matter = result
|
|
|
|
check_common_fields(metadata, front_matter, path, validator)
|
|
|
|
if section == "vocabulaire":
|
|
check_vocabulary(metadata, path, validator)
|
|
else:
|
|
for table_name, fields in SECTION_FIELDS[section].items():
|
|
check_fields(metadata, table_name, fields, path, validator)
|
|
|
|
return validator.finish()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|