diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a982ba3..5b106b8 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', '3.10', '3.11', '3.12', '3.13', '3.14'] container: image: python:${{ matrix.python }}-alpine steps: diff --git a/email_normalize/__init__.py b/email_normalize/__init__.py index 825ca1c..4a8cd8c 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: @@ -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): @@ -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 c329e2a..ae0496c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -21,9 +21,13 @@ 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 + 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 @@ -43,7 +47,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 = [