SQLPyHelper 0.1.3__py3-none-any.whl → 0.1.5__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.
sqlpyhelper/__init__.py CHANGED
@@ -1,2 +1,9 @@
1
1
  # Match the version in setup.py
2
- __version__ = "0.1.3"
2
+ __version__ = "0.1.5"
3
+
4
+ from sqlpyhelper.db_helper import ( # noqa: F401
5
+ BackupError,
6
+ ConnectionError,
7
+ QueryError,
8
+ SQLPyHelperError,
9
+ )
@@ -0,0 +1,156 @@
1
+ import os
2
+ import shutil
3
+ import subprocess
4
+ from datetime import datetime
5
+
6
+ import pandas as pd
7
+
8
+ from sqlpyhelper.db_helper import SQLPyHelper
9
+
10
+
11
+ class AutomationUtils:
12
+ def __init__(self, db=None, **db_kwargs):
13
+ """
14
+ Optionally accepts db instance or connection parameters like:
15
+ db_type, host, user, password, database, port, driver.
16
+ """
17
+ self.db = db or SQLPyHelper(**db_kwargs)
18
+
19
+ def backup_database(self, target="local", tag="autobackup"):
20
+ """
21
+ Backs up the active PostgreSQL database using pg_dump.
22
+
23
+ Args:
24
+ target (str): Backup destination ("local" only for now).
25
+ tag (str): Custom tag for backup file naming.
26
+ """
27
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M")
28
+ filename = f"{tag}_{timestamp}.sql"
29
+ backup_dir = "backups"
30
+ os.makedirs(backup_dir, exist_ok=True)
31
+ filepath = os.path.join(backup_dir, filename)
32
+
33
+ db_name = self.db.database
34
+ user = self.db.user
35
+ host = self.db.host or "localhost"
36
+ port = str(self.db.port or "5432")
37
+
38
+ try:
39
+ if self.db.db_type == "sqlite":
40
+ filename2 = f"{tag}_{timestamp}.db"
41
+ sqlite_filepath = os.path.join(backup_dir, filename2)
42
+ shutil.copy2(self.db.database, sqlite_filepath)
43
+ else:
44
+ print(f"📦 Backing up database to {filepath}")
45
+ subprocess.run(
46
+ [
47
+ "pg_dump",
48
+ "-h",
49
+ host,
50
+ "-p",
51
+ port,
52
+ "-U",
53
+ user,
54
+ db_name,
55
+ "-F",
56
+ "c",
57
+ "-f",
58
+ filepath,
59
+ ],
60
+ check=True,
61
+ shell=False,
62
+ )
63
+ except subprocess.CalledProcessError as e:
64
+ print("❌ Backup failed:", e)
65
+
66
+ def load_data_from_csv(self, file_path, table_name, if_exists="append"):
67
+ """
68
+ Loads a CSV file into the specified database table.
69
+
70
+ Args:
71
+ file_path (str): Path to the CSV file.
72
+ table_name (str): Destination table name in the database.
73
+ if_exists (str): 'append' or 'replace'. Default is 'append'.
74
+ """
75
+ df = pd.read_csv(file_path)
76
+
77
+ if if_exists == "replace":
78
+ self.db.execute_query(f"DROP TABLE IF EXISTS {table_name}")
79
+
80
+ for _, row in df.iterrows():
81
+ self.db.insert_dynamic(table_name, row.to_dict())
82
+
83
+ def detect_missing_periods(self, table, entity_column, date_column):
84
+ """
85
+ Flags rows where recurring time periods (e.g. monthly) are missing per entity.
86
+
87
+ Args:
88
+ table (str): Table name to query.
89
+ entity_column (str): Column representing entity ID.
90
+ date_column (str): Column representing timestamp/date.
91
+ """
92
+ if self.db.db_type == "sqlite":
93
+ month_expr = f"strftime('%Y-%m', {date_column})"
94
+ else:
95
+ month_expr = f"DATE_TRUNC('month', {date_column})"
96
+ query = f"""
97
+ SELECT {entity_column}, COUNT(DISTINCT {month_expr}) AS recorded_months
98
+ FROM {table}
99
+ GROUP BY {entity_column}
100
+ HAVING COUNT(DISTINCT {month_expr}) < 12
101
+ """
102
+ self.db.execute_query(query)
103
+ return self.db.fetch_all()
104
+
105
+ def aggregate_column(
106
+ self, table, value_column, group_column=None, time_column=None
107
+ ):
108
+ """
109
+ Computes sum of any value column grouped by entity or month.
110
+
111
+ Args:
112
+ table (str): Table name.
113
+ value_column (str): Numeric column to aggregate.
114
+ group_column (str, optional): Entity or category to group by.
115
+ time_column (str, optional): Timestamp to extract month grouping.
116
+ """
117
+ if self.db.db_type == "sqlite":
118
+ month_expr = f"strftime('%Y-%m', {time_column})"
119
+ else:
120
+ month_expr = f"DATE_TRUNC('month', {time_column})"
121
+
122
+ if group_column and time_column:
123
+ query = f"""
124
+ SELECT {group_column}, {month_expr} AS month, SUM({value_column}) AS total
125
+ FROM {table}
126
+ GROUP BY {group_column}, month
127
+ ORDER BY month
128
+ """
129
+ else:
130
+ query = f"SELECT SUM({value_column}) FROM {table}"
131
+
132
+ self.db.execute_query(query)
133
+ return self.db.fetch_all()
134
+
135
+ def detect_outliers(self, table, numeric_column, threshold=2):
136
+ """
137
+ Detects statistical outliers based on deviation from mean.
138
+
139
+ Args:
140
+ table (str): Table name.
141
+ numeric_column (str): Column to analyze.
142
+ threshold (int): Number of standard deviations from mean to flag as outlier.
143
+ """
144
+ query = f"""
145
+ SELECT *, {numeric_column} AS value FROM {table}
146
+ """
147
+ self.db.execute_query(query)
148
+ data = pd.DataFrame(
149
+ self.db.fetch_all(),
150
+ columns=[desc[0] for desc in self.db.cursor.description],
151
+ )
152
+
153
+ mean_val = data["value"].mean()
154
+ std_val = data["value"].std()
155
+ outliers = data[abs(data["value"] - mean_val) > threshold * std_val]
156
+ return outliers.values.tolist()
sqlpyhelper/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ import click
2
+
3
+ from sqlpyhelper.automation_utils import AutomationUtils
4
+ from sqlpyhelper.db_helper import SQLPyHelper
5
+
6
+
7
+ @click.group()
8
+ def cli():
9
+ """SQLPyHelper Command Line Interface"""
10
+ pass
11
+
12
+
13
+ @cli.command()
14
+ @click.option("--db_type", help="Type of database (e.g., sqlite, postgres, mysql)")
15
+ @click.option("--host", help="Database host")
16
+ @click.option("--user", help="Username")
17
+ @click.option("--password", help="Password")
18
+ @click.option("--database", help="Database name or file")
19
+ @click.option("--query", required=True, help="SQL query to run")
20
+ def run_query(db_type, host, user, password, database, query):
21
+ """Run a single SQL query and print results"""
22
+ db = SQLPyHelper(
23
+ db_type=db_type, host=host, user=user, password=password, database=database
24
+ )
25
+ results = db.execute_query(query)
26
+ for row in results:
27
+ click.echo(row)
28
+ db.close()
29
+
30
+
31
+ @cli.command()
32
+ @click.option("--db_type", required=True)
33
+ @click.option("--host")
34
+ @click.option("--user")
35
+ @click.option("--password")
36
+ @click.option("--database", required=True)
37
+ def interactive_shell(db_type, host, user, password, database):
38
+ """Launch an interactive SQL shell"""
39
+ db = SQLPyHelper(
40
+ db_type=db_type, host=host, user=user, password=password, database=database
41
+ )
42
+ click.echo("Interactive shell started. Type your SQL query or 'exit'")
43
+ while True:
44
+ query = input("sqlpy> ")
45
+ if query.lower() in ("exit", "quit"):
46
+ break
47
+ try:
48
+ db.execute_query(query)
49
+ results = db.fetch_all()
50
+ for row in results:
51
+ click.echo(row)
52
+ except Exception as e:
53
+ click.echo(f"Error: {e}")
54
+ db.close()
55
+
56
+
57
+ @cli.command()
58
+ @click.option("--target", default="local", help="Backup destination")
59
+ @click.option("--tag", default="autobackup", help="Tag for backup file naming")
60
+ @click.option("--db-type")
61
+ @click.option("--host")
62
+ @click.option("--user")
63
+ @click.option("--password")
64
+ @click.option("--database")
65
+ @click.option("--port")
66
+ def backup(target, tag, db_type, host, user, password, database, port):
67
+ """Create a timestamped backup of the connected database."""
68
+ utils = AutomationUtils(
69
+ db_type=db_type,
70
+ host=host,
71
+ user=user,
72
+ password=password,
73
+ database=database,
74
+ port=port,
75
+ )
76
+ utils.backup_database(target=target, tag=tag)
77
+
78
+
79
+ @cli.command()
80
+ @click.option("--file", required=True, help="Path to CSV file")
81
+ @click.option("--table", required=True, help="Destination table")
82
+ @click.option(
83
+ "--if-exists",
84
+ default="append",
85
+ type=click.Choice(["append", "replace"]),
86
+ help="What to do if table exists",
87
+ )
88
+ @click.option("--db-type")
89
+ @click.option("--host")
90
+ @click.option("--user")
91
+ @click.option("--password")
92
+ @click.option("--database")
93
+ @click.option("--port")
94
+ def load_data(file, table, if_exists, db_type, host, user, password, database, port):
95
+ """Load data from CSV into database table."""
96
+ utils = AutomationUtils(
97
+ db_type=db_type,
98
+ host=host,
99
+ user=user,
100
+ password=password,
101
+ database=database,
102
+ port=port,
103
+ )
104
+ utils.load_data_from_csv(file, table, if_exists=if_exists)
105
+
106
+
107
+ @cli.command()
108
+ @click.option("--table", required=True)
109
+ @click.option("--entity-column", required=True)
110
+ @click.option("--date-column", required=True)
111
+ @click.option("--db-type")
112
+ @click.option("--host")
113
+ @click.option("--user")
114
+ @click.option("--password")
115
+ @click.option("--database")
116
+ @click.option("--port")
117
+ def detect_missing_periods(
118
+ table, entity_column, date_column, db_type, host, user, password, database, port
119
+ ):
120
+ """Flag entities with fewer than 12 months of activity."""
121
+ utils = AutomationUtils(
122
+ db_type=db_type,
123
+ host=host,
124
+ user=user,
125
+ password=password,
126
+ database=database,
127
+ port=port,
128
+ )
129
+ results = utils.detect_missing_periods(table, entity_column, date_column)
130
+ for row in results:
131
+ click.echo(row)
132
+
133
+
134
+ @cli.command()
135
+ @click.option("--table", required=True)
136
+ @click.option("--value-column", required=True)
137
+ @click.option("--group-column")
138
+ @click.option("--time-column")
139
+ @click.option("--db-type")
140
+ @click.option("--host")
141
+ @click.option("--user")
142
+ @click.option("--password")
143
+ @click.option("--database")
144
+ @click.option("--port")
145
+ def aggregate(
146
+ table,
147
+ value_column,
148
+ group_column,
149
+ time_column,
150
+ db_type,
151
+ host,
152
+ user,
153
+ password,
154
+ database,
155
+ port,
156
+ ):
157
+ """Aggregate numeric column optionally grouped by entity and time."""
158
+ utils = AutomationUtils(
159
+ db_type=db_type,
160
+ host=host,
161
+ user=user,
162
+ password=password,
163
+ database=database,
164
+ port=port,
165
+ )
166
+ results = utils.aggregate_column(table, value_column, group_column, time_column)
167
+ for row in results:
168
+ click.echo(row)
169
+
170
+
171
+ @cli.command()
172
+ @click.option("--table", required=True)
173
+ @click.option("--numeric-column", required=True)
174
+ @click.option("--threshold", default=2, type=int)
175
+ @click.option("--db-type")
176
+ @click.option("--host")
177
+ @click.option("--user")
178
+ @click.option("--password")
179
+ @click.option("--database")
180
+ @click.option("--port")
181
+ def detect_outliers(
182
+ table, numeric_column, threshold, db_type, host, user, password, database, port
183
+ ):
184
+ """Flag rows where values deviate statistically from average."""
185
+ utils = AutomationUtils(
186
+ db_type=db_type,
187
+ host=host,
188
+ user=user,
189
+ password=password,
190
+ database=database,
191
+ port=port,
192
+ )
193
+ results = utils.detect_outliers(table, numeric_column, threshold)
194
+ for row in results:
195
+ click.echo(row)
196
+
197
+
198
+ if __name__ == "__main__":
199
+ cli()
sqlpyhelper/db_helper.py CHANGED
@@ -1,53 +1,141 @@
1
1
  import csv
