"""Détection des colonnes dans les tableurs de bénévoles. Usage : python scripts/import_test.py Couvre les formes réellement rencontrées dans les fichiers de festival : en-têtes français accentués, séparateur point-virgule d'Excel FR, colonne « NOM Prénom » fusionnée, numéros abîmés par Excel, feuille sans en-tête. """ from __future__ import annotations import csv import io import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from app import imports # noqa: E402 passed = failed = 0 def check(label: str, condition: bool, detail: str = "") -> None: global passed, failed if condition: passed += 1 print(f" \033[32m✓\033[0m {label}") else: failed += 1 print(f" \033[31m✗\033[0m {label} {detail}") def csv_bytes(rows: list[list[str]], delimiter: str = ";", encoding: str = "utf-8") -> bytes: buffer = io.StringIO() csv.writer(buffer, delimiter=delimiter, lineterminator="\n").writerows(rows) return buffer.getvalue().encode(encoding) def xlsx_bytes(rows: list[list], sheet_name: str = "Bénévoles") -> bytes: from openpyxl import Workbook workbook = Workbook() sheet = workbook.active sheet.title = sheet_name for row in rows: sheet.append(row) buffer = io.BytesIO() workbook.save(buffer) return buffer.getvalue() def parse(name: str, data: bytes): return imports.parse(name, data) def main() -> int: print("\n\033[1m1. En-têtes français\033[0m") data = csv_bytes( [ ["Prénom", "Nom", "Téléphone"], ["Jean", "Dupont", "06 12 34 56 78"], ["Amélie", "Nothomb", "0798765432"], ] ) mapping, contacts, rejected = parse("benevoles.csv", data) check("en-tête accentué reconnu", mapping.header_row == 0, str(mapping)) check("colonnes prénom/nom/téléphone", (mapping.first, mapping.last, mapping.phone) == (0, 1, 2)) check("deux contacts extraits", len(contacts) == 2, str(contacts)) check( "premier contact correct", contacts[0].phone == "0612345678" and contacts[0].first_name == "Jean", str(contacts[0]), ) check("aucun rejet", not rejected, str(rejected)) # « Prénom » contient « nom » : le piège classique de la détection. data = csv_bytes([["Nom", "Prénom", "Portable"], ["DUPONT", "Jean", "0612345678"]]) mapping, contacts, _ = parse("x.csv", data) check( "ordre Nom/Prénom respecté", (mapping.last, mapping.first) == (0, 1), f"first={mapping.first} last={mapping.last}", ) check( "prénom et nom au bon endroit", contacts[0].first_name == "Jean" and contacts[0].last_name == "DUPONT", str(contacts[0]), ) for header in ("Tél. portable", "N° de téléphone", "Mobile", "TELEPHONE", "Numéro"): data = csv_bytes([["Prénom", "Nom", header], ["Jean", "Dupont", "0612345678"]]) mapping, contacts, _ = parse("x.csv", data) check(f"variante d'intitulé « {header} »", mapping.phone == 2 and len(contacts) == 1) print("\n\033[1m2. Colonne « Nom complet »\033[0m") data = csv_bytes( [ ["Nom complet", "Téléphone"], ["DUPONT Jean", "0612345678"], ["Amélie Nothomb", "0798765432"], ["Martin, Claire", "0611223344"], ] ) mapping, contacts, _ = parse("x.csv", data) check("colonne fusionnée détectée", mapping.full == 0, str(mapping)) check( "« DUPONT Jean » -> Jean / DUPONT", (contacts[0].first_name, contacts[0].last_name) == ("Jean", "DUPONT"), str(contacts[0]), ) check( "« Amélie Nothomb » -> Amélie / Nothomb", (contacts[1].first_name, contacts[1].last_name) == ("Amélie", "Nothomb"), str(contacts[1]), ) check( "« Martin, Claire » -> Claire / Martin", (contacts[2].first_name, contacts[2].last_name) == ("Claire", "Martin"), str(contacts[2]), ) check( "nom à particule en capitales", imports.split_full_name("DE LA TOUR Jean-Pierre") == ("Jean-Pierre", "DE LA TOUR"), str(imports.split_full_name("DE LA TOUR Jean-Pierre")), ) print("\n\033[1m3. Numéros abîmés par Excel\033[0m") data = csv_bytes( [ ["Prénom", "Nom", "Téléphone"], ["Jean", "Dupont", "612345678.0"], # zéro initial mangé, format nombre ["Luc", "Martin", "+33 6 11 22 33 44"], ["Eva", "Bernard", "0033798765432"], ["Zoé", "Petit", "06.12.34.56.79"], ] ) _, contacts, rejected = parse("x.csv", data) numbers = [c.phone for c in contacts] check("zéro initial restauré", "0612345678" in numbers, str(numbers)) check("format +33 normalisé", "0611223344" in numbers, str(numbers)) check("format 0033 normalisé", "0798765432" in numbers, str(numbers)) check("séparateurs points acceptés", "0612345679" in numbers, str(numbers)) check("aucun rejet sur ces formats", not rejected, str(rejected)) print("\n\033[1m4. Lignes inexploitables\033[0m") data = csv_bytes( [ ["Prénom", "Nom", "Téléphone"], ["Jean", "Dupont", "0612345678"], ["Sans", "Numéro", ""], ["Fixe", "Interdit", "0145678901"], ["Doublon", "Dupont", "06 12 34 56 78"], ["", "", ""], ] ) _, contacts, rejected = parse("x.csv", data) check("un seul contact retenu", len(contacts) == 1, str(contacts)) reasons = {r["reason"] for r in rejected} check("ligne sans numéro rejetée", "numéro introuvable" in reasons, str(rejected)) check("doublon détecté", "doublon dans le fichier" in reasons, str(rejected)) check("ligne vide ignorée sans rejet", len(rejected) == 3, str(rejected)) print("\n\033[1m5. Sans en-tête\033[0m") data = csv_bytes( [ ["DUPONT", "Jean", "0612345678"], ["MARTIN", "Luc", "0611223344"], ["BERNARD", "Eva", "0798765432"], ] ) mapping, contacts, _ = parse("x.csv", data) check("colonne téléphone trouvée par le contenu", mapping.phone == 2, str(mapping)) check("capitales identifiées comme nom de famille", mapping.last == 0 and mapping.first == 1, str(mapping)) check("pas d'hypothèse signalée", mapping.assumed_order is False) check("trois contacts", len(contacts) == 3 and contacts[0].last_name == "DUPONT") data = csv_bytes( [ ["Jean", "Dupont", "0612345678"], ["Luc", "Martin", "0611223344"], ] ) mapping, _, _ = parse("x.csv", data) check("ordre supposé signalé quand rien ne tranche", mapping.assumed_order is True, str(mapping)) print("\n\033[1m6. Encodages et séparateurs\033[0m") data = csv_bytes([["Prénom", "Nom", "Téléphone"], ["Amélie", "Côté", "0612345678"]], encoding="cp1252") _, contacts, _ = parse("x.csv", data) check("CSV Windows-1252 décodé", contacts[0].last_name == "Côté", str(contacts)) data = "Prénom,Nom,Téléphone\nJean,Dupont,0612345678\n".encode("utf-8") mapping, contacts, _ = parse("x.csv", data) check("BOM UTF-8 et virgules", mapping.phone == 2 and len(contacts) == 1, str(mapping)) data = csv_bytes([["Prénom", "Nom", "Téléphone"], ["Jean", "Dupont", "0612345678"]], delimiter="\t") mapping, contacts, _ = parse("x.tsv", data) check("tabulations", mapping.phone == 2 and len(contacts) == 1, str(mapping)) print("\n\033[1m7. Fichiers .xlsx\033[0m") data = xlsx_bytes( [ ["Prénom", "Nom", "Téléphone portable"], ["Jean", "Dupont", "0612345678"], ["Luc", "Martin", 611223344], # saisi en nombre : zéro initial perdu ] ) mapping, contacts, rejected = parse("benevoles.xlsx", data) check("xlsx lu", len(contacts) == 2, f"{contacts} / {rejected}") check("numéro stocké en nombre récupéré", contacts[1].phone == "0611223344", str(contacts[1])) check("en-tête xlsx détecté", mapping.header_row == 0 and mapping.phone == 2, str(mapping)) # Un .xlsx renommé en .csv reste identifiable par sa signature zip. mapping, contacts, _ = parse("mal_nomme.csv", data) check("xlsx renommé .csv reconnu", len(contacts) == 2, str(contacts)) # Lignes de titre au-dessus du tableau : cas très fréquent. data = xlsx_bytes( [ ["Liste des bénévoles 2026"], [], ["Prénom", "Nom", "Téléphone"], ["Jean", "Dupont", "0612345678"], ] ) mapping, contacts, _ = parse("x.xlsx", data) check("en-tête précédé d'un titre", mapping.header_row == 1 and len(contacts) == 1, str(mapping)) print("\n\033[1m8. Refus explicites\033[0m") for name, blob, label in [ ("vide.csv", b"", "fichier vide"), ("vieux.xls", b"\xd0\xcf\x11\xe0", "format .xls"), ]: try: parse(name, blob) check(f"{label} refusé", False, "aucune erreur levée") except imports.ImportError_ as exc: check(f"{label} refusé", True, str(exc)) data = csv_bytes([["Prénom", "Nom", "Ville"], ["Jean", "Dupont", "Carhaix"]]) try: parse("x.csv", data) check("fichier sans téléphone refusé", False, "aucune erreur levée") except imports.ImportError_: check("fichier sans téléphone refusé", True) print(f"\n\033[1m{passed} réussis, {failed} échoués\033[0m\n") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())