From 08bbd9e4eb3d2c88185fc7d63d912923ea085f76 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Wed, 11 Feb 2026 19:53:48 -0500 Subject: [PATCH 1/6] Remove trailing semicolon flagged by flake8 --- email_normalize/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/email_normalize/__init__.py b/email_normalize/__init__.py index 825ca1c..c3e5bb8 100644 --- a/email_normalize/__init__.py +++ b/email_normalize/__init__.py @@ -215,7 +215,7 @@ def _local_part_as_hostname(local_part: str, def _lookup_provider(mx_records: typing.List[typing.Tuple[int, str]]) \ -> typing.Optional[providers.MailboxProvider]: for priority, host in mx_records: - lchost = host.lower(); + lchost = host.lower() for provider in providers.Providers: for domain in provider.MXDomains: if lchost.endswith(domain): From 3b4c81bc8fb907ba0b0e784378bad8bec6d85282 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Wed, 11 Feb 2026 20:05:39 -0500 Subject: [PATCH 2/6] Fall back to failure_ttl when DNS returns negative TTL pycares 4.x returns ttl=-1 for MX records, causing cache entries to expire immediately. Treat negative TTL as unavailable and use failure_ttl instead. TTL=0 is respected as a valid "do not cache" signal for pycares 5.x compatibility. --- email_normalize/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/email_normalize/__init__.py b/email_normalize/__init__.py index c3e5bb8..faab1c9 100644 --- a/email_normalize/__init__.py +++ b/email_normalize/__init__.py @@ -154,8 +154,8 @@ async def mx_records(self, domain_part: str) -> MXRecords: mx_records, ttl = [], self.failure_ttl else: mx_records = [(r.priority, r.host) for r in records] - ttl = min(r.ttl for r in records) \ - if records else self.failure_ttl + ttl = min((r.ttl for r in records if r.ttl >= 0), + default=self.failure_ttl) # Prune the cache if over the limit, finding least used, oldest if len(cache.keys()) >= self.cache_limit: From 6cb7b1d59b962182b559db1d580606714cedea2b Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Wed, 11 Feb 2026 20:34:14 -0500 Subject: [PATCH 3/6] Drop Python 3.7 --- .github/workflows/testing.yaml | 2 +- setup.cfg | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a982ba3..e0e903f 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 3 strategy: matrix: - python: [3.7, 3.8, 3.9] + python: ['3.8', '3.9'] container: image: python:${{ matrix.python }}-alpine steps: diff --git a/setup.cfg b/setup.cfg index c329e2a..2dd6bc6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,7 +21,6 @@ classifiers = Natural Language :: English Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Topic :: Communications From 46412bf4af03e1d511864e85f006edda5d064da9 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Wed, 11 Feb 2026 20:37:31 -0500 Subject: [PATCH 4/6] Replace asynctest with unittest.mock and IsolatedAsyncioTestCase --- setup.cfg | 1 - tests/test_normalize.py | 3 +- tests/test_normalizer.py | 71 ++++++---------------------------------- 3 files changed, 11 insertions(+), 64 deletions(-) diff --git a/setup.cfg b/setup.cfg index 2dd6bc6..3b0ccf9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -42,7 +42,6 @@ zip_safe = true [options.extras_require] testing = - asynctest coverage flake8 flake8-comprehensions diff --git a/tests/test_normalize.py b/tests/test_normalize.py index 3c73b5e..f64fe55 100644 --- a/tests/test_normalize.py +++ b/tests/test_normalize.py @@ -2,8 +2,7 @@ import unittest import uuid import warnings - -from asynctest import mock +from unittest import mock import email_normalize diff --git a/tests/test_normalizer.py b/tests/test_normalizer.py index 453404a..1452a0d 100644 --- a/tests/test_normalizer.py +++ b/tests/test_normalizer.py @@ -1,78 +1,34 @@ import asyncio -import functools -import logging import operator -import os import time import unittest import uuid +from unittest import mock import aiodns -from asynctest import mock import email_normalize -LOGGER = logging.getLogger(__name__) - -def async_test(*func): - if func: - @functools.wraps(func[0]) - def wrapper(*args, **kwargs): - LOGGER.debug('Starting test with loop %r', args[0]) - args[0].loop.run_until_complete(func[0](*args, **kwargs)) - LOGGER.debug('Test completed') - return wrapper - - -class AsyncTestCase(unittest.TestCase): +class NormalizerTestCase(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - self.loop.set_debug(True) - self.timeout = int(os.environ.get('ASYNC_TIMEOUT', '5')) - self.timeout_handle = self.loop.call_later( - self.timeout, self.on_timeout) - self.resolver = aiodns.DNSResolver(loop=self.loop) self.normalizer = email_normalize.Normalizer() - self.normalizer._resolver = self.resolver - email_normalize.cache = {} + email_normalize.cache.clear() def tearDown(self): - LOGGER.debug('In AsyncTestCase.tearDown') - if not self.timeout_handle.cancelled(): - self.timeout_handle.cancel() - self.loop.run_until_complete(self.loop.shutdown_asyncgens()) - if self.loop.is_running: - self.loop.close() - super().tearDown() - - def on_timeout(self): - self.loop.stop() - raise TimeoutError( - 'Test duration exceeded {} seconds'.format(self.timeout)) - - -class NormalizerTestCase(AsyncTestCase): - - def setUp(self) -> None: - super().setUp() - if 'gmail.com' in email_normalize.cache: - del email_normalize.cache['gmail.com'] + email_normalize.cache.clear() - @async_test async def test_mx_records(self): - result = await self.resolver.query('gmail.com', 'MX') - expectation = [] - for record in result: - expectation.append((record.priority, record.host)) - expectation.sort(key=operator.itemgetter(0, 1)) + resolver = aiodns.DNSResolver() + result = await resolver.query('gmail.com', 'MX') + expectation = sorted( + [(r.priority, r.host) for r in result], + key=operator.itemgetter(0, 1)) self.assertListEqual( await self.normalizer.mx_records('gmail.com'), expectation) - @async_test async def test_cache(self): await self.normalizer.mx_records('gmail.com') await self.normalizer.mx_records('gmail.com') @@ -82,7 +38,6 @@ async def test_cache(self): with self.assertRaises(KeyError): self.assertIsNone(email_normalize.cache['foo']) - @async_test async def test_cache_max_size(self): for offset in range(0, self.normalizer.cache_limit): key = 'key-{}'.format(offset) @@ -100,7 +55,6 @@ async def test_cache_max_size(self): self.assertNotIn(key1, email_normalize.cache) self.assertIn(key2, email_normalize.cache) - @async_test async def test_cache_expiration(self): await self.normalizer.mx_records('gmail.com') cached_at = email_normalize.cache['gmail.com'].cached_at @@ -111,7 +65,6 @@ async def test_cache_expiration(self): self.assertGreater( email_normalize.cache['gmail.com'].cached_at, cached_at) - @async_test async def test_empty_mx_list(self): with mock.patch.object(self.normalizer, 'mx_records') as mx_records: mx_records.return_value = [] @@ -120,22 +73,18 @@ async def test_empty_mx_list(self): self.assertIsNone(result.mailbox_provider) self.assertListEqual(result.mx_records, []) - @async_test async def test_failure_cached(self): key = str(uuid.uuid4()) records = await self.normalizer.mx_records(key) self.assertListEqual(records, []) self.assertIn(key, email_normalize.cache.keys()) - @async_test async def test_failure_not_cached(self): - email_normalize.cache_failures = False + self.normalizer.cache_failures = False key = str(uuid.uuid4()) records = await self.normalizer.mx_records(key) self.assertListEqual(records, []) - email_normalize.cache_failures = True - @async_test async def test_weird_mx_list(self): with mock.patch.object(self.normalizer, 'mx_records') as recs: recs.return_value = [ From 2d43d1b21a400b83f03d33277533bace4c071268 Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Wed, 11 Feb 2026 20:37:40 -0500 Subject: [PATCH 5/6] Add support for Python 3.10 through 3.14 --- .github/workflows/testing.yaml | 2 +- email_normalize/__init__.py | 6 +++--- setup.cfg | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index e0e903f..9051318 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 3 strategy: matrix: - python: ['3.8', '3.9'] + python: ['3.8', '3.9', '3.12', '3.14'] container: image: python:${{ matrix.python }}-alpine steps: diff --git a/email_normalize/__init__.py b/email_normalize/__init__.py index faab1c9..4a8cd8c 100644 --- a/email_normalize/__init__.py +++ b/email_normalize/__init__.py @@ -253,6 +253,6 @@ def normalize(email_address: str) -> Result: :param email_address: The address to normalize """ - loop = asyncio.get_event_loop() - normalizer = Normalizer() - return loop.run_until_complete(normalizer.normalize(email_address)) + async def _normalize(): + return await Normalizer().normalize(email_address) + return asyncio.run(_normalize()) diff --git a/setup.cfg b/setup.cfg index 3b0ccf9..ae0496c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,6 +23,11 @@ classifiers = 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 :: Communications Topic :: Communications :: Email Topic :: Internet From cfaf1f3ad96d36709ea3e3d9af8505c2193daf7e Mon Sep 17 00:00:00 2001 From: Andrew Rabert Date: Thu, 12 Feb 2026 18:45:19 -0500 Subject: [PATCH 6/6] Add 3.10, 3.11, 3.13 to test matrix --- .github/workflows/testing.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index 9051318..5b106b8 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 3 strategy: matrix: - python: ['3.8', '3.9', '3.12', '3.14'] + python: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] container: image: python:${{ matrix.python }}-alpine steps: