Skip to content

Stop as_dict() from modifying the ValidatedEmail it is called on - #174

Merged
JoshData merged 1 commit into
JoshData:mainfrom
feiiiiii5:fix/as-dict-not-mutating
Sep 28, 2026
Merged

JoshData merged 1 commit into
JoshData:mainfrom
feiiiiii5:fix/as-dict-not-mutating

Conversation

@feiiiiii5

Copy link
Copy Markdown
Contributor

as_dict() hands back the instance's live __dict__ and then writes repr() of domain_address into it, so reading the dict modifies the object it was read from:

v = validate_email("me@[127.0.0.1]", allow_domain_literal=True, check_deliverability=False)
v.domain_address                      # IPv4Address('127.0.0.1')
v.as_dict()["domain_address"]         # "IPv4Address('127.0.0.1')"
v.domain_address                      # "IPv4Address('127.0.0.1')"  <- now a str
v.as_dict()["domain_address"]         # '"IPv4Address(\'127.0.0.1\')"'  <- repr of the repr

domain_address is documented to hold the parsed ipaddress.IPv4Address/IPv6Address object, so anything downstream that uses it — comparing addresses, or passing it to ipaddress/socket helpers — breaks after a single read, and each further call compounds the damage. Because the dict is the live namespace, v.as_dict()["domain"] = "x" also writes through to the object.

as_constructor() in the same class does the same "make it printable" job with repr(getattr(self, key)) and never mutates, so the fix is to copy first:

d = dict(self.__dict__)

Worth flagging: the existing test_dict_accessor_with_domain_address asserted '"IPv4Address(\'127.0.0.1\')"' — the doubly repr'd value, which only ever existed because of this mutation. That test now asserts the single repr, and a new test_dict_accessor_does_not_modify_validated_email pins that the attribute is still the original object after a call and that a second call returns the same dict.

Test: pytest tests/test_main.py -k dict_accessor fails on 0552069 with assert "IPv4Address('127.0.0.1')" is IPv4Address('127.0.0.1') and passes here. The suite is 317 passed / 1 deselected (the deselected one needs network), flake8 --ignore=E501,E126,W503 email_validator tests is clean, and mypy reports no issues across the 13 source files.

I did not add a CHANGELOG entry under "In Development" — happy to if you would like one.

as_dict() handed back the instance's live __dict__ and then wrote repr() of
domain_address into it, so reading the dict replaced the documented
ipaddress.IPv4Address object on the object itself. Every later call repr'd
the string again, and writing to the returned dict mutated the
ValidatedEmail.

as_constructor() does the same "make it printable" job with
repr(getattr(self, key)) and never mutates, so take a copy instead.

The existing test asserted the doubly repr'd value, which only existed
because of the mutation; it now asserts the single repr and a new test pins
that the attribute survives an as_dict() call.
@JoshData
JoshData merged commit c0cc1bc into JoshData:main Sep 28, 2026
5 checks passed
@JoshData

Copy link
Copy Markdown
Owner

Thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants