diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d2277..426f61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +In Development +-------------- + +* The library now includes an asynchronous version of the main method named validate_email_async. + 2.1.0 (October 22, 2023) ------------------------ diff --git a/README.md b/README.md index 8b3b7b8..8920966 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Key features: can display to end-users. * Checks deliverability (optional): Does the domain name resolve? (You can override the default DNS resolver to add query caching.) +* Can be called asynchronously with `await`. * Supports internationalized domain names and internationalized local parts. * Rejects addresses with unsafe Unicode characters, obsolete email address syntax that you'd find unexpected, special use domain names like @@ -83,6 +84,9 @@ This validates the address and gives you its normalized form. You should checking if an address is in your database. When using this in a login form, set `check_deliverability` to `False` to avoid unnecessary DNS queries. +See below for examples for caching DNS queries and calling the library +asynchronously with `await`. + Usage ----- @@ -161,6 +165,30 @@ while True: validate_email(email, dns_resolver=resolver) ``` +### Asynchronous call + +The library has an alternative, asynchronous method named `validate_email_async` which must be called with `await`. This method uses an [asynchronous DNS resolver](https://dnspython.readthedocs.io/en/latest/async.html) so that multiple DNS-based deliverability checks can be performed in parallel. + +Here how to use it. In this example, `import ... as` is used to alias the async method to the usual method name `validate_email`. + +```python +from email_validator import validate_email_async as validate_email, \ + EmailNotValidError, caching_async_resolver + +resolver = caching_async_resolver(timeout=10) + +email = "my+address@example.org" +try: + emailinfo = await validate_email(email) + email = emailinfo.normalized +except EmailNotValidError as e: + print(str(e)) +``` + +Note that to create a caching asynchronous resolver, use `caching_async_resolver`. As with the synchronous version, creating a resolver is optional. + +When processing batches of email addresses, I found that chunking around 25 email addresses at a time (using e.g. `asyncio.gather()`) resulted in the highest performance. I tested on a residential Internet connection with valid addresses. + ### Test addresses This library rejects email addresses that use the [Special Use Domain Names](https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml) `invalid`, `localhost`, `test`, and some others by raising `EmailSyntaxError`. This is to protect your system from abuse: You probably don't want a user to be able to cause an email to be sent to `localhost` (although they might be able to still do so via a malicious MX record). However, in your non-production test environments you may want to use `@test` or `@myname.test` email addresses. There are three ways you can allow this: diff --git a/email_validator/__init__.py b/email_validator/__init__.py index cd1b301..cb942ef 100644 --- a/email_validator/__init__.py +++ b/email_validator/__init__.py @@ -1,13 +1,14 @@ # Export the main method, helper methods, and the public data types. from .exceptions_types import ValidatedEmail, EmailNotValidError, \ EmailSyntaxError, EmailUndeliverableError -from .validate_email import validate_email +from .validate_email import validate_email_sync as validate_email, validate_email_async from .version import __version__ -__all__ = ["validate_email", +__all__ = ["validate_email", "validate_email_async", "ValidatedEmail", "EmailNotValidError", "EmailSyntaxError", "EmailUndeliverableError", - "caching_resolver", "__version__"] + "caching_resolver", "caching_async_resolver", + "__version__"] def caching_resolver(*args, **kwargs): @@ -17,6 +18,13 @@ def caching_resolver(*args, **kwargs): return caching_resolver(*args, **kwargs) +def caching_async_resolver(*args, **kwargs): + # Lazy load `deliverability` as it is slow to import (due to dns.resolver) + from .deliverability import caching_async_resolver + + return caching_async_resolver(*args, **kwargs) + + # These global attributes are a part of the library's API and can be # changed by library users. diff --git a/email_validator/__main__.py b/email_validator/__main__.py index a414ff6..cbf02d6 100644 --- a/email_validator/__main__.py +++ b/email_validator/__main__.py @@ -5,11 +5,19 @@ # python -m email_validator test@example.org # python -m email_validator < LIST_OF_ADDRESSES.TXT # -# Provide email addresses to validate either as a command-line argument -# or in STDIN separated by newlines. Validation errors will be printed for -# invalid email addresses. When passing an email address on the command -# line, if the email address is valid, information about it will be printed. -# When using STDIN, no output will be given for valid email addresses. +# Provide email addresses to validate either as a single command-line argument +# or on STDIN separated by newlines. +# +# When passing an email address on the command line, if the email address +# is valid, information about it will be printed to STDOUT. If the email +# address is invalid, an error message will be printed to STDOUT and +# the exit code will be set to 1. +# +# When passsing email addresses on STDIN, validation errors will be printed +# for invalid email addresses. No output is given for valid email addresses. +# Validation errors are preceded by the email address that failed and a tab +# character. It is the user's responsibility to ensure email addresses +# do not contain tab or newline characters. # # Keyword arguments to validate_email can be set in environment variables # of the same name but upprcase (see below). @@ -17,12 +25,66 @@ import json import os import sys +import itertools -from .validate_email import validate_email -from .deliverability import caching_resolver +from .deliverability import caching_async_resolver from .exceptions_types import EmailNotValidError +def main_command_line(email_address, options, dns_resolver): + # Validate the email address passed on the command line. + + from . import validate_email + + try: + result = validate_email(email_address, dns_resolver=dns_resolver, **options) + print(json.dumps(result.as_dict(), indent=2, sort_keys=True, ensure_ascii=False)) + return True + except EmailNotValidError as e: + print(e) + return False + + +async def main_stdin(options, dns_resolver): + # Validate the email addresses pased line-by-line on STDIN. + # Chunk the addresses and call the async version of validate_email + # for all the addresses in the chunk, and wait for the chunk + # to complete. + + import asyncio + + from . import validate_email_async as validate_email + + dns_resolver = dns_resolver or caching_async_resolver() + + # https://stackoverflow.com/a/312467 + def split_seq(iterable, size): + it = iter(iterable) + item = list(itertools.islice(it, size)) + while item: + yield item + item = list(itertools.islice(it, size)) + + CHUNK_SIZE = 25 + + async def process_line(line): + email = line.strip() + try: + await validate_email(email, dns_resolver=dns_resolver, **options) + # If the email was valid, do nothing. + return None + except EmailNotValidError as e: + return (email, e) + + chunks = split_seq(sys.stdin, CHUNK_SIZE) + for chunk in chunks: + awaitables = [process_line(line) for line in chunk] + errors = await asyncio.gather(*awaitables) + for error in errors: + if error is not None: + print(*error, sep='\t') + + def main(dns_resolver=None): # The dns_resolver argument is for tests. @@ -36,24 +98,14 @@ def main(dns_resolver=None): if varname in os.environ: options[varname.lower()] = float(os.environ[varname]) - if len(sys.argv) == 1: - # Validate the email addresses pased line-by-line on STDIN. - dns_resolver = dns_resolver or caching_resolver() - for line in sys.stdin: - email = line.strip() - try: - validate_email(email, dns_resolver=dns_resolver, **options) - except EmailNotValidError as e: - print(f"{email} {e}") + if len(sys.argv) == 2: + return main_command_line(sys.argv[1], options, dns_resolver) else: - # Validate the email address passed on the command line. - email = sys.argv[1] - try: - result = validate_email(email, dns_resolver=dns_resolver, **options) - print(json.dumps(result.as_dict(), indent=2, sort_keys=True, ensure_ascii=False)) - except EmailNotValidError as e: - print(e) + import asyncio + asyncio.run(main_stdin(options, dns_resolver)) + return True if __name__ == "__main__": - main() + if not main(): + sys.exit(1) diff --git a/email_validator/deliverability.py b/email_validator/deliverability.py index 182331a..b3d6f7f 100644 --- a/email_validator/deliverability.py +++ b/email_validator/deliverability.py @@ -3,6 +3,7 @@ from .exceptions_types import EmailUndeliverableError import dns.resolver +import dns.asyncresolver import dns.exception @@ -16,30 +17,74 @@ def caching_resolver(*, timeout: Optional[int] = None, cache=None, dns_resolver= return resolver -def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Optional[int] = None, dns_resolver=None): +def caching_async_resolver(*, timeout: Optional[int] = None, cache=None, dns_resolver=None): + if timeout is None: + from . import DEFAULT_TIMEOUT + timeout = DEFAULT_TIMEOUT + resolver = dns_resolver or dns.asyncresolver.Resolver() + resolver.cache = cache or dns.resolver.LRUCache() # type: ignore + resolver.lifetime = timeout # type: ignore # timeout, in seconds + return resolver + + +async def validate_email_deliverability( + domain: str, + domain_i18n: str, + timeout: Optional[int] = None, + dns_resolver=None, + async_loop: Optional[bool] = None +) -> Dict[str, Any]: + # Check that the domain resolves to an MX record. If there is no MX record, # try an A or AAAA record which is a deprecated fallback for deliverability. # Raises an EmailUndeliverableError on failure. On success, returns a dict # with deliverability information. + # When async_loop is None, the caller drives the coroutine manually to get + # the result synchronously, and consequently this call must not yield execution. + # It can use 'await' so long as the callee does not yield execution either. + # Otherwise, if async_loop is not None, there is no restriction on 'await' calls'. + # If no dns.resolver.Resolver was given, get dnspython's default resolver. - # Override the default resolver's timeout. This may affect other uses of - # dnspython in this process. + # Use the asyncresolver if async_loop is not None. if dns_resolver is None: + if not async_loop: + dns_resolver = dns.resolver.get_default_resolver() + else: + dns_resolver = dns.asyncresolver.get_default_resolver() + + # Override the default resolver's timeout. This may affect other uses of + # dnspython in this process. from . import DEFAULT_TIMEOUT if timeout is None: timeout = DEFAULT_TIMEOUT - dns_resolver = dns.resolver.get_default_resolver() dns_resolver.lifetime = timeout + elif timeout is not None: raise ValueError("It's not valid to pass both timeout and dns_resolver.") + # Define a resolve function that works with a regular or + # asynchronous dns.resolver.Resolver instance. + async def resolve(qname, rtype): + # When called non-asynchronously, expect a regular + # resolver that returns synchronously. Or if async_loop + # is not None but the caller didn't pass an + # dns.asyncresolver.Resolver, call it synchronously. + if not async_loop or not isinstance(dns_resolver, dns.asyncresolver.Resolver): + return dns_resolver.resolve(qname, rtype) + + # When async_loop is not None and if given a + # dns.asyncresolver.Resolver, call it asynchronously. + else: + return await dns_resolver.resolve(qname, rtype) + + # Collect successful deliverability information here. deliverability_info: Dict[str, Any] = {} try: try: # Try resolving for MX records (RFC 5321 Section 5). - response = dns_resolver.resolve(domain, "MX") + response = await resolve(domain, "MX") # For reporting, put them in priority order and remove the trailing dot in the qnames. mtas = sorted([(r.preference, str(r.exchange).rstrip('.')) for r in response]) @@ -59,7 +104,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option except dns.resolver.NoAnswer: # If there was no MX record, fall back to an A record. (RFC 5321 Section 5) try: - response = dns_resolver.resolve(domain, "A") + response = await resolve(domain, "A") deliverability_info["mx"] = [(0, str(r)) for r in response] deliverability_info["mx_fallback_type"] = "A" @@ -68,7 +113,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option # If there was no A record, fall back to an AAAA record. # (It's unclear if SMTP servers actually do this.) try: - response = dns_resolver.resolve(domain, "AAAA") + response = await resolve(domain, "AAAA") deliverability_info["mx"] = [(0, str(r)) for r in response] deliverability_info["mx_fallback_type"] = "AAAA" @@ -85,7 +130,7 @@ def validate_email_deliverability(domain: str, domain_i18n: str, timeout: Option # absence of an MX record, this is probably a good sign that the # domain is not used for email. try: - response = dns_resolver.resolve(domain, "TXT") + response = await resolve(domain, "TXT") for rec in response: value = b"".join(rec.strings) if value.startswith(b"v=spf1 "): diff --git a/email_validator/validate_email.py b/email_validator/validate_email.py index d6051a9..dafc33c 100644 --- a/email_validator/validate_email.py +++ b/email_validator/validate_email.py @@ -1,3 +1,4 @@ +from asyncio import Future from typing import Optional, Union from .exceptions_types import EmailSyntaxError, ValidatedEmail @@ -5,7 +6,14 @@ from .rfc_constants import CASE_INSENSITIVE_MAILBOX_NAMES -def validate_email( +# This is the main function of the package. Through some magic, +# it can be called both non-asynchronously and, if async_loop +# is not None, also asynchronously with 'await'. If called +# asynchronously, dns_resolver may be an instance of +# dns.asyncresolver.Resolver. +def validate_email_sync_or_async( + # NOTE: Arguments other than async_loop must match + # validate_email_sync/async defined below. email: Union[str, bytes], /, # prior arguments are positional-only *, # subsequent arguments are keyword-only @@ -17,8 +25,9 @@ def validate_email( test_environment: Optional[bool] = None, globally_deliverable: Optional[bool] = None, timeout: Optional[int] = None, - dns_resolver: Optional[object] = None -) -> ValidatedEmail: + dns_resolver: Optional[object] = None, + async_loop: Optional[object] = None +) -> Union[ValidatedEmail, Future]: # Future[ValidatedEmail] works in Python 3.10+ """ Given an email address, and some options, returns a ValidatedEmail instance with information about the address if it is valid or, if the address is not @@ -127,20 +136,151 @@ def validate_email( # Check the length of the address. validate_email_length(ret) - if check_deliverability and not test_environment: - # Validate the email address's deliverability using DNS - # and update the returned ValidatedEmail object with metadata. - - if is_domain_literal: - # There is nothing to check --- skip deliverability checks. + # If no deliverability checks will be performed, return the validation + # information immediately. + if not check_deliverability or is_domain_literal or test_environment: + # When called non-asynchronously, just return --- that's easy. + if not async_loop: return ret - # Lazy load `deliverability` as it is slow to import (due to dns.resolver) - from .deliverability import validate_email_deliverability - deliverability_info = validate_email_deliverability( - ret.ascii_domain, ret.domain, timeout, dns_resolver + # When this method is called asynchronously, we must return an awaitable, + # not the regular return value. Normally 'async def' handles that for you, + # but to not duplicate this entire function in an asynchronous version, we + # have a single function that works both both ways, depending on if + # async_loop is set. + # + # Wrap the ValidatedEmail object in a Future that is immediately + # done. If async_loop holds a loop object, use it to create the Future. + # Otherwise create a default Future instance. + fut: Future + if async_loop is True: + fut = Future() + elif not hasattr(async_loop, 'create_future'): # suppress typing warning + raise RuntimeError("async_loop parameter must have a create_future method.") + else: + fut = async_loop.create_future() + fut.set_result(ret) + return fut + + # Validate the email address's deliverability using DNS + # and update the returned ValidatedEmail object with metadata. + # + # Domain literals are not DNS names so deliverability checks are + # skipped (above) if is_domain_literal is set. + + # Lazy load `deliverability` as it is slow to import (due to dns.resolver) + from .deliverability import validate_email_deliverability + + # Wrap validate_email_deliverability, which is an async function, in another + # async function that merges the resulting information with the ValidatedEmail + # instance. Since this method may be used in a non-asynchronous call, it + # must not await on anything that might yield execution. + async def run_deliverability_checks(): + # Run the DNS-based deliverabiltiy checks. + # + # Although validate_email_deliverability (and this local function) + # are async functions, when async_loop is None it must not yield + # execution. See below. + info = await validate_email_deliverability( + ret.ascii_domain, ret.domain, timeout, dns_resolver, + async_loop ) - for key, value in deliverability_info.items(): + + # Merge deliverability info with the syntax info (if there was no exception). + for key, value in info.items(): setattr(ret, key, value) + return ret + + if not async_loop: + # When this function is called non-asynchronously, we will manually + # drive the coroutine returned by the async run_deliverability_checks + # function. Since we know that it does not yield execution, it will + # finish by raising StopIteration after the first 'send()' call. (If + # it doesn't, something serious went wrong.) + try: + # This call will either raise StopIteration on success or it will + # raise an EmailUndeliverableError on failure. + run_deliverability_checks().send(None) + + # If we come here, the coroutine yielded execution. We can't recover + # from this. + raise RuntimeError("Asynchronous resolver used in non-asychronous call or validate_email_deliverability mistakenly yielded.") + + except StopIteration as e: + # This is how a successful return occurs when driving a coroutine. + # The 'value' attribute on the exception holds the return value. + # Since we're in a non-asynchronous call, we can return it directly. + return e.value + + else: + # When this method is called asynchronously, return + # a coroutine. + return run_deliverability_checks() + + +# Validates an email address with DNS queries issued synchronously. +# This is exposed as the package's main validate_email method. +def validate_email_sync( + email: Union[str, bytes], + /, # prior arguments are positional-only + *, # subsequent arguments are keyword-only + allow_smtputf8: Optional[bool] = None, + allow_empty_local: bool = False, + allow_quoted_local: Optional[bool] = None, + allow_domain_literal: Optional[bool] = None, + check_deliverability: Optional[bool] = None, + test_environment: Optional[bool] = None, + globally_deliverable: Optional[bool] = None, + timeout: Optional[int] = None, + dns_resolver: Optional[object] = None +) -> ValidatedEmail: + ret = validate_email_sync_or_async( + email, + allow_smtputf8=allow_smtputf8, + allow_empty_local=allow_empty_local, + allow_quoted_local=allow_quoted_local, + allow_domain_literal=allow_domain_literal, + check_deliverability=check_deliverability, + test_environment=test_environment, + globally_deliverable=globally_deliverable, + timeout=timeout, + dns_resolver=dns_resolver, + async_loop=None) + if not isinstance(ret, ValidatedEmail): # suppress typing warning + raise RuntimeError(type(ret)) return ret + + +# Validates an email address with DNS queries issued asynchronously. +async def validate_email_async( + email: Union[str, bytes], + /, # prior arguments are positional-only + *, # subsequent arguments are keyword-only + allow_smtputf8: Optional[bool] = None, + allow_empty_local: bool = False, + allow_quoted_local: Optional[bool] = None, + allow_domain_literal: Optional[bool] = None, + check_deliverability: Optional[bool] = None, + test_environment: Optional[bool] = None, + globally_deliverable: Optional[bool] = None, + timeout: Optional[int] = None, + dns_resolver: Optional[object] = None, + loop: Optional[object] = None +) -> ValidatedEmail: + coro = validate_email_sync_or_async( + email, + allow_smtputf8=allow_smtputf8, + allow_empty_local=allow_empty_local, + allow_quoted_local=allow_quoted_local, + allow_domain_literal=allow_domain_literal, + check_deliverability=check_deliverability, + test_environment=test_environment, + globally_deliverable=globally_deliverable, + timeout=timeout, + dns_resolver=dns_resolver, + async_loop=loop or True) + import inspect + if not inspect.isawaitable(coro): # suppress typing warning + raise RuntimeError(type(coro)) + return await coro diff --git a/find_optimal_async_chunk_size.py b/find_optimal_async_chunk_size.py new file mode 100644 index 0000000..c31664d --- /dev/null +++ b/find_optimal_async_chunk_size.py @@ -0,0 +1,80 @@ +# Try different chunk sizes to find the optimal +# size for the fastest performance. +# +# Read in a list of email addresses on STDIN and +# draw from it random addresses for each call. + +import asyncio +import random +import sys +import time + +from email_validator import validate_email_async, EmailNotValidError, \ + caching_async_resolver + +async def wrap_validate_email(email, dns_resolver): + # Wrap validate_email_async to catch + # exceptions. + try: + return await validate_email_async(email, dns_resolver=dns_resolver) + except EmailNotValidError as e: + return e + +async def is_valid(email, dns_resolver): + try: + await validate_email_async(email, dns_resolver=dns_resolver) + return True + except EmailNotValidError as e: + return False + +async def go(): + # Read in all of the test addresses from STDIN. + all_email_addreses = [line.strip() for line in sys.stdin.readlines()] + + # Sample the whole set and throw out addresses that are + # invalid. + resolver = caching_async_resolver(timeout=5) + all_email_addreses = random.sample(all_email_addreses, 10000) + all_email_addreses = [email for email in all_email_addreses + if is_valid(is_valid, resolver)] + + print("Starting...") + + # Start testing various chunk sizes. + for chunk_size in range(1, 200): + reps = max(1, int(15 / chunk_size)) + + # Draw a random sample of email addresses to use + # in this test. For low chunk sizes where we perform + # multiple reps, draw the samples for all of the + # reps ahead of time so that we don't time the + # sampling. + samples = [ + random.sample(all_email_addreses, chunk_size) + for _ in range(reps) + ] + + # Create a resolver with a short timeout. + # Use a caching resolver to better reflect real-world practice. + resolver = caching_async_resolver(timeout=5) + resolver.nameservers = ["8.8.8.8"] + + # Start timing. + t_start = time.time_ns() + + # Run the reps. + for i in range(reps): + # Run the chunk. + coros = [ + wrap_validate_email(email, dns_resolver=resolver) + for email in samples[i]] + await asyncio.gather(*coros) + + # End timing. + t_end = time.time_ns() + + duration = t_end - t_start + + print(chunk_size, int(round(duration / (chunk_size * reps) / 1000)), sep='\t') + +asyncio.run(go()) diff --git a/tests/mocked_dns_response.py b/tests/mocked_dns_response.py index 1c7d157..8a4e90d 100644 --- a/tests/mocked_dns_response.py +++ b/tests/mocked_dns_response.py @@ -3,7 +3,7 @@ import os.path import pytest -from email_validator.deliverability import caching_resolver +from email_validator.deliverability import caching_resolver, caching_async_resolver # To run deliverability checks without actually making # DNS queries, we use a caching resolver where the cache @@ -21,7 +21,7 @@ class MockedDnsResponseData: DATA_PATH = os.path.dirname(__file__) + "/mocked-dns-answers.json" @staticmethod - def create_resolver(): + def create_resolver(_async=False): if not hasattr(MockedDnsResponseData, 'INSTANCE'): # Create a singleton instance of this class and load the saved DNS responses. # Except when BUILD_MOCKED_DNS_RESPONSE_DATA is true, don't load the data. @@ -32,8 +32,12 @@ def create_resolver(): # Return a new dns.resolver.Resolver configured for caching # using the singleton instance. - dns_resolver = dns.resolver.Resolver(configure=BUILD_MOCKED_DNS_RESPONSE_DATA) - return caching_resolver(cache=MockedDnsResponseData.INSTANCE, dns_resolver=dns_resolver) + if not _async: + dns_resolver = dns.resolver.Resolver(configure=BUILD_MOCKED_DNS_RESPONSE_DATA) + return caching_resolver(cache=MockedDnsResponseData.INSTANCE, dns_resolver=dns_resolver) + else: + dns_resolver = dns.asyncresolver.Resolver(configure=BUILD_MOCKED_DNS_RESPONSE_DATA) + return caching_async_resolver(cache=MockedDnsResponseData.INSTANCE, dns_resolver=dns_resolver) def __init__(self): self.data = {} diff --git a/tests/test_deliverability.py b/tests/test_deliverability.py index 52124eb..062cb59 100644 --- a/tests/test_deliverability.py +++ b/tests/test_deliverability.py @@ -3,13 +3,21 @@ from email_validator import EmailUndeliverableError, \ validate_email, caching_resolver -from email_validator.deliverability import validate_email_deliverability +from email_validator.deliverability import validate_email_deliverability as validate_email_deliverability_async from mocked_dns_response import MockedDnsResponseData, MockedDnsResponseDataCleanup # noqa: F401 RESOLVER = MockedDnsResponseData.create_resolver() +def validate_email_deliverability(*args, **kwargs): + try: + validate_email_deliverability_async(*args, **kwargs).send(None) + raise RuntimeError("validate_email_deliverability did not run synchronously.") + except StopIteration as e: + return e.value + + def test_deliverability_found(): response = validate_email_deliverability('gmail.com', 'gmail.com', dns_resolver=RESOLVER) assert response.keys() == {'mx', 'mx_fallback_type'} diff --git a/tests/test_main.py b/tests/test_main.py index 579163f..5091fea 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -41,7 +41,8 @@ def test_main_multi_input(monkeypatch, capsys): test_input = io.StringIO("\n".join(test_cases)) monkeypatch.setattr('sys.stdin', test_input) monkeypatch.setattr('sys.argv', ['email_validator']) - validator_command_line_tool(dns_resolver=RESOLVER) + ASYNC_RESOLVER = MockedDnsResponseData.create_resolver(_async=True) + validator_command_line_tool(dns_resolver=ASYNC_RESOLVER) stdout, _ = capsys.readouterr() assert test_cases[0] not in stdout assert test_cases[1] not in stdout