2
- from dotenv import load_dotenv
2
+ import logging
3
3
  import os
4
+ import re
5
+ from typing import Any, Literal, Optional
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
14
+ datefmt="%Y-%m-%d %H:%M:%S",
15
+ )
16
+ logger = logging.getLogger("sqlpyhelper")
17
+
18
+
19
+ def _validate_identifier(name: str) -> str:
20
+ """
21
+ Validate a SQL identifier (table or column name).
22
+ Allows only alphanumeric characters and underscores.
23
+ Raises ValueError for anything else, preventing SQL injection via identifiers.
24
+ """
25
+ if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
26
+ raise ValueError(
27
+ f"Invalid SQL identifier: {name!r}. "
28
+ "Only letters, digits, and underscores are allowed."
29
+ )
30
+ return name
31
+
32
+
33
+ class SQLPyHelperError(Exception):
34
+ """Base exception for SQLPyHelper errors."""
35
+
36
+
37
+ class ConnectionError(SQLPyHelperError):
38
+ """Raised when a database connection fails."""
39
+
4
40
 
5
- load_dotenv() # Load environment variables from .env file
41
+ class QueryError(SQLPyHelperError):
42
+ """Raised when a query fails to execute."""
6
43
 
7
44
 
8
- def log_query(query):
9
- """Logs queries for debugging purposes."""
10
- with open("query_log.txt", "a") as f:
11
- f.write(query + "\n")
45
+ class BackupError(SQLPyHelperError):
46
+ """Raised when a backup operation fails."""
12
47
 
