Source code for testsuite.mockserver.pytest_plugin

import asyncio
import contextlib
import logging
import warnings

import pytest

from testsuite import types
from testsuite.tracing import TraceidManager

from . import classes, exceptions, server

MOCKSERVER_DEFAULT_PORT = 9999
MOCKSERVER_SSL_DEFAULT_PORT = 9998

_SSL_KEY_FILE_INI_KEY = 'mockserver-ssl-key-file'
_SSL_CERT_FILE_INI_KEY = 'mockserver-ssl-cert-file'

MOCKSERVER_PORT_HELP = """
{proto} mockserver port for default worker.
Random port is used by default. If testsuite is started with
--service-wait or --service-disabled default is forced to {default}.
"""

logger = logging.getLogger(__name__)


class MockserverPlugin:
    mockserver_config: classes.MockserverConfig
    mockserver_socket: classes.MockserverSocket
    mockserver_ssl_socket: classes.MockserverSocket

    def pytest_sessionstart(self, session):
        self.mockserver_config = self._create_mockserver_config(session.config)
        self.mockserver_socket = self._create_mockserver_socket(session.config)
        self.mockserver_ssl_socket = self._create_mockserver_ssl_socket(
            session.config
        )

    def pytest_report_header(self):
        headers = [
            f'mockserver: {self.mockserver_socket.info.base_url}',
            f'mockserver-ssl: {self.mockserver_ssl_socket.info.base_url}',
        ]
        return headers

    def _create_mockserver_config(self, pytestconfig):
        return classes.MockserverConfig(
            nofail=pytestconfig.option.mockserver_nofail,
            debug=pytestconfig.option.mockserver_debug,
            tracing_enabled=pytestconfig.getini('mockserver-tracing-enabled'),
            trace_id_header=pytestconfig.getini('mockserver-trace-id-header'),
            span_id_header=pytestconfig.getini('mockserver-span-id-header'),
            http_proxy_enabled=pytestconfig.getini(
                'mockserver-http-proxy-enabled'
            ),
        )

    def _create_mockserver_socket(self, config):
        port = _mockserver_getport(
            config,
            config.option.mockserver_port,
            default_port=MOCKSERVER_DEFAULT_PORT,
        )
        return server._create_mockserver_socket(
            socket_path=config.option.mockserver_unix_socket,
            host=config.option.mockserver_host,
            port=port,
        )

    def _create_mockserver_ssl_socket(self, config):
        port = _mockserver_getport(
            config,
            config.option.mockserver_ssl_port,
            default_port=MOCKSERVER_SSL_DEFAULT_PORT,
        )
        return server._create_mockserver_socket(
            host=config.option.mockserver_ssl_host,
            port=port,
            https=True,
        )


def pytest_addoption(parser):
    group = parser.getgroup('mockserver')
    group.addoption(
        '--mockserver-nofail',
        action='store_true',
        help='Do not fail if no handler is set.',
    )
    group.addoption(
        '--mockserver-host',
        default='localhost',
        help='Default host for http mockserver.',
    )
    group.addoption(
        '--mockserver-port',
        type=int,
        default=0,
        help=MOCKSERVER_PORT_HELP.format(
            proto='HTTP',
            default=MOCKSERVER_DEFAULT_PORT,
        ),
    )
    group.addoption(
        '--mockserver-ssl-host',
        default='localhost',
        help='Default host for https mockserver.',
    )
    group.addoption(
        '--mockserver-ssl-port',
        type=int,
        default=0,
        help=MOCKSERVER_PORT_HELP.format(
            proto='HTTPS',
            default=MOCKSERVER_SSL_DEFAULT_PORT,
        ),
    )
    group.addoption(
        '--mockserver-unix-socket',
        type=str,
        help='Bind server to unix socket instead of tcp',
    )
    group.addoption(
        '--mockserver-debug',
        action='store_true',
        help='Enable debugging logs.',
    )
    parser.addini(
        'mockserver-tracing-enabled',
        type='bool',
        default=False,
        help=(
            'When request trace-id header not from testsuite:\n'
            '  True: handle, if handler missing return http status 500\n'
            '  False: handle, if handler missing raise '
            'HandlerNotFoundError\n'
            'When request trace-id header from other test:\n'
            '  True: do not handle, return http status 500\n'
            '  False: handle, if handler missing raise HandlerNotFoundError'
        ),
    )
    parser.addini(
        'mockserver-trace-id-header',
        default=server.DEFAULT_TRACE_ID_HEADER,
        help=(
            'name of tracing http header, value changes from test to test and '
            'is constant within test'
        ),
    )
    parser.addini(
        'mockserver-span-id-header',
        default=server.DEFAULT_SPAN_ID_HEADER,
        help='name of tracing http header, value is unique for each request',
    )
    parser.addini(
        'mockserver-ssl-cert-file',
        type='pathlist',
        help='path to ssl certificate file to setup mockserver_ssl',
    )
    parser.addini(
        'mockserver-ssl-key-file',
        type='pathlist',
        help='path to ssl key file to setup mockserver_ssl',
    )
    parser.addini(
        'mockserver-http-proxy-enabled',
        type='bool',
        default=False,
        help='If enabled mockserver acts as http proxy',
    )


