← Files EveosARCHIVED FILE
skills/prepare-eveos-imports/scripts/validate_eveos_import.py
11.7 KB · Oct 8, 2026 · 00:04 UTC
#!/usr/bin/env python3
"""Read-only structural validator for Eveos conference and speaker workbooks."""
import argparse
from collections import Counter
from datetime import date, datetime, time
import json
from pathlib import Path
import sys
from openpyxl import load_workbook
FORMULA_ERRORS = {"#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A"}
def parse_args():
parser = argparse.ArgumentParser()
parser.add_argument("--conferences", required=True)
parser.add_argument("--speakers")
parser.add_argument("--baseline-conferences")
parser.add_argument("--baseline-speakers")
parser.add_argument("--config", required=True)
parser.add_argument("--report")
return parser.parse_args()
def add_issue(items, code, message, **context):
issue = {"code": code, "message": message}
if context:
issue["context"] = context
items.append(issue)
def parse_datetime(value, formats):
if isinstance(value, datetime):
return value
if isinstance(value, date):
return datetime.combine(value, time.min)
if isinstance(value, str):
text = value.strip()
for fmt in formats:
try:
return datetime.strptime(text, fmt)
except ValueError:
pass
return None
def header_map(ws, header_row):
result = {}
for cell in ws[header_row]:
if cell.value is not None:
result[str(cell.value).strip()] = cell.column
return result
def require_columns(headers, requested, errors, entity):
resolved = {}
for logical, label in requested.items():
if label not in headers:
add_issue(errors, "missing_column", f"Colonne absente : {label}", entity=entity, logical=logical)
else:
resolved[logical] = headers[label]
return resolved
def extract_keys(path, sheet_name, header_row, key_label):
workbook = load_workbook(path, data_only=False, read_only=False)
if sheet_name not in workbook.sheetnames:
return None
ws = workbook[sheet_name]
headers = header_map(ws, header_row)
if key_label not in headers:
return None
column = headers[key_label]
return {
str(ws.cell(row, column).value).strip()
for row in range(header_row + 1, ws.max_row + 1)
if ws.cell(row, column).value not in (None, "")
}
def scan_formula_errors(path, workbook, errors):
values = load_workbook(path, data_only=True, read_only=False)
for ws in workbook.worksheets:
value_ws = values[ws.title]
for row in ws.iter_rows():
for cell in row:
if cell.data_type == "f":
cached = value_ws[cell.coordinate].value
if isinstance(cached, str) and cached in FORMULA_ERRORS:
add_issue(errors, "formula_error", f"Formule en erreur : {cached}", sheet=ws.title, cell=cell.coordinate)
def main():
args = parse_args()
config = json.loads(Path(args.config).read_text(encoding="utf-8"))
errors, warnings = [], []
header_row = int(config.get("header_row", 1))
separator = config.get("speaker_separator", ";")
formats = config.get("date_formats", ["%d/%m/%Y %H:%M"])
conf_path = Path(args.conferences)
conf_wb = load_workbook(conf_path, data_only=False, read_only=False)
conf_sheet = config["conference_sheet"]
if conf_sheet not in conf_wb.sheetnames:
add_issue(errors, "missing_sheet", f"Feuille absente : {conf_sheet}", entity="conference")
report = {"valid": False, "errors": errors, "warnings": warnings, "summary": {}}
finish(report, args.report)
return 1
conf_ws = conf_wb[conf_sheet]
conf_headers = header_map(conf_ws, header_row)
conf_cols = require_columns(conf_headers, config["conference_columns"], errors, "conference")
if "key" not in conf_cols:
report = {"valid": False, "errors": errors, "warnings": warnings, "summary": {}}
finish(report, args.report)
return 1
conference_rows = [
row for row in range(header_row + 1, conf_ws.max_row + 1)
if conf_ws.cell(row, conf_cols["key"]).value not in (None, "")
]
keys = [str(conf_ws.cell(row, conf_cols["key"]).value).strip() for row in conference_rows]
duplicates = sorted(key for key, count in Counter(keys).items() if count > 1)
for key in duplicates:
add_issue(errors, "duplicate_conference_key", f"Clé conférence dupliquée : {key}", key=key)
statuses = Counter()
allowed_statuses = set(config.get("allowed_statuses", []))
if "status" in conf_cols:
for row in conference_rows:
key = str(conf_ws.cell(row, conf_cols["key"]).value).strip()
status = conf_ws.cell(row, conf_cols["status"]).value
statuses[str(status)] += 1
if allowed_statuses and status not in allowed_statuses:
add_issue(errors, "invalid_status", f"Statut non autorisé : {status}", row=row, key=key)
event_start = datetime.fromisoformat(config["event_start"]) if config.get("event_start") else None
event_end = datetime.fromisoformat(config["event_end"]) if config.get("event_end") else None
max_duration = config.get("max_duration_minutes")
expected_number_format = config.get("excel_date_number_format")
allow_blank_dates = set(config.get("allow_blank_dates_for_keys", []))
if "start" in conf_cols and "end" in conf_cols:
for row in conference_rows:
key = str(conf_ws.cell(row, conf_cols["key"]).value).strip()
raw_start = conf_ws.cell(row, conf_cols["start"]).value
raw_end = conf_ws.cell(row, conf_cols["end"]).value
if raw_start in (None, "") and raw_end in (None, "") and key in allow_blank_dates:
continue
start = parse_datetime(raw_start, formats)
end = parse_datetime(raw_end, formats)
if start is None or end is None:
add_issue(errors, "invalid_date", "Date absente ou illisible", row=row, key=key)
continue
if start > end:
add_issue(errors, "invalid_date_order", "La fin précède le début", row=row, key=key)
if event_start and start < event_start:
add_issue(errors, "start_out_of_range", "Début hors période", row=row, key=key)
if event_end and end > event_end:
add_issue(errors, "end_out_of_range", "Fin hors période", row=row, key=key)
if max_duration is not None and (end - start).total_seconds() / 60 > float(max_duration):
add_issue(errors, "duration_too_long", "Durée supérieure au maximum autorisé", row=row, key=key, minutes=(end - start).total_seconds() / 60)
if expected_number_format:
for logical in ("start", "end"):
cell = conf_ws.cell(row, conf_cols[logical])
if isinstance(cell.value, (datetime, date)) and cell.number_format != expected_number_format:
add_issue(errors, "invalid_excel_date_format", f"Format Excel incorrect : {cell.number_format}", row=row, key=key, column=logical)
allow_blank_taxonomy = config.get("allow_blank_taxonomy_for_keys", {})
required_taxonomies = set(config.get("required_taxonomies", []))
for label, allowed_values in config.get("taxonomies", {}).items():
if label not in conf_headers:
add_issue(errors, "missing_taxonomy_column", f"Colonne de taxonomie absente : {label}")
continue
column = conf_headers[label]
allowed = set(allowed_values)
blank_allowed = set(allow_blank_taxonomy.get(label, []))
for row in conference_rows:
key = str(conf_ws.cell(row, conf_cols["key"]).value).strip()
raw = conf_ws.cell(row, column).value
if raw in (None, ""):
if key not in blank_allowed:
target = errors if label in required_taxonomies else warnings
add_issue(target, "blank_taxonomy", f"Taxonomie vide : {label}", row=row, key=key)
continue
for value in [part.strip() for part in str(raw).split(separator) if part.strip()]:
if value not in allowed:
add_issue(errors, "invalid_taxonomy", f"Valeur de taxonomie non autorisée : {value}", row=row, key=key, taxonomy=label)
speaker_keys = set()
if args.speakers:
sp_path = Path(args.speakers)
sp_wb = load_workbook(sp_path, data_only=False, read_only=False)
sp_sheet = config["speaker_sheet"]
if sp_sheet not in sp_wb.sheetnames:
add_issue(errors, "missing_sheet", f"Feuille absente : {sp_sheet}", entity="speaker")
else:
sp_ws = sp_wb[sp_sheet]
sp_headers = header_map(sp_ws, header_row)
sp_cols = require_columns(sp_headers, config["speaker_columns"], errors, "speaker")
if "key" in sp_cols:
sp_key_list = [
str(sp_ws.cell(row, sp_cols["key"]).value).strip()
for row in range(header_row + 1, sp_ws.max_row + 1)
if sp_ws.cell(row, sp_cols["key"]).value not in (None, "")
]
speaker_keys = set(sp_key_list)
for key, count in Counter(sp_key_list).items():
if count > 1:
add_issue(errors, "duplicate_speaker_key", f"Clé speaker dupliquée : {key}", key=key)
scan_formula_errors(sp_path, sp_wb, errors)
if "speakers" in conf_cols:
for row in conference_rows:
key = str(conf_ws.cell(row, conf_cols["key"]).value).strip()
raw = conf_ws.cell(row, conf_cols["speakers"]).value
if raw in (None, ""):
continue
refs = [part.strip() for part in str(raw).split(separator) if part.strip()]
for ref in refs:
if args.speakers and ref not in speaker_keys:
add_issue(errors, "unknown_speaker_reference", f"Référence speaker inconnue : {ref}", row=row, key=key)
if args.baseline_conferences:
baseline_keys = extract_keys(args.baseline_conferences, conf_sheet, header_row, config["conference_columns"]["key"])
if baseline_keys is None:
add_issue(errors, "invalid_conference_baseline", "Impossible de lire les clés du fichier conférences de référence")
else:
for missing in sorted(baseline_keys - set(keys)):
add_issue(errors, "missing_existing_conference_key", f"Clé conférence existante disparue : {missing}", key=missing)
if args.baseline_speakers:
baseline_keys = extract_keys(args.baseline_speakers, config["speaker_sheet"], header_row, config["speaker_columns"]["key"])
if baseline_keys is None:
add_issue(errors, "invalid_speaker_baseline", "Impossible de lire les clés du fichier speakers de référence")
else:
for missing in sorted(baseline_keys - speaker_keys):
add_issue(errors, "missing_existing_speaker_key", f"Clé speaker existante disparue : {missing}", key=missing)
scan_formula_errors(conf_path, conf_wb, errors)
report = {
"valid": not errors,
"errors": errors,
"warnings": warnings,
"summary": {
"conference_count": len(conference_rows),
"conference_key_count": len(set(keys)),
"speaker_count": len(speaker_keys),
"statuses": dict(statuses),
},
}
finish(report, args.report)
return 0 if not errors else 1
def finish(report, report_path):
output = json.dumps(report, ensure_ascii=False, indent=2)
print(output)
if report_path:
Path(report_path).write_text(output + "\n", encoding="utf-8")
if __name__ == "__main__":
sys.exit(main())
SHA-256: 68e6872a31ff32d8a771fae66c0cae946a36452977f8e78bf764c377ff493c69