From f46aad6461e6f403b8dea0e59860835b9fdc0f16 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 15 Jun 2026 00:22:26 -0300 Subject: [PATCH 01/16] perf: only reregister fileno when necessary in wait_conn --- psycopg/psycopg/waiting.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/psycopg/psycopg/waiting.py b/psycopg/psycopg/waiting.py index 3ce7f20c8..e6ad7e843 100644 --- a/psycopg/psycopg/waiting.py +++ b/psycopg/psycopg/waiting.py @@ -109,16 +109,17 @@ def wait_conn(gen: PQGenConn[RV], interval: float = 0.0) -> RV: try: fileno, s = next(gen) with DefaultSelector() as sel: - sel.register(fileno, s) + sel.register((last_fileno := fileno), (last_s := s)) while True: if not (rlist := sel.select(timeout=interval)): gen.send(READY_NONE) continue - sel.unregister(fileno) ready = rlist[0][1] fileno, s = gen.send(ready) - sel.register(fileno, s) + if fileno != last_fileno or last_s != s: + sel.unregister(last_fileno) + sel.register((last_fileno := fileno), (last_s := s)) except StopIteration as ex: rv: RV = ex.value -- 2.47.3 From 5369bbc9286f94cc2ed983288a607082e60f0371 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 15 Jun 2026 00:27:22 -0300 Subject: [PATCH 02/16] test: main loop of each of the wait functions --- tests/test_waiting.py | 31 ++++++++++++++++++++----------- tests/test_waiting_async.py | 31 ++++++++++++++++++++----------- 2 files changed, 40 insertions(+), 22 deletions(-) diff --git a/tests/test_waiting.py b/tests/test_waiting.py index fee80a773..fae89ae22 100644 --- a/tests/test_waiting.py +++ b/tests/test_waiting.py @@ -36,9 +36,11 @@ events = ["R", "W", "RW"] intervals = [0, 0.2, 2] -def tgen(wait): +def tgen(wait, times=1): """A generator waiting for a specific event and returning what waited on.""" - r = yield wait + assert times >= 1 + for _ in range(times): + r = yield wait return r @@ -61,8 +63,9 @@ def test_wait_conn_bad(dsn): @pytest.mark.parametrize("interval", [i for i in intervals if i > 0]) @pytest.mark.parametrize("ready", ["R", "NONE"]) @pytest.mark.parametrize("event", ["R", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -def test_wait_r(waitfn, event, ready, interval, request): +def test_wait_r(waitfn, event, nevents, ready, interval, request): # Test that wait functions handle waiting and returning state correctly # This test doesn't work on macOS for some internal race condition betwwn # listen/connect/accept. @@ -96,12 +99,15 @@ def test_wait_r(waitfn, event, ready, interval, request): ev.set() # Wait for socket ready to read or timing out t0 = time.time() - r = waitfn(tgen(wait), s.fileno(), interval) + r = waitfn(tgen(wait, times=nevents), s.fileno(), interval) dt = time.time() - t0 # Check timing and received waiting state assert r == ready if check_timing(request): - exptime = {waiting.Ready.R: delay, waiting.Ready.NONE: interval}[ready] + exptime = { + waiting.Ready.R: delay, + waiting.Ready.NONE: nevents * interval, + }[ready] assert exptime <= dt < exptime * 1.2 finally: gather(*tasks) @@ -111,8 +117,9 @@ def test_wait_r(waitfn, event, ready, interval, request): @pytest.mark.skipif("sys.platform == 'linux'") @pytest.mark.parametrize("interval", [2]) @pytest.mark.parametrize("ready", ["R", "NONE"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -def test_wait_r_no_linux(waitfn, ready, interval, request): +def test_wait_r_no_linux(waitfn, nevents, ready, interval, request): # A version of test_wait_r that works on macOS too, but doesn't allow to # test for the RW wait (because it seems that the sockets returned by # socketpair() is immediately w-ready, including the r one. @@ -151,7 +158,7 @@ def test_wait_r_no_linux(waitfn, ready, interval, request): ev.set() # Wait for socket ready to read or timing out t0 = time.time() - r = waitfn(tgen(wait), rs.fileno(), interval) + r = waitfn(tgen(wait, times=nevents), rs.fileno(), interval) dt = time.time() - t0 # Check timing and received waiting state assert r == ready @@ -166,8 +173,9 @@ def test_wait_r_no_linux(waitfn, ready, interval, request): @pytest.mark.parametrize("ready", ["R", "NONE"]) @pytest.mark.parametrize("event", ["R", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -def test_wait_r_nowait(waitfn, event, ready, request): +def test_wait_r_nowait(waitfn, event, nevents, ready, request): # Test that wait functions handle a poll when called with no timeout waitfn = getattr(waiting, waitfn) wait = getattr(waiting.Wait, event) @@ -205,7 +213,7 @@ def test_wait_r_nowait(waitfn, event, ready, request): ev1.set() ev2.wait() t0 = time.time() - r = waitfn(tgen(wait), s.fileno()) + r = waitfn(tgen(wait, times=nevents), s.fileno()) dt = time.time() - t0 ev3.set() # unblock the unblocker if check_timing(request): @@ -218,8 +226,9 @@ def test_wait_r_nowait(waitfn, event, ready, request): @pytest.mark.slow @pytest.mark.parametrize("event", ["W", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -def test_wait_w(waitfn, event, request): +def test_wait_w(waitfn, event, nevents, request): # Test that wait functions handle waiting and returning state correctly waitfn = getattr(waiting, waitfn) wait = getattr(waiting.Wait, event) @@ -229,7 +238,7 @@ def test_wait_w(waitfn, event, request): ws.setblocking(False) with rs, ws: t0 = time.time() - r = waitfn(tgen(wait), ws.fileno(), 0.5) + r = waitfn(tgen(wait, times=nevents), ws.fileno(), 0.5) dt = time.time() - t0 # Check timing and received waiting state assert r == waiting.Ready.W diff --git a/tests/test_waiting_async.py b/tests/test_waiting_async.py index 18d2e58f9..d7aecd1f6 100644 --- a/tests/test_waiting_async.py +++ b/tests/test_waiting_async.py @@ -44,9 +44,11 @@ events = ["R", "W", "RW"] intervals = [0, 0.2, 2] -def tgen(wait): +def tgen(wait, times=1): """A generator waiting for a specific event and returning what waited on.""" - r = yield wait + assert times >= 1 + for _ in range(times): + r = yield wait return r @@ -69,8 +71,9 @@ async def test_wait_conn_bad(dsn): @pytest.mark.parametrize("interval", [i for i in intervals if i > 0]) @pytest.mark.parametrize("ready", ["R", "NONE"]) @pytest.mark.parametrize("event", ["R", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -async def test_wait_r(waitfn, event, ready, interval, request): +async def test_wait_r(waitfn, event, nevents, ready, interval, request): # Test that wait functions handle waiting and returning state correctly # This test doesn't work on macOS for some internal race condition betwwn # listen/connect/accept. @@ -104,12 +107,15 @@ async def test_wait_r(waitfn, event, ready, interval, request): ev.set() # Wait for socket ready to read or timing out t0 = time.time() - r = await waitfn(tgen(wait), s.fileno(), interval) + r = await waitfn(tgen(wait, times=nevents), s.fileno(), interval) dt = time.time() - t0 # Check timing and received waiting state assert r == ready if check_timing(request): - exptime = {waiting.Ready.R: delay, waiting.Ready.NONE: interval}[ready] + exptime = { + waiting.Ready.R: delay, + waiting.Ready.NONE: nevents * interval, + }[ready] assert exptime <= dt < (exptime * 1.2) finally: await gather(*tasks) @@ -119,8 +125,9 @@ async def test_wait_r(waitfn, event, ready, interval, request): @pytest.mark.skipif("sys.platform == 'linux'") @pytest.mark.parametrize("interval", [2]) @pytest.mark.parametrize("ready", ["R", "NONE"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -async def test_wait_r_no_linux(waitfn, ready, interval, request): +async def test_wait_r_no_linux(waitfn, nevents, ready, interval, request): # A version of test_wait_r that works on macOS too, but doesn't allow to # test for the RW wait (because it seems that the sockets returned by # socketpair() is immediately w-ready, including the r one. @@ -159,7 +166,7 @@ async def test_wait_r_no_linux(waitfn, ready, interval, request): ev.set() # Wait for socket ready to read or timing out t0 = time.time() - r = await waitfn(tgen(wait), rs.fileno(), interval) + r = await waitfn(tgen(wait, times=nevents), rs.fileno(), interval) dt = time.time() - t0 # Check timing and received waiting state assert r == ready @@ -174,8 +181,9 @@ async def test_wait_r_no_linux(waitfn, ready, interval, request): @pytest.mark.parametrize("ready", ["R", "NONE"]) @pytest.mark.parametrize("event", ["R", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -async def test_wait_r_nowait(waitfn, event, ready, request): +async def test_wait_r_nowait(waitfn, event, nevents, ready, request): # Test that wait functions handle a poll when called with no timeout waitfn = getattr(waiting, waitfn) wait = getattr(waiting.Wait, event) @@ -213,7 +221,7 @@ async def test_wait_r_nowait(waitfn, event, ready, request): ev1.set() await ev2.wait() t0 = time.time() - r = await waitfn(tgen(wait), s.fileno()) + r = await waitfn(tgen(wait, times=nevents), s.fileno()) dt = time.time() - t0 ev3.set() # unblock the unblocker if check_timing(request): @@ -226,8 +234,9 @@ async def test_wait_r_nowait(waitfn, event, ready, request): @pytest.mark.slow @pytest.mark.parametrize("event", ["W", "RW"]) +@pytest.mark.parametrize("nevents", [1, 2]) @pytest.mark.parametrize("waitfn", waitfns) -async def test_wait_w(waitfn, event, request): +async def test_wait_w(waitfn, event, nevents, request): # Test that wait functions handle waiting and returning state correctly waitfn = getattr(waiting, waitfn) wait = getattr(waiting.Wait, event) @@ -237,7 +246,7 @@ async def test_wait_w(waitfn, event, request): ws.setblocking(False) with rs, ws: t0 = time.time() - r = await waitfn(tgen(wait), ws.fileno(), 0.5) + r = await waitfn(tgen(wait, times=nevents), ws.fileno(), 0.5) dt = time.time() - t0 # Check timing and received waiting state assert r == waiting.Ready.W -- 2.47.3 From 0a214f80f66be6fc883f22f7d4a22ceb82e10ceb Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 15 Jun 2026 00:31:53 -0300 Subject: [PATCH 03/16] docs: add news item about wait_selector fix --- docs/news.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 75664bf3b..13c68dc37 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -15,6 +15,8 @@ Psycopg 3.3.5 (unreleased) - Discard prepared statements upon :sql:`ALTER *` or `DISCARD *` (:ticket:`#1307`). +- Fix `!wait_selector` wait function to not raise `!KeyError` + (:ticket:`#1327`). Current release -- 2.47.3 From 550dff4539d6046fc5e2b78c9a8b598ee1f9cf84 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 12 Jun 2026 01:57:07 -0300 Subject: [PATCH 04/16] fix(py): dumping non-None values when no NoneType dumper registered - in pure python implementation (c was already correct) --- psycopg/psycopg/_py_transformer.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/psycopg/psycopg/_py_transformer.py b/psycopg/psycopg/_py_transformer.py index 41ffbbc5b..65426b20f 100644 --- a/psycopg/psycopg/_py_transformer.py +++ b/psycopg/psycopg/_py_transformer.py @@ -65,6 +65,7 @@ class Transformer(AdaptContext): def __init__(self, context: AdaptContext | None = None): self._pgresult = self.types = self.formats = None + self._none_oid = -1 # WARNING: don't store context, or you'll create a loop with the Cursor if context: @@ -187,12 +188,16 @@ class Transformer(AdaptContext): out[i] = self._row_dumpers[i].dump(param) return out - types = [self._get_none_oid()] * nparams + types = [-1] * nparams pqformats = [TEXT] * nparams for i in range(nparams): if (param := params[i]) is None: + if self._none_oid < 0: + self._none_oid = self._get_none_oid() + types[i] = self._none_oid continue + dumper = self.get_dumper(param, formats[i]) out[i] = dumper.dump(param) types[i] = dumper.oid @@ -267,17 +272,10 @@ class Transformer(AdaptContext): def _get_none_oid(self) -> int: try: - return self._none_oid - except AttributeError: - pass - - try: - rv = self._none_oid = self._adapters.get_dumper(NoneType, PY_TEXT).oid + return self._adapters.get_dumper(NoneType, PY_TEXT).oid except KeyError: raise e.InterfaceError("None dumper not found") - return rv - def get_dumper_by_oid(self, oid: int, format: pq.Format) -> abc.Dumper: """ Return a Dumper to dump an object to the type with given oid. -- 2.47.3 From e1b67149c643b76118832190fb41e4403a845ada Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Fri, 5 Jun 2026 14:52:28 -0300 Subject: [PATCH 05/16] test: dumping without NoneType dumper registered --- tests/test_adapt.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_adapt.py b/tests/test_adapt.py index f80b0aacc..ab4a61a68 100644 --- a/tests/test_adapt.py +++ b/tests/test_adapt.py @@ -59,6 +59,15 @@ def test_quote_none(data, result, global_adapters): assert dumper.quote(data) == result +@pytest.mark.parametrize("fmt_in", PyFormat) +def test_no_none_dumper_registered(dsn, fmt_in): + with psycopg.connect(dsn, context=make_str_map()) as conn: + with conn.cursor() as cur: + cur.execute(f"SELECT %{fmt_in.value}", ["not_none"]) + with pytest.raises(e.ProgrammingError, match="NoneType"): + cur.execute(f"SELECT %{fmt_in.value}", [None]) + + def test_register_dumper_by_class(conn): dumper = make_dumper("x") assert conn.adapters.get_dumper(MyStr, PyFormat.TEXT) is not dumper @@ -561,3 +570,17 @@ def make_bin_loader(suffix): cls = make_loader(suffix) cls.format = pq.Format.BINARY return cls + + +def make_str_map(): + from psycopg._oids import INVALID_OID + + # Construct a minimal AdaptersMap to no none dumper bug + str_map = psycopg.adapt.AdaptersMap() + invalid_oid_loader = psycopg.adapters.get_loader(INVALID_OID, pq.Format.TEXT) + assert invalid_oid_loader is not None + str_map.adapters.register_loader(INVALID_OID, invalid_oid_loader) + str_map.register_dumper(str, StrDumper) + str_map.register_dumper(str, StrBinaryDumper) + + return str_map -- 2.47.3 From ee0f1d07fc8d2b2dca597d4d5b81373b36ce59be Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Sun, 14 Jun 2026 20:27:49 -0300 Subject: [PATCH 06/16] docs: add news entry for fix dumping without NoneType dumper --- docs/news.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/news.rst b/docs/news.rst index 13c68dc37..deffeb11c 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -15,6 +15,8 @@ Psycopg 3.3.5 (unreleased) - Discard prepared statements upon :sql:`ALTER *` or `DISCARD *` (:ticket:`#1307`). +- Fix `!ProgrammingError` when dumping non-`!None` values with + no `!NoneType` dumper registered in python implementation (:ticket:`#1325`). - Fix `!wait_selector` wait function to not raise `!KeyError` (:ticket:`#1327`). -- 2.47.3 From b8b295d616977fff3e669fe449552cf63e79ae46 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 23 Jun 2026 14:26:31 -0300 Subject: [PATCH 07/16] fixup! fix(py): dumping non-None values when no NoneType dumper registered --- psycopg/psycopg/_py_transformer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/psycopg/psycopg/_py_transformer.py b/psycopg/psycopg/_py_transformer.py index 65426b20f..e9845b58d 100644 --- a/psycopg/psycopg/_py_transformer.py +++ b/psycopg/psycopg/_py_transformer.py @@ -193,9 +193,7 @@ class Transformer(AdaptContext): for i in range(nparams): if (param := params[i]) is None: - if self._none_oid < 0: - self._none_oid = self._get_none_oid() - types[i] = self._none_oid + types[i] = self._get_none_oid() continue dumper = self.get_dumper(param, formats[i]) @@ -271,10 +269,12 @@ class Transformer(AdaptContext): return dumper def _get_none_oid(self) -> int: - try: - return self._adapters.get_dumper(NoneType, PY_TEXT).oid - except KeyError: - raise e.InterfaceError("None dumper not found") + if self._none_oid < 0: + try: + self._none_oid = self._adapters.get_dumper(NoneType, PY_TEXT).oid + except KeyError: + raise e.InterfaceError("None dumper not found") + return self._none_oid def get_dumper_by_oid(self, oid: int, format: pq.Format) -> abc.Dumper: """ -- 2.47.3 From 6a52e77dc38827214b294759823a072841b4819f Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Mon, 22 Jun 2026 22:43:27 -0300 Subject: [PATCH 08/16] dev: gitignore any directory prefixed with .venv --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 92910757b..5c711820c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ __pycache__/ *.html /psycopg_binary/ .vscode -.venv +.venv* .coverage htmlcov .idea -- 2.47.3 From 8c896bdc3e8dd47a01414337783b30fac25084b6 Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Wed, 8 Jul 2026 13:20:34 -0300 Subject: [PATCH 09/16] chore: fix some linting issues Caused by: - mypy upgrade to version 2.2 - types-setuptools upgrade to 83 --- psycopg/psycopg/_compat.py | 15 +++++++++------ psycopg/psycopg/_tstrings.py | 8 ++++---- psycopg_c/build_backend/psycopg_build_ext.py | 1 + 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/psycopg/psycopg/_compat.py b/psycopg/psycopg/_compat.py index 53637b253..b7ec4162b 100644 --- a/psycopg/psycopg/_compat.py +++ b/psycopg/psycopg/_compat.py @@ -33,24 +33,27 @@ else: if sys.version_info >= (3, 14): from string.templatelib import Interpolation, Template else: + from typing import Generic, Literal from dataclasses import dataclass + T = TypeVar("T") + class Template: strings: tuple[str] - interpolations: tuple[Interpolation] + interpolations: tuple[Interpolation[Any]] - def __new__(cls, *args: str | Interpolation) -> Self: + def __new__(cls, *args: str | Interpolation[Any]) -> Self: return cls() - def __iter__(self) -> Iterator[str | Interpolation]: + def __iter__(self) -> Iterator[str | Interpolation[Any]]: return yield @dataclass - class Interpolation: - value: Any + class Interpolation(Generic[T]): + value: T expression: str - conversion: str | None + conversion: Literal["a", "r", "s"] | None format_spec: str diff --git a/psycopg/psycopg/_tstrings.py b/psycopg/psycopg/_tstrings.py index 78b54a07b..e37086c7f 100644 --- a/psycopg/psycopg/_tstrings.py +++ b/psycopg/psycopg/_tstrings.py @@ -40,7 +40,7 @@ class TemplateProcessor: self._process_template(self.template) self.query = b"".join(self._chunks) - def _check_template_format(self, item: Interpolation, want_fmt: str) -> None: + def _check_template_format(self, item: Interpolation[Any], want_fmt: str) -> None: if item.format_spec == want_fmt: return fmt = f":{item.format_spec}" if item.format_spec else "" @@ -94,7 +94,7 @@ class TemplateProcessor: else: self._process_client_variable(item, fmt) - def _process_server_variable(self, item: Interpolation, fmt: str) -> None: + def _process_server_variable(self, item: Interpolation[Any], fmt: str) -> None: try: pyfmt = PyFormat(fmt) except ValueError: @@ -107,7 +107,7 @@ class TemplateProcessor: self.params.append(item.value) self._chunks.append(b"$%d" % len(self.params)) - def _process_client_variable(self, item: Interpolation, fmt: str) -> None: + def _process_client_variable(self, item: Interpolation[Any], fmt: str) -> None: try: PyFormat(fmt) except ValueError: @@ -120,7 +120,7 @@ class TemplateProcessor: self._chunks.append(param) self.params.append(param) - def _process_composable(self, item: Interpolation) -> None: + def _process_composable(self, item: Interpolation[Any]) -> None: if isinstance(item.value, sql.Identifier): self._check_template_format(item, FMT_IDENT) self._chunks.append(item.value.as_bytes(self._tx)) diff --git a/psycopg_c/build_backend/psycopg_build_ext.py b/psycopg_c/build_backend/psycopg_build_ext.py index 66a3d5489..7e32bd046 100644 --- a/psycopg_c/build_backend/psycopg_build_ext.py +++ b/psycopg_c/build_backend/psycopg_build_ext.py @@ -38,6 +38,7 @@ class psycopg_build_ext(build_ext): # MSVC requires an explicit "libpq" libpq = "pq" if sys.platform != "win32" else "libpq" + assert self.distribution.ext_modules is not None for ext in self.distribution.ext_modules: ext.libraries.append(libpq) ext.include_dirs.append(get_config("includedir")) -- 2.47.3 From 376415ad3bcf02081e1f2c8f5679729fe8d82cb6 Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Thu, 16 Jul 2026 00:36:46 +0200 Subject: [PATCH 10/16] chore: solve mypy 2.3 issue --- tests/pool/test_pool.py | 20 +++++++------------- tests/pool/test_pool_async.py | 20 +++++++------------- 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/tests/pool/test_pool.py b/tests/pool/test_pool.py index cae859629..8158a8e6e 100644 --- a/tests/pool/test_pool.py +++ b/tests/pool/test_pool.py @@ -529,26 +529,20 @@ def test_reconnect_failure(proxy, async_cb): t1 = None - if async_cb: - - def failed(pool): - assert pool.name == "this-one" - nonlocal t1 - t1 = time() - - else: + def failed(pool): + assert pool.name == "this-one" + nonlocal t1 + t1 = time() - def failed(pool): - assert pool.name == "this-one" - nonlocal t1 - t1 = time() + def afailed(pool): + failed(pool) with pool.ConnectionPool( proxy.client_dsn, name="this-one", min_size=1, reconnect_timeout=1.0, - reconnect_failed=failed, + reconnect_failed=afailed if async_cb else failed, ) as p: p.wait(2.0) proxy.stop() diff --git a/tests/pool/test_pool_async.py b/tests/pool/test_pool_async.py index bd9915ccb..a02b506a3 100644 --- a/tests/pool/test_pool_async.py +++ b/tests/pool/test_pool_async.py @@ -531,26 +531,20 @@ async def test_reconnect_failure(proxy, async_cb): t1 = None - if async_cb: - - async def failed(pool): - assert pool.name == "this-one" - nonlocal t1 - t1 = time() - - else: + def failed(pool): + assert pool.name == "this-one" + nonlocal t1 + t1 = time() - def failed(pool): - assert pool.name == "this-one" - nonlocal t1 - t1 = time() + async def afailed(pool): + failed(pool) async with pool.AsyncConnectionPool( proxy.client_dsn, name="this-one", min_size=1, reconnect_timeout=1.0, - reconnect_failed=failed, + reconnect_failed=afailed if async_cb else failed, ) as p: await p.wait(2.0) proxy.stop() -- 2.47.3 From 454e00257f05adb994b06ae377261a54d145ac29 Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Wed, 1 Jul 2026 19:27:24 +0200 Subject: [PATCH 11/16] fix: check that we receive exactly one result after executing a command This should never happen with a working FE-BE communication, but it is not impossible it seems: see #1337, where I think it is happening for a lock not behaving correctly in a broken Python runtime. It is also not impossible that some broken Postgres implementation would send us this curveball, so let's identify it as an unexpected condition. Replace #1340 where it was proposed to accept the condition as good, but this is not a condition to ignore IMO as it underlines something serious. --- psycopg/psycopg/_connection_base.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/psycopg/psycopg/_connection_base.py b/psycopg/psycopg/_connection_base.py index 566ee4ab1..24e1d0e2e 100644 --- a/psycopg/psycopg/_connection_base.py +++ b/psycopg/psycopg/_connection_base.py @@ -477,7 +477,13 @@ class BaseConnection(Generic[Row]): else: self.pgconn.send_query_params(command, None, result_format=result_format) - result: PGresult = (yield from generators.execute(self.pgconn))[-1] + results: list[PGresult] = (yield from generators.execute(self.pgconn)) + if len(results) != 1: + raise e.InternalError( + f"received {len(results)} results from command {command.decode()!r}" + ) + + result = results[0] if result.status != COMMAND_OK and result.status != TUPLES_OK: if result.status == FATAL_ERROR: raise e.error_from_result(result, encoding=self.pgconn._encoding) @@ -514,7 +520,10 @@ class BaseConnection(Generic[Row]): self.pgconn.send_close_prepared(name) - result = (yield from generators.execute(self.pgconn))[-1] + if not (results := (yield from generators.execute(self.pgconn))): + raise e.InternalError("no result from deallocate command") + + result = results[-1] if result.status != COMMAND_OK: if result.status == FATAL_ERROR: raise e.error_from_result(result, encoding=self.pgconn._encoding) -- 2.47.3 From bae82008307edf02d40c122e3b9ecfa203e55519 Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Sat, 18 Jul 2026 20:11:16 +0200 Subject: [PATCH 12/16] chore: run schedule workflow on the main repo only Don't run them on forks too. --- .github/workflows/lint.yml | 2 +- .github/workflows/packages-bin.yml | 6 +++--- .github/workflows/packages-pool.yml | 1 + .github/workflows/packages-src.yml | 2 +- .github/workflows/tests.yml | 10 +++++----- 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9edce1955..da4b9ce5a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,7 +19,7 @@ concurrency: jobs: lint: runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/packages-bin.yml b/.github/workflows/packages-bin.yml index b8d947247..45d490357 100644 --- a/.github/workflows/packages-bin.yml +++ b/.github/workflows/packages-bin.yml @@ -43,7 +43,7 @@ jobs: linux: # {{{ runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -124,7 +124,7 @@ jobs: macos: # {{{ runs-on: macos-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -178,7 +178,7 @@ jobs: windows: # {{{ # TODO: move it back to windows-latest when the default runner switches. runs-on: windows-2025 - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false diff --git a/.github/workflows/packages-pool.yml b/.github/workflows/packages-pool.yml index 11c9c99a8..30071c9db 100644 --- a/.github/workflows/packages-pool.yml +++ b/.github/workflows/packages-pool.yml @@ -12,6 +12,7 @@ jobs: sdist: runs-on: ubuntu-latest + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false diff --git a/.github/workflows/packages-src.yml b/.github/workflows/packages-src.yml index 9cde9798f..f8fd7bf8e 100644 --- a/.github/workflows/packages-src.yml +++ b/.github/workflows/packages-src.yml @@ -12,7 +12,7 @@ jobs: sdist: runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 838d0af3a..0b1559811 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,7 @@ jobs: linux: # {{{ runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -151,7 +151,7 @@ jobs: pool-integration: # {{{ # Test the current pool version with older psycopg versions runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -192,7 +192,7 @@ jobs: macos-14: # {{{ runs-on: macos-14 - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -260,7 +260,7 @@ jobs: windows: # {{{ # TODO: move it back to windows-latest when the default runner switches. runs-on: windows-2025 - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false @@ -354,7 +354,7 @@ jobs: crdb: # {{{ runs-on: ubuntu-latest - if: true + if: github.event_name != 'schedule' || github.repository == 'psycopg/psycopg' strategy: fail-fast: false -- 2.47.3 From 0f14cfe7b932f1741992df08e22073638457e382 Mon Sep 17 00:00:00 2001 From: winklemad Date: Fri, 17 Jul 2026 06:39:00 +0530 Subject: [PATCH 13/16] Fix missing f-string prefix in two DataError messages Two error messages were built from plain string literals containing {...} placeholders but missing the f prefix, so the literal placeholder text was shown to the user instead of the offending value: - types/datetime.py: "timestamp too small (before year 1): {s!r}" - types/json.py: "unknown jsonb binary format: {data[0]}" The sibling branches next to the datetime message are already f-strings, which is what makes the omission stand out. Add the missing prefix to both so the value is interpolated. Fixes #1372 --- docs/news.rst | 3 +++ psycopg/psycopg/types/datetime.py | 2 +- psycopg/psycopg/types/json.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/news.rst b/docs/news.rst index deffeb11c..2837b6966 100644 --- a/docs/news.rst +++ b/docs/news.rst @@ -19,6 +19,9 @@ Psycopg 3.3.5 (unreleased) no `!NoneType` dumper registered in python implementation (:ticket:`#1325`). - Fix `!wait_selector` wait function to not raise `!KeyError` (:ticket:`#1327`). +- Fix `!DataError` messages leaking the literal ``{...}`` placeholder instead + of the offending value when loading a pre-year-1 :sql:`timestamp` or a + malformed binary :sql:`jsonb` value (:ticket:`#1372`). Current release diff --git a/psycopg/psycopg/types/datetime.py b/psycopg/psycopg/types/datetime.py index 8bd4e0f24..3226935a9 100644 --- a/psycopg/psycopg/types/datetime.py +++ b/psycopg/psycopg/types/datetime.py @@ -689,7 +689,7 @@ def _get_timestamp_load_error( return len(s.split()[-1]) > 4 # year is last token if s == "-infinity" or s.endswith("BC"): - return DataError("timestamp too small (before year 1): {s!r}") + return DataError(f"timestamp too small (before year 1): {s!r}") elif s == "infinity" or is_overflow(s): return DataError(f"timestamp too large (after year 10K): {s!r}") else: diff --git a/psycopg/psycopg/types/json.py b/psycopg/psycopg/types/json.py index 6ebab6cbe..1d4a12648 100644 --- a/psycopg/psycopg/types/json.py +++ b/psycopg/psycopg/types/json.py @@ -277,7 +277,7 @@ class JsonbBinaryLoader(_JsonLoader): def load(self, data: Buffer) -> Any: if data and data[0] != 1: - raise DataError("unknown jsonb binary format: {data[0]}") + raise DataError(f"unknown jsonb binary format: {data[0]}") if not isinstance((data := data[1:]), bytes): data = bytes(data) return self.loads(data) -- 2.47.3 From 7485979275db27adb68deceed7b0c99f2c1589cb Mon Sep 17 00:00:00 2001 From: Dylan Young Date: Tue, 14 Jul 2026 22:22:06 -0300 Subject: [PATCH 14/16] test: fix overflow in faker datetime handling Causes at least test_adapt::test_random to fail sometimes. --- tests/fix_faker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fix_faker.py b/tests/fix_faker.py index 905b6ad4a..cd6511f7d 100644 --- a/tests/fix_faker.py +++ b/tests/fix_faker.py @@ -334,10 +334,10 @@ class Faker: return self.schema_time(cls) def make_datetime(self, spec): - # Add a day because with timezone we might go BC + # Add/subtract a day because with timezone we might overflow dtmin = dt.datetime.min + dt.timedelta(days=1) - delta = dt.datetime.max - dtmin - micros = randrange((delta.days + 1) * 24 * 60 * 60 * 1_000_000) + delta = dt.datetime.max - dt.timedelta(days=1) - dtmin + micros = randrange(int(delta.total_seconds() * 1_000_000)) rv = dtmin + dt.timedelta(microseconds=micros) if spec[1]: rv = rv.replace(tzinfo=self._make_tz(spec)) -- 2.47.3 From a593b5996aae0c436ba899a3878d28716ac2b1dd Mon Sep 17 00:00:00 2001 From: "Joey@macstudio" Date: Mon, 13 Jul 2026 04:13:53 +0800 Subject: [PATCH 15/16] fix: reject unterminated text copy rows Close #1361 --- psycopg/psycopg/_copy_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/psycopg/psycopg/_copy_base.py b/psycopg/psycopg/_copy_base.py index bedc98a8b..e10755648 100644 --- a/psycopg/psycopg/_copy_base.py +++ b/psycopg/psycopg/_copy_base.py @@ -350,6 +350,8 @@ def _format_row_binary(row: Sequence[Any], tx: Transformer, out: bytearray) -> N def _parse_row_text(data: Buffer, tx: Transformer) -> tuple[Any, ...]: if not isinstance(data, bytes): data = bytes(data) + if not data.endswith(b"\n"): + raise e.DataError("bad copy data: field delimiter not found") fields = data.split(b"\t") fields[-1] = fields[-1][:-1] # drop \n row = [None if f == b"\\N" else _load_re.sub(_load_sub, f) for f in fields] -- 2.47.3 From c079c37c959a89a8684a3d3858cbaff7e5543e71 Mon Sep 17 00:00:00 2001 From: Daniele Varrazzo Date: Mon, 20 Jul 2026 01:01:13 +0200 Subject: [PATCH 16/16] fix: raise DataError on malformed data in multirange binary loading Close #1358 --- psycopg/psycopg/types/multirange.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/psycopg/psycopg/types/multirange.py b/psycopg/psycopg/types/multirange.py index 3a3e8956c..a3d741735 100644 --- a/psycopg/psycopg/types/multirange.py +++ b/psycopg/psycopg/types/multirange.py @@ -342,14 +342,20 @@ class MultirangeBinaryLoader(BaseMultirangeLoader[T]): format = Format.BINARY def load(self, data: Buffer) -> Multirange[T]: + if len(data) < 4: + raise e.DataError(f"invalid multirange data: len = {len(data)}") + nelems = unpack_len(data, 0)[0] pos = 4 out = Multirange[T]() - for i in range(nelems): - length = unpack_len(data, pos)[0] - pos += 4 - out.append(load_range_binary(data[pos : pos + length], self._load)) - pos += length + try: + for i in range(nelems): + length = unpack_len(data, pos)[0] + pos += 4 + out.append(load_range_binary(data[pos : pos + length], self._load)) + pos += length + except Exception as ex: + raise e.DataError(f"invalid multirange data: {type(ex).__name__} - {ex}") if pos != len(data): raise e.DataError("unexpected trailing data in multirange") -- 2.47.3