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:
17
core/data/__init__.py
Normal file
17
core/data/__init__.py
Normal 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
219
core/data/db_iterator.py
Normal 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
555
core/data/excel_importer.py
Normal 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
|
||||
Reference in New Issue
Block a user