13
48
 
14
49
  class SQLPyHelper:
15
- def __init__(self):
16
- self.db_type = os.getenv("DB_TYPE").lower()
17
- self.host = os.getenv("DB_HOST")
18
- self.user = os.getenv("DB_USER")
19
- self.password = os.getenv("DB_PASSWORD")
20
- self.database = os.getenv("DB_NAME")
21
- self.driver = os.getenv("DB_DRIVER")
22
- self.oracle_sid = os.getenv("ORACLE_SID")
23
- self.pool = None
50
+ def __init__(
51
+ self,
52
+ db_type: Optional[str] = None,
53
+ host: Optional[str] = None,
54
+ user: Optional[str] = None,
55
+ password: Optional[str] = None,
56
+ database: Optional[str] = None,
57
+ driver: Optional[str] = None,
58
+ port: Optional[str] = None,
59
+ oracle_sid: Optional[str] = None,
60
+ ) -> None:
61
+
62
+ # Store original params so reconnect() can replay them
63
+ self._init_kwargs = {
64
+ "db_type": db_type,
65
+ "host": host,
66
+ "user": user,
67
+ "password": password,
68
+ "database": database,
69
+ "driver": driver,
70
+ "port": port,
71
+ "oracle_sid": oracle_sid,
72
+ }
73
+
74
+ self.db_type: str = (db_type or os.getenv("DB_TYPE") or "").lower()
75
+ self.host: Optional[str] = host or os.getenv("DB_HOST")
76
+ self.user: Optional[str] = user or os.getenv("DB_USER")
77
+ self.password: Optional[str] = password or os.getenv("DB_PASSWORD")
78
+ self.database: Optional[str] = database or os.getenv("DB_NAME")
79
+ self.driver: Optional[str] = driver or os.getenv("DB_DRIVER")
80
+ self.port: Optional[str] = port or os.getenv("DB_PORT")
81
+ self.oracle_sid: Optional[str] = oracle_sid or os.getenv("ORACLE_SID")
82
+ self.pool: Any = None
83
+
84
+ if not self.db_type or not self.database:
85
+ raise ValueError("Missing required database configuration.")
24
86
 
25
87
  if self.db_type == "sqlite":
26
88
  import sqlite3
89
+
27
90
  self.connection = sqlite3.connect(self.database)
28
91
  elif self.db_type == "postgres":
29
92
  import psycopg2
30
- self.connection = psycopg2.connect(host=self.host, user=self.user,
31
- password=self.password, dbname=self.database)
93
+
94
+ self.connection = psycopg2.connect(
95
+ host=self.host,
96
+ user=self.user,
97
+ password=self.password,
98
+ dbname=self.database,
99
+ )
32
100
  elif self.db_type == "mysql":
33
101
  import mysql.connector
34
- self.connection = mysql.connector.connect(host=self.host, user=self.user,
35
- password=self.password, database=self.database)
102
+
103
+ self.connection = mysql.connector.connect(
104
+ host=self.host,
105
+ user=self.user,
106
+ password=self.password,
107
+ database=self.database,
108
+ ) # type: ignore[assignment]
36
109
  elif self.db_type == "sqlserver":
37
110
  import pyodbc
38
- self.connection = pyodbc.connect(f"DRIVER={self.driver};SERVER={self.host};DATABASE={self.database};"
39
- f"UID={self.user};PWD={self.password}")
111
+
112
+ self.connection = pyodbc.connect(
113
+ f"DRIVER={self.driver};SERVER={self.host};DATABASE={self.database};"
114
+ f"UID={self.user};PWD={self.password}"
115
+ )
40
116
  elif self.db_type == "oracle":
41
- import cx_Oracle
42
- oracle_port = os.getenv("ORACLE_DB_PORT", "1521") # Default to 1521 if not set
43
- dsn = cx_Oracle.makedsn(self.host, oracle_port, self.oracle_sid)
44
- self.connection = cx_Oracle.connect(self.user, self.password, dsn)
117
+ import oracledb
118
+
119
+ oracle_port = os.getenv("ORACLE_DB_PORT", 1521)
120
+ dsn = oracledb.makedsn(
121
+ self.host, oracle_port, sid=self.oracle_sid # type: ignore[arg-type]
122
+ )
123
+ self.connection = oracledb.connect(
124
+ user=self.user, password=self.password, dsn=dsn
125
+ ) # type: ignore[assignment]
45
126
  else:
46
127
  raise ValueError("Unsupported database type")
47
128
 
48
129
  self.cursor = self.connection.cursor()
49
130
 
50
- def execute_query(self, query, params=None):
131
+ def __enter__(self) -> "SQLPyHelper":
132
+ return self
133
+
134
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> Literal[False]:
135
+ self.close()
136
+ return False
137
+
138
+ def execute_query(self, query: str, params: Optional[tuple] = None) -> None:
51
139
  """Executes a query with optional parameters"""
52
140
  try:
53
141
  if params:
@@ -56,154 +144,240 @@ class SQLPyHelper:
56
144
  self.cursor.execute(query)
57
145
  self.connection.commit()
58
146
  except Exception as e:
59
- if "server has gone away" in str(e): # Example check for MySQL lost connection
147
+ if "server has gone away" in str(
148
+ e
149
+ ): # Example check for MySQL lost connection
60
150
  self.reconnect()
61
- self.cursor.execute(query, params)
151
+ self.cursor.execute(query, params) # type: ignore[arg-type]
62
152
  self.connection.commit()
63
153
  else:
64
- print(f"Error executing query: {e}")
154
+ raise QueryError(f"Query failed: {e}") from e
65
155
 
66
- def fetch_one(self):
156
+ def fetch_one(self) -> Optional[tuple]:
67
157
  """Fetches a single row"""
68
158
  try:
69
159
  return self.cursor.fetchone()
70
160
  except Exception as e:
71
- print(f"Error fetching row: {e}")
72
- return None
161
+ raise QueryError(f"Failed to fetch row: {e}") from e
73
162
 
74
- def fetch_all(self):
163
+ def fetch_all(self) -> list[tuple]:
75
164
  """Fetches all rows from the last executed query"""
76
165
  try:
77
166
  return self.cursor.fetchall()
78
167
  except Exception as e:
79
- print(f"Error fetching rows: {e}")
80
- return None
168
+ raise QueryError(f"Failed to fetch rows: {e}") from e
81
169
 
82
- def fetch_by_param(self, table_name, column_name, value):
170
+ def fetch_by_param(
171
+ self, table_name: str, column_name: str, value: Any
172
+ ) -> list[tuple]:
173
+ """Fetches rows from a table where a column matches the given value."""
83
174
  try:
