From 52b24c66abdc79067769734dd5dc2e02b650ef65 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 27 Oct 2025 11:09:10 -0400 Subject: [PATCH 1/5] Added a few more tests to improve code coverage (#163) --- tests/mocked-dns-answers.json | 26 +++++++++++++++++++++++++- tests/test_deliverability.py | 6 ++++++ tests/test_main.py | 27 +++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/tests/mocked-dns-answers.json b/tests/mocked-dns-answers.json index 12d3885..8da7db6 100644 --- a/tests/mocked-dns-answers.json +++ b/tests/mocked-dns-answers.json @@ -129,7 +129,7 @@ "type": "A", "class": "IN" }, - "answer": [] + "answer": ["127.0.0.1"] }, { "query": { @@ -139,6 +139,30 @@ }, "answer": [] }, + { + "query": { + "name": "ipv6only.joshdata.me", + "type": "MX", + "class": "IN" + }, + "answer": [] + }, + { + "query": { + "name": "ipv6only.joshdata.me", + "type": "A", + "class": "IN" + }, + "answer": [] + }, + { + "query": { + "name": "ipv6only.joshdata.me", + "type": "AAAA", + "class": "IN" + }, + "answer": ["::1"] + }, { "query": { "name": "mail.example", diff --git a/tests/test_deliverability.py b/tests/test_deliverability.py index 1754e52..e1307c2 100644 --- a/tests/test_deliverability.py +++ b/tests/test_deliverability.py @@ -35,6 +35,7 @@ def test_deliverability_found(domain: str, expected_response: str) -> None: # No MX or A/AAAA records, but some other DNS records must # exist such that the response is NOANSWER instead of NXDOMAIN. ('justtxt.joshdata.me', 'The domain name {domain} does not accept email'), + ('ipv6only.joshdata.me', 'The domain name {domain} does not accept email'), ], ) def test_deliverability_fails(domain: str, error: str) -> None: @@ -65,6 +66,11 @@ def test_deliverability_dns_timeout() -> None: assert response.get("unknown-deliverability") == "timeout" +def test_timeout_and_resolver() -> None: + with pytest.raises(ValueError, match="It's not valid to pass both timeout and dns_resolver."): + validate_email_deliverability('timeout.com', 'timeout.com', timeout=1, dns_resolver=RESOLVER) + + @pytest.mark.network def test_caching_dns_resolver() -> None: class TestCache: diff --git a/tests/test_main.py b/tests/test_main.py index ab8eecd..f11087b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import typing import pytest from email_validator import validate_email, EmailSyntaxError @@ -16,6 +17,14 @@ def test_dict_accessor() -> None: assert valid_email.as_dict()["original"] == input_email +def test_dict_accessor_with_domain_address() -> None: + input_email = "me@[127.0.0.1]" + valid_email = validate_email(input_email, check_deliverability=False, allow_domain_literal=True) + assert valid_email.domain == "[127.0.0.1]" + assert isinstance(valid_email.as_dict(), dict) + assert valid_email.as_dict()["domain_address"] == '"IPv4Address(\'127.0.0.1\')"' + + def test_main_single_good_input(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]) -> None: import json test_email = "google@google.com" @@ -65,3 +74,21 @@ def test_deprecation() -> None: valid_email = validate_email(input_email, check_deliverability=False) with pytest.deprecated_call(): assert valid_email.email is not None + + +@pytest.mark.parametrize('invalid_email', [ + None, + 12345, + [], + {}, + lambda x: x, +]) +def test_invalid_type(invalid_email: typing.Any) -> None: + with pytest.raises(TypeError, match="email must be str or bytes"): + validate_email(invalid_email, check_deliverability=False) + + +def test_invalid_ascii() -> None: + invalid_email = b'\xd0\xba\xd0\xb2\xd1\x96\xd1\x82\xd0\xbe\xd1\x87\xd0\xba\xd0\xb0@\xd0\xbf\xd0\xbe\xd1\x88\xd1\x82\xd0\xb0.test' + with pytest.raises(EmailSyntaxError, match="The email address is not valid ASCII."): + validate_email(invalid_email, check_deliverability=False) From 934541aac96b62d0665bba93f7a2353958b9e53b Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Wed, 14 Jan 2026 15:47:10 -0500 Subject: [PATCH 2/5] =?UTF-8?q?Use=20"PEP=20604=20=E2=80=93=20Allow=20writ?= =?UTF-8?q?ing=20union=20types=20as=20X=20|=20Y"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First added in Python 3.10, now that 3.9 is EOL'd. --- .github/workflows/test_and_build.yaml | 2 +- CHANGELOG.md | 2 +- email_validator/syntax.py | 6 +++--- email_validator/types.py | 4 ++-- email_validator/validate_email.py | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test_and_build.yaml b/.github/workflows/test_and_build.yaml index 4686f2c..8bea027 100644 --- a/.github/workflows/test_and_build.yaml +++ b/.github/workflows/test_and_build.yaml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e3043..76b9112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ In Development -------------- -* Python 3.8 is no longer supported. +* Python 3.8 and 3.9 are no longer supported. 2.3.0 (August 26, 2025) ----------------------- diff --git a/email_validator/syntax.py b/email_validator/syntax.py index 71699dc..4e87937 100644 --- a/email_validator/syntax.py +++ b/email_validator/syntax.py @@ -8,7 +8,7 @@ import unicodedata import idna # implements IDNA 2008; Python's codec is only IDNA 2003 import ipaddress -from typing import Optional, TypedDict, Union +from typing import Optional, TypedDict def split_email(email: str) -> tuple[Optional[str], str, str, bool]: @@ -758,7 +758,7 @@ def validate_email_length(addrinfo: ValidatedEmail) -> None: class DomainLiteralValidationResult(TypedDict): - domain_address: Union[ipaddress.IPv4Address, ipaddress.IPv6Address] + domain_address: ipaddress.IPv4Address | ipaddress.IPv6Address domain: str @@ -767,7 +767,7 @@ def validate_email_domain_literal(domain_literal: str) -> DomainLiteralValidatio # a compressed/normalized address. # RFC 5321 4.1.3 and RFC 5322 3.4.1. - addr: Union[ipaddress.IPv4Address, ipaddress.IPv6Address] + addr: ipaddress.IPv4Address | ipaddress.IPv6Address # Try to parse the domain literal as an IPv4 address. # There is no tag for IPv4 addresses, so we can never diff --git a/email_validator/types.py b/email_validator/types.py index bb6c3e7..42cf174 100644 --- a/email_validator/types.py +++ b/email_validator/types.py @@ -1,5 +1,5 @@ import warnings -from typing import Any, Optional, Union +from typing import Any, Optional class ValidatedEmail: @@ -68,7 +68,7 @@ def email(self) -> str: """For backwards compatibility, some fields are also exposed through a dict-like interface. Note that some of the names changed when they became attributes.""" - def __getitem__(self, key: str) -> Union[Optional[str], bool, list[tuple[int, str]]]: + def __getitem__(self, key: str) -> Optional[str] | bool | list[tuple[int, str]]: warnings.warn("dict-like access to the return value of validate_email is deprecated and may not be supported in the future.", DeprecationWarning, stacklevel=2) if key == "email": return self.normalized diff --git a/email_validator/validate_email.py b/email_validator/validate_email.py index ae5d963..5837ed2 100644 --- a/email_validator/validate_email.py +++ b/email_validator/validate_email.py @@ -1,4 +1,4 @@ -from typing import Optional, Union, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING import unicodedata from .exceptions import EmailSyntaxError @@ -14,7 +14,7 @@ def validate_email( - email: Union[str, bytes], + email: str | bytes, /, # prior arguments are positional-only *, # subsequent arguments are keyword-only allow_smtputf8: Optional[bool] = None, From 9f2a837360d6379e13251492d2dc4941f0006e4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:15:16 -0400 Subject: [PATCH 3/5] Bump pytest from 8.2.2 to 9.0.3 (#166) Bumps [pytest](https://github.com/pytest-dev/pytest) from 8.2.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.2.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- test_requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_requirements.txt b/test_requirements.txt index bea5d5a..9a18fe2 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -20,7 +20,7 @@ packaging==24.1 pluggy==1.5.0 pycodestyle==2.12.0 pyflakes==3.2.0 -pytest==8.2.2 +pytest==9.0.3 pytest-cov==5.0.0 tomli==2.0.1 typing_extensions==4.12.2 From bb074a2acade91572666e64ce8d4beffa0d78951 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Tue, 26 Aug 2025 09:06:24 -0400 Subject: [PATCH 4/5] Minor release process tweaks --- README.md | 3 +-- release_to_pypi.sh | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0d1f0eb..3beed68 100644 --- a/README.md +++ b/README.md @@ -425,8 +425,7 @@ To release: * Update CHANGELOG.md. * Update the version number in `email_validator/version.py`. * Make & push a commit with the new version number and make sure tests pass. -* Make & push a tag (see command below). -* Make a release at https://github.com/JoshData/python-email-validator/releases/new. +* Make a release at https://github.com/JoshData/python-email-validator/releases/new creating a new tag (or use command below). * Publish a source and wheel distribution to pypi (see command below). ```sh diff --git a/release_to_pypi.sh b/release_to_pypi.sh index 466f4f8..927591c 100755 --- a/release_to_pypi.sh +++ b/release_to_pypi.sh @@ -1,5 +1,5 @@ #!/bin/bash -source env/bin/activate +source venv/bin/activate pip3 install --upgrade build twine rm -rf dist python3 -m build From c89956bdc9df35cf6fbd12eb183ad3bb5317eb32 Mon Sep 17 00:00:00 2001 From: Joshua Tauberer Date: Mon, 13 Apr 2026 21:22:10 -0400 Subject: [PATCH 5/5] Complete the drop of Python 3.8, 3.9 and update test requirements pinned versions --- README.md | 2 +- setup.cfg | 6 +++--- test_requirements.txt | 35 +++++++++++++++++++---------------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3beed68..5739337 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ email-validator: Validate Email Addresses ========================================= A robust email address syntax and deliverability validation library for -Python 3.8+ by [Joshua Tauberer](https://joshdata.me). +Python 3.10+ by [Joshua Tauberer](https://joshdata.me). This library validates that a string is of the form `name@example.com` and optionally checks that the domain name is set up to receive email. diff --git a/setup.cfg b/setup.cfg index 8ceac96..6cfeb0d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,11 +14,11 @@ classifiers = Intended Audience :: Developers License :: OSI Approved :: The Unlicense (Unlicense) Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 + Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Topic :: Software Development :: Libraries :: Python Modules keywords = email address validator @@ -27,7 +27,7 @@ packages = find: install_requires = dnspython>=2.0.0 # optional if deliverability check isn't needed idna>=2.0.0 -python_requires = >=3.8 +python_requires = >=3.10 [options.package_data] * = py.typed diff --git a/test_requirements.txt b/test_requirements.txt index 9a18fe2..d2ce05f 100644 --- a/test_requirements.txt +++ b/test_requirements.txt @@ -1,5 +1,5 @@ # This file was generated by running: -# sudo docker run --rm -it --network=host python:3.8-slim /bin/bash +# sudo docker run --rm -it --network=host python:3.10-slim /bin/bash # pip install dnspython idna # from setup.cfg # pip install pytest pytest-cov coverage flake8 mypy # pip freeze @@ -7,20 +7,23 @@ # the earliest Python version we support, and some exception # messages may depend on package versions, so we pin versions # for reproducible testing.) -coverage==7.5.3 -dnspython==2.6.1 -exceptiongroup==1.2.1 -flake8==7.1.0 -idna==3.7 -iniconfig==2.0.0 +coverage==7.13.5 +dnspython==2.8.0 +exceptiongroup==1.3.1 +flake8==7.3.0 +idna==3.11 +iniconfig==2.3.0 +librt==0.9.0 mccabe==0.7.0 -mypy==1.10.0 -mypy-extensions==1.0.0 -packaging==24.1 -pluggy==1.5.0 -pycodestyle==2.12.0 -pyflakes==3.2.0 +mypy==1.20.1 +mypy_extensions==1.1.0 +packaging==26.0 +pathspec==1.0.4 +pluggy==1.6.0 +pycodestyle==2.14.0 +pyflakes==3.4.0 +Pygments==2.20.0 pytest==9.0.3 -pytest-cov==5.0.0 -tomli==2.0.1 -typing_extensions==4.12.2 +pytest-cov==7.1.0 +tomli==2.4.1 +typing_extensions==4.15.0