csv-to-db-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from csv_to_db_py.core import get_connector, load_csv_to_db
2
+ from csv_to_db_py.config import load_config
3
+
4
+ __all__ = ["get_connector", "load_csv_to_db", "load_config"]
csv_to_db_py/config.py ADDED
@@ -0,0 +1,44 @@
1
+ import json
2
+
3
+ SUPPORTED_DB_TYPES = {"postgres", "mysql", "mssql", "oracle"}
4
+
5
+
6
+ def validate_config(cfg: dict) -> None:
7
+ db_config = cfg.get("DATABASE_CONFIG")
8
+ if not isinstance(db_config, dict):
9
+ raise ValueError("config 'DATABASE_CONFIG' must be a dict")
10
+ db_type = db_config.get("type")
11
+ if db_type not in SUPPORTED_DB_TYPES:
12
+ raise ValueError(
13
+ f"config 'DATABASE_CONFIG.type' must be one of {', '.join(sorted(SUPPORTED_DB_TYPES))}, got {db_type!r}"
14
+ )
15
+ if "files_dir" not in cfg:
16
+ raise ValueError("config is missing required key 'files_dir'")
17
+
18
+
19
+ def load_config(config_path: str) -> dict:
20
+ with open(config_path, "r") as f:
21
+ cfg = json.load(f)
22
+ validate_config(cfg)
23
+ return cfg
24
+
25
+
26
+ PG_RESERVED = {
27
+ "insert",
28
+ "delete",
29
+ "update",
30
+ "select",
31
+ "drop",
32
+ "and",
33
+ "or",
34
+ "from",
35
+ "create",
36
+ "join",
37
+ "limit",
38
+ "table",
39
+ "order",
40
+ "user",
41
+ "not",
42
+ "where",
43
+ "group",
44
+ }
csv_to_db_py/core.py ADDED
@@ -0,0 +1,90 @@
1
+ import logging
2
+ import os
3
+
4
+ import polars as pl
5
+
6
+ from csv_to_db_py.db_connectors import postgres, mysql, mssql, oracle
7
+ from csv_to_db_py.db_connectors.base import BaseDBConnector
8
+ from csv_to_db_py.utils.table_creator import create_table_from_csv
9
+ from csv_to_db_py.utils.csv_loader import (
10
+ postgrestype_dict,
11
+ overwrite_table_with_csv_data,
12
+ convert_datetime_columns,
13
+ )
14
+ from csv_to_db_py.utils.cleaned_csv import apply_filter, get_dataframe_cleaned_new
15
+
16
+
17
+ def get_connector(db_config: dict) -> BaseDBConnector:
18
+ db_type = db_config["type"]
19
+ if db_type == "postgres":
20
+ return postgres.PostgresConnector(db_config)
21
+ elif db_type == "mysql":
22
+ return mysql.MySQLConnector(db_config)
23
+ elif db_type == "mssql":
24
+ return mssql.MSSQLConnector(db_config)
25
+ elif db_type == "oracle":
26
+ return oracle.OracleConnector(db_config)
27
+ else:
28
+ raise ValueError("Base de données non supportée")
29
+
30
+
31
+ def load_csv_to_db(config: dict, input_path: str | None = None) -> None:
32
+ if input_path is None:
33
+ input_path = config["files_dir"]
34
+
35
+ connector = get_connector(config["DATABASE_CONFIG"])
36
+ connector.connect()
37
+ engine = connector.get_engine()
38
+
39
+ # Détection du(s) fichier(s) CSV ou XLSX
40
+ if os.path.isfile(input_path) and (
41
+ input_path.endswith(".csv") or input_path.endswith(".xlsx")
42
+ ):
43
+ files = [input_path]
44
+
45
+ elif os.path.isdir(input_path):
46
+ files = [
47
+ os.path.join(input_path, f)
48
+ for f in os.listdir(input_path)
49
+ if f.endswith(".csv") or f.endswith(".xlsx")
50
+ ]
51
+
52
+ else:
53
+ raise ValueError(
54
+ "Le chemin fourni n'est ni un fichier CSV/XLSX existant ni un dossier valide"
55
+ )
56
+
57
+ print(f"Fichiers trouvés : {files}")
58
+ isUniqueFile = len(files) == 1
59
+ for file_path in files:
60
+ print(f"Traitement du fichier : {file_path}")
61
+ df = get_dataframe_cleaned_new(file_path, delimiter=config.get("delimiter"))
62
+
63
+ # Appliquer le filtre sur la colonne pour la valeur souhaitée
64
+ filter_config = config.get("FILTER", {})
65
+ df = apply_filter(df, filter_config)
66
+ if filter_config.get("column") and filter_config.get("value") is not None:
67
+ logging.info(
68
+ f"Filtre appliqué : {filter_config.get('column')} = {filter_config.get('value')} ({len(df)} lignes restantes)"
69
+ )
70
+
71
+ print(df.columns)
72
+ # ponytail: strptime runs on the whole file; polars is fast, re-sample only if it proves too slow
73
+ df = convert_datetime_columns(
74
+ df, threshold=config.get("datetime_detection_threshold", 1.0)
75
+ )
76
+ types_dict = postgrestype_dict(df)
77
+ print(types_dict)
78
+
79
+ if isUniqueFile:
80
+ table_name = config["table"]
81
+ else:
82
+ table_name = os.path.splitext(os.path.basename(file_path))[0]
83
+ create_table_from_csv(engine, df, table_name, types_dict)
84
+ print(f"Table {table_name} créée")
85
+ # Drop rows where all values are null (polars equivalent)
86
+ df = df.filter(~pl.all_horizontal(pl.all().is_null()))
87
+ overwrite_table_with_csv_data(
88
+ engine, df, table_name, batch_size=config.get("batch_size", 10000)
89
+ )
90
+ print(f"Données du fichier {file_path} chargées dans la table {table_name}")
@@ -0,0 +1,5 @@
1
+ # Expose les connecteurs pour un import simple
2
+ from .postgres import PostgresConnector as PostgresConnector
3
+ from .mysql import MySQLConnector as MySQLConnector
4
+ from .mssql import MSSQLConnector as MSSQLConnector
5
+ from .oracle import OracleConnector as OracleConnector
@@ -0,0 +1,14 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class BaseDBConnector(ABC):
5
+ def __init__(self, config):
6
+ self.config = config
7
+ self.engine = None
8
+
9
+ @abstractmethod
10
+ def connect(self):
11
+ pass
12
+
13
+ def get_engine(self):
14
+ return self.engine
@@ -0,0 +1,17 @@
1
+ from sqlalchemy import URL, create_engine
2
+ from csv_to_db_py.db_connectors.base import BaseDBConnector
3
+
4
+
5
+ class MSSQLConnector(BaseDBConnector):
6
+ def connect(self):
7
+ cfg = self.config
8
+ url = URL.create(
9
+ "mssql+pyodbc",
10
+ username=cfg["user"],
11
+ password=cfg["password"],
12
+ host=cfg["host"],
13
+ port=cfg["port"],
14
+ database=cfg["database"],
15
+ query={"driver": "ODBC Driver 17 for SQL Server"},
16
+ )
17
+ self.engine = create_engine(url)
@@ -0,0 +1,16 @@
1
+ from sqlalchemy import URL, create_engine
2
+ from csv_to_db_py.db_connectors.base import BaseDBConnector
3
+
4
+
5
+ class MySQLConnector(BaseDBConnector):
6
+ def connect(self):
7
+ cfg = self.config
8
+ url = URL.create(
9
+ "mysql+pymysql",
10
+ username=cfg["user"],
11
+ password=cfg["password"],
12
+ host=cfg["host"],
13
+ port=cfg["port"],
14
+ database=cfg["database"],
15
+ )
16
+ self.engine = create_engine(url)
@@ -0,0 +1,17 @@
1
+ from sqlalchemy import URL, create_engine
2
+ from csv_to_db_py.db_connectors.base import BaseDBConnector
3
+
4
+
5
+ class OracleConnector(BaseDBConnector):
6
+ def connect(self):
7
+ cfg = self.config
8
+ # DSN Oracle: host:port/service_name (ex: localhost:1521/XEPDB1)
9
+ url = URL.create(
10
+ "oracle+oracledb",
11
+ username=cfg["user"],
12
+ password=cfg["password"],
13
+ host=cfg["host"],
14
+ port=cfg["port"],
15
+ database=cfg["database"],
16
+ )
17
+ self.engine = create_engine(url)
@@ -0,0 +1,17 @@
1
+ from sqlalchemy import URL, create_engine
2
+ from csv_to_db_py.db_connectors.base import BaseDBConnector
3
+
4
+
5
+ class PostgresConnector(BaseDBConnector):
6
+ def connect(self):
7
+ cfg = self.config
8
+ # url = f"postgresql+psycopg2://{cfg['user']}:{cfg['password']}@{cfg['host']}:{cfg['port']}/{cfg['database']}"
9
+ url = URL.create(
10
+ "postgresql+psycopg2",
11
+ username=cfg["user"],
12
+ password=cfg["password"],
13
+ host=cfg["host"],
14
+ port=cfg["port"],
15
+ database=cfg["database"],
16
+ )
17
+ self.engine = create_engine(url)
csv_to_db_py/logger.py ADDED
@@ -0,0 +1,19 @@
1
+ import logging
2
+ from rich.logging import RichHandler
3
+
4
+
5
+ def set_logger(level: str):
6
+ logging.basicConfig(
7
+ format="%(message)s",
8
+ level=level.upper(),
9
+ handlers=[RichHandler()],
10
+ )
11
+ logger = logging.getLogger()
12
+ return logger
13
+
14
+
15
+ def get_logger():
16
+ return logging.getLogger(__name__)
17
+
18
+
19
+ logger = get_logger()
csv_to_db_py/main.py ADDED
@@ -0,0 +1,32 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from csv_to_db_py.config import load_config
5
+ from csv_to_db_py.core import load_csv_to_db
6
+ from csv_to_db_py.logger import set_logger
7
+
8
+
9
+ def parse_args(argv) -> argparse.Namespace:
10
+ parser = argparse.ArgumentParser(
11
+ prog="csv-to-db",
12
+ description="Load CSV/XLSX files into a database table",
13
+ )
14
+ parser.add_argument("input_path", nargs="?", default=None)
15
+ parser.add_argument("--config", default="mainConfig.json")
16
+ parser.add_argument(
17
+ "--log-level",
18
+ default="info",
19
+ choices=["debug", "info", "warning", "error"],
20
+ )
21
+ return parser.parse_args(argv)
22
+
23
+
24
+ def main(argv=None) -> None:
25
+ args = parse_args(sys.argv[1:] if argv is None else argv)
26
+ set_logger(args.log_level)
27
+ cfg = load_config(args.config)
28
+ load_csv_to_db(cfg, args.input_path)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,7 @@
1
+ # Expose les fonctions utiles si tu veux les importer facilement
2
+ from .table_creator import create_table_from_csv as create_table_from_csv
3
+ from .csv_loader import overwrite_table_with_csv_data as overwrite_table_with_csv_data
4
+ from .cleaned_csv import (
5
+ get_dataframe_cleaned_new as get_dataframe_cleaned_new,
6
+ normalize_column as normalize_column,
7
+ )
@@ -0,0 +1,105 @@
1
+ from collections import Counter
2
+ import polars as pl
3
+ import re
4
+ from csv_to_db_py.config import PG_RESERVED
5
+ from csv_to_db_py.logger import logger
6
+
7
+ import csv
8
+
9
+
10
+ def detect_delimiter(file_path):
11
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
12
+ sample = f.read(2048)
13
+ try:
14
+ delimiter = csv.Sniffer().sniff(sample).delimiter
15
+ except Exception:
16
+ delimiter = ";" # fallback si détection impossible : tu peux mettre "," si tu préfères
17
+ return delimiter
18
+
19
+
20
+ def quote_identifier(name: str) -> str:
21
+ return '"' + name.replace('"', '""') + '"'
22
+
23
+
24
+ def normalize_column(name: str) -> str:
25
+ reserved_word = Counter()
26
+ name = name.strip().lower() # lowercase + trim
27
+ name = re.sub(r"[^0-9a-zA-Z_]+", "_", name) # replace non-alphanum with "_"
28
+ name = re.sub(r"_+", "_", name) # collapse multiple "_"
29
+ name = name.strip("_") # remove leading/trailing "_"
30
+
31
+ # si le nom est un mot réservé SQL → ajouter suffixe incrémental
32
+ if name in PG_RESERVED:
33
+ reserved_word[name] += 1
34
+ name = f"{name}_{reserved_word[name]}"
35
+
36
+ return name
37
+
38
+
39
+ def get_dataframe_cleaned_new(file_path, delimiter=None):
40
+ # --- 1) Lecture du fichier selon l'extension ---
41
+ if file_path.endswith(".xlsx"):
42
+ print(f"Lecture Excel : {file_path}")
43
+ df = pl.read_excel(file_path)
44
+
45
+ elif file_path.endswith(".csv"):
46
+ print(f"Lecture CSV : {file_path}")
47
+
48
+ if delimiter is None:
49
+ delimiter = detect_delimiter(file_path)
50
+ print(f"Délimiteur utilisé pour {file_path}: '{delimiter}'")
51
+
52
+ # Essayer UTF-8, sinon fallback Latin-1
53
+ try:
54
+ df = pl.read_csv(file_path, separator=delimiter, encoding="utf-8")
55
+ except UnicodeDecodeError:
56
+ print("[ENCODING] UTF-8 échoué, tentative avec Latin-1...")
57
+ df = pl.read_csv(file_path, separator=delimiter, encoding="latin1")
58
+
59
+ else:
60
+ raise ValueError(f"Format non supporté : {file_path}")
61
+
62
+ # --- 2) Nettoyage des colonnes ---
63
+ new_columns = [
64
+ col.replace(" ", "").replace(".", "").replace("/", "") for col in df.columns
65
+ ]
66
+ new_columns = normalize_and_dedup_columns(new_columns)
67
+ df = df.rename(dict(zip(df.columns, new_columns)))
68
+
69
+ print(df)
70
+ return df
71
+
72
+
73
+ def apply_filter(df: pl.DataFrame, filter_config: dict) -> pl.DataFrame:
74
+ filter_col = filter_config.get("column")
75
+ filter_val = filter_config.get("value")
76
+ if not filter_col or filter_val is None:
77
+ return df
78
+ normalized_col = normalize_column(filter_col)
79
+ if normalized_col not in df.columns:
80
+ logger.warning(
81
+ f"Filtre ignoré : colonne '{filter_col}' absente après normalisation."
82
+ )
83
+ return df
84
+ col = df[normalized_col]
85
+ try:
86
+ cast_val = pl.lit(filter_val).cast(col.dtype)
87
+ return df.filter(col == cast_val)
88
+ except Exception:
89
+ logger.warning(
90
+ f"Filtre ignoré : valeur '{filter_val}' non convertible pour la colonne '{filter_col}'."
91
+ )
92
+ return df
93
+
94
+
95
+ def normalize_and_dedup_columns(columns):
96
+ seen = Counter()
97
+ new_cols = []
98
+ for col in columns:
99
+ normed = normalize_column(col) # ta fonction de normalisation
100
+ seen[normed] += 1
101
+ if seen[normed] == 1:
102
+ new_cols.append(normed)
103
+ else:
104
+ new_cols.append(f"{normed}_{seen[normed]}")
105
+ return new_cols
@@ -0,0 +1,265 @@
1
+ import polars as pl
2
+ from sqlalchemy import inspect, text
3
+ from csv_to_db_py.logger import logger
4
+ from csv_to_db_py.utils.cleaned_csv import quote_identifier
5
+
6
+
7
+ def _truncate_if_exists(engine, table_name, expected_columns):
8
+ """TRUNCATE if table exists → preserve schema & dependent views. Returns True if truncated."""
9
+ inspector = inspect(engine)
10
+ if not inspector.has_table(table_name):
11
+ return False
12
+ db_columns = [column["name"] for column in inspector.get_columns(table_name)]
13
+ if sorted(db_columns) != sorted(expected_columns):
14
+ # ponytail: column-name drift is guarded; TYPE drift is not (still fails loudly after truncate)
15
+ raise ValueError(
16
+ f"Schema drift on table {table_name!r}: existing columns {sorted(db_columns)} "
17
+ f"differ from CSV columns {sorted(expected_columns)}. "
18
+ "DROP TABLE ... CASCADE and recreate dependent views, or align the CSV columns."
19
+ )
20
+ with engine.begin() as conn:
21
+ conn.execute(text(f"TRUNCATE TABLE {quote_identifier(table_name)}"))
22
+ return True
23
+
24
+
25
+ def map_polars_dtype_to_postgresql(series):
26
+ dtype = series.dtype
27
+ dtype_str = str(dtype)
28
+
29
+ if "Int" in dtype_str:
30
+ return "INTEGER"
31
+ elif "Float" in dtype_str:
32
+ # Check if all non-null values are integers
33
+ non_null = series.drop_nulls()
34
+ if len(non_null) > 0 and (non_null == non_null.cast(pl.Int64)).all():
35
+ return "INTEGER"
36
+ else:
37
+ return "FLOAT"
38
+ elif "Boolean" in dtype_str or "Bool" in dtype_str:
39
+ return "BOOLEAN"
40
+ elif "Datetime" in dtype_str or "Date" in dtype_str:
41
+ return "TIMESTAMP"
42
+ elif "Utf8" in dtype_str or "String" in dtype_str:
43
+ return "VARCHAR"
44
+ else:
45
+ return "VARCHAR"
46
+
47
+
48
+ def detect_and_parse_datetime_column(dataframe, column_name, threshold=1.0):
49
+ """
50
+ Détecte automatiquement si une colonne string contient des dates et trouve le format approprié.
51
+ Utilise l'inférence automatique de Polars qui supporte de nombreux formats.
52
+
53
+ Logique de validation:
54
+ - Filtre les nulls et chaînes vides (valeurs non significatives pour l'analyse)
55
+ - Parmi les valeurs "utiles" restantes, au moins `datetime_detection_threshold` doivent être des dates valides
56
+ - Les valeurs invalides sont converties en null automatiquement (strict=False)
57
+ - Préserve tous les nulls et chaînes vides originaux
58
+ """
59
+ column = dataframe[column_name]
60
+ dtype_str = str(column.dtype)
61
+
62
+ # Only process string columns
63
+ if "Utf8" not in dtype_str and "String" not in dtype_str:
64
+ return dataframe
65
+
66
+ # Filter out nulls AND empty strings from original data
67
+ # Ces valeurs ne sont pas significatives pour déterminer si c'est une colonne de dates
68
+ useful_values = column.filter(
69
+ (column.is_not_null()) & (column.str.strip_chars() != "")
70
+ )
71
+
72
+ num_useful = len(useful_values)
73
+ if num_useful == 0:
74
+ return dataframe
75
+
76
+ # Seuil configurable pour la détection de dates (défaut: 1.0 = 100%, désactivé)
77
+ MIN_SUCCESS_RATE = threshold
78
+
79
+ # Try automatic datetime parsing with Polars (supports many formats automatically)
80
+ try:
81
+ # Polars can auto-detect many datetime formats
82
+ # strict=False means invalid values become null instead of raising errors
83
+ parsed = dataframe.with_columns(
84
+ pl.col(column_name).str.to_datetime(strict=False)
85
+ )
86
+
87
+ # Count how many useful values were successfully parsed as dates
88
+ # On compte les dates parsées DANS LE DATAFRAME ORIGINAL (pour exclure les nulls initiaux)
89
+ parsed_col = parsed[column_name]
90
+
91
+ # On doit compter combien de valeurs utiles ont été converties en dates
92
+ # Pour cela, on regarde la colonne parsée aux mêmes positions que les valeurs utiles
93
+ original_indices = (
94
+ column.to_frame()
95
+ .with_row_count("idx")
96
+ .filter((column.is_not_null()) & (column.str.strip_chars() != ""))["idx"]
97
+ )
98
+
99
+ parsed_useful = parsed_col.gather(original_indices)
100
+ parsed_useful = parsed_useful.filter(parsed_useful.is_not_null())
101
+ num_parsed = len(parsed_useful)
102
+
103
+ success_rate = num_parsed / num_useful
104
+
105
+ # Si au moins la part configurée des valeurs utiles est parsée, on convertit
106
+ if success_rate >= MIN_SUCCESS_RATE:
107
+ num_invalid = num_useful - num_parsed
108
+ logger.info(
109
+ f"Colonne '{column_name}' détectée comme datetime (format auto-détecté) - "
110
+ f"{num_parsed}/{num_useful} valeurs utiles parsées ({success_rate:.1%}), "
111
+ f"{num_invalid} valeur(s) invalide(s) convertie(s) en null"
112
+ )
113
+ return parsed
114
+ except Exception as e:
115
+ logger.debug(f"Auto-détection datetime échouée pour '{column_name}': {e}")
116
+
117
+ # Try common date formats manually if auto-detection fails
118
+ common_formats = [
119
+ # ISO formats (YYYY-MM-DD)
120
+ "%Y-%m-%d",
121
+ "%Y-%m-%d %H:%M:%S",
122
+ "%Y-%m-%dT%H:%M:%S",
123
+ "%Y-%m-%dT%H:%M:%S.%f",
124
+ # French/European formats (DD/MM/YYYY)
125
+ "%d/%m/%Y",
126
+ "%d/%m/%Y %H:%M:%S",
127
+ "%d/%m/%y",
128
+ # American formats (MM/DD/YYYY) - with AM/PM support
129
+ "%m/%d/%Y",
130
+ "%m/%d/%Y %H:%M:%S",
131
+ "%m/%d/%Y %I:%M:%S %p", # 12-hour format with AM/PM
132
+ # European dot format (DD.MM.YYYY)
133
+ "%d.%m.%Y",
134
+ "%d.%m.%Y %H:%M:%S",
135
+ # Other formats
136
+ "%d-%m-%Y",
137
+ "%d-%m-%Y %H:%M:%S",
138
+ "%d-%m-%y",
139
+ "%Y/%m/%d",
140
+ "%Y/%m/%d %H:%M:%S",
141
+ ]
142
+
143
+ for date_format in common_formats:
144
+ try:
145
+ # strict=False: invalid values become null
146
+ parsed = dataframe.with_columns(
147
+ pl.col(column_name).str.strptime(
148
+ pl.Datetime, format=date_format, strict=False
149
+ )
150
+ )
151
+
152
+ # Same logic as above
153
+ parsed_col = parsed[column_name]
154
+ original_indices = (
155
+ column.to_frame()
156
+ .with_row_count("idx")
157
+ .filter((column.is_not_null()) & (column.str.strip_chars() != ""))[
158
+ "idx"
159
+ ]
160
+ )
161
+
162
+ parsed_useful = parsed_col.gather(original_indices)
163
+ parsed_useful = parsed_useful.filter(parsed_useful.is_not_null())
164
+ num_parsed = len(parsed_useful)
165
+ success_rate = num_parsed / num_useful
166
+
167
+ # Si au moins la part configurée des valeurs utiles est parsée, on accepte
168
+ if success_rate >= MIN_SUCCESS_RATE:
169
+ num_invalid = num_useful - num_parsed
170
+ logger.info(
171
+ f"Colonne '{column_name}' détectée comme datetime (format: {date_format}) - "
172
+ f"{num_parsed}/{num_useful} valeurs utiles parsées ({success_rate:.1%}), "
173
+ f"{num_invalid} valeur(s) invalide(s) convertie(s) en null"
174
+ )
175
+ return parsed
176
+ except Exception:
177
+ continue
178
+
179
+ # If no format worked, return original dataframe
180
+ # Les nulls et valeurs vides sont préservés dans tous les cas
181
+ return dataframe
182
+
183
+
184
+ def convert_datetime_columns(dataframe, threshold=1.0):
185
+ """Convert every string column that looks like a date, using the given detection threshold."""
186
+ for column_name in dataframe.columns:
187
+ dataframe = detect_and_parse_datetime_column(
188
+ dataframe, column_name, threshold=threshold
189
+ )
190
+ return dataframe
191
+
192
+
193
+ def postgrestype_dict(dataframe, sample_size=10000):
194
+ """
195
+ Détermine les types PostgreSQL pour chaque colonne du DataFrame.
196
+
197
+ Args:
198
+ dataframe: DataFrame Polars
199
+ sample_size: Taille maximale de l'échantillon pour l'analyse des types.
200
+ Si le DataFrame est plus grand, un échantillon aléatoire est utilisé.
201
+
202
+ Returns:
203
+ dict: Mapping {nom_colonne: type_postgresql}
204
+ """
205
+ column_types = {}
206
+
207
+ # Use sample if dataframe is larger than sample_size
208
+ num_rows = len(dataframe)
209
+ if num_rows > sample_size:
210
+ logger.info(
211
+ f"DataFrame contient {num_rows} lignes. Utilisation d'un échantillon aléatoire "
212
+ f"de {sample_size} lignes pour la détection des types."
213
+ )
214
+ df_sample = dataframe.sample(n=sample_size, seed=42)
215
+ else:
216
+ df_sample = dataframe
217
+
218
+ for column_name in df_sample.columns:
219
+ column_types[column_name] = map_polars_dtype_to_postgresql(
220
+ df_sample[column_name]
221
+ )
222
+
223
+ return column_types
224
+
225
+
226
+ def overwrite_table_with_csv_data(engine, csv_data, table_name, batch_size=10000):
227
+ # Convert polars DataFrame to pandas for SQLAlchemy compatibility
228
+ pandas_data = csv_data.to_pandas()
229
+
230
+ total_rows = len(pandas_data)
231
+
232
+ # Typed table always pre-created (create_table_from_csv raises on failure); to_sql only appends.
233
+ truncated = _truncate_if_exists(engine, table_name, csv_data.columns)
234
+
235
+ if total_rows <= batch_size:
236
+ pandas_data.to_sql(name=table_name, con=engine, if_exists="append", index=False)
237
+ logger.info(
238
+ f"Table '{table_name}' {'vidée puis remplie' if truncated else 'créée'} "
239
+ f"avec {total_rows} lignes (insertion unique)"
240
+ )
241
+ else:
242
+ # Multi-batch insertion
243
+ num_batches = (total_rows + batch_size - 1) // batch_size # Ceiling division
244
+ logger.info(
245
+ f"Insertion de {total_rows} lignes dans '{table_name}' par batch de {batch_size} "
246
+ f"({num_batches} batch(s))"
247
+ )
248
+
249
+ for i in range(0, total_rows, batch_size):
250
+ batch_num = (i // batch_size) + 1
251
+ end_idx = min(i + batch_size, total_rows)
252
+ batch_data = pandas_data.iloc[i:end_idx]
253
+
254
+ batch_data.to_sql(
255
+ name=table_name, con=engine, if_exists="append", index=False
256
+ )
257
+
258
+ logger.info(
259
+ f" Batch {batch_num}/{num_batches}: {len(batch_data)} lignes insérées "
260
+ f"(lignes {i + 1} à {end_idx})"
261
+ )
262
+
263
+ logger.info(
264
+ f"Insertion terminée: {total_rows} lignes insérées dans '{table_name}'"
265
+ )
@@ -0,0 +1,19 @@
1
+ from csv_to_db_py.logger import logger
2
+ from csv_to_db_py.utils.cleaned_csv import quote_identifier
3
+
4
+
5
+ def create_table_from_csv(engine, dataframe, table, types_dict):
6
+ # Génération de la requête SQL pour créer la table
7
+ # query = f"CREATE TABLE {table} ({', '.join([column + ' '+types_dict[column] for column in columns])});"
8
+ columns = dataframe.columns
9
+ query = f"""CREATE TABLE IF NOT EXISTS {quote_identifier(table)} ({", ".join([quote_identifier(column) + " " + types_dict[column] for column in columns])});"""
10
+ with engine.begin() as conn:
11
+ conn.execute(query)
12
+
13
+ logger.info(f"Table '{table}' crée")
14
+
15
+ columns = dataframe.columns
16
+
17
+ logger.debug(columns)
18
+
19
+ logger.debug(query)
@@ -0,0 +1,276 @@
1
+ Metadata-Version: 2.5
2
+ Name: csv-to-db-py
3
+ Version: 0.1.0
4
+ Summary: Convert CSV file or folder of CSV to DB with table filled with the CSV data
5
+ Author: Karim Ounnoughi, Claude Fauconnet, Juste Bonzene
6
+ License: MIT
7
+ License-File: LICENSE.md
8
+ Requires-Python: >=3.13
9
+ Requires-Dist: fastexcel>=0.20.2
10
+ Requires-Dist: openpyxl<4.0.0,>=3.1.5
11
+ Requires-Dist: oracledb<3.0.0,>=2.0.0
12
+ Requires-Dist: pandas<3.0.0,>=2.3.2
13
+ Requires-Dist: polars>=1.20.0
14
+ Requires-Dist: psycopg2<3.0.0,>=2.9.10
15
+ Requires-Dist: pyarrow<19.0.0,>=18.0.0
16
+ Requires-Dist: pymysql<2.0.0,>=1.1.0
17
+ Requires-Dist: pyodbc<6.0.0,>=5.0.0
18
+ Requires-Dist: requests<3.0.0,>=2.32.5
19
+ Requires-Dist: rich<15.0.0,>=14.1.0
20
+ Requires-Dist: sqlalchemy<3.0.0,>=2.0.43
21
+ Description-Content-Type: text/markdown
22
+
23
+ # CSV to DB
24
+
25
+ Convert table files or folders to PostgreSQL database tables.The database need to already exists. It have a feature of autodection of types.
26
+ -dates (multiples formats cited on tests parts)
27
+ -int
28
+ -float
29
+ -string
30
+
31
+
32
+ ## Prerequisites
33
+
34
+ - Python 3.13+
35
+ - UV (https://docs.astral.sh/uv/getting-started/installation/)
36
+ - PostgreSQL database
37
+ - files with CSV or XLSX format
38
+ - MySQL/MSSQL/Oracle connections rely on bundled drivers: `pymysql`, `pyodbc`, `oracledb`
39
+ - `pyodbc` additionally requires an ODBC driver; `oracledb` runs in thin mode (no Oracle client needed)
40
+
41
+
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ git clone https://github.com/ounnoughikarim/CSV_TO_DB_PY.git
47
+ cd CSV_TO_DB_PY
48
+ uv sync
49
+ ```
50
+
51
+ ## Install from PyPI
52
+
53
+ The package is published to PyPI and can be installed with:
54
+
55
+ ```bash
56
+ uv add csv-to-db-py
57
+ ```
58
+
59
+ or with pip:
60
+
61
+ ```bash
62
+ pip install csv-to-db-py
63
+ ```
64
+
65
+ ## Publishing
66
+
67
+ To publish a new release to PyPI, push a tag matching the version in `pyproject.toml`:
68
+
69
+ ```bash
70
+ git tag 0.1.0
71
+ git push origin 0.1.0
72
+ ```
73
+
74
+ The `publish` GitHub Actions workflow builds the package and publishes it to PyPI via trusted publishing.
75
+
76
+ ## Configuration
77
+
78
+ Create `mainConfig.json` from the template:
79
+
80
+ ```json
81
+ {
82
+ "DATABASE_CONFIG": {
83
+ "type": "postgres",
84
+ "host": "hostname",
85
+ "port": 5432,
86
+ "user": "postgres",
87
+ "password": "your_password",
88
+ "database": "your_database"
89
+ },
90
+ "files_dir": "C:\\path\\to\\your\\file",
91
+ "table": "table_name",
92
+ "delimiter": ";",
93
+ "datetime_detection_threshold": 1.0,
94
+ "batch_size": 100000
95
+ }
96
+ ```
97
+
98
+ ### Configuration Parameters
99
+
100
+ - `DATABASE_CONFIG`: Database connection settings
101
+ - `type`: Database type (postgres, mysql, mssql, oracle)
102
+ - `host`: Database hostname
103
+ - `port`: Database port
104
+ - `user`: Database username
105
+ - `password`: Database password
106
+ - `database`: Database name
107
+ - `files_dir`: Path to a file or folder containing multiple files (CSV or XLSX)
108
+ - `table`: Table name (optional - uses filename if not specified for single file)
109
+ - `delimiter`: CSV delimiter character (e.g., ";", ",", "\t")
110
+ - `datetime_detection_threshold`: *(optional)* Threshold for automatic datetime detection
111
+ - Range: `0.0` to `1.0` (0% to 100%)
112
+ - Default: `1.0` (100%, strict - only perfect matches)
113
+ - Recommended: `0.7` (70% threshold for flexible detection)
114
+ - A column is detected as datetime if at least X% of non-null, non-empty values can be parsed as valid dates
115
+ - Invalid values are automatically converted to NULL
116
+ - **Examples:**
117
+ - `1.0`: Only columns with 100% valid dates are detected (safest, default)
118
+ - `0.9`: Allows up to 10% invalid date values
119
+ - `0.7`: Allows up to 30% invalid date values (recommended for real-world data)
120
+ - `batch_size`: *(optional)* Number of rows to insert per batch for large files
121
+ - Default: `10000` (10k rows per batch)
122
+ - Reduces memory usage for large datasets
123
+ - SQLAlchemy can struggle with very large files, batching prevents memory issues
124
+ - **Recommended values:**
125
+ - `100000`: Good for most cases (files up to several GB)
126
+ - `50000`: For memory-constrained environments
127
+ - `10000`: Default (10k rows per batch), good balance of speed and memory
128
+ - `500000`: For high-memory systems and faster insertions
129
+
130
+ ## Usage
131
+
132
+ ```bash
133
+ uv run csv-to-db --config mainConfig.json
134
+ ```
135
+
136
+ With custom log level:
137
+ ```bash
138
+ uv run csv-to-db --config mainConfig.json --log-level info
139
+ ```
140
+
141
+ ## Testing
142
+
143
+ Run the test suite with pytest:
144
+ ```bash
145
+ uv run pytest
146
+ ```
147
+
148
+ This suite tests automatic datetime detection with various date formats, using [tests/date_formats_test.csv](tests/date_formats_test.csv) containing 14 columns:
149
+
150
+ **Date columns (should be detected as datetime):**
151
+ - **FR_date**: French format (DD/MM/YYYY)
152
+ - **FR_datetime**: French format with time (DD/MM/YYYY HH:MM:SS)
153
+ - **ISO_date**: ISO 8601 date (YYYY-MM-DD)
154
+ - **ISO_datetime**: ISO 8601 with time (YYYY-MM-DDTHH:MM:SS)
155
+ - **USA_date**: American format (MM/DD/YYYY)
156
+ - **USA_datetime**: American format with 12-hour time (MM/DD/YYYY hh:mm:ss AM/PM)
157
+ - **EUR_date**: European format (DD.MM.YYYY)
158
+ - **EUR_datetime**: European format with time (DD.MM.YYYY HH:MM:SS)
159
+ - **ISO_time**: ISO format with space separator (YYYY-MM-DD HH:MM:SS)
160
+
161
+ **Non-date columns (should NOT be detected as datetime):**
162
+ - **int_column**: Integer values
163
+ - **string_column**: Pure text strings
164
+ - **mixed_column**: Mix of text with 1-2 dates (should not be detected as datetime)
165
+ - **float_column**: Floating point numbers
166
+ - **bool_column**: Boolean values
167
+
168
+ The test demonstrates automatic format detection, handling of null values, conversion of invalid values to null, and proper rejection of non-date columns.
169
+
170
+ ### Batch Insertion for Large Files
171
+
172
+ For large datasets, the tool automatically splits data insertion into batches to prevent memory issues:
173
+
174
+ **How it works:**
175
+ - If total rows ≤ `batch_size`: Single insertion (fastest)
176
+ - If total rows > `batch_size`: Multiple insertions in batches
177
+
178
+ **Example with 250,000 rows and `batch_size: 100000`:**
179
+ ```
180
+ Pre-step: TRUNCATE table if it already exists (preserves schema & dependent views)
181
+ Batch 1: Rows 1 to 100,000 (100,000 rows) - APPEND
182
+ Batch 2: Rows 100,001 to 200,000 (100,000 rows) - APPEND
183
+ Batch 3: Rows 200,001 to 250,000 (50,000 rows) - APPEND
184
+ ```
185
+
186
+ **Benefits:**
187
+ - ✅ Prevents memory overflow with large files
188
+ - ✅ Better progress tracking through logs
189
+ - ✅ Allows processing files larger than available RAM
190
+ - ✅ Automatic recovery possible (first batch creates table structure)
191
+
192
+ ### Table Overwrite Strategy
193
+
194
+ When a table already exists, the tool **does not DROP** it. Instead it runs `TRUNCATE` then appends the new data.
195
+
196
+ **Why:**
197
+
198
+ - Dropping a table fails if a view (or other object) depends on it.
199
+ - TRUNCATE empties the rows while preserving the table schema and any dependent views/constraints.
200
+
201
+ **Behavior:**
202
+
203
+ - Table does not exist → pre-created by `create_table_from_csv` with detected types.
204
+ - Table exists → `TRUNCATE TABLE "<name>"` then `INSERT` (append mode). If the existing table's columns differ from the CSV (schema drift), the run aborts with a clear error **before truncating**, so the old data is preserved.
205
+
206
+ **Caveat — schema drift:**
207
+
208
+ - TRUNCATE keeps the **existing** column definitions. Column-name drift (added/removed/renamed columns) is detected and blocks the overwrite before any data is lost. Type drift is not detected and still fails loudly after truncate.
209
+ - To load a CSV whose schema differs, manually `DROP TABLE ... CASCADE` (and recreate dependent views) or alter the table to match the new schema.
210
+
211
+ **Performance tips:**
212
+ - Larger batch sizes = faster insertion but more memory usage
213
+ - Smaller batch sizes = slower but safer for limited memory
214
+ - Default 10k is optimal for most use cases
215
+
216
+ ### Type Detection Optimization (Sampling)
217
+
218
+ For large datasets, type detection automatically uses random sampling to improve performance:
219
+
220
+ **How it works:**
221
+ - If DataFrame has ≤ 10,000 rows: Full dataset is analyzed
222
+ - If DataFrame has > 10,000 rows: Random sample of 10,000 rows is used
223
+ - Sample uses fixed seed (42) for reproducibility
224
+ - Applies to both type detection and datetime format detection
225
+
226
+ **Benefits:**
227
+ - ✅ Significantly faster type detection for large files (millions of rows)
228
+ - ✅ Sample size of 10,000 rows provides excellent type accuracy
229
+ - ✅ Reduces processing time without compromising quality
230
+ - ✅ Transparent logging when sampling is used
231
+
232
+ **Example:**
233
+ ```
234
+ DataFrame contient 5,000,000 lignes. Utilisation d'un échantillon aléatoire de 10000 lignes pour la détection des types.
235
+ ```
236
+
237
+ **Note:** This optimization only affects type detection. The full dataset is still loaded and inserted into the database.
238
+
239
+ ### Datetime Detection Behavior
240
+
241
+ The datetime detection threshold controls how strict the algorithm is when detecting date columns:
242
+
243
+ **With `datetime_detection_threshold: 1.0` (default, 100%):**
244
+ - Only columns where **ALL** non-null, non-empty values are valid dates will be detected
245
+ - This is the safest option to avoid false positives
246
+ - Use this when you want explicit control over which columns are dates
247
+
248
+ **With `datetime_detection_threshold: 0.7` (recommended for flexible detection):**
249
+ - Columns where **at least 70%** of non-null, non-empty values are valid dates will be detected
250
+ - Invalid values are automatically converted to NULL
251
+ - Useful when your data might contain occasional typos or invalid date entries
252
+ - Example: A column with 9 valid dates and 1 "invalid" string → detected as datetime
253
+
254
+ **Example scenarios:**
255
+ ```
256
+ Column: ["2024-01-01", "2024-01-02", "invalid", null, ""]
257
+ - 3 non-null, non-empty values total
258
+ - 2 valid dates (66.7%)
259
+ - With threshold 1.0: NOT detected (66.7% < 100%)
260
+ - With threshold 0.7: NOT detected (66.7% < 70%)
261
+
262
+ Column: ["2024-01-01", "2024-01-02", "2024-01-03", "invalid", null]
263
+ - 4 non-null, non-empty values total
264
+ - 3 valid dates (75%)
265
+ - With threshold 1.0: NOT detected (75% < 100%)
266
+ - With threshold 0.7: DETECTED (75% ≥ 70%), "invalid" becomes NULL
267
+ ```
268
+
269
+ ## Development
270
+
271
+ Check code before committing:
272
+ ```bash
273
+ uv run ruff check . --fix
274
+ uv run ruff check .
275
+ uv run ruff format
276
+ ```
@@ -0,0 +1,20 @@
1
+ csv_to_db_py/__init__.py,sha256=wHSdd1uCl2Z9KWUYYOy1SjUIjTiZYkETXaEzf9OYdv0,166
2
+ csv_to_db_py/config.py,sha256=ThLnOjgWFlKQz_AV8e0Ig_bgGjT-BR8BUNLccQ24Bds,986
3
+ csv_to_db_py/core.py,sha256=gyeeENYIqVr2Uh-RKJFKO1BSYsgok_6-s0swIiwOc4o,3326
4
+ csv_to_db_py/logger.py,sha256=WAZ4TyG4_7i9brjeUqkZSKWN9-fvsXXSwf18cL2Ayp0,340
5
+ csv_to_db_py/main.py,sha256=M-uvY-sUVMmT00JmPmriJWJJNuwkE2eHhcDZ6tWXD6I,875
6
+ csv_to_db_py/db_connectors/__init__.py,sha256=9tMXKzesBuar1Vt1ZfviYDxPehZLRc1YrU06YCDcyPA,267
7
+ csv_to_db_py/db_connectors/base.py,sha256=09VWv1-VIVX0_-3rWWqhH4Cw-zIoFiqYq62UHmYwcFM,265
8
+ csv_to_db_py/db_connectors/mssql.py,sha256=_--AcjHDYEB1tpP3H4atA9SAktCeOVZx4WvFU8veRxg,530
9
+ csv_to_db_py/db_connectors/mysql.py,sha256=vdEw5wETA4qv6QmAUibRHNRjPS6eopJ_SiuuJ-EHmko,468
10
+ csv_to_db_py/db_connectors/oracle.py,sha256=h7bKg9rf8xMWutYoeLAM2-9_gyyw7mEgMiD6k_t0rhk,544
11
+ csv_to_db_py/db_connectors/postgres.py,sha256=w8gayoPoOAIvRr86qbvPiB506U8nD1cy5ZP41ASuUFY,596
12
+ csv_to_db_py/utils/__init__.py,sha256=FRBa1lAxuuXDF8ZxaU5BQsnQ3e-jlaqVpid_33_n5Fg,357
13
+ csv_to_db_py/utils/cleaned_csv.py,sha256=13qRbegpXdWMEZ0vncUO37HAy3SwvVbAdt9ojb8foD4,3426
14
+ csv_to_db_py/utils/csv_loader.py,sha256=YMCZZKVW-PoL5wg1XxG8cF5VpxoFZMpctiB1iVUI2LU,10241
15
+ csv_to_db_py/utils/table_creator.py,sha256=O2fszUoGd-6ONGdERUagRPnR0wDA-JsXvnH5pEMaT20,720
16
+ csv_to_db_py-0.1.0.dist-info/METADATA,sha256=PfyE9KWW3fWxxSIMSCESCZteNlZoPbGBY6XMcHKTZVg,9864
17
+ csv_to_db_py-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
18
+ csv_to_db_py-0.1.0.dist-info/entry_points.txt,sha256=5XTyokDAkfbkSQM0uIfW1IkKfHM6lKHcthbNTVMBGiQ,53
19
+ csv_to_db_py-0.1.0.dist-info/licenses/LICENSE.md,sha256=xrhxfiSLPm17ApyK9kdDxAMHUYSN1zw-2JRkxvrp2qo,1073
20
+ csv_to_db_py-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ csv-to-db = csv_to_db_py.main:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 OUNNOUGHI KARIM
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+