84
- query = f"SELECT * FROM {table_name} WHERE {column_name} = %s"
175
+ table_name = _validate_identifier(table_name)
176
+ column_name = _validate_identifier(column_name)
177
+ placeholder = "?" if self.db_type == "sqlite" else "%s"
178
+ query = f"SELECT * FROM {table_name} WHERE {column_name} = {placeholder}"
85
179
  self.cursor.execute(query, (value,))
86
180
  return self.cursor.fetchall()
87
181
  except Exception as e:
88
- print(f"Error fetching row(s): {e}")
89
- return None
182
+ raise QueryError(f"Failed to fetch by param: {e}") from e
90
183
 
91
- def close(self):
92
- """Closes the connection"""
184
+ def close(self) -> None:
185
+ """Closes the cursor and database connection."""
93
186
  try:
94
187
  self.cursor.close()
95
188
  self.connection.close()
96
189
  except Exception as e:
97
- print(f"Error closing connection: {e}")
98
- return None
190
+ raise ConnectionError(f"Failed to close connection: {e}") from e
99
191
 
100
- def create_table(self, table_name, columns):
192
+ def create_table(self, table_name: str, columns: dict[str, str]) -> None:
101
193
  """
102
194
  Creates a table dynamically using a dictionary format.
103
195
  Example:
104
196
  columns = {'id': 'INTEGER PRIMARY KEY', 'name': 'TEXT', 'age': 'INTEGER'}
105
197
  """
106
198
  try:
107
- column_defs = ", ".join(f"{col} {dtype}" for col, dtype in columns.items())
108
- query = f"CREATE TABLE {table_name} ({column_defs})"
199
+ table_name = _validate_identifier(table_name)
200
+ validated_cols = {
201
+ _validate_identifier(col): dtype for col, dtype in columns.items()
202
+ }
203
+ columns_def = ", ".join(
204
+ [f"{col} {dtype}" for col, dtype in validated_cols.items()]
205
+ )
206
+ query = f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_def})"
109
207
  self.execute_query(query)
110
208
  except Exception as e:
111
- print(f"Error creating table: {e}")
112
- return None
209
+ raise QueryError(f"Failed to create table: {e}") from e
113
210
 
114
- def insert_bulk(self, table_name, data):
211
+ def insert_bulk(self, table_name: str, data: list[dict[str, Any]]) -> None:
115
212
  """
116
213
  Inserts multiple rows at once.
117
214
  Example:
118
215
  data = [{'id': 1, 'name': 'Alice'}, {'id': 2, 'name': 'Bob'}]
119
216
  """
120
217
  try:
121
- columns = ", ".join(data[0].keys()) # Extract column names
122
- placeholders = ", ".join(["%s" for _ in data[0].keys()]) # Generate placeholders
218
+ table_name = _validate_identifier(table_name)
219
+ col_names = [_validate_identifier(col) for col in data[0].keys()]
220
+ columns = ", ".join(col_names)
221
+ placeholder = "?" if self.db_type == "sqlite" else "%s"
222
+ placeholders = ", ".join([placeholder] * len(data[0]))
123
223
  query = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})"
124
224
  values = [tuple(row.values()) for row in data]
125
225
  self.cursor.executemany(query, values)
126
226
  self.connection.commit()
127
227
 
128
228
  except Exception as e:
129
- print(f"Error inserting bulk rows: {e}")
130
- return None
229
+ raise QueryError(f"Failed to insert bulk rows: {e}") from e
131
230
 
132
- def backup_table(self, table_name, backup_file):
231
+ def backup_table(self, table_name: str, backup_file: str) -> None:
133
232
  """
134
233
  Exports table data into a CSV file.
135
234
  Example:
136
235
  backup_table('users', 'users_backup.csv')
137
236
  """
138
237
  try:
238
+ table_name = _validate_identifier(table_name)
139
239
  query = f"SELECT * FROM {table_name}"
140
240
  self.execute_query(query)
141
241
  rows = self.fetch_all()
142
242
 
143
243
  with open(backup_file, mode="w", newline="") as file:
144
244
  writer = csv.writer(file)
145
- writer.writerow([desc[0] for desc in self.cursor.description]) # Column headers
245
+ writer.writerow(
246
+ [desc[0] for desc in self.cursor.description]
247
+ ) # Column headers
146
248
  writer.writerows(rows)
147
249
  except Exception as e:
148
- print(f"Error backing up table: {e}")
149
- return None
250
+ raise BackupError(f"Failed to backup table: {e}") from e
150
251
 
151
- def setup_connection_pool(self, min_conn=1, max_conn=5, pool_size=5):
252
+ def setup_connection_pool(
253
+ self, min_conn: int = 1, max_conn: int = 5, pool_size: int = 5
254
+ ) -> None:
152
255
  """Sets up connection pooling based on the database type"""
153
256
  try:
154
257
  if self.db_type == "postgres":
155
258
  from psycopg2 import pool
156
- self.pool = pool.SimpleConnectionPool(min_conn, max_conn,
157
- host=self.host, user=self.user,
158
- password=self.password, dbname=self.database)
259
+
260
+ self.pool = pool.SimpleConnectionPool(
261
+ min_conn,
262
+ max_conn,
263
+ host=self.host,
264
+ user=self.user,
265
+ password=self.password,
266
+ dbname=self.database,
267
+ )
159
268
 
160
269
  elif self.db_type == "mysql":
161
270
  import mysql.connector.pooling
