#
# Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
# This software is distributed under the terms of the MIT License.
#
# (@@@@%%%%%%%%%&@@&.
# /%&&%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%&@@(
# *@&%%%%%%%%%&&%%%%%%%%%%%%%%%%%%&&&%%%%%%%
# @ @@@(@@@@%%%%%%%%%%%%%%%%&@@&* @@@ .
# , . . .@@@& /
# . . *
# @@ . @
# @&&&&&&@. . . *@%&@
# &&&&&&&&&&&&&&&&@@ *@@############@
# *&/ @@ #&&&&&&&&&&&&&&&&&&&&@ ###################*
# @&&&&&&&&&&&&&&&&&&##################@
# %@&&&&&&&&&&&&&&################@
# @&&&&&&&&&&%#######&@%
# nanaimo (@&&&&####@@*
#
"""
Nanaimo provides a collection of pytest fixtures all defined in this module.
.. invisible-code-block: python
import nanaimo
import nanaimo.fixtures
import pytest
import typing
.. code-block:: python
# In my_namespace/__init__.py
class MyTestFixture(nanaimo.fixtures.Fixture):
fixture_name = 'my_fixture_name'
argument_prefix = 'mfn'
@classmethod
def on_visit_test_arguments(cls, arguments: nanaimo.Arguments) -> None:
pass
async def on_gather(self, args: nanaimo.Namespace) -> nanaimo.Artifacts:
artifacts = nanaimo.Artifacts()
# Do your on-target testing here and store results in nanaimo.Artifacts.
return artifacts
def pytest_nanaimo_fixture_type() -> typing.Type['nanaimo.fixtures.Fixture']:
'''
This is required to provide the fixture to pytest as a fixture.
'''
return MyTestFixture
In your setup.cfg you'll first need to register nanaimo with pytest ::
[options]
pytest11 =
pytest_nanaimo = nanaimo.pytest.plugin
then you'll need to add your fixture's namespace::
[options]
pytest11 =
pytest_nanaimo = nanaimo.pytest.plugin
pytest_nanaimo_plugin_my_fixture = my_namspace
.. note ::
For Nanaimo the key for your plugin in the pytest11 map is unimportant. It must be unique but
nothing else about it will be visible to your tests unless you integrate more deeply with pytest.
The fixture you expose from your plugin via ``pytest_nanaimo_fixture_type`` will be
named for the canonical name of your :class:`Fixture <nanaimo.fixtures.Fixture>` (in this case,
"my_fixture_name").
You can add as many additional nanaimo plugins as you want but you can only have one
:class:`Fixture <nanaimo.fixtures.Fixture>` in each module.
Where you don't need your Nanaimo fixture to be redistributable you may also use standard
pytest fixture creation by using the :func:`nanaimo_fixture_manager <nanaimo.pytest.plugin.nanaimo_fixture_manager>`
and :func:`nanaimo_arguments <nanaimo.pytest.plugin.nanaimo_arguments>` fixtures supplied by
the core nanaimo pytest plugin. For example, assuming you have ``nanaimo.pytest.plugin`` in your setup.cfg
(see above) you can do something like this in a ``conftest.py`` ::
@pytest.fixture
def my_fixture_name(nanaimo_fixture_manager, nanaimo_arguments) -> 'nanaimo.fixtures.Fixture':
'''
It is considered a best-practice to always name your pytest.fixture method the
same as your Fixture's canonical name (i.e. fixture_name).
'''
return MyTestFixture(nanaimo_fixture_manager, nanaimo_arguments)
"""
import asyncio
import logging
import re
import textwrap
import typing
import _pytest
import py
import pytest
import nanaimo
import nanaimo.config
import nanaimo.fixtures
import nanaimo.display
[docs]def create_pytest_fixture(request: typing.Any, fixture_name: str) -> 'nanaimo.fixtures.Fixture':
"""
DEPRECATED. Use the appropriate :meth:`FixtureManager <nanaimo.fixtures.FixtureManager>`
and its
:meth:`FixtureManager.create_fixture <nanaimo.fixtures.FixtureManager.create_fixture>`
method instead.
"""
raise DeprecationWarning('This method has been removed. All fixtures must be created '
'using a FixtureManager instance in this version and going forward.')
# +---------------------------------------------------------------------------+
# | NANAIMO PYTEST FIXTURES
# | (not Nanaimo Fixture, fixtures. I know, just go with it)
# +---------------------------------------------------------------------------+
[docs]@pytest.fixture
def nanaimo_fixture_manager(request: typing.Any) \
-> nanaimo.fixtures.FixtureManager:
"""
Provides a default :class:`FixtureManager <nanaimo.fixtures.FixtureManager>` to a test.
.. invisible-code-block: python
import nanaimo
import nanaimo.fixtures
.. code-block:: python
def test_example(nanaimo_fixture_manager: nanaimo.Namespace) -> None:
common_loop = nanaimo_fixture_manager.loop
:param pytest_request: The request object passed into the pytest fixture factory.
:type pytest_request: _pytest.fixtures.FixtureRequest
:return: A new fixture manager.
:rtype: nanaimo.fixtures.FixtureManager
"""
return PytestFixtureManager(request.config.pluginmanager)
[docs]@pytest.fixture
def nanaimo_arguments(request: typing.Any) -> nanaimo.Namespace:
"""
Exposes the commandline arguments and defaults provided to a test.
.. invisible-code-block: python
import nanaimo
import nanaimo.fixtures
.. code-block:: python
def test_example(nanaimo_arguments: nanaimo.Namespace) -> None:
an_argument = nanaimo_arguments.some_arg
:param pytest_request: The request object passed into the pytest fixture factory.
:type pytest_request: _pytest.fixtures.FixtureRequest
:return: A namespace with the pytest commandline args added per the documented rules.
:rtype: nanaimo.Namespace
"""
return nanaimo.Namespace(request.config.option)
[docs]@pytest.fixture
def nanaimo_log(request: typing.Any) -> logging.Logger:
"""
Provides the unit tests with a logger configured for use with the Nanaimo
framework.
.. note ::
For now this is just a Python logger. Future revisions may add capabilities like the
ability to log to a display or otherwise provide feedback to humans about the status
of a test.
.. invisible-code-block: python
import nanaimo
import logging
.. code-block:: python
def test_example(nanaimo_log: logging.Logger) -> None:
nanaimo_log.info('Hiya')
It's recommended that all Nanaimo tests configure logging in a tox.ini, pytest.ini, or
pyproject.toml (when this is supported). For example, the following section in tox.ini
would enable cli logging for nanaimo tests::
[pytest]
log_cli = true
log_cli_level = DEBUG
log_format = %(asctime)s %(levelname)s %(name)s: %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
:param pytest_request: The request object passed into the pytest fixture factory.
:type pytest_request: _pytest.fixtures.FixtureRequest
:return: A logger for use by Nanaimo tests.
:rtype: logging.Logger
"""
return logging.getLogger(request.function.__name__)
# +---------------------------------------------------------------------------+
# | NANAIMO PYTEST HELPERS
# +---------------------------------------------------------------------------+
[docs]def assert_success(artifacts: nanaimo.Artifacts) -> nanaimo.Artifacts:
"""
Syntactic sugar to allow more fluent handling of :meth:`fixtures.Fixture.gather`
artifacts. For example:
.. invisible-code-block: python
import asyncio
import nanaimo
from nanaimo import Artifacts
from nanaimo.fixtures import Fixture, FixtureManager
_doc_loop = asyncio.new_event_loop()
class DummyFixture(Fixture):
@classmethod
def on_visit_test_arguments(cls, arguments: nanaimo.Arguments) -> None:
pass
async def on_gather(self, args: nanaimo.Namespace) -> nanaimo.Artifacts:
return nanaimo.Artifacts()
fixture = DummyFixture(FixtureManager(loop=_doc_loop))
.. code-block:: python
from nanaimo.pytest.plugin import assert_success
async def test_my_fixture():
artifacts = assert_success(await fixture.gather())
# Now we can use the artifacts. If the gather had returned
# non-zero for the result_code an assertion error would have
# been raised.
.. invisible-code-block: python
_doc_loop.run_until_complete(test_my_fixture())
:param artifacts: The artifacts to assert on.
:type artifacts: nanaimo.Artifacts
:returns: artifacts (for convenience).
:rtype: nanaimo.Artifacts()
"""
assert artifacts.result_code == 0
return artifacts
[docs]def assert_success_if(artifacts: nanaimo.Artifacts,
conditional: typing.Callable[[nanaimo.Artifacts], bool]) -> nanaimo.Artifacts:
"""
Syntactic sugar to allow more fluent handling of :meth:`fixtures.Fixture.gather`
artifacts but with a user-supplied conditional.
.. invisible-code-block: python
import asyncio
import pytest
import nanaimo
from nanaimo import Artifacts
from nanaimo.fixtures import Fixture, FixtureManager
_doc_loop = asyncio.new_event_loop()
class DummyFixture(Fixture):
@classmethod
def on_visit_test_arguments(cls, arguments: nanaimo.Arguments) -> None:
pass
async def on_gather(self, args: nanaimo.Namespace) -> nanaimo.Artifacts:
a = nanaimo.Artifacts()
setattr(a, 'foo', 'bar')
return a
fixture = DummyFixture(FixtureManager(loop=_doc_loop))
.. code-block:: python
from nanaimo.pytest.plugin import assert_success_if
async def test_my_fixture():
def fail_if_no_foo(artifacts: Artifacts) -> bool:
return 'foo' in artifacts
artifacts = assert_success_if(await fixture.gather(), fail_if_no_foo)
print('artifacts have foo. It\'s value is {}'.format(artifacts.foo))
.. invisible-code-block: python
_doc_loop.run_until_complete(test_my_fixture())
async def test_failure():
assert_success_if(await fixture.gather(), lambda _: False)
with pytest.raises(AssertionError):
_doc_loop.run_until_complete(test_failure())
:param artifacts: The artifacts to assert on.
:type artifacts: nanaimo.Artifacts
:param conditiona: A method called to evaluate gathered artifacts iff :data:`Artifacts.result_code` is 0.
Return False to trigger an assertion, True to pass.
:returns: artifacts (for convenience).
:rtype: nanaimo.Artifacts()
"""
assert artifacts.result_code == 0
assert conditional(artifacts)
return artifacts
# +===========================================================================+
# | NANAIMO PYTEST PLUGIN INTERNALS
# +===========================================================================+
[docs]class PytestFixtureManager(nanaimo.fixtures.FixtureManager):
"""
:class:`FixtureManager <nanaimo.fixtures.FixtureManager>` implemented using
pytest plugin APIs.
"""
def __init__(self,
pluginmanager: '_pytest.config.PytestPluginManager',
loop: typing.Optional[asyncio.AbstractEventLoop] = None):
super().__init__(loop=loop)
self._pluginmanager = pluginmanager
[docs] def create_fixture(self,
canonical_name: str,
args: typing.Optional[nanaimo.Namespace] = None,
loop: typing.Optional[asyncio.AbstractEventLoop] = None) -> nanaimo.fixtures.Fixture:
fixture_plugin = self._pluginmanager.get_plugin(canonical_name)
fixture_type = fixture_plugin.fixture_type
return fixture_type(self, args)
# +---------------------------------------------------------------------------+
# | INTERNALS :: INTEGRATED DISPLAY
# +---------------------------------------------------------------------------+
_display_singleton = None # type: typing.Optional[nanaimo.display.CharacterDisplay]
def _get_display(config: _pytest.config.Config) -> 'nanaimo.display.CharacterDisplay':
global _display_singleton
if _display_singleton is None:
for fixture_type in config.pluginmanager.hook.pytest_nanaimo_fixture_type():
if fixture_type.get_canonical_name() == 'character_display':
_display_singleton = fixture_type(nanaimo.fixtures.FixtureManager(asyncio.get_event_loop()))
if _display_singleton is None:
raise KeyError('character_display fixture was not found.')
return _display_singleton
# +---------------------------------------------------------------------------+
# | INTERNALS :: TEST SYNTHESIS (nait mode)
# +---------------------------------------------------------------------------+
_nait_mode = False
[docs]def is_nait_mode() -> bool:
"""
A special mode enabled by the 'nait' commandline that discards all collected tests
and inserts a single test item driven by nait. This mode is used to interact with
Nanaimo fixtures from a commandline.
"""
return _nait_mode
class _NanaimoItem(pytest.Item):
"""
When running in :func:`is_nait_mode` a single test is synthesized out of this class. The action
(i.e. :meth:`runtest`) is always executed as the single activity for the invocation.
"""
nanaimo_results_sneaky_key = '_nanaimo_artifacts'
"""
An attribute we add to the session's :class:`_pytest.config.Config` instance to store
fixture artifacts for later reporting in the terminal.
"""
def __init__(self, parent, fixture_names):
super().__init__('nanaimo : {}'.format(str(fixture_names)), parent)
self._logger = logging.getLogger(__name__)
self._fixture_names = fixture_names
def on_setup(self) -> None:
if not hasattr(self.session.config, self.nanaimo_results_sneaky_key):
setattr(self.session.config, self.nanaimo_results_sneaky_key, dict())
def runtest(self) -> None:
"""
Run all fixtures specified on the command-line.
"""
loop = asyncio.get_event_loop()
fixtures = []
nanaimo_defaults = nanaimo.config.ArgumentDefaults.create_defaults_with_early_rc_config()
nanaimo_args = nanaimo.Namespace(self.session.config.option, nanaimo_defaults)
fixture_manager = PytestFixtureManager(self.config.pluginmanager, loop)
for fixture_name in self._fixture_names:
fixtures.append(fixture_manager.create_fixture(fixture_name, nanaimo_args))
gathers = [asyncio.ensure_future(f.gather()) for f in fixtures]
results = loop.run_until_complete(asyncio.gather(*gathers, loop=loop))
combined = nanaimo.Artifacts.combine(*results)
assert combined.result_code == 0
getattr(self.session.config, self.nanaimo_results_sneaky_key)[','.join(self._fixture_names)] = combined
for fixture in fixtures:
fixture.on_test_teardown('nait')
def _prunetraceback(self, excinfo):
"""
Removes pytest itself from exception traces.
"""
traceback = excinfo.traceback
traceback_before_this_item = traceback.cut(path=self.fspath)
excinfo.traceback = traceback_before_this_item.filter()
# +---------------------------------------------------------------------------+
# | INTERNALS :: PYTEST HOOKS
# +---------------------------------------------------------------------------+
class _SyntheticPlugin:
"""
Used to synthesize a pytest plugin with the same name as a fixture's canonical name.
"""
_RemoveInvisiblesPattern = re.compile(r'\.\.\s+invisible-code-block:.*\n(?:[\n]|\s{4,}.*\n)+')
def __init__(self, fixture_type):
self._fixture_type = fixture_type
fixture_type_name = fixture_type.get_canonical_name()
def _generic_async_fixture(request, nanaimo_fixture_manager):
fixture = self._create_pytest_fixture(request, nanaimo_fixture_manager, fixture_type)
if fixture is not None and fixture.get_canonical_name() != request.fixturename:
raise ValueError('Requested fixture {} but that fixture\'s canonical name is {}'.format(
request.fixturename,
fixture.get_canonical_name()))
return fixture
dedent_docstring = textwrap.dedent(fixture_type.__doc__)
cleaned_docstring = self._RemoveInvisiblesPattern.sub('', dedent_docstring)
_generic_async_fixture.__doc__ = cleaned_docstring
self.__dict__[fixture_type_name] = pytest.fixture(_generic_async_fixture,
name=fixture_type_name)
@property
def fixture_type(self) -> typing.Type['nanaimo.fixtures.Fixture']:
return self._fixture_type
def _create_pytest_fixture(self,
pytest_request: typing.Any,
nanaimo_fixture_manager: nanaimo.fixtures.FixtureManager,
fixture_type: typing.Type['nanaimo.fixtures.Fixture']) -> nanaimo.fixtures.Fixture:
args = pytest_request.config.option
args_ns = nanaimo.Namespace(args, nanaimo.config.ArgumentDefaults(args), allow_none_values=False)
return fixture_type(nanaimo_fixture_manager, args_ns)
[docs]def pytest_addoption(parser: '_pytest.config.argparsing.Parser', pluginmanager: '_pytest.config.PytestPluginManager')\
-> None:
"""
See :func:`_pytest.hookspec.pytest_addoption` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
nanaimo_defaults = nanaimo.config.ArgumentDefaults.create_defaults_with_early_rc_config()
nanaimo_options = parser.getgroup('nanaimo',
description='Nanaimo Options')
nanaimo_options.addoption('--environ', action='append', help='Environment variables to provide to subprocesses as '
'a key=value pair')
nanaimo_options.addoption('--gather-timeout-seconds',
type=float,
help=textwrap.dedent('''
A gather timeout in fractional seconds to use for all fixtures.
If not provided then Fixture.gather will not timeout.''').lstrip())
if is_nait_mode():
if hasattr(parser, '_usage'):
# Try to help the user with our hacks by rewriting the usage help string.
parser._usage = textwrap.dedent('''
%(prog)s [options] [nanaimo_fixture] [nanaimo_fixture] [...]
*** NOTICE ***
When running Nanaimo using %(prog)s the pytest "file_or_dir" positional arguments are
interpreted as a list of fixtures to run/gather. Because of this some of the help
text emitted from pytest may be misleading.
**************
''').lstrip()
nanaimo_options.addoption('--concurrent',
action='store_true',
help=textwrap.dedent('''
Run specified fixtures concurrently (i.e. asyncio.gather, NOT multi-threaded).
The default is to run/gather each fixture in a separate, synthetic test item.
''').lstrip())
nanaimo_options.addoption('--environ-shell', '-S', action='store_true', help=textwrap.dedent('''
Dump environment variables to stdout as a set of shell commands.
Use this to export the subprocess environment for a system to a user
shell. For example::
eval $(nait -S)
''').lstrip())
nanaimo_arguments = nanaimo.Arguments(nanaimo_options, defaults=nanaimo_defaults, filter_duplicates=True)
# We modify the pytest default behavior here; loading all the plugins here so we are able to visit them
# so they can contribute options.
pluginmanager.load_setuptools_entrypoints('pytest11')
for fixture_type in pluginmanager.hook.pytest_nanaimo_fixture_type():
pluginmanager.register(_SyntheticPlugin(fixture_type), fixture_type.get_canonical_name())
group = parser.getgroup(fixture_type.get_canonical_name())
nanaimo_arguments.set_inner_arguments(group)
fixture_type.visit_test_arguments(nanaimo_arguments)
[docs]def pytest_addhooks(pluginmanager):
"""
See :func:`_pytest.hookspec.pytest_addhooks` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
from nanaimo.pytest import hooks
pluginmanager.add_hookspecs(hooks)
[docs]def pytest_collection(session: _pytest.main.Session):
"""
See :func:`_pytest.hookspec.pytest_collection_modifyitems` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
if is_nait_mode():
f = pytest.File(py.path.local(__file__), session)
if session.config.option.concurrent:
session.items = [_NanaimoItem(f, session.config.option.file_or_dir.copy())]
session.testscollected = 1
else:
session.items = []
for fixture_name in session.config.option.file_or_dir:
session.items.append(_NanaimoItem(f, [fixture_name]))
session.testscollected = len(session.items)
return True
[docs]def pytest_sessionstart(session: _pytest.main.Session) -> None:
"""
See :func:`_pytest.hookspec.pytest_sessionstart` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
args = session.config.option
args_ns = nanaimo.Namespace(args, nanaimo.config.ArgumentDefaults(args), allow_none_values=False)
nanaimo.set_subprocess_environment(args_ns)
_get_display(session.config).set_status('busy')
[docs]def pytest_runtest_setup(item: pytest.Item) -> None:
"""
See :func:`_pytest.hookspec.pytest_runtest_setup` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
if isinstance(item, _NanaimoItem):
item.on_setup()
display = _get_display(item.config)
display.clear(display_default_message=False)
display.write(item.name)
[docs]def pytest_runtest_teardown(item: pytest.Item, nextitem: pytest.Item) -> None:
"""
See :func:`_pytest.hookspec.pytest_teardown` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
if hasattr(item, 'funcargs'):
args = item.funcargs
for name, value in args.items():
if isinstance(value, nanaimo.fixtures.Fixture):
typing.cast(nanaimo.fixtures.Fixture, value).on_test_teardown(item.name)
[docs]def pytest_sessionfinish(session: _pytest.main.Session, exitstatus: int) -> None:
"""
See :func:`_pytest.hookspec.pytest_sessionfinish` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
display = _get_display(session.config)
display.clear(display_default_message=True)
display.set_status('okay' if exitstatus == 0 else 'fail')
# +---------------------------------------------------------------------------+
# | INTERNALS :: PYTEST HOOKS :: REPORTING
# +---------------------------------------------------------------------------+
[docs]def pytest_terminal_summary(terminalreporter: '_pytest.terminal.TerminalReporter',
exitstatus: int,
config: _pytest.config.Config) -> None:
"""
See :func:`_pytest.hookspec.pytest_sessionfinish` for documentation.
Also see the "`Writing Plugins <https://docs.pytest.org/en/latest/writing_plugins.html>`_"
guide.
"""
terminalreporter.write_sep('-', title='nanaimo', bold=True)
if hasattr(config, _NanaimoItem.nanaimo_results_sneaky_key):
results = getattr(config, _NanaimoItem.nanaimo_results_sneaky_key) # type: typing.Dict[str, nanaimo.Artifacts]
for fixture_names, artifacts in results.items():
terminalreporter.write_line('Fixture(s) "{}" result = {}'.format(fixture_names, artifacts.result_code),
green=(artifacts.result_code == 0),
red=(artifacts.result_code != 0))
for fixture_names, artifacts in results.items():
terminalreporter.write_sep('.', title='Artifacts for {}'.format(fixture_names), bold=False)
artifact_vars = vars(artifacts)
for key, value in artifact_vars.items():
if not key.startswith('_'):
terminalreporter.write_line('{}={}'.format(key, value))