Meilleurs scripts

This commit is contained in:
julien
2026-08-06 16:42:42 +02:00
parent cc1b03b78e
commit 7a0e75442c
6 changed files with 242 additions and 357 deletions
+17 -2
View File
@@ -1,4 +1,6 @@
"""Définitions communes au générateur et au validateur de contenus."""
"""Schémas et règles structurelles communs aux scripts de fiches."""
import re
SECTION_ALIASES = {
"article": "articles",
@@ -58,4 +60,17 @@ VOCABULARY_FIELDS = {
),
}
KNOWN_SECTIONS = frozenset(SECTION_ALIASES.values())
SECTION_FIELDS = {
"articles": {"extra": ("banner", "banner_alt", "banner_credit")},
"grammaire": {"taxonomies": ("themes",)},
"signes": {
"extra": ("sign", "mzl"),
"taxonomies": ("lectures",),
},
"textes": {"taxonomies": ("genres",)},
}
LIST_FIELDS = frozenset({"logograms", "lectures", "themes", "genres"})
CONTENT_SECTIONS = frozenset(SECTION_FIELDS) | {"vocabulaire"}
UPDATED_LINE_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
+112 -198
View File
@@ -1,21 +1,30 @@
#!/usr/bin/env python3
"""Vérifie la structure des contenus, le vocabulaire et les signes."""
"""Vérifie que chaque fiche contient les champs prévus pour son modèle."""
import re
import sys
import tomllib
import unicodedata
from datetime import datetime
from pathlib import Path
from typing import Any
from _schema import KNOWN_SECTIONS, VOCABULARY_FIELDS
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"
MZL_FILENAME_RE = re.compile(r"mzl-([0-9]{3})\.md")
MZL_VALUE_RE = re.compile(r"[0-9]{3}")
COMMON_FIELDS = {
"title": str,
"date": datetime,
"description": str,
"draft": bool,
}
class Validator:
@@ -23,10 +32,7 @@ class Validator:
self.errors = 0
def error(self, path: Path | None, message: str) -> None:
if path is None:
label = ""
else:
label = f"{path.relative_to(ROOT_DIR).as_posix()} : "
label = f"{path.relative_to(ROOT_DIR).as_posix()} : " if path else ""
print(f"ERREUR : {label}{message}", file=sys.stderr)
self.errors += 1
@@ -35,24 +41,16 @@ class Validator:
return 1 if self.errors else 0
def is_non_empty_string(value: Any) -> bool:
return isinstance(value, str) and bool(value.strip())
def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None:
def read_front_matter(
path: Path,
validator: Validator,
) -> tuple[dict[str, Any], str] | None:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
validator.error(path, f"fichier non valide en UTF-8 ({exc})")
return None
except OSError as exc:
lines = path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
validator.error(path, f"lecture impossible ({exc})")
return None
if not unicodedata.is_normalized("NFC", text):
validator.error(path, "contenu non normalisé en Unicode NFC")
lines = text.splitlines()
if not lines or lines[0] != "+++":
validator.error(path, "front matter TOML initial absent")
return None
@@ -63,231 +61,147 @@ def read_front_matter(path: Path, validator: Validator) -> dict[str, Any] | None
validator.error(path, "délimiteur final +++ absent")
return None
source = "\n".join(lines[1:closing_index])
try:
return tomllib.loads("\n".join(lines[1:closing_index]))
return tomllib.loads(source), source
except tomllib.TOMLDecodeError as exc:
validator.error(path, f"front matter TOML invalide ({exc})")
return None
def require_table(
metadata: dict[str, Any],
key: str,
def check_type(
value: Any,
expected_type: type,
label: str,
path: Path,
validator: Validator,
) -> dict[str, Any] | None:
value = metadata.get(key)
if not isinstance(value, dict):
validator.error(path, f"{key} doit être une table TOML")
return None
return value
) -> bool:
if isinstance(value, expected_type):
return True
validator.error(path, f"{label} doit être de type {expected_type.__name__}")
return False
def require_string(
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],
key: str,
table_name: str,
fields: tuple[str, ...],
path: Path,
validator: Validator,
) -> None:
if not is_non_empty_string(metadata.get(key)):
validator.error(path, f"{key} doit être une chaîne non vide")
def require_timestamp(
metadata: dict[str, Any],
key: str,
path: Path,
validator: Validator,
*,
optional: bool = False,
) -> None:
value = metadata.get(key)
if optional and value is None:
table = metadata.get(table_name)
if not isinstance(table, dict):
validator.error(path, f"{table_name} doit être une table TOML")
return
if not isinstance(value, datetime) or value.tzinfo is None or value.utcoffset() is None:
validator.error(
path,
f"{key} doit être un horodatage TOML avec fuseau horaire",
)
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_structure(
path: Path,
metadata: dict[str, Any],
validator: Validator,
) -> tuple[str, bool] | None:
relative_path = path.relative_to(CONTENT_DIR)
section = relative_path.parts[0] if len(relative_path.parts) > 1 else ""
if not unicodedata.is_normalized("NFC", relative_path.as_posix()):
validator.error(path, "chemin non normalisé en Unicode NFC")
if section and section not in KNOWN_SECTIONS:
validator.error(path, f"section inconnue « {section} »")
if path.name == "_index.md":
require_string(metadata, "title", path, validator)
return None
draft = metadata.get("draft")
if not isinstance(draft, bool):
validator.error(path, "draft doit être un booléen")
return None
if draft:
return section, True
require_string(metadata, "title", path, validator)
if section:
require_timestamp(metadata, "date", path, validator)
require_timestamp(metadata, "updated", path, validator, optional=True)
return section, False
def vocabulary_nature(
def check_common_fields(
metadata: dict[str, Any],
front_matter: str,
path: Path,
validator: Validator,
) -> str | None:
taxonomies = require_table(metadata, "taxonomies", path, validator)
if taxonomies is None:
return None
) -> 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)
natures = taxonomies.get("natures")
if not isinstance(natures, list) or len(natures) != 1:
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
return None
updated_lines = len(UPDATED_LINE_RE.findall(front_matter))
if updated_lines != 1:
validator.error(path, "la fiche doit contenir exactement une ligne updated")
nature = natures[0]
if not isinstance(nature, str):
validator.error(path, "taxonomies.natures doit contenir une chaîne")
return None
if nature not in VOCABULARY_FIELDS:
validator.error(path, f"nature grammaticale inconnue « {nature} »")
return None
return nature
if "updated" in metadata:
check_type(metadata["updated"], datetime, "updated", path, validator)
def check_vocabulary(
path: Path,
metadata: dict[str, Any],
path: Path,
validator: Validator,
) -> None:
nature = vocabulary_nature(metadata, path, validator)
extra = require_table(metadata, "extra", path, validator)
if nature is None or extra is None:
taxonomies = metadata.get("taxonomies")
if not isinstance(taxonomies, dict):
validator.error(path, "taxonomies doit être une table TOML")
return
expected_fields = VOCABULARY_FIELDS[nature]
expected_keys = set(expected_fields)
actual_keys = set(extra)
missing_fields = [field for field in expected_fields if field not in actual_keys]
if missing_fields:
validator.error(
path,
"champs extra manquants pour "
f"la nature « {nature} » : {', '.join(missing_fields)}",
)
unexpected_fields = sorted(actual_keys - expected_keys)
if unexpected_fields:
validator.error(
path,
"champs extra inattendus pour "
f"la nature « {nature} » : {', '.join(unexpected_fields)}",
)
for field in expected_fields:
if field not in extra:
continue
value = extra[field]
if field == "logograms":
if not isinstance(value, list) or not all(
is_non_empty_string(item) for item in value
):
validator.error(
path,
"extra.logograms doit être un tableau de chaînes non vides",
)
elif not isinstance(value, str):
validator.error(path, f"extra.{field} doit être une chaîne")
required_fields = ("meaning", "stem") if nature == "verbe" else ("meaning",)
for field in required_fields:
value = extra.get(field)
if isinstance(value, str) and not value.strip():
validator.error(path, f"extra.{field} doit être une chaîne non vide")
def filename_mzl(path: Path, validator: Validator) -> str | None:
match = MZL_FILENAME_RE.fullmatch(path.name)
if match is None:
validator.error(path, "le nom du fichier doit suivre le format mzl-XXX.md")
return None
value = match.group(1)
if int(value) == 0:
validator.error(path, "le numéro MZL doit être compris entre 001 et 999")
return None
return value
def check_sign(
path: Path,
metadata: dict[str, Any],
expected_mzl: str | None,
validator: Validator,
) -> None:
extra = require_table(metadata, "extra", path, validator)
if extra is None:
if "natures" not in taxonomies:
validator.error(path, "champ taxonomies manquant : natures")
return
require_string(extra, "sign", path, validator)
natures = taxonomies["natures"]
if not check_string_list(natures, "taxonomies.natures", path, validator):
return
declared_mzl = extra.get("mzl")
if not isinstance(declared_mzl, str) or MZL_VALUE_RE.fullmatch(declared_mzl) is None:
validator.error(path, "extra.mzl doit être une chaîne de trois chiffres ASCII")
if len(natures) != 1:
validator.error(path, "taxonomies.natures doit contenir exactement une nature")
return
if int(declared_mzl) == 0:
validator.error(path, "extra.mzl doit être compris entre 001 et 999")
nature = natures[0]
if nature not in VOCABULARY_FIELDS:
validator.error(path, f"nature grammaticale inconnue : {nature!r}")
return
if expected_mzl is not None and declared_mzl != expected_mzl:
validator.error(
path,
f'extra.mzl vaut "{declared_mzl}", mais le fichier indique MZL {expected_mzl}',
)
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 contenus…", flush=True)
print("Vérification des fiches…", flush=True)
for path in sorted(CONTENT_DIR.rglob("*.md")):
metadata = read_front_matter(path, validator)
if metadata is None:
relative_path = path.relative_to(CONTENT_DIR)
if path.name == "_index.md" or len(relative_path.parts) == 1:
continue
structure = check_structure(path, metadata, validator)
if structure is None:
section = relative_path.parts[0]
if section not in CONTENT_SECTIONS:
validator.error(path, f"section inconnue : {section}")
continue
section, is_draft = structure
expected_mzl = filename_mzl(path, validator) if section == "signes" else None
if is_draft:
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(path, metadata, validator)
elif section == "signes":
check_sign(path, metadata, expected_mzl, validator)
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()
+76 -130
View File
@@ -10,14 +10,14 @@ from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from _schema import SECTION_ALIASES, VOCABULARY_FIELDS
from _schema import LIST_FIELDS, SECTION_ALIASES, SECTION_FIELDS, VOCABULARY_FIELDS
ROOT_DIR = Path(__file__).resolve().parent.parent
CONTENT_DIR = ROOT_DIR / "src" / "content"
TIMEZONE_NAME = "Europe/Paris"
MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})")
FieldValue = str | list[str]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
@@ -48,55 +48,33 @@ def normalize(value: str) -> str:
return unicodedata.normalize("NFC", value.strip())
def toml_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def current_timestamp() -> str:
try:
timezone = ZoneInfo(TIMEZONE_NAME)
except ZoneInfoNotFoundError as exc:
raise RuntimeError(
f"fuseau {TIMEZONE_NAME} indisponible ; installer les données de fuseaux horaires"
) from exc
return datetime.now(timezone).isoformat(timespec="seconds")
def validate_identifier(identifier: str, *, allow_leading_hyphen: bool = False) -> str:
"""Valide un identifiant déjà écrit sous sa forme de nom de fichier."""
def validate_identifier(identifier: str, allow_leading_hyphen: bool) -> str:
value = normalize(identifier)
if value.startswith("-") and not allow_leading_hyphen:
raise ValueError("seul le vocabulaire accepte un identifiant commençant par un tiret")
body = value[1:] if value.startswith("-") else value
if not body or value in {".", "..", "_index"}:
if value.startswith("-") and not allow_leading_hyphen:
raise ValueError(
"seul le vocabulaire accepte un identifiant commençant par un tiret"
)
if (
not body
or value in {".", "..", "_index"}
or value != value.lower()
or value.endswith("-")
or "--" in value
or not all(character.isalnum() or character == "-" for character in body)
):
raise ValueError("identifiant de fichier invalide")
if value != value.lower():
raise ValueError("lidentifiant doit être écrit en minuscules")
if value.endswith("-") or "--" in value:
raise ValueError(
"lidentifiant ne doit pas finir par un tiret ni en contenir deux de suite"
)
if not all(character.isalnum() or character == "-" for character in body):
raise ValueError(
"lidentifiant ne doit contenir que des lettres, des chiffres et des tirets"
)
return value
def parse_mzl(identifier: str) -> tuple[str, str]:
value = normalize(identifier)
match = MZL_RE.fullmatch(value)
if match is None:
raise ValueError(
"un signe doit être identifié par un numéro MZL de un à trois chiffres ASCII"
)
number = int(match.group(1))
if not 1 <= number <= 999:
match = MZL_RE.fullmatch(normalize(identifier))
if match is None or not 1 <= int(match.group(1)) <= 999:
raise ValueError("le numéro MZL doit être compris entre 1 et 999")
mzl = f"{number:03d}"
mzl = f"{int(match.group(1)):03d}"
return f"mzl-{mzl}", mzl
@@ -126,88 +104,55 @@ def title_from_identifier(identifier: str) -> str:
return title[:1].upper() + title[1:]
def build_vocabulary(title: str, timestamp: str, nature: str) -> str:
fields = "\n".join(
f"{field} = []" if field == "logograms" else f'{field} = ""'
for field in VOCABULARY_FIELDS[nature]
)
return f"""+++
title = {toml_string(title)}
date = {timestamp}
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
description = ""
draft = true
[extra]
{fields}
[taxonomies]
natures = [{toml_string(nature)}]
+++
"""
def empty_fields(fields: tuple[str, ...]) -> dict[str, FieldValue]:
return {field: [] if field in LIST_FIELDS else "" for field in fields}
def build_sign(title: str, timestamp: str, mzl: str) -> str:
return f"""+++
title = {toml_string(title)}
date = {timestamp}
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
description = ""
draft = true
def content_tables(
section: str,
nature: str | None = None,
mzl: str | None = None,
) -> dict[str, dict[str, FieldValue]]:
if section == "vocabulaire":
assert nature is not None
return {
"extra": empty_fields(VOCABULARY_FIELDS[nature]),
"taxonomies": {"natures": [nature]},
}
[extra]
sign = ""
mzl = "{mzl}"
[taxonomies]
lectures = []
+++
"""
tables = {
table: empty_fields(fields)
for table, fields in SECTION_FIELDS[section].items()
}
if section == "signes":
assert mzl is not None
tables["extra"]["mzl"] = mzl
return tables
def build_page(section: str, title: str, timestamp: str) -> str:
if section == "articles":
return f"""+++
title = {toml_string(title)}
date = {timestamp}
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
description = ""
draft = true
def build_content(
title: str,
timestamp: str,
tables: dict[str, dict[str, FieldValue]],
) -> str:
lines = [
"+++",
f"title = {json.dumps(title, ensure_ascii=False)}",
f"date = {timestamp}",
"# updated = YYYY-MM-DDTHH:MM:SS+HH:MM",
'description = ""',
"draft = true",
]
[extra]
banner = ""
banner_alt = ""
banner_credit = ""
+++
"""
for table, fields in tables.items():
lines.extend(("", f"[{table}]"))
lines.extend(
f"{field} = {json.dumps(value, ensure_ascii=False)}"
for field, value in fields.items()
)
if section == "grammaire":
return f"""+++
title = {toml_string(title)}
date = {timestamp}
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
description = ""
draft = true
[taxonomies]
themes = []
+++
"""
if section == "textes":
return f"""+++
title = {toml_string(title)}
date = {timestamp}
# updated = YYYY-MM-DDTHH:MM:SS+HH:MM
description = ""
draft = true
[taxonomies]
genres = []
+++
"""
raise ValueError(f"section non prise en charge : {section}")
lines.extend(("+++", ""))
return "\n".join(lines)
def main() -> int:
@@ -218,13 +163,13 @@ def main() -> int:
if section != "vocabulaire" and args.nature is not None:
raise ValueError("--nature est réservé aux fiches de vocabulaire")
timestamp = current_timestamp()
custom_title = normalize(args.title) if args.title is not None else None
nature = None
mzl = None
if section == "signes":
filename, mzl = parse_mzl(args.identifiant)
title = custom_title if custom_title is not None else ""
content = build_sign(title, timestamp, mzl)
else:
filename = validate_identifier(
args.identifiant,
@@ -233,21 +178,27 @@ def main() -> int:
if section == "vocabulaire":
nature = choose_nature(args.nature)
title = custom_title if custom_title is not None else filename
content = build_vocabulary(title, timestamp, nature)
else:
title = (
custom_title
if custom_title is not None
else title_from_identifier(filename)
)
content = build_page(section, title, timestamp)
except (ValueError, RuntimeError) as exc:
timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat(
timespec="seconds"
)
content = build_content(
title,
timestamp,
content_tables(section, nature, mzl),
)
except (ValueError, ZoneInfoNotFoundError) as exc:
print(f"Erreur : {exc}", file=sys.stderr)
return 2
destination = CONTENT_DIR / section / f"{filename}.md"
try:
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("x", encoding="utf-8") as stream:
stream.write(content)
except FileExistsError:
@@ -257,15 +208,10 @@ def main() -> int:
)
return 1
except OSError as exc:
print(
f"Erreur : impossible de créer {destination.relative_to(ROOT_DIR)} ({exc})",
file=sys.stderr,
)
print(f"Erreur : impossible de créer la fiche ({exc})", file=sys.stderr)
return 1
print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}")
print("Compléter la fiche, passer draft à false, puis lancer :")
print(" ./scripts/check-content.py")
return 0
+11 -4
View File
@@ -8,6 +8,8 @@ from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from _schema import CONTENT_SECTIONS, UPDATED_LINE_RE
ROOT = Path(__file__).resolve().parent.parent
CONTENT = (ROOT / "src" / "content").resolve()
@@ -15,7 +17,6 @@ FRONT_MATTER_RE = re.compile(
r"\A\+\+\+\r?\n(?P<body>.*?)(?:\r?\n\+\+\+(?=\r?\n|\Z))",
re.DOTALL,
)
UPDATED_RE = re.compile(r"(?m)^(?:#\s*)?updated\s*=.*?(?=\r?$)")
def parse_args() -> argparse.Namespace:
@@ -39,8 +40,14 @@ def resolve_path(value: str) -> Path:
raise ValueError("la fiche doit se trouver dans src/content/")
if not path.is_file():
raise ValueError(f"fichier introuvable : {path}")
if path.suffix != ".md" or path.name == "_index.md":
raise ValueError("le chemin doit désigner une fiche Markdown, pas un index")
relative_path = path.relative_to(CONTENT)
if (
path.suffix != ".md"
or path.name == "_index.md"
or len(relative_path.parts) == 1
or relative_path.parts[0] not in CONTENT_SECTIONS
):
raise ValueError("le chemin doit désigner une fiche dune section connue")
return path
@@ -52,7 +59,7 @@ def update_timestamp(path: Path) -> str:
if front_matter is None:
raise ValueError("front matter TOML délimité par +++ absent")
matches = list(UPDATED_RE.finditer(front_matter.group("body")))
matches = list(UPDATED_LINE_RE.finditer(front_matter.group("body")))
if len(matches) != 1:
raise ValueError("la fiche doit contenir exactement une ligne updated")