def pytest_configure(config):
    config.addinivalue_line(
        'markers',
        'mockserver_assert_lost_calls: assert that all calls to mockservers are checked',
    )

    config.pluginmanager.register(MockserverPlugin(), 'testsuite_mockserver')


def pytest_register_object_hooks():
    return {
        '$mockserver': {'$fixture': '_mockserver_hook'},
        '$mockserver_https': {'$fixture': '_mockserver_https_hook'},
    }


@pytest.fixture(name='mockserver_strict_default')
def fixture_mockserver_strict_default():
    return False


@pytest.fixture(name='mockserver_create_session')
def fixture_mockserver_create_session(
    request,
    asyncexc_append,
    testsuite_traceid_manager: TraceidManager,
    mockserver_strict_default: bool,
):
    assert_lost_calls = request.node.get_closest_marker(
        'mockserver_assert_lost_calls'
    )

    @contextlib.contextmanager
    def create_session(mockserver):
        with mockserver.new_session(
            asyncexc_append=asyncexc_append,
            traceid_manager=testsuite_traceid_manager,
        ) as session:
            yield server.MockserverFixture(
                mockserver,
                session,
                strict_default=mockserver_strict_default,
            )

            calls = session.collect_calls()
            if assert_lost_calls:
                if not calls:
                    raise exceptions.MockServerError(
                        f'mockserver is expected to have lost calls, but it doesnt'
                    )
            else:
                if calls:
                    raise exceptions.MockServerError(
                        f'mockserver handler with strict=True has skipped calls: {calls}'
                    )

    return create_session


@pytest.fixture(name='_mockserver_create_session')
def legacy_fixture_mockserver_create_session(
    mockserver_create_session,
):
    def create_session(*args, **kwargs):
        warnings.warn(
            'Use mockserver_create_session() fixture instead',
            DeprecationWarning,
        )
        return mockserver_create_session(*args, **kwargs)

    return create_session


