pgtern 0.3.0__tar.gz → 0.4.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.
Files changed (63) hide show
  1. pgtern-0.4.0/CHANGELOG.md +48 -0
  2. pgtern-0.4.0/PKG-INFO +303 -0
  3. pgtern-0.4.0/README.md +275 -0
  4. {pgtern-0.3.0 → pgtern-0.4.0}/pyproject.toml +3 -3
  5. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/__init__.py +3 -2
  6. pgtern-0.4.0/src/pgtern/_auth.py +185 -0
  7. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_binary.py +14 -5
  8. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_conn.py +227 -67
  9. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_copy.py +11 -7
  10. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_cursor.py +5 -3
  11. pgtern-0.4.0/src/pgtern/_dsn.py +298 -0
  12. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_protocol.py +44 -3
  13. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_query.py +6 -4
  14. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_scram.py +19 -6
  15. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_types.py +83 -28
  16. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/aio.py +474 -125
  17. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/hooks.py +31 -1
  18. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/pipeline.py +108 -41
  19. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/pool.py +39 -0
  20. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/sql.py +1 -1
  21. pgtern-0.4.0/src/pgtern.egg-info/PKG-INFO +303 -0
  22. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern.egg-info/SOURCES.txt +7 -0
  23. pgtern-0.4.0/src/pgtern.egg-info/requires.txt +7 -0
  24. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_binary.py +2 -2
  25. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_features.py +2 -1
  26. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_integration.py +18 -8
  27. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_journey.py +4 -1
  28. pgtern-0.4.0/tests/test_low_items.py +219 -0
  29. pgtern-0.4.0/tests/test_medium.py +420 -0
  30. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_pipeline.py +22 -18
  31. pgtern-0.4.0/tests/test_pool_gaps.py +94 -0
  32. pgtern-0.4.0/tests/test_regressions.py +477 -0
  33. pgtern-0.4.0/tests/test_scram_plus.py +108 -0
  34. pgtern-0.4.0/tests/test_session_state.py +163 -0
  35. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_unit.py +9 -7
  36. pgtern-0.3.0/CHANGELOG.md +0 -7
  37. pgtern-0.3.0/PKG-INFO +0 -265
  38. pgtern-0.3.0/README.md +0 -240
  39. pgtern-0.3.0/src/pgtern/_dsn.py +0 -182
  40. pgtern-0.3.0/src/pgtern.egg-info/PKG-INFO +0 -265
  41. pgtern-0.3.0/src/pgtern.egg-info/requires.txt +0 -4
  42. {pgtern-0.3.0 → pgtern-0.4.0}/CONTRIBUTING.md +0 -0
  43. {pgtern-0.3.0 → pgtern-0.4.0}/LICENSE +0 -0
  44. {pgtern-0.3.0 → pgtern-0.4.0}/MANIFEST.in +0 -0
  45. {pgtern-0.3.0 → pgtern-0.4.0}/SECURITY.md +0 -0
  46. {pgtern-0.3.0 → pgtern-0.4.0}/scripts/start-test-postgres.sh +0 -0
  47. {pgtern-0.3.0 → pgtern-0.4.0}/setup.cfg +0 -0
  48. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/_composite.py +0 -0
  49. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/adapters.py +0 -0
  50. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/errors.py +0 -0
  51. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/py.typed +0 -0
  52. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/rows.py +0 -0
  53. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/types/__init__.py +0 -0
  54. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern/types/json.py +0 -0
  55. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern.egg-info/dependency_links.txt +0 -0
  56. {pgtern-0.3.0 → pgtern-0.4.0}/src/pgtern.egg-info/top_level.txt +0 -0
  57. {pgtern-0.3.0 → pgtern-0.4.0}/tests/conftest.py +0 -0
  58. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_adapters.py +0 -0
  59. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_async.py +0 -0
  60. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_executemany.py +0 -0
  61. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_executemany_async.py +0 -0
  62. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_restart.py +0 -0
  63. {pgtern-0.3.0 → pgtern-0.4.0}/tests/test_transaction_pooler.py +0 -0
