"""pytest-asyncio implementation.""" from __future__ import annotations import asyncio import contextlib import contextvars import enum import functools import inspect import socket import sys import traceback import warnings from asyncio import AbstractEventLoop, AbstractEventLoopPolicy from collections.abc import ( AsyncIterator, Awaitable, Callable, Generator, Iterable, Iterator, Sequence, ) from types import AsyncGeneratorType, CoroutineType from typing import ( Any, Literal, ParamSpec, TypeVar, overload, ) import pluggy import pytest from _pytest.fixtures import resolve_fixture_function from _pytest.scope import Scope from pytest import ( Config, FixtureDef, FixtureRequest, Function, Item, Mark, MonkeyPatch, Parser, PytestCollectionWarning, PytestDeprecationWarning, PytestPluginManager, ) if sys.version_info >= (3, 11): from asyncio import Runner else: from backports.asyncio.runner import Runner if sys.version_info >= (3, 13): from typing import TypeIs else: from typing_extensions import TypeIs _ScopeName = Literal["session", "package", "module", "class", "function"] _R = TypeVar("_R", bound=Awaitable[Any] | AsyncIterator[Any]) _P = ParamSpec("_P") FixtureFunction = Callable[_P, _R] class PytestAsyncioError(Exception): """Base class for exceptions raised by pytest-asyncio""" class Mode(str, enum.Enum): AUTO = "auto" STRICT = "strict" ASYNCIO_MODE_HELP = """\ 'auto' - for automatically handling all async functions by the plugin 'strict' - for autoprocessing disabling (useful if different async frameworks \ should be tested together, e.g. \ both pytest-asyncio and pytest-trio are used in the same project) """ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None: group = parser.getgroup("asyncio") group.addoption( "--asyncio-mode", dest="asyncio_mode", default=None, metavar="MODE", help=ASYNCIO_MODE_HELP, ) group.addoption( "--asyncio-debug", dest="asyncio_debug", action="store_true", default=None, help="enable asyncio debug mode for the default event loop", ) parser.addini( "asyncio_mode", help="default value for --asyncio-mode", default="strict", ) parser.addini( "asyncio_debug", help="enable asyncio debug mode for the default event loop", type="bool", default="false", ) parser.addini( "asyncio_default_fixture_loop_scope", type="string", help="default scope of the asyncio event loop used to execute async fixtures", default=None, ) parser.addini( "asyncio_default_test_loop_scope", type="string", help="default scope of the asyncio event loop used to execute tests", default="function", ) @overload def fixture( fixture_function: FixtureFunction[_P, _R], *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( Iterable[str | float | int | bool | None] | Callable[[Any], object | None] | None ) = ..., name: str | None = ..., ) -> FixtureFunction[_P, _R]: ... @overload def fixture( fixture_function: None = ..., *, scope: _ScopeName | Callable[[str, Config], _ScopeName] = ..., loop_scope: _ScopeName | None = ..., params: Iterable[object] | None = ..., autouse: bool = ..., ids: ( Iterable[str | float | int | bool | None] | Callable[[Any], object | None] | None ) = ..., name: str | None = None, ) -> Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]]: ... def fixture( fixture_function: FixtureFunction[_P, _R] | None = None, loop_scope: _ScopeName | None = None, **kwargs: Any, ) -> ( FixtureFunction[_P, _R] | Callable[[FixtureFunction[_P, _R]], FixtureFunction[_P, _R]] ): if fixture_function is not None: _make_asyncio_fixture_function(fixture_function, loop_scope) return pytest.fixture(fixture_function, **kwargs) else: @functools.wraps(fixture) def inner(fixture_function: FixtureFunction[_P, _R]) -> FixtureFunction[_P, _R]: return fixture(fixture_function, loop_scope=loop_scope, **kwargs) return inner def _is_asyncio_fixture_function(obj: Any) -> bool: obj = getattr(obj, "__func__", obj) # instance method maybe? return getattr(obj, "_force_asyncio_fixture", False) def _make_asyncio_fixture_function(obj: Any, loop_scope: _ScopeName | None) -> None: if hasattr(obj, "__func__"): # instance method, check the function object obj = obj.__func__ obj._force_asyncio_fixture = True obj._loop_scope = loop_scope def _is_coroutine_or_asyncgen(obj: Any) -> bool: return inspect.iscoroutinefunction(obj) or inspect.isasyncgenfunction(obj) def _get_asyncio_mode(config: Config) -> Mode: val = config.getoption("asyncio_mode") if val is None: val = config.getini("asyncio_mode") try: return Mode(val) except ValueError as e: modes = ", ".join(m.value for m in Mode) raise pytest.UsageError( f"{val!r} is not a valid asyncio_mode. Valid modes: {modes}." ) from e def _get_asyncio_debug(config: Config) -> bool: val = config.getoption("asyncio_debug") if val is None: val = config.getini("asyncio_debug") if isinstance(val, bool): return val else: return val == "true" _DEFAULT_FIXTURE_LOOP_SCOPE_UNSET = """\ The configuration option "asyncio_default_fixture_loop_scope" is unset. The event loop scope for asynchronous fixtures will default to the fixture caching \ scope. Future versions of pytest-asyncio will default the loop scope for asynchronous \ fixtures to function scope. Set the default fixture loop scope explicitly in order to \ avoid unexpected behavior in the future. Valid fixture loop scopes are: \ "function", "class", "module", "package", "session" """ def _validate_scope(scope: str | None, option_name: str) -> None: if scope is None: return valid_scopes = [s.value for s in Scope] if scope not in valid_scopes: raise pytest.UsageError( f"{scope!r} is not a valid {option_name}. " f"Valid scopes are: {', '.join(valid_scopes)}." ) def pytest_configure(config: Config) -> None: default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope") _validate_scope(default_fixture_loop_scope, "asyncio_default_fixture_loop_scope") if not default_fixture_loop_scope: warnings.warn(PytestDeprecationWarning(_DEFAULT_FIXTURE_LOOP_SCOPE_UNSET)) default_test_loop_scope = config.getini("asyncio_default_test_loop_scope") _validate_scope(default_test_loop_scope, "asyncio_default_test_loop_scope") config.addinivalue_line( "markers", "asyncio: " "mark the test as a coroutine, it will be " "run using an asyncio event loop", ) @pytest.hookimpl(tryfirst=True) def pytest_report_header(config: Config) -> list[str]: """Add asyncio config to pytest header.""" mode = _get_asyncio_mode(config) debug = _get_asyncio_debug(config) default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope") default_test_loop_scope = _get_default_test_loop_scope(config) header = [ f"mode={mode}", f"debug={debug}", f"asyncio_default_fixture_loop_scope={default_fixture_loop_scope}", f"asyncio_default_test_loop_scope={default_test_loop_scope}", ] return [ "asyncio: " + ", ".join(header), ] def _fixture_synchronizer( fixturedef: FixtureDef, runner: Runner, request: FixtureRequest ) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] else: return fixturedef.func AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType") def _wrap_asyncgen_fixture( fixture_function: Callable[ AsyncGenFixtureParams, AsyncGeneratorType[AsyncGenFixtureYieldType, Any] ], runner: Runner, request: FixtureRequest, ) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( *args: AsyncGenFixtureParams.args, **kwargs: AsyncGenFixtureParams.kwargs, ): gen_obj = fixture_function(*args, **kwargs) async def setup(): res = await gen_obj.__anext__() return res context = contextvars.copy_context() result = runner.run(setup(), context=context) reset_contextvars = _apply_contextvar_changes(context) def finalizer() -> None: """Yield again, to finalize.""" async def async_finalizer() -> None: try: await gen_obj.__anext__() except StopAsyncIteration: pass else: msg = "Async generator fixture didn't stop." msg += "Yield only once." raise ValueError(msg) runner.run(async_finalizer(), context=context) if reset_contextvars is not None: reset_contextvars() request.addfinalizer(finalizer) return result return _asyncgen_fixture_wrapper AsyncFixtureParams = ParamSpec("AsyncFixtureParams") AsyncFixtureReturnType = TypeVar("AsyncFixtureReturnType") def _wrap_async_fixture( fixture_function: Callable[ AsyncFixtureParams, CoroutineType[Any, Any, AsyncFixtureReturnType] ], runner: Runner, request: FixtureRequest, ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: @functools.wraps(fixture_function) def _async_fixture_wrapper( *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs, ): async def setup(): res = await fixture_function(*args, **kwargs) return res context = contextvars.copy_context() result = runner.run(setup(), context=context) # Copy the context vars modified by the setup task into the current # context, and (if needed) add a finalizer to reset them. # # Note that this is slightly different from the behavior of a non-async # fixture, which would rely on the fixture author to add a finalizer # to reset the variables. In this case, the author of the fixture can't # write such a finalizer because they have no way to capture the Context # in which the setup function was run, so we need to do it for them. reset_contextvars = _apply_contextvar_changes(context) if reset_contextvars is not None: request.addfinalizer(reset_contextvars) return result return _async_fixture_wrapper def _apply_contextvar_changes( context: contextvars.Context, ) -> Callable[[], None] | None: """ Copy contextvar changes from the given context to the current context. If any contextvars were modified by the fixture, return a finalizer that will restore them. """ context_tokens = [] for var in context: try: if var.get() is context.get(var): # This variable is not modified, so leave it as-is. continue except LookupError: # This variable isn't yet set in the current context at all. pass token = var.set(context.get(var)) context_tokens.append((var, token)) if not context_tokens: return None def restore_contextvars(): while context_tokens: (var, token) = context_tokens.pop() var.reset(token) return restore_contextvars class PytestAsyncioFunction(Function): """Base class for all test functions managed by pytest-asyncio.""" @classmethod def item_subclass_for(cls, item: Function, /) -> type[PytestAsyncioFunction] | None: """ Returns a subclass of PytestAsyncioFunction if there is a specialized subclass for the specified function item. Return None if no specialized subclass exists for the specified item. """ for subclass in cls.__subclasses__(): if subclass._can_substitute(item): return subclass return None @classmethod def _from_function(cls, function: Function, /) -> Function: """ Instantiates this specific PytestAsyncioFunction type from the specified Function item. """ assert function.get_closest_marker("asyncio") assert function.parent is not None subclass_instance = cls.from_parent( function.parent, name=function.name, callspec=getattr(function, "callspec", None), callobj=function.obj, fixtureinfo=function._fixtureinfo, keywords=function.keywords, originalname=function.originalname, ) subclass_instance.own_markers = function.own_markers assert subclass_instance.own_markers == function.own_markers return subclass_instance @staticmethod def _can_substitute(item: Function) -> bool: """Returns whether the specified function can be replaced by this class""" raise NotImplementedError() def setup(self) -> None: runner_fixture_id = f"_{self._loop_scope}_scoped_runner" if runner_fixture_id not in self.fixturenames: self.fixturenames.append(runner_fixture_id) return super().setup() def runtest(self) -> None: runner_fixture_id = f"_{self._loop_scope}_scoped_runner" runner = self._request.getfixturevalue(runner_fixture_id) context = contextvars.copy_context() synchronized_obj = _synchronize_coroutine( getattr(*self._synchronization_target_attr), runner, context ) with MonkeyPatch.context() as c: c.setattr(*self._synchronization_target_attr, synchronized_obj) super().runtest() @functools.cached_property def _loop_scope(self) -> _ScopeName: """ Return the scope of the asyncio event loop this item is run in. The effective scope is determined lazily. It is identical to to the `loop_scope` value of the closest `asyncio` pytest marker. If no such marker is present, the the loop scope is determined by the configuration value of `asyncio_default_test_loop_scope`, instead. """ marker = self.get_closest_marker("asyncio") assert marker is not None default_loop_scope = _get_default_test_loop_scope(self.config) return _get_marked_loop_scope(marker, default_loop_scope) @property def _synchronization_target_attr(self) -> tuple[object, str]: """ Return the coroutine that needs to be synchronized during the test run. This method is inteded to be overwritten by subclasses when they need to apply the coroutine synchronizer to a value that's different from self.obj e.g. the AsyncHypothesisTest subclass. """ return self, "obj" class Coroutine(PytestAsyncioFunction): """Pytest item created by a coroutine""" @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj return inspect.iscoroutinefunction(func) class AsyncGenerator(PytestAsyncioFunction): """Pytest item created by an asynchronous generator""" @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj return inspect.isasyncgenfunction(func) @classmethod def _from_function(cls, function: Function, /) -> Function: async_gen_item = super()._from_function(function) unsupported_item_type_message = ( f"Tests based on asynchronous generators are not supported. " f"{function.name} will be ignored." ) async_gen_item.warn(PytestCollectionWarning(unsupported_item_type_message)) async_gen_item.add_marker( pytest.mark.xfail(run=False, reason=unsupported_item_type_message) ) return async_gen_item class AsyncStaticMethod(PytestAsyncioFunction): """ Pytest item that is a coroutine or an asynchronous generator decorated with staticmethod """ @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj return isinstance(func, staticmethod) and _is_coroutine_or_asyncgen( func.__func__ ) class AsyncHypothesisTest(PytestAsyncioFunction): """ Pytest item that is coroutine or an asynchronous generator decorated by @hypothesis.given. """ def setup(self) -> None: if not getattr(self.obj, "hypothesis", False) and getattr( self.obj, "is_hypothesis_test", False ): pytest.fail( f"test function `{self!r}` is using Hypothesis, but pytest-asyncio " "only works with Hypothesis 3.64.0 or later." ) return super().setup() @staticmethod def _can_substitute(item: Function) -> bool: func = item.obj return ( getattr(func, "is_hypothesis_test", False) # type: ignore[return-value] and getattr(func, "hypothesis", None) and inspect.iscoroutinefunction(func.hypothesis.inner_test) ) @property def _synchronization_target_attr(self) -> tuple[object, str]: return self.obj.hypothesis, "inner_test" # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", hookwrapper=True) def pytest_pycollect_makeitem_convert_async_functions_to_subclass( collector: pytest.Module | pytest.Class, name: str, obj: object ) -> Generator[None, pluggy.Result, None]: """ Converts coroutines and async generators collected as pytest.Functions to AsyncFunction items. """ hook_result = yield try: node_or_list_of_nodes: ( pytest.Item | pytest.Collector | list[pytest.Item | pytest.Collector] | None ) = hook_result.get_result() except BaseException as e: hook_result.force_exception(e) return if not node_or_list_of_nodes: return if isinstance(node_or_list_of_nodes, Sequence): node_iterator = iter(node_or_list_of_nodes) else: # Treat single node as a single-element iterable node_iterator = iter((node_or_list_of_nodes,)) updated_node_collection = [] for node in node_iterator: updated_item = node if isinstance(node, Function): specialized_item_class = PytestAsyncioFunction.item_subclass_for(node) if specialized_item_class: if _get_asyncio_mode( node.config ) == Mode.AUTO and not node.get_closest_marker("asyncio"): node.add_marker("asyncio") if node.get_closest_marker("asyncio"): updated_item = specialized_item_class._from_function(node) updated_node_collection.append(updated_item) hook_result.force_result(updated_node_collection) @contextlib.contextmanager def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: old_loop_policy = _get_event_loop_policy() try: old_loop = _get_event_loop_no_warn() except RuntimeError: old_loop = None _set_event_loop_policy(policy) try: yield finally: _set_event_loop_policy(old_loop_policy) _set_event_loop(old_loop) def _get_event_loop_policy() -> AbstractEventLoopPolicy: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) return asyncio.get_event_loop_policy() def _set_event_loop_policy(policy: AbstractEventLoopPolicy) -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) asyncio.set_event_loop_policy(policy) def _get_event_loop_no_warn( policy: AbstractEventLoopPolicy | None = None, ) -> asyncio.AbstractEventLoop: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) if policy is not None: return policy.get_event_loop() else: return asyncio.get_event_loop() def _set_event_loop(loop: AbstractEventLoop | None) -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) asyncio.set_event_loop(loop) @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: """Pytest hook called before a test case is run.""" if pyfuncitem.get_closest_marker("asyncio") is not None: if is_async_test(pyfuncitem): asyncio_mode = _get_asyncio_mode(pyfuncitem.config) for fixname, fixtures in pyfuncitem._fixtureinfo.name2fixturedefs.items(): # name2fixturedefs is a dict between fixture name and a list of matching # fixturedefs. The last entry in the list is closest and the one used. func = fixtures[-1].func if ( asyncio_mode == Mode.STRICT and _is_coroutine_or_asyncgen(func) and not _is_asyncio_fixture_function(func) ): warnings.warn( PytestDeprecationWarning( f"asyncio test {pyfuncitem.name!r} requested async " "@pytest.fixture " f"{fixname!r} in strict mode. " "You might want to use @pytest_asyncio.fixture or switch " "to auto mode. " "This will become an error in future versions of " "pytest-asyncio." ), stacklevel=1, ) # no stacklevel points at the users code, so we set stacklevel=1 # so it at least indicates that it's the plugin complaining. # Pytest gives the test file & name in the warnings summary at least else: pyfuncitem.warn( pytest.PytestWarning( f"The test {pyfuncitem} is marked with '@pytest.mark.asyncio' " "but it is not an async function. " "Please remove the asyncio mark. " "If the test is not marked explicitly, " "check for global marks applied via 'pytestmark'." ) ) yield return None def _synchronize_coroutine( func: Callable[..., CoroutineType], runner: asyncio.Runner, context: contextvars.Context, ): """ Return a sync wrapper around a coroutine executing it in the specified runner and context. """ @functools.wraps(func) def inner(*args, **kwargs): coro = func(*args, **kwargs) runner.run(coro, context=context) return inner @pytest.hookimpl(wrapper=True) def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: asyncio_mode = _get_asyncio_mode(request.config) if not _is_asyncio_fixture_function(fixturedef.func): if asyncio_mode == Mode.STRICT: # Ignore async fixtures without explicit asyncio mark in strict mode # This applies to pytest_trio fixtures, for example return (yield) if not _is_coroutine_or_asyncgen(fixturedef.func): return (yield) default_loop_scope = request.config.getini("asyncio_default_fixture_loop_scope") loop_scope = ( getattr(fixturedef.func, "_loop_scope", None) or default_loop_scope or fixturedef.scope ) runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = request.getfixturevalue(runner_fixture_id) synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: c.setattr(fixturedef, "func", synchronizer) hook_result = yield return hook_result _DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR = """\ An asyncio pytest marker defines both "scope" and "loop_scope", \ but it should only use "loop_scope". """ _MARKER_SCOPE_KWARG_DEPRECATION_WARNING = """\ The "scope" keyword argument to the asyncio marker has been deprecated. \ Please use the "loop_scope" argument instead. """ def _get_marked_loop_scope( asyncio_marker: Mark, default_loop_scope: _ScopeName ) -> _ScopeName: assert asyncio_marker.name == "asyncio" if asyncio_marker.args or ( asyncio_marker.kwargs and set(asyncio_marker.kwargs) - {"loop_scope", "scope"} ): raise ValueError("mark.asyncio accepts only a keyword argument 'loop_scope'.") if "scope" in asyncio_marker.kwargs: if "loop_scope" in asyncio_marker.kwargs: raise pytest.UsageError(_DUPLICATE_LOOP_SCOPE_DEFINITION_ERROR) warnings.warn(PytestDeprecationWarning(_MARKER_SCOPE_KWARG_DEPRECATION_WARNING)) scope = asyncio_marker.kwargs.get("loop_scope") or asyncio_marker.kwargs.get( "scope" ) if scope is None: scope = default_loop_scope assert scope in {"function", "class", "module", "package", "session"} return scope def _get_default_test_loop_scope(config: Config) -> Any: return config.getini("asyncio_default_test_loop_scope") _RUNNER_TEARDOWN_WARNING = """\ An exception occurred during teardown of an asyncio.Runner. \ The reason is likely that you closed the underlying event loop in a test, \ which prevents the cleanup of asynchronous generators by the runner. This warning will become an error in future versions of pytest-asyncio. \ Please ensure that your tests don't close the event loop. \ Here is the traceback of the exception triggered during teardown: %s """ def _create_scoped_runner_fixture(scope: _ScopeName) -> Callable: @pytest.fixture( scope=scope, name=f"_{scope}_scoped_runner", ) def _scoped_runner( event_loop_policy, request: FixtureRequest, ) -> Iterator[Runner]: new_loop_policy = event_loop_policy debug_mode = _get_asyncio_debug(request.config) with _temporary_event_loop_policy(new_loop_policy): runner = Runner(debug=debug_mode).__enter__() try: yield runner except Exception as e: runner.__exit__(type(e), e, e.__traceback__) else: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", ".*BaseEventLoop.shutdown_asyncgens.*", RuntimeWarning ) try: runner.__exit__(None, None, None) except RuntimeError: warnings.warn( _RUNNER_TEARDOWN_WARNING % traceback.format_exc(), RuntimeWarning, ) return _scoped_runner for scope in Scope: globals()[f"_{scope.value}_scoped_runner"] = _create_scoped_runner_fixture( scope.value ) @pytest.fixture(scope="session", autouse=True) def event_loop_policy() -> AbstractEventLoopPolicy: """Return an instance of the policy used to create asyncio event loops.""" return _get_event_loop_policy() def is_async_test(item: Item) -> TypeIs[PytestAsyncioFunction]: """Returns whether a test item is a pytest-asyncio test""" return isinstance(item, PytestAsyncioFunction) def _unused_port(socket_type: int) -> int: """Find an unused localhost port from 1024-65535 and return it.""" with contextlib.closing(socket.socket(type=socket_type)) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] @pytest.fixture def unused_tcp_port() -> int: return _unused_port(socket.SOCK_STREAM) @pytest.fixture def unused_udp_port() -> int: return _unused_port(socket.SOCK_DGRAM) @pytest.fixture(scope="session") def unused_tcp_port_factory() -> Callable[[], int]: """A factory function, producing different unused TCP ports.""" produced = set() def factory(): """Return an unused port.""" port = _unused_port(socket.SOCK_STREAM) while port in produced: port = _unused_port(socket.SOCK_STREAM) produced.add(port) return port return factory @pytest.fixture(scope="session") def unused_udp_port_factory() -> Callable[[], int]: """A factory function, producing different unused UDP ports.""" produced = set() def factory(): """Return an unused port.""" port = _unused_port(socket.SOCK_DGRAM) while port in produced: port = _unused_port(socket.SOCK_DGRAM) produced.add(port) return port return factory