pysql-executor 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.
- pysql_executor-0.1.0.dist-info/METADATA +85 -0
- pysql_executor-0.1.0.dist-info/RECORD +8 -0
- pysql_executor-0.1.0.dist-info/WHEEL +5 -0
- pysql_executor-0.1.0.dist-info/top_level.txt +1 -0
- sql_executor/__init__.py +23 -0
- sql_executor/base.py +120 -0
- sql_executor/fake.py +44 -0
- sql_executor/sqlserver.py +25 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pysql-executor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend-agnostic stored-procedure / SQL executor with a pluggable base class
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Provides-Extra: sqlserver
|
|
8
|
+
Requires-Dist: pyodbc>=4.0; extra == "sqlserver"
|
|
9
|
+
|
|
10
|
+
# sql-executor (v1, 0.1.0)
|
|
11
|
+
|
|
12
|
+
Backend-agnostic stored-procedure / SQL executor. `BaseSqlExecutor` owns
|
|
13
|
+
connection lifecycle, commit/rollback, fetch-to-dict, chunked streaming,
|
|
14
|
+
and error wrapping. A new backend only implements two methods.
|
|
15
|
+
|
|
16
|
+
## Usage (SQL Server)
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from sql_executor import SqlServerExecutor, DatabaseError
|
|
20
|
+
|
|
21
|
+
db = SqlServerExecutor(connstring="DRIVER={ODBC Driver 17 for SQL Server};...")
|
|
22
|
+
|
|
23
|
+
# Stored procedure
|
|
24
|
+
rows = db.call_procedure("dbo.GetIncidents", params=("2026-06-01",), fetch=True)
|
|
25
|
+
|
|
26
|
+
# Raw SQL
|
|
27
|
+
db.execute("UPDATE dbo.Logs SET status = ? WHERE id = ?", params=("done", 42))
|
|
28
|
+
|
|
29
|
+
# Stream large result sets in chunks instead of loading everything into memory
|
|
30
|
+
for row in db.stream_procedure("dbo.GetBigReport", chunk_size=1000):
|
|
31
|
+
process(row)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
db.execute("DELETE FROM dbo.Logs WHERE id = ?", params=(42,))
|
|
35
|
+
except DatabaseError:
|
|
36
|
+
# Always raised on failure — never swallowed as None/False.
|
|
37
|
+
raise
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Adding a new backend (e.g. Postgres, Oracle)
|
|
41
|
+
|
|
42
|
+
Subclass `BaseSqlExecutor` and implement two methods:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import psycopg2
|
|
46
|
+
from sql_executor import BaseSqlExecutor
|
|
47
|
+
|
|
48
|
+
class PostgresExecutor(BaseSqlExecutor):
|
|
49
|
+
driver_error = psycopg2.Error
|
|
50
|
+
|
|
51
|
+
def __init__(self, dsn: str):
|
|
52
|
+
self.dsn = dsn
|
|
53
|
+
|
|
54
|
+
def _connect(self):
|
|
55
|
+
return psycopg2.connect(self.dsn)
|
|
56
|
+
|
|
57
|
+
def _call_procedure_sql(self, sp_name, params):
|
|
58
|
+
placeholders = ",".join("%s" for _ in params)
|
|
59
|
+
return f"CALL {sp_name}({placeholders})"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`call_procedure`, `execute`, `stream`, and `stream_procedure` all come for
|
|
63
|
+
free from the base class.
|
|
64
|
+
|
|
65
|
+
## Testing
|
|
66
|
+
|
|
67
|
+
`FakeExecutor` is a drop-in for `SqlServerExecutor` with no real DB:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from sql_executor import FakeExecutor
|
|
71
|
+
|
|
72
|
+
fake = FakeExecutor()
|
|
73
|
+
fake.register("dbo.GetIncidents", lambda since: [{"id": 1, "since": since}])
|
|
74
|
+
fake.call_procedure("dbo.GetIncidents", params=("2026-06-01",), fetch=True)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Offline install (corporate VM, no internet)
|
|
78
|
+
|
|
79
|
+
Build the wheel on a machine with internet access, then copy the `.whl`
|
|
80
|
+
to the VM:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install --no-index --find-links=. sql_executor-0.1.0-py3-none-any.whl
|
|
84
|
+
pip install pyodbc # only needed if you use SqlServerExecutor, also offline-installed
|
|
85
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
sql_executor/__init__.py,sha256=MPuKwTp_HuGCacbesI5IN9vGmHzmeRamCTV49s4--QY,715
|
|
2
|
+
sql_executor/base.py,sha256=XY1KRKe1TG50toiEwMoZTvQK6id4xVbybnKjap7rEew,4366
|
|
3
|
+
sql_executor/fake.py,sha256=JOcDeKdP9QVh65eqSNR-lE9woSWTYMOGj3qY0HhmMpE,1581
|
|
4
|
+
sql_executor/sqlserver.py,sha256=KaccpBPHOnvzNMe8KIujvscCe4IW6_OSRshjZkuz0SQ,682
|
|
5
|
+
pysql_executor-0.1.0.dist-info/METADATA,sha256=_IVCfr0QYwOADikiXTNlQkv-G6M4uMm7AfRvUtjrWFk,2476
|
|
6
|
+
pysql_executor-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
pysql_executor-0.1.0.dist-info/top_level.txt,sha256=QT6VLZ2kt3I5MHLJKJcSavBGerkvIw57RvQyZu43A48,13
|
|
8
|
+
pysql_executor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sql_executor
|
sql_executor/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .base import BaseSqlExecutor, DatabaseError
|
|
2
|
+
from .fake import FakeExecutor
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"BaseSqlExecutor",
|
|
6
|
+
"DatabaseError",
|
|
7
|
+
"SqlServerExecutor",
|
|
8
|
+
"FakeExecutor",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def __getattr__(name: str):
|
|
15
|
+
# Lazy import: SqlServerExecutor pulls in pyodbc (+ system unixODBC),
|
|
16
|
+
# which not every environment has (e.g. running tests with FakeExecutor
|
|
17
|
+
# only). Importing it on first access keeps `from sql_executor import
|
|
18
|
+
# FakeExecutor` working even if pyodbc isn't installed.
|
|
19
|
+
if name == "SqlServerExecutor":
|
|
20
|
+
from .sqlserver import SqlServerExecutor
|
|
21
|
+
|
|
22
|
+
return SqlServerExecutor
|
|
23
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
sql_executor/base.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Backend-agnostic base for stored-procedure / SQL executors.
|
|
2
|
+
|
|
3
|
+
Template Method pattern: this class owns everything that's the same
|
|
4
|
+
across DB backends (cursor lifecycle, commit/rollback, fetch-to-dict,
|
|
5
|
+
chunked streaming, error wrapping). A subclass only has to implement:
|
|
6
|
+
|
|
7
|
+
- _connect() -> open a DB-API 2.0 connection
|
|
8
|
+
- _call_procedure_sql() -> dialect-specific "call this SP" string
|
|
9
|
+
|
|
10
|
+
Everything else (call_procedure, execute, stream, stream_procedure) is
|
|
11
|
+
inherited for free. See sqlserver.py for a ~15-line concrete example.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import abc
|
|
17
|
+
import logging
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
from typing import Any, Dict, Iterator, List, Sequence
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DatabaseError(Exception):
|
|
25
|
+
"""Raised when a stored procedure / SQL execution fails.
|
|
26
|
+
|
|
27
|
+
Callers should catch this rather than checking for None/False —
|
|
28
|
+
it's always raised on failure, never swallowed.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _rows_to_dicts(cursor) -> List[Dict[str, Any]]:
|
|
33
|
+
columns = [col[0].lower() for col in cursor.description]
|
|
34
|
+
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class BaseSqlExecutor(abc.ABC):
|
|
38
|
+
# Subclass should override with their driver's exception type,
|
|
39
|
+
# e.g. `pyodbc.Error`, `psycopg2.Error`, `cx_Oracle.Error`.
|
|
40
|
+
# Left as plain Exception here so a forgetful subclass still works,
|
|
41
|
+
# just with a less precise catch.
|
|
42
|
+
driver_error: type = Exception
|
|
43
|
+
|
|
44
|
+
# ---- subclasses must implement -------------------------------------
|
|
45
|
+
@abc.abstractmethod
|
|
46
|
+
def _connect(self):
|
|
47
|
+
"""Return a new DB-API 2.0 connection."""
|
|
48
|
+
|
|
49
|
+
@abc.abstractmethod
|
|
50
|
+
def _call_procedure_sql(self, sp_name: str, params: Sequence[Any]) -> str:
|
|
51
|
+
"""Return the dialect-specific SQL string to call a stored procedure."""
|
|
52
|
+
|
|
53
|
+
# ---- shared cursor lifecycle ----------------------------------------
|
|
54
|
+
@contextmanager
|
|
55
|
+
def _get_cursor(self):
|
|
56
|
+
conn = self._connect()
|
|
57
|
+
try:
|
|
58
|
+
cursor = conn.cursor()
|
|
59
|
+
yield cursor
|
|
60
|
+
conn.commit()
|
|
61
|
+
except Exception:
|
|
62
|
+
conn.rollback()
|
|
63
|
+
raise
|
|
64
|
+
finally:
|
|
65
|
+
conn.close()
|
|
66
|
+
|
|
67
|
+
def _run(self, sql: str, params: Sequence[Any], fetch: bool):
|
|
68
|
+
try:
|
|
69
|
+
with self._get_cursor() as cursor:
|
|
70
|
+
if params:
|
|
71
|
+
cursor.execute(sql, params)
|
|
72
|
+
else:
|
|
73
|
+
cursor.execute(sql)
|
|
74
|
+
return _rows_to_dicts(cursor) if fetch else True
|
|
75
|
+
except self.driver_error as db_ex:
|
|
76
|
+
logger.exception("DB error executing: %s", sql)
|
|
77
|
+
raise DatabaseError(str(db_ex)) from db_ex
|
|
78
|
+
|
|
79
|
+
# ---- public API ----------------------------------------------------
|
|
80
|
+
def call_procedure(
|
|
81
|
+
self, sp_name: str, params: Sequence[Any] = (), fetch: bool = False
|
|
82
|
+
):
|
|
83
|
+
sql = self._call_procedure_sql(sp_name, params)
|
|
84
|
+
return self._run(sql, tuple(params), fetch)
|
|
85
|
+
|
|
86
|
+
def execute(self, sql: str, params: Sequence[Any] = (), fetch: bool = False):
|
|
87
|
+
"""Run raw SQL (SELECT/INSERT/UPDATE/DELETE)."""
|
|
88
|
+
return self._run(sql, tuple(params), fetch)
|
|
89
|
+
|
|
90
|
+
def stream(
|
|
91
|
+
self,
|
|
92
|
+
sql: str,
|
|
93
|
+
params: Sequence[Any] = (),
|
|
94
|
+
chunk_size: int = 500,
|
|
95
|
+
) -> Iterator[Dict[str, Any]]:
|
|
96
|
+
"""Yield rows in chunks — for big SELECTs or SPs returning lots of rows."""
|
|
97
|
+
try:
|
|
98
|
+
with self._get_cursor() as cursor:
|
|
99
|
+
cursor.arraysize = chunk_size
|
|
100
|
+
if params:
|
|
101
|
+
cursor.execute(sql, params)
|
|
102
|
+
else:
|
|
103
|
+
cursor.execute(sql)
|
|
104
|
+
columns = [col[0].lower() for col in cursor.description]
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
rows = cursor.fetchmany(chunk_size)
|
|
108
|
+
if not rows:
|
|
109
|
+
break
|
|
110
|
+
for row in rows:
|
|
111
|
+
yield dict(zip(columns, row))
|
|
112
|
+
except self.driver_error as db_ex:
|
|
113
|
+
logger.exception("DB error streaming: %s", sql)
|
|
114
|
+
raise DatabaseError(str(db_ex)) from db_ex
|
|
115
|
+
|
|
116
|
+
def stream_procedure(
|
|
117
|
+
self, sp_name: str, params: Sequence[Any] = (), chunk_size: int = 500
|
|
118
|
+
) -> Iterator[Dict[str, Any]]:
|
|
119
|
+
sql = self._call_procedure_sql(sp_name, params)
|
|
120
|
+
yield from self.stream(sql, tuple(params), chunk_size)
|
sql_executor/fake.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""In-memory test double — no real DB connection involved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, Dict, Sequence
|
|
6
|
+
|
|
7
|
+
from .base import BaseSqlExecutor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeExecutor(BaseSqlExecutor):
|
|
11
|
+
"""Register a Python callable per stored-procedure name and call it
|
|
12
|
+
just like the real thing, for unit tests.
|
|
13
|
+
|
|
14
|
+
fake = FakeExecutor()
|
|
15
|
+
fake.register("dbo.GetIncidents", lambda since: [{"id": 1}])
|
|
16
|
+
fake.call_procedure("dbo.GetIncidents", params=("2026-06-01",), fetch=True)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
driver_error = Exception
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.handlers: Dict[str, Callable[..., Any]] = {}
|
|
23
|
+
|
|
24
|
+
def register(self, sp_name: str, handler: Callable[..., Any]):
|
|
25
|
+
self.handlers[sp_name] = handler
|
|
26
|
+
|
|
27
|
+
def _connect(self):
|
|
28
|
+
raise NotImplementedError("FakeExecutor never opens a real connection")
|
|
29
|
+
|
|
30
|
+
def _call_procedure_sql(self, sp_name: str, params: Sequence[Any]) -> str:
|
|
31
|
+
# Unused — call_procedure is overridden below instead of going
|
|
32
|
+
# through _run()/_connect(), since there's no real cursor here.
|
|
33
|
+
return sp_name
|
|
34
|
+
|
|
35
|
+
def call_procedure(
|
|
36
|
+
self, sp_name: str, params: Sequence[Any] = (), fetch: bool = False
|
|
37
|
+
):
|
|
38
|
+
if sp_name not in self.handlers:
|
|
39
|
+
raise ValueError(f"No handler registered for {sp_name!r}")
|
|
40
|
+
result = self.handlers[sp_name](*params)
|
|
41
|
+
return result if fetch else True
|
|
42
|
+
|
|
43
|
+
def execute(self, sql: str, params: Sequence[Any] = (), fetch: bool = False):
|
|
44
|
+
raise NotImplementedError("FakeExecutor only fakes stored procedures")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""SQL Server backend — the only piece that knows about pyodbc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
import pyodbc
|
|
8
|
+
|
|
9
|
+
from .base import BaseSqlExecutor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqlServerExecutor(BaseSqlExecutor):
|
|
13
|
+
"""Stored-procedure / SQL executor for SQL Server, backed by pyodbc."""
|
|
14
|
+
|
|
15
|
+
driver_error = pyodbc.Error
|
|
16
|
+
|
|
17
|
+
def __init__(self, connstring: str):
|
|
18
|
+
self.connstring = connstring
|
|
19
|
+
|
|
20
|
+
def _connect(self):
|
|
21
|
+
return pyodbc.connect(self.connstring)
|
|
22
|
+
|
|
23
|
+
def _call_procedure_sql(self, sp_name: str, params: Sequence[Any]) -> str:
|
|
24
|
+
placeholders = ",".join("?" for _ in params)
|
|
25
|
+
return f"EXEC {sp_name} {placeholders}".strip()
|