@@ -0,0 +1,48 @@
1
+ # Changelog
2
+
3
+ All notable changes to pgtern are documented in this file.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.4.0] - 2026-11-06
8
+
9
+ ### Changed (behaviour)
10
+
11
+ These changes can affect existing code. Check them when upgrading.
12
+
13
+ - `timestamptz` values are returned in UTC in text mode as well as binary mode, so both formats give the same value and `tzinfo`.
14
+ - `connect_timeout` defaults to 30 seconds and applies to each host attempt. `connect_timeout=0` means no limit.
15
+ - `prepare=True` raises `NotSupportedError`. `prepare=False` is accepted.
16
+ - Bare `%b` and `%t` placeholders are refused with `ProgrammingError`. Only `%s` is accepted.
17
+ - A statement with more placeholders than parameters raises `ProgrammingError` instead of `IndexError`.
18
+ - Non-ASCII SQL or parameters under a non-UTF-8 `client_encoding` raise `NotSupportedError` before anything is sent.
19
+ - Using a connection from inside a COPY block or from inside a notice or notify handler raises `ProgrammingError` at once, instead of breaking the protocol or deadlocking.
20
+ - A query hook that fails on entry or exit is logged, not raised. A notice or notify handler that raises is logged too.
21
+
22
+ ### Added
23
+
24
+ - SCRAM-SHA-256-PLUS: when the server offers it over TLS, the login is bound to that connection (`tls-server-end-point`, RFC 5929).
25
+ - `max_result_bytes` connection attribute, a cap on the bytes one statement may return. It is `None` (no cap) by default. A result over the cap raises `DataError` and leaves the connection usable.
26
+ - The async pool resets itself after `fork()`, so a forked child never uses the parent's connections.
27
+
28
+ ### Fixed
29
+
30
+ - `24:00:00` is refused with `DataError` in binary mode. It used to be read as midnight.
31
+ - A `float4` parameter outside its range is sent as text, so the server reports the error instead of the driver raising it.
32
+ - Python `bool` values are no longer captured by a dumper registered for `int`.
33
+ - Session settings are followed after `SET client_encoding` and `SET DateStyle`, both when reading rows and when copying data.
34
+ - A failed connection open no longer leaves sessions behind.
35
+ - Array element delimiters come from the type, so arrays of `box` split correctly on `;`.
36
+ - The SCRAM client rejects a server nonce that does not extend its own, and rejects malformed base64.
37
+ - Bytes literals are correct when `standard_conforming_strings` is off.
38
+ - The type-loader cache is cleared after DDL, so a changed type is read again.
39
+ - Connect timeouts are enforced for the whole startup, so a silent server cannot hang a connection.
40
+
41
+ ### Infrastructure
42
+
43
+ - Continuous integration: lint and type check, a test matrix over Python 3.11 to 3.13 and PostgreSQL 14 to 18, a TLS, auth-roles, pooler and restart job, an installed-wheel test in a clean environment, and a build job that checks metadata and runtime dependencies.
44
+ - Development status classifier set to Beta.
45
+
46
+ ## [0.3.0]
47
+
48
+ Earlier release. Notes for this version were not kept in this file.
pgtern-0.4.0/PKG-INFO ADDED
@@ -0,0 +1,303 @@
1
+ Metadata-Version: 2.4
2
+ Name: pgtern
3
+ Version: 0.4.0
4
+ Summary: A zero-dependency, pure-Python PostgreSQL client with blocking and asyncio APIs.
5
+ Author: Ahmad Karawash
6
+ License-Expression: Apache-2.0
7
+ Keywords: postgresql,postgres,database,driver,dbapi,scram,pure-python,zero-dependency
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Database
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-timeout>=2; extra == "dev"
24
+ Requires-Dist: build>=1.2; extra == "dev"
25
+ Requires-Dist: pyflakes>=3; extra == "dev"
26
+ Requires-Dist: mypy>=1.10; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # pgtern
30
+
31
+ [![CI](https://github.com/karawash-projects/pgtern/actions/workflows/ci.yml/badge.svg)](https://github.com/karawash-projects/pgtern/actions/workflows/ci.yml)
32
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
33
+
34
+ pgtern is a PostgreSQL client for Python. It speaks the PostgreSQL wire protocol directly and uses only the Python standard library, so there are no runtime dependencies, no C extensions and no build step. It provides a DB-API style interface in two forms: blocking, and `asyncio`.
35
+
36
+ ```python
37
+ import pgtern
38
+ from pgtern.rows import dict_row
39
+
40
+ with pgtern.connect("postgresql://app:secret@localhost/app", row_factory=dict_row) as conn:
41
+ with conn.transaction():
42
+ conn.execute("INSERT INTO notes (body) VALUES (%s)", ["hello"])
43
+ print(conn.execute("SELECT id, body FROM notes ORDER BY id").fetchall())
44
+ ```
45
+
46
+ ```python
47
+ import asyncio
48
+ from pgtern import aio
49
+
50
+ async def main():
51
+ async with aio.AsyncConnectionPool("postgresql://app:secret@localhost/app", min_size=2, max_size=10) as pool:
52
+ async with pool.connection() as conn:
53
+ cur = await conn.execute("SELECT %s::int", [1])
54
+ print(cur.fetchall())
55
+
56
+ asyncio.run(main())
57
+ ```
58
+
59
+ ## Features
60
+
61
+ - No runtime dependencies. The package is a pure-Python wheel for Python 3.11 and newer.
62
+ - Blocking and `asyncio` APIs with the same names and behaviour, including pools, transactions, `COPY`, notifications and pipelines.
63
+ - SCRAM-SHA-256 authentication, and SCRAM-SHA-256-PLUS channel binding over TLS.
64
+ - TLS modes `disable`, `allow`, `prefer`, `require`, `verify-ca` and `verify-full`.
65
+ - Connection pools with liveness checks, idle and lifetime limits, and a fork-safe reset.
66
+ - Parameters are always sent separately from the SQL text. A helper module composes identifiers and literals safely.
67
+ - Opt-in binary result format for common types, and text format for everything else.
68
+ - Server-side cursors for large results, and batched `executemany`.
69
+ - Query hooks for logging, timing and tracing. They never receive bound parameter values.
70
+ - Exceptions organised by SQLSTATE class.
71
+
72
+ ## Installation
73
+
74
+ ```
75
+ pip install pgtern
76
+ ```
77
+
78
+ pgtern requires Python 3.11 or newer.
79
+
80
+ ## Connecting
81
+
82
+ `pgtern.connect` accepts URL and key=value connection strings, the standard `PG*` environment variables, Unix sockets, and a list of hosts for failover.
83
+
84
+ ```python
85
+ conn = pgtern.connect("postgresql://app@db1.internal,db2.internal/app?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem")
86
+ ```
87
+
88
+ TLS follows the PostgreSQL conventions. Under `allow` and `prefer`, the client refuses to send MD5 or cleartext passwords over an unencrypted connection. Under `disable` it sends them, because encryption was explicitly turned off. Set `require_auth=scram-sha-256` to refuse any server that does not use SCRAM, including servers that allow trust authentication.
89
+
90
+ Failover moves to the next host after any startup failure, including a timeout during TLS negotiation. Passwords can come from a `.pgpass` file (`PGPASSFILE` or `~/.pgpass`). A password file that other users can read is ignored.
91
+
92
+ Other supported options:
93
+
94
+ - `target_session_attrs`: `read-write`, `read-only`, `primary`, `standby` or `prefer-standby`.
95
+ - `hostaddr`: connect to an address while keeping the host name for certificate checks.
96
+ - `sslpassword` and `sslcrl`.
97
+ - `connect_timeout`: seconds allowed for each host attempt, from opening the socket to the end of startup. The default is 30. `0` means no limit.
98
+
99
+ ## Queries and transactions
100
+
101
+ ```python
102
+ with pgtern.connect(dsn) as conn:
103
+ conn.execute("CREATE TABLE IF NOT EXISTS accounts (id int PRIMARY KEY, balance numeric)")
104
+
105
+ with conn.transaction(): # commits on success, rolls back on error
106
+ conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", [10, 1])
107
+ conn.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", [10, 2])
108
+
109
+ cur = conn.execute("SELECT id, balance FROM accounts WHERE balance > %s", [0])
110
+ for row in cur:
111
+ print(row)
112
+ ```
113
+
114
+ Placeholders are `%s`. Values are sent as parameters, never interpolated into the SQL text. To build identifiers and literals safely, use the `sql` module:
115
+
116
+ ```python
117
+ from pgtern import sql
118
+
119
+ with pgtern.connect(dsn) as conn:
120
+ query = sql.SQL("SELECT * FROM {} WHERE id = %s").format(sql.Identifier("accounts"))
121
+ conn.execute(query, [1])
122
+ ```
123
+
124
+ ## Row formats
125
+
126
+ Choose how rows are returned with a row factory: `tuple_row` (the default), `dict_row` or `namedtuple_row`. Pass one to `connect`, or to a single cursor.
127
+
128
+ ## Bulk loading
129
+
130
+ `executemany` sends the whole batch as one pipelined transaction. In autocommit mode, a failing row rolls back the entire batch. Inside a transaction, it aborts that transaction as usual.
131
+
132
+ ```python
133
+ with pgtern.connect(dsn, autocommit=True) as conn:
134
+ conn.cursor().executemany("INSERT INTO events (kind, payload) VALUES (%s, %s)", rows)
135
+ ```
136
+
137
+ For the largest loads, use `COPY`:
138
+
139
+ ```python
140
+ with pgtern.connect(dsn) as conn:
141
+ with conn.cursor().copy("COPY events (kind, payload) FROM STDIN") as copy:
142
+ for kind, payload in rows:
143
+ copy.write_row((kind, payload))
144
+ ```
145
+
146
+ Export uses `COPY ... TO STDOUT`, read with `for chunk in copy` or `copy.rows()`.
147
+
148
+ ## Streaming large results
149
+
150
+ A named cursor runs on the server and fetches rows in batches, so a result never has to fit in memory:
151
+
152
+ ```python
153
+ with pgtern.connect(dsn) as conn:
154
+ with conn.transaction():
155
+ with conn.cursor("big_report") as cur:
156
+ cur.execute("SELECT * FROM events")
157
+ cur.itersize = 1000
158
+ for row in cur:
159
+ process(row)
160
+ ```
161
+
162
+ Server-side cursors support `scrollable`, `withhold` and `scroll()`.
163
+
164
+ ## Notifications
165
+
166
+ ```python
167
+ with pgtern.connect(dsn) as conn:
168
+ conn.execute("LISTEN orders")
169
+ for note in conn.notifies(timeout=30):
170
+ print(note.channel, note.payload)
171
+ ```
172
+
173
+ The async connection provides the same notifications through `async for`. `LISTEN` needs a session that stays on one server connection, so use a direct connection or session-mode pooling for it.
174
+
175
+ ## Pipelines
176
+
177
+ Pipeline mode sends many statements before reading any reply:
178
+
179
+ ```python
180
+ with pgtern.connect(dsn) as conn:
181
+ with conn.pipeline() as pipe:
182
+ a = pipe.execute("INSERT INTO queue (job) VALUES (%s)", ["a"])
183
+ b = pipe.execute("SELECT count(*) FROM queue")
184
+ print(b.fetchone()) # results are ready once the block ends
185
+ ```
186
+
187
+ Replies are read in order at each sync point: `pipe.sync()`, the end of the block, or every `batch_size` statements. A sync point raises the first failure in its batch, so a failed statement cannot pass silently. Under autocommit, a sync point is also a commit point, so the statements in one batch succeed or fail together. If the block exits with an exception under autocommit, the batch is rolled back. Statements after a failure raise `pgtern.errors.PipelineAborted`. Pipelines support text format only, and `COPY` cannot run inside one.
188
+
189
+ ## Values and types
190
+
191
+ - Python `int` parameters are sent as `int4`, `int8` or `numeric`, depending on size. Use an explicit cast such as `%s::smallint` when a function takes `smallint`.
192
+ - `timestamptz` values are returned in UTC, in both text and binary format, with the same `tzinfo`.
193
+ - Intervals with months or years decode to `pgtern.Interval(months, delta)`, because a month has no fixed length. Other intervals decode to `timedelta`.
194
+ - `time` values of `24:00:00` raise `DataError`, because Python has no such time.
195
+ - Dates and times follow the session's `DateStyle`, and text follows its `client_encoding`. Both are read from the server after every change. Non-ASCII text under a non-UTF-8 encoding raises `NotSupportedError` before anything is sent.
196
+
197
+ Register loaders for PostgreSQL types and dumpers for Python classes:
198
+
199
+ ```python
200
+ from enum import Enum
201
+ from pgtern import adapters
202
+
203
+ class Mood(Enum):
204
+ SAD = "sad"
205
+ HAPPY = "happy"
206
+
207
+ adapters.register_loader("mood", adapters.enum_loader(Mood)) # CREATE TYPE mood AS ENUM (...)
208
+ adapters.register_loader("span", adapters.range_loader(int)) # CREATE TYPE span AS RANGE (subtype = int4)
209
+ adapters.register_dumper(Mood, lambda m: m.value)
210
+ ```
211
+
212
+ Loaders are looked up by type name once per connection and cached. A registered loader applies to connections opened afterwards, and to pooled connections when they are next returned to the pool.
213
+
214
+ ## Binary format
215
+
216
+ Opt in per statement with `binary=True`. It covers integers, floats, booleans, text, `bytea`, `uuid`, dates, times, intervals, `json` and `jsonb`, `numeric` results, and one-dimensional arrays of these. Other types use text.
217
+
218
+ ## Pools
219
+
220
+ ```python
221
+ from pgtern.pool import ConnectionPool
222
+
223
+ pool = ConnectionPool(dsn, min_size=4, max_size=20, timeout=5)
224
+ with pool.connection() as conn:
225
+ conn.execute("SELECT 1")
226
+ ```
227
+
228
+ A pool keeps `min_size` connections open and grows up to `max_size` on demand. The async pool keeps `min_size` open with a background task, and retires connections that have been idle longer than `max_idle` while the pool is above `min_size`. A connection is checked for liveness when it is handed out, rolled back if it was returned in the middle of a transaction, and replaced if the server has closed it. A caller that waits longer than `timeout` seconds receives `pgtern.PoolTimeout`.
229
+
230
+ ## Forking
231
+
232
+ Do not use a connection across `fork()`. A forked child that uses an inherited connection gets `OperationalError`. A pool resets itself in the child: on first use it discards the parent's connections without closing them, which would otherwise end the parent's sessions, and then opens its own.
233
+
234
+ ## Query hooks
235
+
236
+ A hook sees every statement, its row count and the connection target. It never sees bound parameter values:
237
+
238
+ ```python
239
+ from contextlib import contextmanager
240
+ import time
241
+ import pgtern
242
+
243
+ @contextmanager
244
+ def timing(info):
245
+ started = time.perf_counter()
246
+ try:
247
+ yield
248
+ finally:
249
+ print(f"{info.statement!r} took {time.perf_counter() - started:.3f}s ({info.rowcount} rows)")
250
+
251
+ remove = pgtern.add_query_hook(timing)
252
+ ```
253
+
254
+ A hook that fails on entry or exit is logged and ignored, so it never changes the result of a statement.
255
+
256
+ ## Limits and safety
257
+
258
+ - `max_result_bytes` is a connection attribute that caps the bytes one statement may return. It is `None` (no cap) by default. A result over the cap is read and discarded, the connection stays usable, and the statement raises `DataError`. Results are held in memory, so use a server-side cursor for large ones.
259
+ - Using a connection from inside a `COPY` block, or from inside a notice or notify handler, raises `ProgrammingError` at once.
260
+ - A password is not sent in plaintext unless you choose `sslmode=disable`, so a server that declines TLS under `prefer` cannot collect it.
261
+ - Over TLS, the login is bound to that connection with SCRAM-SHA-256-PLUS, so a login relayed through another connection fails.
262
+ - `verify-ca` and `verify-full` require a root certificate (`sslrootcert`). They fail rather than fall back to the system store.
263
+
264
+ ## Errors
265
+
266
+ Every exception derives from `pgtern.Error`. Each common SQLSTATE has its own class, such as `UniqueViolation` or `DeadlockDetected`, and each class also belongs to its broader category, such as `IntegrityError`:
267
+
268
+ ```python
269
+ with pgtern.connect(dsn) as conn:
270
+ try:
271
+ conn.execute("INSERT INTO accounts (id) VALUES (%s)", [1])
272
+ except pgtern.IntegrityError:
273
+ conn.rollback() # a failed transaction must be rolled back before the block ends
274
+ ```
275
+
276
+ ## Compatibility
277
+
278
+ - **PostgreSQL:** 14, 15, 16, 17 and 18.
279
+ - **Python:** 3.11, 3.12 and 3.13 are tested in continuous integration.
280
+ - **Platforms:** anywhere CPython runs.
281
+
282
+ ## Development
283
+
284
+ ```
285
+ pip install -e ".[dev]"
286
+ eval "$(scripts/start-test-postgres.sh)" # throwaway server with SCRAM, MD5, cleartext, trust and TLS users
287
+ pytest
288
+ scripts/start-test-postgres.sh stop
289
+ ```
290
+
291
+ The unit tests run without a server. The integration tests need `PGTERN_TEST_DSN`. Optional suites read `PGTERN_TEST_POOLER_DSN`, which points at a transaction-mode connection pooler, and `PGTERN_TEST_RESTART_CMD`, a command that restarts the test server.
292
+
293
+ ## Contributing
294
+
295
+ See [CONTRIBUTING.md](https://github.com/karawash-projects/pgtern/blob/main/CONTRIBUTING.md). Report security issues privately as described in [SECURITY.md](https://github.com/karawash-projects/pgtern/blob/main/SECURITY.md).
296
+
297
+ ## Changelog
298
+
299
+ Release history is in [CHANGELOG.md](https://github.com/karawash-projects/pgtern/blob/main/CHANGELOG.md).
300
+
301
+ ## License
302
+
303
+ Apache License 2.0. See [LICENSE](https://github.com/karawash-projects/pgtern/blob/main/LICENSE).
pgtern-0.4.0/README.md ADDED
@@ -0,0 +1,275 @@
1
+ # pgtern
2
+
3
+ [![CI](https://github.com/karawash-projects/pgtern/actions/workflows/ci.yml/badge.svg)](https://github.com/karawash-projects/pgtern/actions/workflows/ci.yml)
4
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
5
+
6
+ pgtern is a PostgreSQL client for Python. It speaks the PostgreSQL wire protocol directly and uses only the Python standard library, so there are no runtime dependencies, no C extensions and no build step. It provides a DB-API style interface in two forms: blocking, and `asyncio`.
7
+
8
+ ```python
9
+ import pgtern
10
+ from pgtern.rows import dict_row
11
+
12
+ with pgtern.connect("postgresql://app:secret@localhost/app", row_factory=dict_row) as conn:
13
+ with conn.transaction():
14
+ conn.execute("INSERT INTO notes (body) VALUES (%s)", ["hello"])
15
+ print(conn.execute("SELECT id, body FROM notes ORDER BY id").fetchall())
16
+ ```
17
+
18
+ ```python
19
+ import asyncio
20
+ from pgtern import aio
21
+
22
+ async def main():
23
+ async with aio.AsyncConnectionPool("postgresql://app:secret@localhost/app", min_size=2, max_size=10) as pool:
24
+ async with pool.connection() as conn:
25
+ cur = await conn.execute("SELECT %s::int", [1])
26
+ print(cur.fetchall())
27
+
28
+ asyncio.run(main())
29
+ ```
30
+
31
+ ## Features
32
+
33
+ - No runtime dependencies. The package is a pure-Python wheel for Python 3.11 and newer.
34
+ - Blocking and `asyncio` APIs with the same names and behaviour, including pools, transactions, `COPY`, notifications and pipelines.
35
+ - SCRAM-SHA-256 authentication, and SCRAM-SHA-256-PLUS channel binding over TLS.
36
+ - TLS modes `disable`, `allow`, `prefer`, `require`, `verify-ca` and `verify-full`.
37
+ - Connection pools with liveness checks, idle and lifetime limits, and a fork-safe reset.
38
+ - Parameters are always sent separately from the SQL text. A helper module composes identifiers and literals safely.
39
+ - Opt-in binary result format for common types, and text format for everything else.
40
+ - Server-side cursors for large results, and batched `executemany`.
41
+ - Query hooks for logging, timing and tracing. They never receive bound parameter values.
42
+ - Exceptions organised by SQLSTATE class.
43
+
44
+ ## Installation
45
+
46
+ ```
47
+ pip install pgtern
48
+ ```
49
+
50
+ pgtern requires Python 3.11 or newer.
51
+
52
+ ## Connecting
53
+
54
+ `pgtern.connect` accepts URL and key=value connection strings, the standard `PG*` environment variables, Unix sockets, and a list of hosts for failover.
55
+
56
+ ```python
57
+ conn = pgtern.connect("postgresql://app@db1.internal,db2.internal/app?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem")
58
+ ```
59
+
60
+ TLS follows the PostgreSQL conventions. Under `allow` and `prefer`, the client refuses to send MD5 or cleartext passwords over an unencrypted connection. Under `disable` it sends them, because encryption was explicitly turned off. Set `require_auth=scram-sha-256` to refuse any server that does not use SCRAM, including servers that allow trust authentication.
61
+
62
+ Failover moves to the next host after any startup failure, including a timeout during TLS negotiation. Passwords can come from a `.pgpass` file (`PGPASSFILE` or `~/.pgpass`). A password file that other users can read is ignored.
63
+
64
+ Other supported options:
65
+
66
+ - `target_session_attrs`: `read-write`, `read-only`, `primary`, `standby` or `prefer-standby`.
67
+ - `hostaddr`: connect to an address while keeping the host name for certificate checks.
68
+ - `sslpassword` and `sslcrl`.
69
+ - `connect_timeout`: seconds allowed for each host attempt, from opening the socket to the end of startup. The default is 30. `0` means no limit.
70
+
71
+ ## Queries and transactions
72
+
73
+ ```python
74
+ with pgtern.connect(dsn) as conn:
75
+ conn.execute("CREATE TABLE IF NOT EXISTS accounts (id int PRIMARY KEY, balance numeric)")
76
+
77
+ with conn.transaction(): # commits on success, rolls back on error
78
+ conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s", [10, 1])
79
+ conn.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s", [10, 2])
80
+
81
+ cur = conn.execute("SELECT id, balance FROM accounts WHERE balance > %s", [0])
82
+ for row in cur:
83
+ print(row)
84
+ ```
85
+
86
+ Placeholders are `%s`. Values are sent as parameters, never interpolated into the SQL text. To build identifiers and literals safely, use the `sql` module:
87
+
88
+ ```python
89
+ from pgtern import sql
90
+
91
+ with pgtern.connect(dsn) as conn:
92
+ query = sql.SQL("SELECT * FROM {} WHERE id = %s").format(sql.Identifier("accounts"))
93
+ conn.execute(query, [1])
94
+ ```
95
+
96
+ ## Row formats
97
+
98
+ Choose how rows are returned with a row factory: `tuple_row` (the default), `dict_row` or `namedtuple_row`. Pass one to `connect`, or to a single cursor.
99
+
100
+ ## Bulk loading
101
+
102
+ `executemany` sends the whole batch as one pipelined transaction. In autocommit mode, a failing row rolls back the entire batch. Inside a transaction, it aborts that transaction as usual.
103
+
104
+ ```python
105
+ with pgtern.connect(dsn, autocommit=True) as conn:
106
+ conn.cursor().executemany("INSERT INTO events (kind, payload) VALUES (%s, %s)", rows)
107
+ ```
108
+
109
+ For the largest loads, use `COPY`:
110
+
111
+ ```python
112
+ with pgtern.connect(dsn) as conn:
113
+ with conn.cursor().copy("COPY events (kind, payload) FROM STDIN") as copy:
114
+ for kind, payload in rows:
115
+ copy.write_row((kind, payload))
116
+ ```
117
+
118
+ Export uses `COPY ... TO STDOUT`, read with `for chunk in copy` or `copy.rows()`.
119
+
120
+ ## Streaming large results
121
+
122
+ A named cursor runs on the server and fetches rows in batches, so a result never has to fit in memory:
123
+
124
+ ```python
125
+ with pgtern.connect(dsn) as conn:
126
+ with conn.transaction():
127
+ with conn.cursor("big_report") as cur:
128
+ cur.execute("SELECT * FROM events")
129
+ cur.itersize = 1000
130
+ for row in cur:
131
+ process(row)
132
+ ```
133
+
134
+ Server-side cursors support `scrollable`, `withhold` and `scroll()`.
135
+
136
+ ## Notifications
137
+
138
+ ```python
139
+ with pgtern.connect(dsn) as conn:
140
+ conn.execute("LISTEN orders")
141
+ for note in conn.notifies(timeout=30):
142
+ print(note.channel, note.payload)
143
+ ```
144
+
145
+ The async connection provides the same notifications through `async for`. `LISTEN` needs a session that stays on one server connection, so use a direct connection or session-mode pooling for it.
146
+
147
+ ## Pipelines
148
+
149
+ Pipeline mode sends many statements before reading any reply:
150
+
151
+ ```python
152
+ with pgtern.connect(dsn) as conn:
153
+ with conn.pipeline() as pipe:
154
+ a = pipe.execute("INSERT INTO queue (job) VALUES (%s)", ["a"])
155
+ b = pipe.execute("SELECT count(*) FROM queue")
156
+ print(b.fetchone()) # results are ready once the block ends
157
+ ```
158
+
159
+ Replies are read in order at each sync point: `pipe.sync()`, the end of the block, or every `batch_size` statements. A sync point raises the first failure in its batch, so a failed statement cannot pass silently. Under autocommit, a sync point is also a commit point, so the statements in one batch succeed or fail together. If the block exits with an exception under autocommit, the batch is rolled back. Statements after a failure raise `pgtern.errors.PipelineAborted`. Pipelines support text format only, and `COPY` cannot run inside one.
160
+
161
+ ## Values and types
162
+
163
+ - Python `int` parameters are sent as `int4`, `int8` or `numeric`, depending on size. Use an explicit cast such as `%s::smallint` when a function takes `smallint`.
164
+ - `timestamptz` values are returned in UTC, in both text and binary format, with the same `tzinfo`.
165
+ - Intervals with months or years decode to `pgtern.Interval(months, delta)`, because a month has no fixed length. Other intervals decode to `timedelta`.
166
+ - `time` values of `24:00:00` raise `DataError`, because Python has no such time.
167
+ - Dates and times follow the session's `DateStyle`, and text follows its `client_encoding`. Both are read from the server after every change. Non-ASCII text under a non-UTF-8 encoding raises `NotSupportedError` before anything is sent.
168
+
169
+ Register loaders for PostgreSQL types and dumpers for Python classes:
170
+
171
+ ```python
172
+ from enum import Enum
173
+ from pgtern import adapters
174
+
175
+ class Mood(Enum):
176
+ SAD = "sad"
177
+ HAPPY = "happy"
178
+
179
+ adapters.register_loader("mood", adapters.enum_loader(Mood)) # CREATE TYPE mood AS ENUM (...)
180
+ adapters.register_loader("span", adapters.range_loader(int)) # CREATE TYPE span AS RANGE (subtype = int4)
181
+ adapters.register_dumper(Mood, lambda m: m.value)
182
+ ```
183
+
184
+ Loaders are looked up by type name once per connection and cached. A registered loader applies to connections opened afterwards, and to pooled connections when they are next returned to the pool.
185
+
186
+ ## Binary format
187
+
188
+ Opt in per statement with `binary=True`. It covers integers, floats, booleans, text, `bytea`, `uuid`, dates, times, intervals, `json` and `jsonb`, `numeric` results, and one-dimensional arrays of these. Other types use text.
189
+
190
+ ## Pools
191
+
192
+ ```python
193
+ from pgtern.pool import ConnectionPool
194
+
195
+ pool = ConnectionPool(dsn, min_size=4, max_size=20, timeout=5)
196
+ with pool.connection() as conn:
197
+ conn.execute("SELECT 1")
198
+ ```
199
+
200
+ A pool keeps `min_size` connections open and grows up to `max_size` on demand. The async pool keeps `min_size` open with a background task, and retires connections that have been idle longer than `max_idle` while the pool is above `min_size`. A connection is checked for liveness when it is handed out, rolled back if it was returned in the middle of a transaction, and replaced if the server has closed it. A caller that waits longer than `timeout` seconds receives `pgtern.PoolTimeout`.
201
+
202
+ ## Forking
203
+
204
+ Do not use a connection across `fork()`. A forked child that uses an inherited connection gets `OperationalError`. A pool resets itself in the child: on first use it discards the parent's connections without closing them, which would otherwise end the parent's sessions, and then opens its own.
205
+
206
+ ## Query hooks
207
+
208
+ A hook sees every statement, its row count and the connection target. It never sees bound parameter values:
209
+
210
+ ```python
211
+ from contextlib import contextmanager
212
+ import time
213
+ import pgtern
214
+
215
+ @contextmanager
216
+ def timing(info):
217
+ started = time.perf_counter()
218
+ try:
219
+ yield
220
+ finally:
221
+ print(f"{info.statement!r} took {time.perf_counter() - started:.3f}s ({info.rowcount} rows)")
222
+
223
+ remove = pgtern.add_query_hook(timing)
224
+ ```
225
+
226
+ A hook that fails on entry or exit is logged and ignored, so it never changes the result of a statement.
227
+
228
+ ## Limits and safety
229
+
230
+ - `max_result_bytes` is a connection attribute that caps the bytes one statement may return. It is `None` (no cap) by default. A result over the cap is read and discarded, the connection stays usable, and the statement raises `DataError`. Results are held in memory, so use a server-side cursor for large ones.
231
+ - Using a connection from inside a `COPY` block, or from inside a notice or notify handler, raises `ProgrammingError` at once.
232
+ - A password is not sent in plaintext unless you choose `sslmode=disable`, so a server that declines TLS under `prefer` cannot collect it.
233
+ - Over TLS, the login is bound to that connection with SCRAM-SHA-256-PLUS, so a login relayed through another connection fails.
234
+ - `verify-ca` and `verify-full` require a root certificate (`sslrootcert`). They fail rather than fall back to the system store.
235
+
236
+ ## Errors
237
+
238
+ Every exception derives from `pgtern.Error`. Each common SQLSTATE has its own class, such as `UniqueViolation` or `DeadlockDetected`, and each class also belongs to its broader category, such as `IntegrityError`:
239
+
240
+ ```python
241
+ with pgtern.connect(dsn) as conn:
242
+ try:
243
+ conn.execute("INSERT INTO accounts (id) VALUES (%s)", [1])
244
+ except pgtern.IntegrityError:
245
+ conn.rollback() # a failed transaction must be rolled back before the block ends
246
+ ```
247
+
248
+ ## Compatibility
249
+
250
+ - **PostgreSQL:** 14, 15, 16, 17 and 18.
251
+ - **Python:** 3.11, 3.12 and 3.13 are tested in continuous integration.
252
+ - **Platforms:** anywhere CPython runs.
253
+
254
+ ## Development
255
+
256
+ ```
257
+ pip install -e ".[dev]"
258
+ eval "$(scripts/start-test-postgres.sh)" # throwaway server with SCRAM, MD5, cleartext, trust and TLS users
259
+ pytest
260
+ scripts/start-test-postgres.sh stop
261
+ ```
262
+
263
+ The unit tests run without a server. The integration tests need `PGTERN_TEST_DSN`. Optional suites read `PGTERN_TEST_POOLER_DSN`, which points at a transaction-mode connection pooler, and `PGTERN_TEST_RESTART_CMD`, a command that restarts the test server.
264
+
265
+ ## Contributing
266
+
267
+ See [CONTRIBUTING.md](https://github.com/karawash-projects/pgtern/blob/main/CONTRIBUTING.md). Report security issues privately as described in [SECURITY.md](https://github.com/karawash-projects/pgtern/blob/main/SECURITY.md).
268
+
269
+ ## Changelog
270
+
271
+ Release history is in [CHANGELOG.md](https://github.com/karawash-projects/pgtern/blob/main/CHANGELOG.md).
272
+
273
+ ## License
274
+
275
+ Apache License 2.0. See [LICENSE](https://github.com/karawash-projects/pgtern/blob/main/LICENSE).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pgtern"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "A zero-dependency, pure-Python PostgreSQL client with blocking and asyncio APIs."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
13
13
  authors = [{ name = "Ahmad Karawash" }]
14
14
  keywords = ["postgresql", "postgres", "database", "driver", "dbapi", "scram", "pure-python", "zero-dependency"]
15
15
  classifiers = [
16
- "Development Status :: 3 - Alpha",
16
+ "Development Status :: 4 - Beta",
17
17
  "Intended Audience :: Developers",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
@@ -28,7 +28,7 @@ classifiers = [
28
28
  dependencies = []
29
29
 
30
30
  [project.optional-dependencies]
31
- dev = ["pytest>=8", "build>=1.2"]
31
+ dev = ["pytest>=8", "pytest-timeout>=2", "build>=1.2", "pyflakes>=3", "mypy>=1.10"]
32
32
 
33
33
  [tool.setuptools.packages.find]
34
34
  where = ["src"]
@@ -16,6 +16,7 @@ from . import adapters, aio, errors, hooks, rows, sql, types
16
16
  from ._conn import Connection, ConnectionInfo, Transaction, TransactionStatus
17
17
  from ._cursor import Cursor, ServerCursor
18
18
  from ._protocol import Notify
19
+ from ._types import Interval
19
20
  from .errors import (
20
21
  DatabaseError, DataError, Error, IntegrityError, InterfaceError, InternalError,
21
22
  NotSupportedError, OperationalError, ProgrammingError, Warning,
@@ -23,7 +24,7 @@ from .errors import (
23
24
  from .hooks import QueryInfo, add_query_hook
24
25
  from .pool import ConnectionPool, PoolClosed, PoolTimeout
25
26
 
26
- __version__ = "0.3.0"
27
+ __version__ = "0.4.0"
27
28
 
28
29
  # DB-API 2.0 module globals
29
30
  apilevel = "2.0"
@@ -38,7 +39,7 @@ def connect(conninfo: str = "", **kwargs: Any) -> Connection:
38
39
 
39
40
 
40
41
  __all__ = [
41
- "connect", "Connection", "ConnectionInfo", "ConnectionPool", "Cursor", "ServerCursor", "Notify", "Transaction",
42
+ "connect", "Connection", "ConnectionInfo", "ConnectionPool", "Cursor", "ServerCursor", "Notify", "Interval", "Transaction",
42
43
  "TransactionStatus", "PoolClosed", "PoolTimeout", "QueryInfo", "add_query_hook",
43
44
  "Warning", "Error", "InterfaceError", "DatabaseError", "DataError", "OperationalError",
44
45
  "IntegrityError", "InternalError", "ProgrammingError", "NotSupportedError",