Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion db_retry/connections.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import dataclasses
import logging
import random
import types
import typing
from operator import itemgetter

Expand Down Expand Up @@ -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,
Expand Down
37 changes: 37 additions & 0 deletions planning/changes/2026-06-27.01-connect-args-readonly/change.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions tests/test_connection_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down