pysql-executor 0.1.0__tar.gz

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,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,76 @@
1
+ # sql-executor (v1, 0.1.0)
2
+
3
+ Backend-agnostic stored-procedure / SQL executor. `BaseSqlExecutor` owns
4
+ connection lifecycle, commit/rollback, fetch-to-dict, chunked streaming,
5
+ and error wrapping. A new backend only implements two methods.
6
+
7
+ ## Usage (SQL Server)
8
+
9
+ ```python
10
+ from sql_executor import SqlServerExecutor, DatabaseError
11
+
12
+ db = SqlServerExecutor(connstring="DRIVER={ODBC Driver 17 for SQL Server};...")
13
+
14
+ # Stored procedure
15
+ rows = db.call_procedure("dbo.GetIncidents", params=("2026-06-01",), fetch=True)
16
+
17
+ # Raw SQL
18
+ db.execute("UPDATE dbo.Logs SET status = ? WHERE id = ?", params=("done", 42))
19
+
20
+ # Stream large result sets in chunks instead of loading everything into memory
21
+ for row in db.stream_procedure("dbo.GetBigReport", chunk_size=1000):
22
+ process(row)
23
+
24
+ try:
25
+ db.execute("DELETE FROM dbo.Logs WHERE id = ?", params=(42,))
26
+ except DatabaseError:
27
+ # Always raised on failure — never swallowed as None/False.
28
+ raise
29
+ ```
30
+
31
+ ## Adding a new backend (e.g. Postgres, Oracle)
32
+
33
+ Subclass `BaseSqlExecutor` and implement two methods:
34
+
35
+ ```python
36
+ import psycopg2
37
+ from sql_executor import BaseSqlExecutor
38
+
39
+ class PostgresExecutor(BaseSqlExecutor):
40
+ driver_error = psycopg2.Error
41
+
42
+ def __init__(self, dsn: str):
43
+ self.dsn = dsn
44
+
45
+ def _connect(self):
46
+ return psycopg2.connect(self.dsn)
47
+
48
+ def _call_procedure_sql(self, sp_name, params):
49
+ placeholders = ",".join("%s" for _ in params)
50
+ return f"CALL {sp_name}({placeholders})"
51
+ ```
52
+
53
+ `call_procedure`, `execute`, `stream`, and `stream_procedure` all come for
54
+ free from the base class.
55
+
56
+ ## Testing
57
+
58
+ `FakeExecutor` is a drop-in for `SqlServerExecutor` with no real DB:
59
+
60
+ ```python
61
+ from sql_executor import FakeExecutor
62
+
63
+ fake = FakeExecutor()
64
+ fake.register("dbo.GetIncidents", lambda since: [{"id": 1, "since": since}])
65
+ fake.call_procedure("dbo.GetIncidents", params=("2026-06-01",), fetch=True)
66
+ ```
67
+
68
+ ## Offline install (corporate VM, no internet)
69
+
70
+ Build the wheel on a machine with internet access, then copy the `.whl`
71
+ to the VM:
72
+
73
+ ```bash
74
+ pip install --no-index --find-links=. sql_executor-0.1.0-py3-none-any.whl
75
+ pip install pyodbc # only needed if you use SqlServerExecutor, also offline-installed
76
+ ```
@@ -0,0 +1,17 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pysql-executor"
7
+ version = "0.1.0"
8
+ description = "Backend-agnostic stored-procedure / SQL executor with a pluggable base class"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = []
12
+
13
+ [project.optional-dependencies]
14
+ sqlserver = ["pyodbc>=4.0"]
15
+
16
+ [tool.setuptools.packages.find]
17
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/pysql_executor.egg-info/PKG-INFO
4
+ src/pysql_executor.egg-info/SOURCES.txt
5
+ src/pysql_executor.egg-info/dependency_links.txt
6
+ src/pysql_executor.egg-info/requires.txt
7
+ src/pysql_executor.egg-info/top_level.txt
8
+ src/sql_executor/__init__.py
9
+ src/sql_executor/base.py
10
+ src/sql_executor/fake.py
11
+ src/sql_executor/sqlserver.py
12
+ tests/test_fake_executor.py
@@ -0,0 +1,3 @@
1
+
2
+ [sqlserver]
3
+ pyodbc>=4.0
@@ -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}")
@@ -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)
@@ -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()
@@ -0,0 +1,33 @@
1
+ import pytest
2
+
3
+ from sql_executor import FakeExecutor
4
+
5
+
6
+ def test_call_procedure_fetch():
7
+ fake = FakeExecutor()
8
+ fake.register("dbo.GetIncidents", lambda since: [{"id": 1, "since": since}])
9
+
10
+ result = fake.call_procedure(
11
+ "dbo.GetIncidents", params=("2026-06-01",), fetch=True
12
+ )
13
+
14
+ assert result == [{"id": 1, "since": "2026-06-01"}]
15
+
16
+
17
+ def test_call_procedure_no_fetch_returns_true():
18
+ fake = FakeExecutor()
19
+ fake.register("dbo.DoThing", lambda: None)
20
+
21
+ assert fake.call_procedure("dbo.DoThing") is True
22
+
23
+
24
+ def test_unregistered_sp_raises():
25
+ fake = FakeExecutor()
26
+ with pytest.raises(ValueError):
27
+ fake.call_procedure("dbo.Unknown")
28
+
29
+
30
+ def test_execute_not_implemented():
31
+ fake = FakeExecutor()
32
+ with pytest.raises(NotImplementedError):
33
+ fake.execute("SELECT 1")