162
- self.pool = mysql.connector.pooling.MySQLConnectionPool(pool_name="mypool",
163
- pool_size=pool_size, host=self.host,
164
- user=self.user, password=self.password,
165
- database=self.database)
271
+
272
+ self.pool = mysql.connector.pooling.MySQLConnectionPool(
273
+ pool_name="mypool",
274
+ pool_size=pool_size,
275
+ host=self.host,
276
+ user=self.user,
277
+ password=self.password,
278
+ database=self.database,
279
+ )
166
280
 
167
281
  elif self.db_type == "sqlserver":
168
282
  import pyodbc
283
+
169
284
  self.pool = [
170
- pyodbc.connect(f"DRIVER={self.driver};SERVER={self.host};DATABASE={self.database};"
171
- f"UID={self.user};PWD={self.password};ConnectionPooling=Yes")
285
+ pyodbc.connect(
286
+ f"DRIVER={self.driver};SERVER={self.host};DATABASE={self.database};"
287
+ f"UID={self.user};PWD={self.password};ConnectionPooling=Yes"
288
+ )
172
289
  for _ in range(pool_size)
173
290
  ]
174
291
 
175
292
  elif self.db_type == "oracle":
176
- import cx_Oracle
177
- oracle_port = os.getenv("ORACLE_DB_PORT", "1521") # Default Oracle port
178
- dsn = cx_Oracle.makedsn(self.host, oracle_port, self.oracle_sid)
179
- self.pool = cx_Oracle.SessionPool(user=self.user, password=self.password, dsn=dsn,
180
- min=min_conn, max=max_conn, increment=1, threaded=True)
293
+ import oracledb
294
+
295
+ oracle_port = os.getenv("ORACLE_DB_PORT", 1521)
296
+ dsn = oracledb.makedsn(self.host, oracle_port, sid=self.oracle_sid) # type: ignore[arg-type]
297
+ self.pool = oracledb.create_pool(
298
+ user=self.user,
299
+ password=self.password,
300
+ dsn=dsn,
301
+ min=min_conn,
302
+ max=max_conn,
303
+ increment=1,
304
+ )
181
305
 
182
306
  else:
183
307
  raise ValueError(f"Connection pooling not supported for {self.db_type}")
184
308
  except Exception as e:
185
- print(f"⚠️ Error setting up connection pool: {e}")
186
- self.pool = None # Prevent broken pool usage
309
+ raise ConnectionError(f"Failed to set up connection pool: {e}") from e
187
310
 
188
- def get_connection_from_pool(self):
311
+ def get_connection_from_pool(self) -> Any:
189
312
  """Fetches a connection from the pool."""
190
313
  return self.pool.get_connection()
191
314
 
192
- def return_connection_to_pool(self):
315
+ def return_connection_to_pool(self, connection: Any = None) -> None:
193
316
  """Returns a connection back to the pool."""
194
- self.connection.close()
317
+ conn = connection or self.connection
318
+ if self.pool is None:
319
+ raise RuntimeError(
320
+ "No connection pool initialised. Call setup_connection_pool() first."
321
+ )
322
+
323
+ if self.db_type == "postgres":
324
+ self.pool.putconn(conn)
325
+ elif self.db_type == "mysql":
326
+ conn.close()
327
+ elif self.db_type == "oracle":
328
+ self.pool.release(conn)
329
+ else:
330
+ conn.close()
195
331
 
196
- def reconnect(self):
332
+ def reconnect(self) -> None:
197
333
  """Reconnects to the database if connection is lost"""
198
334
  try:
199
- self.connection.close() # Close existing connection
200
- self.__init__() # Reinitialize the connection
335
+ self.connection.close()
336
+ self.__init__(**self._init_kwargs) # type: ignore[misc]
201
337
  print("Database reconnected successfully.")
202
338
  except Exception as e:
203
- print(f"Error during reconnection: {e}")
339
+ raise ConnectionError(f"Reconnection failed: {e}") from e
204
340
 
205
- def begin_transaction(self):
206
- self.execute_query("START TRANSACTION")
341
+ def begin_transaction(self) -> None:
342
+ """Begin an explicit transaction. Works across all supported databases."""
343
+ try:
344
+ if self.db_type == "sqlite":
345
+ self.execute_query("BEGIN")
346
+ elif self.db_type in ("postgres", "mysql"):
347
+ self.execute_query("START TRANSACTION")
348
+ elif self.db_type == "sqlserver":
349
+ self.execute_query("BEGIN TRANSACTION")
350
+ elif self.db_type == "oracle":
351
+ pass # Oracle starts transactions implicitly on first DML statement
352
+ logger.info("Transaction started on %s database", self.db_type)
353
+ except Exception as e:
354
+ raise QueryError(f"Failed to begin transaction: {e}") from e
355
+
356
+ def commit_transaction(self) -> None:
357
+ """Commit the current transaction."""
358
+ try:
359
+ self.connection.commit()
360
+ logger.info("Transaction committed on %s database", self.db_type)
361
+ except Exception as e:
362
+ raise QueryError(f"Failed to commit transaction: {e}") from e
363
+
364
+ def rollback_transaction(self) -> None:
365
+ """Roll back the current transaction."""
366
+ try:
367
+ self.connection.rollback()
368
+ logger.info("Transaction rolled back on %s database", self.db_type)
369
+ except Exception as e:
370
+ raise QueryError(f"Failed to rollback transaction: {e}") from e
371
+
372
+ def insert_dynamic(self, table: str, data: dict[str, Any]) -> None:
373
+ """
374
+ Dynamically constructs and executes an INSERT query with database-specific placeholders.
375
+ """
376
+ table = _validate_identifier(table)
377
+ columns = ", ".join(_validate_identifier(col) for col in data.keys())
378
+ placeholders_style = "?" if self.db_type == "sqlite" else "%s"
379
+ placeholders = ", ".join([placeholders_style] * len(data))
380
+ values = tuple(data.values())
207
381
 
