From e81343df4eec5899508c2323e758b23b179df84b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 27 Jun 2026 09:37:10 +0300 Subject: [PATCH] refactor: make ConnectionPlan.connect_args read-only via MappingProxyType Wraps the popped connect_args dict in types.MappingProxyType so the Mapping annotation is literally read-only at runtime. No behavior change (the dict was never mutated post-construction); _connect's ** unpacking works unchanged. Lightweight-lane change bundle included. Co-Authored-By: Claude Opus 4.8 (1M context) --- db_retry/connections.py | 3 +- .../change.md | 37 +++++++++++++++++++ tests/test_connection_factory.py | 7 ++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 planning/changes/2026-06-27.01-connect-args-readonly/change.md diff --git a/db_retry/connections.py b/db_retry/connections.py index 45be158..740240a 100644 --- a/db_retry/connections.py +++ b/db_retry/connections.py @@ -1,6 +1,7 @@ import dataclasses import logging import random +import types import typing from operator import itemgetter @@ -48,7 +49,7 @@ def build_connection_plan(url: sqlalchemy.URL) -> ConnectionPlan: primary_port = raw_ports failover = () return ConnectionPlan( - connect_args=connect_args, + connect_args=types.MappingProxyType(connect_args), target_session_attrs=target_session_attrs, primary_host=primary_host, primary_port=primary_port, diff --git a/planning/changes/2026-06-27.01-connect-args-readonly/change.md b/planning/changes/2026-06-27.01-connect-args-readonly/change.md new file mode 100644 index 0000000..5f36381 --- /dev/null +++ b/planning/changes/2026-06-27.01-connect-args-readonly/change.md @@ -0,0 +1,37 @@ +--- +summary: Wrapped ConnectionPlan.connect_args in types.MappingProxyType so the Mapping annotation is literally read-only at runtime; no behavior change. +--- + +# Change: Make ConnectionPlan.connect_args read-only + +**Lane:** lightweight — ≲30 LOC net, ≤2 files, no new file, no public-API +change, a single straightforward test. + +## Goal + +`ConnectionPlan.connect_args` is annotated `Mapping[str, Any]` (read-only intent) +but stored the live `dict`. Make the annotation literally true so the seam can't +be mutated through. Cosmetic hardening folded in from the connection-plan-split +final review; no behavior change (the dict was never mutated post-construction). + +## Approach + +`build_connection_plan` wraps the popped `connect_args` dict in +`types.MappingProxyType(...)` when constructing the `ConnectionPlan`. `_connect` +spreads it with `**plan.connect_args`, which works unchanged on a +`MappingProxyType`. Internal seam only — no public-API or `architecture/` +contract change. + +## Files + +- `db_retry/connections.py` — `import types`; `connect_args=types.MappingProxyType(connect_args)`. +- `tests/test_connection_factory.py` — test that `plan.connect_args` rejects mutation. + +## Verification + +- [x] Failing test first — `plan.connect_args["injected"] = "value"` succeeded on + the plain dict (no `TypeError`). +- [x] Apply the wrap — mutation now raises `TypeError`. +- [x] `just test` — full suite green (28 passed, 100% coverage); `**` unpacking + in `_connect` still works (loop tests pass). +- [x] `just lint-ci` — clean. diff --git a/tests/test_connection_factory.py b/tests/test_connection_factory.py index 74b994a..2a0673d 100644 --- a/tests/test_connection_factory.py +++ b/tests/test_connection_factory.py @@ -81,6 +81,13 @@ def test_build_connection_plan_multihost() -> None: assert "target_session_attrs" not in plan.connect_args +def test_build_connection_plan_connect_args_is_read_only() -> None: + url: typing.Final = sqlalchemy.make_url("postgresql+asyncpg://user:password@host1:5432/database") + plan: typing.Final[ConnectionPlan] = build_connection_plan(url) + with pytest.raises(TypeError): + plan.connect_args["injected"] = "value" # ty: ignore[invalid-assignment] # read-only at runtime + + def test_build_connection_plan_single_host() -> None: port: typing.Final = 5432 url: typing.Final = sqlalchemy.make_url(f"postgresql+asyncpg://user:password@host1:{port}/database")