feat: import Excel → SQLite + boucle données → UI dans le VWB

- ExcelImporter : import .xlsx → SQLite auto (détection types, batch insert)
- DBIterator : lecture ligne par ligne avec filtre/tri/limite
- VWB actions : "Importer Excel" + "Pour chaque ligne" dans la palette
- DAG executor : pré-exécution import, boucle foreach avec injection
  ${current_row.colonne} dans les étapes dépendantes
- 36 tests unitaires Excel/DB (tous passent)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-16 23:10:51 +01:00
parent 5e3865d328
commit 9da804bb6e
9 changed files with 1832 additions and 4 deletions

17
core/data/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
core.data — Import Excel et itération SQLite pour l'injection UI.
Modules :
- ExcelImporter : import Excel → SQLite (auto-détection colonnes/types)
- DBIterator : itération sur tables SQLite pour le DAGExecutor
"""
from .excel_importer import ExcelImporter, ImportResult, PreviewResult
from .db_iterator import DBIterator
__all__ = [
"ExcelImporter",
"ImportResult",
"PreviewResult",
"DBIterator",
]

219
core/data/db_iterator.py Normal file
View File

@@ -0,0 +1,219 @@
"""
DBIterator — Itération sur les lignes d'une table SQLite pour l'injection UI.
Utilisé par le DAGExecutor pour lire chaque ligne et remplir
les champs d'un logiciel via les actions UI.
Auteur : Dom, Claude — mars 2026
"""
import logging
import sqlite3
from pathlib import Path
from typing import Dict, Iterator, List, Optional
logger = logging.getLogger(__name__)
# Chemin par défaut (même que ExcelImporter)
DEFAULT_DB_PATH = "data/databases/rpa_data.db"
class DBIterator:
"""Itère sur les lignes d'une table SQLite pour l'injection UI.
Fournit une interface simple pour :
- Itérer sur les lignes (comme dictionnaires)
- Compter les lignes
- Lister les tables et colonnes
- Récupérer une ligne par son rowid
Thread-safe : chaque appel ouvre sa propre connexion.
"""
def __init__(self, db_path: str = DEFAULT_DB_PATH):
"""
Initialise l'itérateur.
Args:
db_path: Chemin vers la base SQLite
"""
self.db_path = Path(db_path)
# ------------------------------------------------------------------
# Connexion
# ------------------------------------------------------------------
def _connect(self) -> sqlite3.Connection:
"""Ouvre une connexion SQLite en mode WAL."""
if not self.db_path.exists():
raise FileNotFoundError(
f"Base de données introuvable : {self.db_path}"
)
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
# ------------------------------------------------------------------
# Itération
# ------------------------------------------------------------------
def iterate(
self,
table_name: str,
where: Optional[str] = None,
order_by: Optional[str] = None,
limit: Optional[int] = None,
) -> Iterator[Dict]:
"""Itère sur les lignes d'une table comme dictionnaires.
Chaque ligne est un dict {nom_colonne: valeur}.
Les colonnes internes (_rowid, _imported_at) sont incluses.
Args:
table_name: Nom de la table
where: Clause WHERE (sans le mot-clé WHERE), ex: "age > 18"
order_by: Clause ORDER BY, ex: "nom ASC"
limit: Nombre max de lignes
Yields:
Dict pour chaque ligne
Exemple:
for row in iterator.iterate("patients", where="age > 30"):
print(row["nom"], row["age"])
"""
sql = f'SELECT * FROM "{table_name}"'
params: list = []
if where:
sql += f" WHERE {where}"
if order_by:
sql += f" ORDER BY {order_by}"
if limit is not None:
sql += " LIMIT ?"
params.append(limit)
conn = self._connect()
try:
cursor = conn.execute(sql, params)
for row in cursor:
yield dict(row)
finally:
conn.close()
# ------------------------------------------------------------------
# Comptage
# ------------------------------------------------------------------
def count(self, table_name: str, where: Optional[str] = None) -> int:
"""Compte les lignes d'une table.
Args:
table_name: Nom de la table
where: Clause WHERE optionnelle
Returns:
Nombre de lignes
"""
sql = f'SELECT COUNT(*) as cnt FROM "{table_name}"'
if where:
sql += f" WHERE {where}"
conn = self._connect()
try:
row = conn.execute(sql).fetchone()
return row["cnt"] if row else 0
finally:
conn.close()
# ------------------------------------------------------------------
# Métadonnées
# ------------------------------------------------------------------
def get_columns(self, table_name: str) -> List[Dict]:
"""Retourne les colonnes et leurs types.
Args:
table_name: Nom de la table
Returns:
Liste de dicts avec : name, type, notnull, default_value, pk
"""
conn = self._connect()
try:
cursor = conn.execute(f'PRAGMA table_info("{table_name}")')
columns = []
for row in cursor:
columns.append({
"name": row["name"],
"type": row["type"],
"notnull": bool(row["notnull"]),
"default_value": row["dflt_value"],
"pk": bool(row["pk"]),
})
return columns
finally:
conn.close()
def list_tables(self, db_path: Optional[str] = None) -> List[str]:
"""Liste les tables disponibles.
Args:
db_path: Chemin alternatif (utilise self.db_path par défaut)
Returns:
Liste des noms de tables (hors tables système sqlite_*)
"""
path = Path(db_path) if db_path else self.db_path
if not path.exists():
return []
conn = sqlite3.connect(str(path))
try:
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' "
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
return [row[0] for row in cursor.fetchall()]
finally:
conn.close()
# ------------------------------------------------------------------
# Accès par identifiant
# ------------------------------------------------------------------
def get_row(self, table_name: str, row_id: int) -> Optional[Dict]:
"""Récupère une ligne par son _rowid (ou rowid SQLite).
Args:
table_name: Nom de la table
row_id: ID de la ligne (_rowid ou rowid)
Returns:
Dict de la ligne ou None si introuvable
"""
# Essayer d'abord avec _rowid (colonne explicite créée par ExcelImporter)
# puis avec le rowid implicite de SQLite
conn = self._connect()
try:
# Vérifier si la table a une colonne _rowid explicite
columns = [
row[1] for row in conn.execute(
f'PRAGMA table_info("{table_name}")'
).fetchall()
]
if "_rowid" in columns:
sql = f'SELECT * FROM "{table_name}" WHERE _rowid = ?'
else:
sql = f'SELECT * FROM "{table_name}" WHERE rowid = ?'
row = conn.execute(sql, (row_id,)).fetchone()
return dict(row) if row else None
finally:
conn.close()

555
core/data/excel_importer.py Normal file
View File

@@ -0,0 +1,555 @@
"""
ExcelImporter — Import de fichiers Excel dans une base SQLite.
Détecte automatiquement les colonnes, types, et crée la table.
Supporte .xlsx et .xls (via openpyxl).
Auteur : Dom, Claude — mars 2026
"""
import logging
import re
import sqlite3
from dataclasses import dataclass, field
from datetime import datetime, date
from pathlib import Path
from typing import Any, Dict, List, Optional
import openpyxl
logger = logging.getLogger(__name__)
# Chemin par défaut de la base de données
DEFAULT_DB_PATH = "data/databases/rpa_data.db"
# Nombre de lignes analysées pour la détection de types
TYPE_DETECTION_SAMPLE_SIZE = 100
@dataclass
class ImportResult:
"""Résultat d'un import Excel."""
table_name: str
row_count: int
column_count: int
columns: Dict[str, str] # nom_colonne → type_sqlite
db_path: str
sheet_name: str
skipped_rows: int = 0
errors: List[str] = field(default_factory=list)
@property
def success(self) -> bool:
return self.row_count > 0 and not self.errors
@dataclass
class PreviewResult:
"""Aperçu d'un fichier Excel avant import."""
headers: List[str]
rows: List[List[Any]]
total_rows: int
sheet_name: str
detected_types: Dict[str, str]
class ExcelImporter:
"""Importe un fichier Excel dans une base SQLite.
Détecte automatiquement les colonnes, types, et crée la table.
Supporte .xlsx et .xls (via openpyxl).
"""
def __init__(self, db_path: str = DEFAULT_DB_PATH):
"""
Initialise l'importeur.
Args:
db_path: Chemin vers la base SQLite (créée si inexistante)
"""
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------------
# API publique
# ------------------------------------------------------------------
def import_file(
self,
excel_path: str,
table_name: Optional[str] = None,
sheet_name: Optional[str] = None,
) -> ImportResult:
"""Importe un fichier Excel complet.
1. Lit les headers (première ligne)
2. Détecte les types (text, integer, real, date)
3. Crée la table SQLite (CREATE TABLE IF NOT EXISTS)
4. Insère toutes les lignes en batch (INSERT)
5. Retourne un résumé
Args:
excel_path: Chemin du fichier .xlsx
table_name: Nom de la table (déduit du fichier si absent)
sheet_name: Feuille à importer (première par défaut)
Returns:
ImportResult avec le résumé de l'import
"""
excel_path = Path(excel_path)
if not excel_path.exists():
raise FileNotFoundError(f"Fichier introuvable : {excel_path}")
# Ouvrir le classeur
wb = openpyxl.load_workbook(str(excel_path), read_only=True, data_only=True)
try:
ws = self._get_sheet(wb, sheet_name)
actual_sheet_name = ws.title
# Lire toutes les lignes
all_rows = list(ws.iter_rows(values_only=True))
finally:
wb.close()
if len(all_rows) < 1:
raise ValueError(f"Le fichier est vide : {excel_path}")
# Extraire les headers de la première ligne
raw_headers = all_rows[0]
headers = self._clean_headers(raw_headers)
data_rows = all_rows[1:]
if not headers:
raise ValueError("Aucune colonne détectée dans la première ligne")
# Déterminer le nom de la table
if table_name is None:
table_name = self._sanitize_table_name(excel_path.stem)
else:
table_name = self._sanitize_table_name(table_name)
# Détecter les types de colonnes
col_types = self._detect_column_types(headers, data_rows)
# Créer la table et insérer les données
row_count, skipped, errors = self._create_and_insert(
table_name, headers, col_types, data_rows
)
result = ImportResult(
table_name=table_name,
row_count=row_count,
column_count=len(headers),
columns=col_types,
db_path=str(self.db_path),
sheet_name=actual_sheet_name,
skipped_rows=skipped,
errors=errors,
)
logger.info(
"Import terminé : %s → table '%s' (%d lignes, %d colonnes)",
excel_path.name,
table_name,
row_count,
len(headers),
)
return result
def preview(
self,
excel_path: str,
max_rows: int = 5,
sheet_name: Optional[str] = None,
) -> PreviewResult:
"""Aperçu des données avant import (headers + quelques lignes).
Args:
excel_path: Chemin du fichier .xlsx
max_rows: Nombre max de lignes à prévisualiser
sheet_name: Feuille à lire (première par défaut)
Returns:
PreviewResult avec les headers, quelques lignes et types détectés
"""
excel_path = Path(excel_path)
if not excel_path.exists():
raise FileNotFoundError(f"Fichier introuvable : {excel_path}")
wb = openpyxl.load_workbook(str(excel_path), read_only=True, data_only=True)
try:
ws = self._get_sheet(wb, sheet_name)
actual_sheet_name = ws.title
all_rows = list(ws.iter_rows(values_only=True))
finally:
wb.close()
if len(all_rows) < 1:
raise ValueError(f"Le fichier est vide : {excel_path}")
raw_headers = all_rows[0]
headers = self._clean_headers(raw_headers)
data_rows = all_rows[1:]
# Convertir les lignes d'aperçu en listes (pas tuples)
preview_rows = [list(row) for row in data_rows[:max_rows]]
# Détecter les types sur l'ensemble des données (pas juste l'aperçu)
detected_types = self._detect_column_types(headers, data_rows)
return PreviewResult(
headers=headers,
rows=preview_rows,
total_rows=len(data_rows),
sheet_name=actual_sheet_name,
detected_types=detected_types,
)
def list_sheets(self, excel_path: str) -> List[str]:
"""Liste les feuilles d'un fichier Excel.
Args:
excel_path: Chemin du fichier .xlsx
Returns:
Liste des noms de feuilles
"""
excel_path = Path(excel_path)
if not excel_path.exists():
raise FileNotFoundError(f"Fichier introuvable : {excel_path}")
wb = openpyxl.load_workbook(str(excel_path), read_only=True)
try:
return wb.sheetnames
finally:
wb.close()
# ------------------------------------------------------------------
# Détection de types
# ------------------------------------------------------------------
def _detect_column_types(
self,
headers: List[str],
data_rows: List[tuple],
) -> Dict[str, str]:
"""Détecte les types SQLite à partir des données.
Analyse un échantillon de lignes et détermine le meilleur type
SQLite pour chaque colonne : TEXT, INTEGER, REAL ou TEXT (pour dates).
Args:
headers: Liste des noms de colonnes
data_rows: Lignes de données (tuples)
Returns:
Dict nom_colonne → type SQLite
"""
sample = data_rows[:TYPE_DETECTION_SAMPLE_SIZE]
col_types: Dict[str, str] = {}
for col_idx, header in enumerate(headers):
# Collecter les valeurs non-nulles de cette colonne
values = []
for row in sample:
if col_idx < len(row) and row[col_idx] is not None:
values.append(row[col_idx])
if not values:
col_types[header] = "TEXT"
continue
col_types[header] = self._infer_type(values)
return col_types
def _infer_type(self, values: List[Any]) -> str:
"""Infère le type SQLite d'une colonne à partir de ses valeurs.
Priorité : INTEGER > REAL > TEXT
Les dates sont stockées en TEXT (format ISO).
"""
has_int = False
has_float = False
has_date = False
has_text = False
for val in values:
if isinstance(val, bool):
# bool est un sous-type de int en Python, traiter avant int
has_int = True
elif isinstance(val, int):
has_int = True
elif isinstance(val, float):
has_float = True
elif isinstance(val, (datetime, date)):
has_date = True
elif isinstance(val, str):
# Essayer de parser comme nombre
stripped = val.strip()
if self._is_integer(stripped):
has_int = True
elif self._is_float(stripped):
has_float = True
elif self._is_date_string(stripped):
has_date = True
else:
has_text = True
else:
has_text = True
# Si on a du texte pur, c'est TEXT
if has_text:
return "TEXT"
# Si mélange int/float, c'est REAL
if has_float:
return "REAL"
# Si que des int (ou bools)
if has_int and not has_date:
return "INTEGER"
# Dates pures → TEXT (format ISO)
if has_date:
return "TEXT"
return "TEXT"
@staticmethod
def _is_integer(s: str) -> bool:
"""Vérifie si une chaîne représente un entier."""
if not s:
return False
try:
int(s)
return True
except ValueError:
return False
@staticmethod
def _is_float(s: str) -> bool:
"""Vérifie si une chaîne représente un nombre décimal."""
if not s:
return False
try:
float(s)
# Rejeter les cas déjà gérés par is_integer (pas de point)
return "." in s or "e" in s.lower()
except ValueError:
return False
@staticmethod
def _is_date_string(s: str) -> bool:
"""Vérifie si une chaîne ressemble à une date (formats courants)."""
# Patterns courants : YYYY-MM-DD, DD/MM/YYYY, DD-MM-YYYY
date_patterns = [
r"^\d{4}-\d{2}-\d{2}$",
r"^\d{2}/\d{2}/\d{4}$",
r"^\d{2}-\d{2}-\d{4}$",
r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}",
]
return any(re.match(p, s) for p in date_patterns)
# ------------------------------------------------------------------
# Création de table et insertion
# ------------------------------------------------------------------
def _create_and_insert(
self,
table_name: str,
headers: List[str],
col_types: Dict[str, str],
data_rows: List[tuple],
) -> tuple:
"""Crée la table SQLite et insère les données en batch.
Args:
table_name: Nom de la table
headers: Noms de colonnes
col_types: Types SQLite par colonne
data_rows: Lignes de données
Returns:
Tuple (nombre_insérées, nombre_ignorées, liste_erreurs)
"""
# Construire la requête CREATE TABLE
col_defs = []
for h in headers:
sqlite_type = col_types.get(h, "TEXT")
# Noms de colonnes échappés avec des guillemets doubles
col_defs.append(f'"{h}" {sqlite_type}')
create_sql = (
f'CREATE TABLE IF NOT EXISTS "{table_name}" (\n'
f" _rowid INTEGER PRIMARY KEY AUTOINCREMENT,\n"
f" {', '.join(col_defs)},\n"
f" _imported_at TEXT DEFAULT CURRENT_TIMESTAMP\n"
f")"
)
# Requête d'insertion
placeholders = ", ".join(["?"] * len(headers))
col_names = ", ".join(f'"{h}"' for h in headers)
insert_sql = f'INSERT INTO "{table_name}" ({col_names}) VALUES ({placeholders})'
row_count = 0
skipped = 0
errors: List[str] = []
conn = sqlite3.connect(str(self.db_path))
try:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
# Créer la table
conn.execute(create_sql)
# Préparer les données pour l'insertion batch
batch: List[tuple] = []
for row_idx, row in enumerate(data_rows):
# Ignorer les lignes entièrement vides
if all(v is None for v in row):
skipped += 1
continue
# Aligner la longueur de la ligne sur le nombre de colonnes
values = []
for col_idx, header in enumerate(headers):
if col_idx < len(row):
val = row[col_idx]
val = self._convert_value(val, col_types.get(header, "TEXT"))
else:
val = None
values.append(val)
batch.append(tuple(values))
# Insertion en batch
if batch:
conn.executemany(insert_sql, batch)
row_count = len(batch)
conn.commit()
except Exception as e:
conn.rollback()
errors.append(f"Erreur lors de l'insertion : {e}")
logger.error("Erreur import : %s", e)
finally:
conn.close()
return row_count, skipped, errors
@staticmethod
def _convert_value(val: Any, target_type: str) -> Any:
"""Convertit une valeur Python pour SQLite.
- datetime/date → str ISO
- int/float → tel quel
- str → nettoyé (strip)
- None → None
"""
if val is None:
return None
if isinstance(val, (datetime, date)):
return val.isoformat()
if isinstance(val, str):
val = val.strip()
if not val:
return None
if target_type == "INTEGER":
try:
return int(val)
except (ValueError, TypeError):
return val
elif target_type == "REAL":
try:
return float(val)
except (ValueError, TypeError):
return val
return val
return val
# ------------------------------------------------------------------
# Utilitaires
# ------------------------------------------------------------------
def _sanitize_table_name(self, name: str) -> str:
"""Nettoie le nom pour SQLite.
- Remplace les caractères spéciaux par des underscores
- Supprime les espaces en début/fin
- Ajoute un préfixe si le nom commence par un chiffre
- Convertit en minuscules
"""
if not name:
return "import_data"
# Supprimer les accents/caractères spéciaux (garder alphanum + underscore)
clean = re.sub(r"[^\w]", "_", name.strip())
# Supprimer les underscores multiples
clean = re.sub(r"_+", "_", clean)
# Supprimer les underscores en début/fin
clean = clean.strip("_")
# Minuscules
clean = clean.lower()
# Préfixer si commence par un chiffre
if clean and clean[0].isdigit():
clean = f"t_{clean}"
# Fallback
if not clean:
clean = "import_data"
return clean
def _clean_headers(self, raw_headers: tuple) -> List[str]:
"""Nettoie les noms de colonnes.
- Supprime les None
- Strip les espaces
- Déduplique les noms identiques (ajoute un suffixe _2, _3, etc.)
"""
headers: List[str] = []
seen: Dict[str, int] = {}
for h in raw_headers:
if h is None:
continue
name = str(h).strip()
if not name:
continue
# Dédupliquer
if name in seen:
seen[name] += 1
name = f"{name}_{seen[name]}"
else:
seen[name] = 1
headers.append(name)
return headers
@staticmethod
def _get_sheet(wb: openpyxl.Workbook, sheet_name: Optional[str]) -> Any:
"""Récupère la feuille demandée ou la première par défaut."""
if sheet_name:
if sheet_name not in wb.sheetnames:
raise ValueError(
f"Feuille '{sheet_name}' introuvable. "
f"Feuilles disponibles : {wb.sheetnames}"
)
return wb[sheet_name]
return wb.active