208
- def rollback_transaction(self):
209
- self.execute_query("ROLLBACK")
382
+ sql = f"INSERT INTO {table} ({columns}) VALUES ({placeholders})"
383
+ self.execute_query(sql, values)
sqlpyhelper/py.typed ADDED
File without changes
@@ -1,11 +1,21 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: SQLPyHelper
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: A simple SQL database helper package for Python.
5
+ Home-page: https://github.com/adebayopeter/sqlpyhelper
5
6
  Author: Adebayo Olaonipekun
6
7
  Author-email: pekunmi@live.com
8
+ Project-URL: Source, https://github.com/adebayopeter/sqlpyhelper
9
+ Project-URL: Bug Tracker, https://github.com/adebayopeter/sqlpyhelper/issues
10
+ Project-URL: Changelog, https://github.com/adebayopeter/sqlpyhelper/blob/main/CHANGELOG.md
11
+ Keywords: database,sql,sqlite,postgresql,mysql,sqlserver,oracle,db,query,helper
7
12
  Classifier: Programming Language :: Python :: 3
8
- Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Development Status :: 4 - Beta
9
19
  Classifier: Intended Audience :: Developers
10
20
  Classifier: Topic :: Database :: Database Engines/Servers
11
21
  Classifier: Operating System :: OS Independent
@@ -13,34 +23,54 @@ Classifier: License :: OSI Approved :: MIT License
13
23
  Requires-Python: >=3.8
14
24
  Description-Content-Type: text/markdown
15
25
  License-File: LICENSE
16
- Requires-Dist: psycopg2
17
- Requires-Dist: mysql-connector-python
18
- Requires-Dist: pyodbc
19
- Requires-Dist: cx_Oracle
20
26
  Requires-Dist: python-dotenv
21
- Provides-Extra: mysql
22
- Requires-Dist: mysql-connector-python; extra == "mysql"
27
+ Requires-Dist: click
23
28
  Provides-Extra: postgres
24
29
  Requires-Dist: psycopg2; extra == "postgres"
25
- Provides-Extra: oracle
26
- Requires-Dist: cx_Oracle; extra == "oracle"
30
+ Provides-Extra: mysql
31
+ Requires-Dist: mysql-connector-python; extra == "mysql"
27
32
  Provides-Extra: sqlserver
28
33
  Requires-Dist: pyodbc; extra == "sqlserver"
29
- Provides-Extra: sqlite
34
+ Provides-Extra: oracle
35
+ Requires-Dist: oracledb; extra == "oracle"
36
+ Provides-Extra: all
37
+ Requires-Dist: psycopg2; extra == "all"
38
+ Requires-Dist: mysql-connector-python; extra == "all"
39
+ Requires-Dist: pyodbc; extra == "all"
40
+ Requires-Dist: oracledb; extra == "all"
30
41
  Dynamic: author
31
42
  Dynamic: author-email
32
43
  Dynamic: classifier
33
44
  Dynamic: description
34
45
  Dynamic: description-content-type
46
+ Dynamic: home-page
47
+ Dynamic: keywords
35
48
  Dynamic: license-file
49
+ Dynamic: project-url
36
50
  Dynamic: provides-extra
37
51
  Dynamic: requires-dist
38
52
  Dynamic: requires-python
39
53
  Dynamic: summary
40
54
 
