databasic 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,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: databasic
3
+ Version: 0.1.0
4
+ Summary: Database connection helper
5
+ Author: ansipunk
6
+ Author-email: ansipunk <ansipunk@proton.me>
7
+ License-Expression: MIT
8
+ Requires-Dist: psycopg[binary,pool]>=3,<4
9
+ Requires-Dist: sqlalchemy>=2,<2.2
10
+ Requires-Python: >=3.11, <3.16
11
+ Description-Content-Type: text/markdown
12
+
13
+ # Databasic
14
+
15
+ A small async database interface built on SQLAlchemy Core and psycopg.
16
+
17
+ SQLAlchemy Core handles query construction and compilation. Databasic handles
18
+ connection and transaction lifetimes and executes the resulting queries directly
19
+ through psycopg.
20
+
21
+ ## Usage
22
+
23
+ ```python
24
+ import sqlalchemy as sa
25
+
26
+ from databasic import Databasic
27
+
28
+
29
+ users = sa.Table(
30
+ "users",
31
+ sa.MetaData(),
32
+ sa.Column("id", sa.Integer, primary_key=True),
33
+ sa.Column("name", sa.Text, nullable=False),
34
+ )
35
+
36
+ db = Databasic("postgresql://user:password@localhost/database")
37
+
38
+ async with db:
39
+ async with db.session() as session:
40
+ rows = await session.fetch_all(
41
+ users.select().where(users.c.name == "Alice")
42
+ )
43
+ ```
44
+
45
+ `Databasic` can also be connected and disconnected explicitly. Both forms have
46
+ the same semantics; explicit lifecycle management is useful when the lifetime is
47
+ controlled by an application or framework.
48
+
49
+ ```python
50
+ db = Databasic("postgresql://user:password@localhost/database")
51
+
52
+ await db.connect()
53
+
54
+ try:
55
+ async with db.session() as session:
56
+ rows = await session.fetch_all(users.select())
57
+ finally:
58
+ await db.disconnect()
59
+ ```
60
+
61
+ A session is a transaction. It commits when the context exits successfully and
62
+ rolls back when it exits with an exception.
63
+
64
+ ## API
65
+
66
+ ### `Databasic`
67
+
68
+ ```python
69
+ db = Databasic(conninfo, force_rollback=False)
70
+ ```
71
+
72
+ #### `connect()`
73
+
74
+ Open the connection pool.
75
+
76
+ ```python
77
+ await db.connect()
78
+ ```
79
+
80
+ #### `disconnect()`
81
+
82
+ Close the connection pool.
83
+
84
+ ```python
85
+ await db.disconnect()
86
+ ```
87
+
88
+ #### `session()`
89
+
90
+ Create a transactional session.
91
+
92
+ ```python
93
+ async with db.session() as session:
94
+ ...
95
+ ```
96
+
97
+ Successful exit commits the transaction. An exception rolls it back.
98
+
99
+ ### `Session`
100
+
101
+ #### `execute()`
102
+
103
+ Execute a statement without returning rows.
104
+
105
+ ```python
106
+ await session.execute(
107
+ users.insert().values(name="Alice")
108
+ )
109
+ ```
110
+
111
+ #### `execute_many()`
112
+
113
+ Execute the same statement multiple times with different parameters.
114
+
115
+ ```python
116
+ query = users.insert().values(
117
+ name=sa.bindparam("name"),
118
+ )
119
+
120
+ await session.execute_many(
121
+ query,
122
+ [
123
+ {"name": "Alice"},
124
+ {"name": "Bob"},
125
+ ],
126
+ )
127
+ ```
128
+
129
+ The statement must define its values using explicit bind parameters. Values are
130
+ supplied exclusively through the parameter mappings.
131
+
132
+ #### `fetch_one()`
133
+
134
+ Execute a statement and return one row, or `None` if there is no row.
135
+
136
+ ```python
137
+ user = await session.fetch_one(
138
+ users.select().where(users.c.id == 1)
139
+ )
140
+ ```
141
+
142
+ A row is simply a `dict[str, Any]`:
143
+
144
+ ```python
145
+ {"id": 1, "name": "Alice"}
146
+ ```
147
+
148
+ There is no result or row wrapper.
149
+
150
+ #### `fetch_all()`
151
+
152
+ Execute a statement and return all rows as a list of dictionaries.
153
+
154
+ ```python
155
+ users = await session.fetch_all(
156
+ users.select()
157
+ )
158
+ ```
159
+
160
+ The result has the shape:
161
+
162
+ ```python
163
+ [
164
+ {"id": 1, "name": "Alice"},
165
+ {"id": 2, "name": "Bob"},
166
+ ]
167
+ ```
168
+
169
+ #### `transaction()`
170
+
171
+ Create a nested transaction within the current session.
172
+
173
+ ```python
174
+ async with db.session() as session:
175
+ await session.execute(
176
+ users.insert().values(name="Alice")
177
+ )
178
+
179
+ async with session.transaction():
180
+ await session.execute(
181
+ users.insert().values(name="Bob")
182
+ )
183
+ ```
184
+
185
+ Nested transactions use database savepoints. An exception rolls back the nested
186
+ transaction without rolling back earlier work in the session.
187
+
188
+ ## Testing
189
+
190
+ `force_rollback=True` runs all sessions within an outer transaction that is
191
+ rolled back when Databasic is disconnected.
192
+
193
+ ```python
194
+ async with Databasic(
195
+ "postgresql://user:password@localhost/test_database",
196
+ force_rollback=True,
197
+ ) as db:
198
+ ...
199
+ ```
200
+
201
+ This allows integration tests to use normal sessions and transactions without
202
+ leaving persistent database state.
@@ -0,0 +1,190 @@
1
+ # Databasic
2
+
3
+ A small async database interface built on SQLAlchemy Core and psycopg.
4
+
5
+ SQLAlchemy Core handles query construction and compilation. Databasic handles
6
+ connection and transaction lifetimes and executes the resulting queries directly
7
+ through psycopg.
8
+
9
+ ## Usage
10
+
11
+ ```python
12
+ import sqlalchemy as sa
13
+
14
+ from databasic import Databasic
15
+
16
+
17
+ users = sa.Table(
18
+ "users",
19
+ sa.MetaData(),
20
+ sa.Column("id", sa.Integer, primary_key=True),
21
+ sa.Column("name", sa.Text, nullable=False),
22
+ )
23
+
24
+ db = Databasic("postgresql://user:password@localhost/database")
25
+
26
+ async with db:
27
+ async with db.session() as session:
28
+ rows = await session.fetch_all(
29
+ users.select().where(users.c.name == "Alice")
30
+ )
31
+ ```
32
+
33
+ `Databasic` can also be connected and disconnected explicitly. Both forms have
34
+ the same semantics; explicit lifecycle management is useful when the lifetime is
35
+ controlled by an application or framework.
36
+
37
+ ```python
38
+ db = Databasic("postgresql://user:password@localhost/database")
39
+
40
+ await db.connect()
41
+
42
+ try:
43
+ async with db.session() as session:
44
+ rows = await session.fetch_all(users.select())
45
+ finally:
46
+ await db.disconnect()
47
+ ```
48
+
49
+ A session is a transaction. It commits when the context exits successfully and
50
+ rolls back when it exits with an exception.
51
+
52
+ ## API
53
+
54
+ ### `Databasic`
55
+
56
+ ```python
57
+ db = Databasic(conninfo, force_rollback=False)
58
+ ```
59
+
60
+ #### `connect()`
61
+
62
+ Open the connection pool.
63
+
64
+ ```python
65
+ await db.connect()
66
+ ```
67
+
68
+ #### `disconnect()`
69
+
70
+ Close the connection pool.
71
+
72
+ ```python
73
+ await db.disconnect()
74
+ ```
75
+
76
+ #### `session()`
77
+
78
+ Create a transactional session.
79
+
80
+ ```python
81
+ async with db.session() as session:
82
+ ...
83
+ ```
84
+
85
+ Successful exit commits the transaction. An exception rolls it back.
86
+
87
+ ### `Session`
88
+
89
+ #### `execute()`
90
+
91
+ Execute a statement without returning rows.
92
+
93
+ ```python
94
+ await session.execute(
95
+ users.insert().values(name="Alice")
96
+ )
97
+ ```
98
+
99
+ #### `execute_many()`
100
+
101
+ Execute the same statement multiple times with different parameters.
102
+
103
+ ```python
104
+ query = users.insert().values(
105
+ name=sa.bindparam("name"),
106
+ )
107
+
108
+ await session.execute_many(
109
+ query,
110
+ [
111
+ {"name": "Alice"},
112
+ {"name": "Bob"},
113
+ ],
114
+ )
115
+ ```
116
+
117
+ The statement must define its values using explicit bind parameters. Values are
118
+ supplied exclusively through the parameter mappings.
119
+
120
+ #### `fetch_one()`
121
+
122
+ Execute a statement and return one row, or `None` if there is no row.
123
+
124
+ ```python
125
+ user = await session.fetch_one(
126
+ users.select().where(users.c.id == 1)
127
+ )
128
+ ```
129
+
130
+ A row is simply a `dict[str, Any]`:
131
+
132
+ ```python
133
+ {"id": 1, "name": "Alice"}
134
+ ```
135
+
136
+ There is no result or row wrapper.
137
+
138
+ #### `fetch_all()`
139
+
140
+ Execute a statement and return all rows as a list of dictionaries.
141
+
142
+ ```python
143
+ users = await session.fetch_all(
144
+ users.select()
145
+ )
146
+ ```
147
+
148
+ The result has the shape:
149
+
150
+ ```python
151
+ [
152
+ {"id": 1, "name": "Alice"},
153
+ {"id": 2, "name": "Bob"},
154
+ ]
155
+ ```
156
+
157
+ #### `transaction()`
158
+
159
+ Create a nested transaction within the current session.
160
+
161
+ ```python
162
+ async with db.session() as session:
163
+ await session.execute(
164
+ users.insert().values(name="Alice")
165
+ )
166
+
167
+ async with session.transaction():
168
+ await session.execute(
169
+ users.insert().values(name="Bob")
170
+ )
171
+ ```
172
+
173
+ Nested transactions use database savepoints. An exception rolls back the nested
174
+ transaction without rolling back earlier work in the session.
175
+
176
+ ## Testing
177
+
178
+ `force_rollback=True` runs all sessions within an outer transaction that is
179
+ rolled back when Databasic is disconnected.
180
+
181
+ ```python
182
+ async with Databasic(
183
+ "postgresql://user:password@localhost/test_database",
184
+ force_rollback=True,
185
+ ) as db:
186
+ ...
187
+ ```
188
+
189
+ This allows integration tests to use normal sessions and transactions without
190
+ leaving persistent database state.
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "databasic"
3
+ version = "0.1.0"
4
+ description = "Database connection helper"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11,<3.16"
8
+ dependencies = [
9
+ "psycopg[binary,pool]>=3,<4",
10
+ "sqlalchemy>=2,<2.2",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "ansipunk"
15
+ email = "ansipunk@proton.me"
16
+
17
+ [build-system]
18
+ requires = ["uv_build>=0.12.17,<0.13.0"]
19
+ build-backend = "uv_build"
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "pytest>=9,<10",
24
+ "pytest-asyncio>=1,<2",
25
+ "pytest-cov>=7,<8",
26
+ "ruff>=0.16.10",
27
+ "ty>=0.0.84",
28
+ ]
29
+
30
+ [tool.pytest]
31
+ addopts = [
32
+ "--cov",
33
+ "databasic",
34
+ "--cov-report",
35
+ "term-missing",
36
+ "--cov-report",
37
+ "html",
38
+ "--cov-fail-under",
39
+ "100",
40
+ ]
41
+ testpaths = ["tests"]
@@ -0,0 +1,38 @@
1
+ [project]
2
+ name = "databasic"
3
+ version = "0.1.0"
4
+ description = "Database connection helper"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "ansipunk", email = "ansipunk@proton.me" }
9
+ ]
10
+ requires-python = ">=3.11,<3.16"
11
+ dependencies = [
12
+ "psycopg[binary,pool]>=3,<4",
13
+ "sqlalchemy>=2,<2.2",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.12.17,<0.13.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [dependency-groups]
21
+ dev = [
22
+ "pytest>=9,<10",
23
+ "pytest-asyncio>=1,<2",
24
+ "pytest-cov>=7,<8",
25
+ "ruff>=0.16.10",
26
+ "ty>=0.0.84",
27
+ ]
28
+
29
+ [tool.pytest]
30
+ addopts = [
31
+ "--cov", "databasic",
32
+ "--cov-report", "term-missing",
33
+ "--cov-report", "html",
34
+ "--cov-fail-under", "100",
35
+ ]
36
+ testpaths = [
37
+ "tests",
38
+ ]
@@ -0,0 +1,198 @@
1
+ from collections.abc import AsyncIterator, Callable, Iterable, Mapping, Sequence
2
+ from contextlib import asynccontextmanager
3
+ from types import TracebackType
4
+ from typing import Any, LiteralString, Self, cast
5
+
6
+ from psycopg import AsyncConnection as BaseAsyncConnection
7
+ from psycopg import AsyncCursor as BaseAsyncCursor
8
+ from psycopg import AsyncTransaction
9
+ from psycopg_pool import AsyncConnectionPool as BaseAsyncConnectionPool
10
+ from sqlalchemy.dialects import postgresql
11
+ from sqlalchemy.sql.elements import ClauseElement
12
+
13
+ __all__ = [
14
+ "DatabaseAlreadyConnectedError",
15
+ "DatabaseNotConnectedError",
16
+ "Databasic",
17
+ "DatabasicError",
18
+ "Row",
19
+ "Session",
20
+ ]
21
+
22
+ Row = dict[str, Any]
23
+ AsyncConnection = BaseAsyncConnection[Row]
24
+ AsyncConnectionPool = BaseAsyncConnectionPool[AsyncConnection]
25
+ AsyncCursor = BaseAsyncCursor[Row]
26
+
27
+
28
+ class Databasic:
29
+ _pool: AsyncConnectionPool
30
+ _conn: AsyncConnection | None = None
31
+ _global_transaction: AsyncTransaction | None = None
32
+ _force_rollback: bool = False
33
+
34
+ def __init__(self, conninfo: str, *, force_rollback: bool = False):
35
+ self._pool = AsyncConnectionPool(
36
+ conninfo,
37
+ open=False,
38
+ kwargs={"row_factory": _dict_row_factory},
39
+ )
40
+ self._force_rollback = force_rollback
41
+
42
+ async def __aenter__(self) -> Self:
43
+ await self.connect()
44
+ return self
45
+
46
+ async def __aexit__(
47
+ self,
48
+ exc_type: type[BaseException] | None,
49
+ exc_value: BaseException | None,
50
+ traceback: TracebackType | None,
51
+ ) -> None:
52
+ _ = exc_type, exc_value, traceback
53
+ await self.disconnect()
54
+
55
+ async def connect(self) -> None:
56
+ if not self._pool.closed:
57
+ raise DatabaseAlreadyConnectedError
58
+
59
+ await self._pool.open(wait=True)
60
+
61
+ if self._force_rollback:
62
+ try:
63
+ self._conn = await self._pool.getconn()
64
+ except BaseException: # pragma: no cover
65
+ await self.disconnect()
66
+ raise
67
+
68
+ self._global_transaction = AsyncTransaction(
69
+ self._conn,
70
+ force_rollback=True,
71
+ )
72
+
73
+ try:
74
+ await self._global_transaction.__aenter__()
75
+ except BaseException: # pragma: no cover
76
+ await self.disconnect()
77
+ raise
78
+
79
+ async def disconnect(self) -> None:
80
+ if self._pool.closed:
81
+ raise DatabaseNotConnectedError
82
+
83
+ if self._conn is not None:
84
+ if self._global_transaction is not None:
85
+ await self._global_transaction.__aexit__(None, None, None)
86
+ self._global_transaction = None
87
+
88
+ await self._pool.putconn(self._conn)
89
+ self._conn = None
90
+
91
+ await self._pool.close()
92
+
93
+ @asynccontextmanager
94
+ async def session(self) -> AsyncIterator["Session"]:
95
+ if self._pool.closed:
96
+ raise DatabaseNotConnectedError
97
+
98
+ if self._conn is not None:
99
+ async with self._conn.transaction():
100
+ yield Session(self._conn)
101
+ else:
102
+ async with self._pool.connection() as conn: # noqa: SIM117
103
+ async with conn.transaction():
104
+ yield Session(conn)
105
+
106
+
107
+ class Session:
108
+ _conn: AsyncConnection
109
+
110
+ def __init__(self, conn: AsyncConnection):
111
+ self._conn = conn
112
+
113
+ async def fetch_one(self, query: ClauseElement) -> Row | None:
114
+ async with self._execute(query) as cursor:
115
+ return await cursor.fetchone()
116
+
117
+ async def fetch_all(self, query: ClauseElement) -> list[Row]:
118
+ async with self._execute(query) as cursor:
119
+ return await cursor.fetchall()
120
+
121
+ async def execute(self, query: ClauseElement) -> int:
122
+ async with self._execute(query) as cursor:
123
+ return cursor.rowcount
124
+
125
+ async def execute_many(
126
+ self,
127
+ query: ClauseElement,
128
+ params: Iterable[Mapping[str, Any]],
129
+ ) -> int:
130
+ """Execute a query multiple times with different parameters.
131
+
132
+ The query must define all values as explicit bind parameters. Values
133
+ must be provided exclusively through `params`, not embedded in the
134
+ SQLAlchemy query itself.
135
+
136
+ Usage would roughly be like:
137
+
138
+ query = sa.insert(table).values(
139
+ val_a=sa.bindparam("val_a"),
140
+ val_b=sa.bindparam("val_b"),
141
+ )
142
+
143
+ await session.execute_many(
144
+ query,
145
+ [
146
+ {"val_a": 1, "val_b": 2},
147
+ {"val_a": 3, "val_b": 4},
148
+ ],
149
+ )
150
+ """
151
+
152
+ compiled_query, _ = _compile_query(query)
153
+
154
+ async with self._conn.cursor() as cursor:
155
+ await cursor.executemany(compiled_query, params)
156
+ return cursor.rowcount
157
+
158
+ @asynccontextmanager
159
+ async def _execute(self, statement: ClauseElement) -> AsyncIterator[AsyncCursor]:
160
+ query, params = _compile_query(statement)
161
+
162
+ async with self._conn.cursor() as cursor:
163
+ await cursor.execute(query, params)
164
+ yield cursor
165
+
166
+ @asynccontextmanager
167
+ async def transaction(self) -> AsyncIterator[None]:
168
+ async with self._conn.transaction():
169
+ yield
170
+
171
+
172
+ class DatabasicError(Exception):
173
+ pass
174
+
175
+
176
+ class DatabaseAlreadyConnectedError(DatabasicError):
177
+ pass
178
+
179
+
180
+ class DatabaseNotConnectedError(DatabasicError):
181
+ pass
182
+
183
+
184
+ def _dict_row_factory(cursor: AsyncCursor) -> Callable[[Sequence[Any]], Row]:
185
+ fields = [c.name for c in cursor.description or ()]
186
+
187
+ def make_row(values: Sequence[Any]) -> Row:
188
+ return dict(zip(fields, values))
189
+
190
+ return make_row
191
+
192
+
193
+ def _compile_query(statement: ClauseElement) -> tuple[LiteralString, dict[str, Any]]:
194
+ compiled = statement.compile(
195
+ dialect=postgresql.dialect(paramstyle="pyformat"),
196
+ compile_kwargs={"render_postcompile": True},
197
+ )
198
+ return cast(LiteralString, compiled.string), compiled.params
File without changes