[docs] @pytest.fixture def mockserver( _mockserver: server.Server, mockserver_create_session, ) -> types.YieldFixture[server.MockserverFixture]: with mockserver_create_session(_mockserver) as fixture: yield fixture
@pytest.fixture def mockserver_ssl( _mockserver_ssl: server.Server | None, mockserver_create_session, ) -> types.AsyncYieldFixture[server.MockserverSslFixture]: if _mockserver_ssl is None: raise exceptions.MockServerError( f'mockserver_ssl is not configured. {_SSL_KEY_FILE_INI_KEY} and ' f'{_SSL_CERT_FILE_INI_KEY} must be specified in pytest.ini', ) with mockserver_create_session(_mockserver_ssl) as fixture: yield fixture
[docs] @pytest.fixture(scope='session') def mockserver_info( _mockserver_socket: classes.MockserverSocket, ) -> classes.MockserverInfo: """Returns mockserver information object.""" return _mockserver_socket.info
@pytest.fixture(scope='session') def mockserver_ssl_info( _mockserver_ssl_socket: classes.MockserverSocket | None, ) -> classes.MockserverInfo | None: if _mockserver_ssl_socket is None: return None return _mockserver_ssl_socket.info @pytest.fixture(scope='session') def mockserver_ssl_cert(pytestconfig) -> classes.SslCertInfo | None: def _get_ini_path(name): values = pytestconfig.getini(name) if not values: return None if len(values) > 1: raise exceptions.MockServerError( f'{name} ini setting has multiple values', ) return str(values[0]) cert_path = _get_ini_path(_SSL_CERT_FILE_INI_KEY) key_path = _get_ini_path(_SSL_KEY_FILE_INI_KEY) if cert_path and key_path: return classes.SslCertInfo( cert_path=cert_path, private_key_path=key_path, ) return None @pytest.fixture(scope='session') async def mockserver_create( _mockserver_config, ): @contextlib.asynccontextmanager async def create( *, host='localhost', port=0, socket_path=None, ssl_cert: classes.SslCertInfo | None = None, config: classes.MockserverConfig | None = None, ): socket_info = server._create_mockserver_socket( host=host, port=port, socket_path=socket_path, https=bool(ssl_cert), ) async with server._create_server_from_socket( socket_info, config or _mockserver_config, ssl_cert=ssl_cert, ) as result: yield result return create @pytest.fixture(scope='session') async def _mockserver( pytestconfig, _mockserver_socket: classes.MockserverSocket, _mockserver_config: classes.MockserverConfig, ) -> types.AsyncYieldFixture[server.Server]: async with server._create_server_from_socket( _mockserver_socket, _mockserver_config ) as result: yield result @pytest.fixture(scope='session') async def _mockserver_ssl( pytestconfig, _mockserver_ssl_socket: classes.MockserverSocket, _mockserver_config: classes.MockserverConfig, mockserver_ssl_cert, ) -> types.AsyncYieldFixture[server.Server]: if mockserver_ssl_cert: async with server._create_server_from_socket( _mockserver_ssl_socket, _mockserver_config, ssl_cert=mockserver_ssl_cert, ) as result: yield result else: yield None @pytest.fixture(scope='session') def _mockserver_hook(mockserver_info): def wrapper(doc: dict): return _mockserver_info_hook(doc, '$mockserver', mockserver_info) return wrapper @pytest.fixture(scope='session') def _mockserver_https_hook(mockserver_ssl_info): def wrapper(doc: dict): return _mockserver_info_hook( doc, '$mockserver_https', mockserver_ssl_info, ) return wrapper @pytest.fixture(scope='session') def _mockserver_plugin(pytestconfig) -> MockserverPlugin: return pytestconfig.pluginmanager.get_plugin('testsuite_mockserver') @pytest.fixture(scope='session') def _mockserver_socket(_mockserver_plugin) -> classes.MockserverSocket: info = [] for sock in _mockserver_plugin.mockserver_socket.sockets: info.append(sock.getsockname()) logger.debug('Mockserver bound to %r', info) return _mockserver_plugin.mockserver_socket @pytest.fixture(scope='session') def _mockserver_ssl_socket( _mockserver_plugin, ) -> classes.MockserverSocket | None: info = [] for sock in _mockserver_plugin.mockserver_socket.sockets: info.append(sock.getsockname()) logger.debug('Mockserver HTTPS bound to %r', info) return _mockserver_plugin.mockserver_ssl_socket @pytest.fixture(scope='session') def _mockserver_config( _mockserver_plugin, ) -> classes.MockserverConfig: return _mockserver_plugin.mockserver_config @pytest.fixture(scope='session') async def mockserver_set_debug(_mockserver, _mockserver_ssl): def set_debug(enabled: bool): loop = asyncio.get_running_loop() for obj in (loop, _mockserver, _mockserver_ssl): if obj is not None: obj.set_debug(enabled) return set_debug def _mockserver_info_hook( doc: dict, key=None, mockserver_info: classes.MockserverInfo | None = None ): if mockserver_info is None: raise RuntimeError(f'Missing {key} argument') if not doc.get('$schema', True): schema = '' elif mockserver_info.https: schema = 'https://' else: schema = 'http://' return '%s%s:%d%s' % ( schema, mockserver_info.host, mockserver_info.port, doc[key], ) def _mockserver_getport(config, option_port, default_port): # If service is started outside of testsuite use constant # port by default. if config.option.service_wait or config.option.service_disable: if option_port == 0: return default_port return option_port