Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions email_normalize/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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())
7 changes: 5 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,7 +47,6 @@ zip_safe = true

[options.extras_require]
testing =
asynctest
coverage
flake8
flake8-comprehensions
Expand Down
3 changes: 1 addition & 2 deletions tests/test_normalize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import unittest
import uuid
import warnings

from asynctest import mock
from unittest import mock

import email_normalize

Expand Down
71 changes: 10 additions & 61 deletions tests/test_normalizer.py
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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 = []
Expand All @@ -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 = [
Expand Down