41
- # 📌 SQLPyHelper v.0.1.3 🚀
55
+ # SQLPyHelper
56
+
57
+ [![PyPI version](https://img.shields.io/pypi/v/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
58
+ [![PyPI downloads](https://img.shields.io/pypi/dm/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
59
+ [![Python versions](https://img.shields.io/pypi/pyversions/sqlpyhelper.svg)](https://pypi.org/project/sqlpyhelper/)
60
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://github.com/adebayopeter/sqlpyhelper/blob/main/LICENSE)
61
+ [![GitHub stars](https://img.shields.io/github/stars/adebayopeter/sqlpyhelper?style=social)](https://github.com/adebayopeter/sqlpyhelper)
62
+
63
+ SQLPyHelper is a lightweight Python library that gives you a single, consistent API across **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle** — without the overhead of an ORM.
42
64
 
43
- A Python library for simplified database interactions across **SQLite, PostgreSQL, MySQL, SQL Server, and Oracle**. SQLPyHelper provides an intuitive API for handling queries, connection pooling, transactions, logging, and backups efficiently.
65
+ If you need to run queries, manage transactions, pool connections, or back up tables across multiple database types without learning SQLAlchemy's abstraction layer or wiring up five different drivers manually, SQLPyHelper handles that boilerplate for you.
66
+
67
+ ```python
68
+ # Works identically across all five supported databases
69
+ with SQLPyHelper(db_type="postgres", host="localhost", user="user",
70
+ password="pass", database="mydb") as db:
71
+ db.execute_query("INSERT INTO orders (item) VALUES (%s)", ("Laptop",))
72
+ results = db.fetch_all()
73
+ ```
44
74
 
45
75
  ## 📖 Table of Contents
46
76
  - [🚀 Features](#-features)
@@ -59,21 +89,32 @@ A Python library for simplified database interactions across **SQLite, PostgreSQ
59
89
 
60
90
  ---
61
91
 
62
- ## 🚀 Features in v0.1.3
63
- Unified connection pooling for multiple databases.
64
- Automatic reconnection for lost connections.
65
- Transaction support (BEGIN, ROLLBACK, COMMIT).
66
- Secure parameterized queries to prevent SQL injection.
67
- Bulk insertion & dynamic table creation.
68
- Logging & error handling for better debugging.
69
- CSV export & database backups.
92
+ ## 🚀 Features in v0.1.4
93
+ - Unified connection pooling for multiple databases.
94
+ - Automatic reconnection for lost connections.
95
+ - Transaction support (BEGIN, ROLLBACK, COMMIT).
96
+ - Secure parameterized queries to prevent SQL injection.
97
+ - Bulk insertion & dynamic table creation.
98
+ - Logging & error handling for better debugging.
99
+ - CSV export & database backups.
70
100
 
71
101
  ---
72
102
  ## 📦 Installation
73
- #### Install via PyPI:
103
+
104
+ Install the base package (includes SQLite support out of the box):
74
105
  ```sh
75
106
  pip install sqlpyhelper
76
107
  ```
108
+
109
+ Install with your database driver:
110
+ ```sh
111
+ pip install sqlpyhelper[postgres] # PostgreSQL
112
+ pip install sqlpyhelper[mysql] # MySQL
113
+ pip install sqlpyhelper[sqlserver] # SQL Server
114
+ pip install sqlpyhelper[oracle] # Oracle
115
+ pip install sqlpyhelper[all] # All databases
116
+ ```
117
+
77
118
  📌 Package on PyPI: [SQLPyHelper on PyPI](https://pypi.org/project/SQLPyHelper/)
78
119
 
79
120
  For local development:
@@ -186,7 +227,9 @@ db.return_connection_to_pool(conn)
186
227
  | `return_connection_to_pool(conn)` | Returns connection back to pool. |
187
228
  | `begin_transaction()` | Begins an **explicit transaction**. |
188
229
  | `rollback_transaction()` | Rolls back **uncommitted transactions**. |
230
+ | `commit_transaction()` | Commits the current transaction. |
189
231
  | `close()` | Closes the database connection safely. |
232
+ | `__enter__` / `__exit__()` | Use as a context manager — connection closes automatically. |
190
233
 
191
234
  ---
192
235
  ## 🌍 Contributing
@@ -0,0 +1,11 @@
1
+ sqlpyhelper/__init__.py,sha256=nkFTrXkOtL4V7KVPw_bdjl5afqWroUG81WRVdCm0-NQ,183
2
+ sqlpyhelper/automation_utils.py,sha256=pC6pH6bJ-k8iPVeHJ4gUiwEe822dasmKg53ya9bMxyE,5381
3
+ sqlpyhelper/cli.py,sha256=yj0kWJu3oh_JLnmi0L7a5ing2_0x4CQGOKSOhZLAtoY,5646
4
+ sqlpyhelper/db_helper.py,sha256=4DbdBVo86zz1d0hNHtSc4b3Tks7bJGTMTyabsydQyOE,14191
5
+ sqlpyhelper/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ sqlpyhelper-0.1.5.dist-info/licenses/LICENSE,sha256=9XzXxZ_8mWFM9-2TlqyE3L69zvRf4VPY_xIzSj5iU-g,1076
7
+ sqlpyhelper-0.1.5.dist-info/METADATA,sha256=s_qc18w4ulfBx46s9azplWDBZsFUSvqnTVDw_32EnkE,9555
8
+ sqlpyhelper-0.1.5.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ sqlpyhelper-0.1.5.dist-info/entry_points.txt,sha256=uAzSqwkAbbJqQUKHlPNwOebTJVA0FqkOvn2CRP6xSz8,52
10
+ sqlpyhelper-0.1.5.dist-info/top_level.txt,sha256=FrLqTmqTGDa8jHnnf2ZVkYO-gFvLXX9QonpUCE6wKGs,12
11
+ sqlpyhelper-0.1.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (82.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ sqlpyhelper = sqlpyhelper.cli:cli
@@ -1,7 +0,0 @@
1
- sqlpyhelper/__init__.py,sha256=v4m1w8m6t3Ypvteu4Ukto47uZG_pAHHkBFcKjWrk__Q,54
2
- sqlpyhelper/db_helper.py,sha256=hRgwzPM5Ze_xKwgphQLJovQmkVpVhKffmAa1C7Lje9g,8447
3
- sqlpyhelper-0.1.3.dist-info/licenses/LICENSE,sha256=9XzXxZ_8mWFM9-2TlqyE3L69zvRf4VPY_xIzSj5iU-g,1076
4
- sqlpyhelper-0.1.3.dist-info/METADATA,sha256=zYkK-eNm3BBm4T0bStWMSWdIR44pyWQgJOjUHh6lnQ8,7253
5
- sqlpyhelper-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- sqlpyhelper-0.1.3.dist-info/top_level.txt,sha256=FrLqTmqTGDa8jHnnf2ZVkYO-gFvLXX9QonpUCE6wKGs,12
7
- sqlpyhelper-0.1.3.dist-info/RECORD,,