diff --git a/launch/launch/actions/execute_local.py b/launch/launch/actions/execute_local.py index 676717cfb..02b91d9a0 100644 --- a/launch/launch/actions/execute_local.py +++ b/launch/launch/actions/execute_local.py @@ -16,6 +16,7 @@ import asyncio import io +import logging import os import platform import signal @@ -91,6 +92,7 @@ def __init__( emulate_tty: bool = False, output: Text = 'log', output_format: Text = '[{this.process_description.final_name}] {line}', + cached_output: bool = False, log_cmd: bool = False, on_exit: Optional[Union[ SomeActionsType, @@ -176,6 +178,8 @@ def __init__( :param: log_cmd if True, prints the final cmd before executing the process, which is useful for debugging when substitutions are involved. + :param: cached_output if `True`, both stdout and stderr will be cached. + Use get_stdout() and get_stderr() to read the buffered output. :param: on_exit list of actions to execute upon process exit. :param: respawn if 'True', relaunch the process that abnormally died. Defaults to 'False'. @@ -191,6 +195,7 @@ def __init__( self.__output_format = output_format self.__log_cmd = log_cmd + self.__cached_output = cached_output self.__on_exit = on_exit self.__respawn = respawn self.__respawn_delay = respawn_delay @@ -329,59 +334,32 @@ def __on_process_stdin( cast(ProcessStdin, event) return None - def __on_process_stdout( - self, event: ProcessIO + def __on_process_output( + self, event: ProcessIO, buffer: io.TextIOBase, logger: logging.Logger ) -> Optional[SomeActionsType]: to_write = event.text.decode(errors='replace') - if self.__stdout_buffer.closed: - # __stdout_buffer was probably closed by __flush_buffers on shutdown. Output without + if buffer.closed: + # buffer was probably closed by __flush_buffers on shutdown. Output without # buffering. - self.__stdout_logger.info( - self.__output_format.format(line=to_write, this=self) - ) - else: - self.__stdout_buffer.write(to_write) - self.__stdout_buffer.seek(0) - last_line = None - for line in self.__stdout_buffer: - if line.endswith(os.linesep): - self.__stdout_logger.info( - self.__output_format.format(line=line[:-len(os.linesep)], this=self) - ) - else: - last_line = line - break - self.__stdout_buffer.seek(0) - self.__stdout_buffer.truncate(0) - if last_line is not None: - self.__stdout_buffer.write(last_line) - - def __on_process_stderr( - self, event: ProcessIO - ) -> Optional[SomeActionsType]: - to_write = event.text.decode(errors='replace') - if self.__stderr_buffer.closed: - # __stderr buffer was probably closed by __flush_buffers on shutdown. Output without - # buffering. - self.__stderr_logger.info( + buffer.info( self.__output_format.format(line=to_write, this=self) ) else: - self.__stderr_buffer.write(to_write) - self.__stderr_buffer.seek(0) + buffer.write(to_write) + buffer.seek(0) last_line = None - for line in self.__stderr_buffer: + for line in buffer: if line.endswith(os.linesep): - self.__stderr_logger.info( + logger.info( self.__output_format.format(line=line[:-len(os.linesep)], this=self) ) else: last_line = line break - self.__stderr_buffer.seek(0) - self.__stderr_buffer.truncate(0) + buffer.seek(0) + buffer.truncate(0) if last_line is not None: - self.__stderr_buffer.write(last_line) + buffer.write(last_line) def __flush_buffers(self, event, context): line = self.__stdout_buffer.getvalue() @@ -407,6 +385,35 @@ def __flush_buffers(self, event, context): self.__stderr_buffer.seek(0) self.__stderr_buffer.truncate(0) + def __on_process_output_cached( + self, event: ProcessIO, buffer, logger + ) -> Optional[SomeActionsType]: + to_write = event.text.decode(errors='replace') + last_cursor = buffer.tell() + buffer.seek(0, os.SEEK_END) # go to end of buffer + buffer.write(to_write) + buffer.seek(last_cursor) + new_cursor = last_cursor + for line in buffer: + if not line.endswith(os.linesep): + break + new_cursor = buffer.tell() + logger.info( + self.__output_format.format(line=line[:-len(os.linesep)], this=self) + ) + buffer.seek(new_cursor) + + def __flush_cached_buffers(self, event, context): + for line in self.__stdout_buffer: + self.__stdout_logger.info( + self.__output_format.format(line=line, this=self) + ) + + for line in self.__stderr_buffer: + self.__stderr_logger.info( + self.__output_format.format(line=line, this=self) + ) + def __on_shutdown(self, event: Event, context: LaunchContext) -> Optional[SomeActionsType]: due_to_sigint = cast(Shutdown, event).due_to_sigint return self._shutdown_process( @@ -614,6 +621,13 @@ def execute(self, context: LaunchContext) -> Optional[List[LaunchDescriptionEnti # If shutdown starts before execution can start, don't start execution. return None + if self.__cached_output: + on_output_method = self.__on_process_output_cached + flush_buffers_method = self.__flush_cached_buffers + else: + on_output_method = self.__on_process_output + flush_buffers_method = self.__flush_buffers + event_handlers = [ EventHandler( matcher=lambda event: is_a_subclass(event, ShutdownProcess), @@ -626,8 +640,10 @@ def execute(self, context: LaunchContext) -> Optional[List[LaunchDescriptionEnti OnProcessIO( target_action=self, on_stdin=self.__on_process_stdin, - on_stdout=self.__on_process_stdout, - on_stderr=self.__on_process_stderr + on_stdout=lambda event: on_output_method( + event, self.__stdout_buffer, self.__stdout_logger), + on_stderr=lambda event: on_output_method( + event, self.__stderr_buffer, self.__stderr_logger), ), OnShutdown( on_shutdown=self.__on_shutdown, @@ -638,7 +654,7 @@ def execute(self, context: LaunchContext) -> Optional[List[LaunchDescriptionEnti ), OnProcessExit( target_action=self, - on_exit=self.__flush_buffers, + on_exit=flush_buffers_method, ), ] for event_handler in event_handlers: @@ -660,3 +676,34 @@ def execute(self, context: LaunchContext) -> Optional[List[LaunchDescriptionEnti def get_asyncio_future(self) -> Optional[asyncio.Future]: """Return an asyncio Future, used to let the launch system know when we're done.""" return self.__completed_future + + def get_stdout(self): + """ + Get cached stdout. + + :raises RuntimeError: if cached_output is false. + """ + if not self.__cached_output: + raise RuntimeError( + 'cached output must be true to be able to get stdout,' + f" proc '{self.__process_description.name}'") + return self.__stdout_buffer.getvalue() + + def get_stderr(self): + """ + Get cached stdout. + + :raises RuntimeError: if cached_output is false. + """ + if not self.__cached_output: + raise RuntimeError( + 'cached output must be true to be able to get stderr, proc' + f" '{self.__process_description.name}'") + return self.__stderr_buffer.getvalue() + + @property + def return_code(self): + """Get the process return code, None if it hasn't finished.""" + if self._subprocess_transport is None: + return None + return self._subprocess_transport.get_returncode() diff --git a/launch/launch/actions/execute_process.py b/launch/launch/actions/execute_process.py index 3acef66c5..1b373a41f 100644 --- a/launch/launch/actions/execute_process.py +++ b/launch/launch/actions/execute_process.py @@ -228,6 +228,8 @@ def __init__( :param: log_cmd if True, prints the final cmd before executing the process, which is useful for debugging when substitutions are involved. + :param: cached_output if `True`, both stdout and stderr will be cached. + Use get_stdout() and get_stderr() to read the buffered output. :param: on_exit list of actions to execute upon process exit. :param: respawn if 'True', relaunch the process that abnormally died. Defaults to 'False'. diff --git a/launch/launch/launch_context.py b/launch/launch/launch_context.py index 1cb44a02d..f06cd8110 100644 --- a/launch/launch/launch_context.py +++ b/launch/launch/launch_context.py @@ -174,9 +174,17 @@ def would_handle_event(self, event: Event) -> bool: """Check whether an event would be handled or not.""" return any(handler.matches(event) for handler in self._event_handlers) - def register_event_handler(self, event_handler: BaseEventHandler) -> None: - """Register a event handler.""" - self._event_handlers.appendleft(event_handler) + def register_event_handler(self, event_handler: BaseEventHandler, append=False) -> None: + """ + Register a event handler. + + :param append: if 'true', the new event handler will be executed after the previously + registered ones. If not, it will prepend the old handlers. + """ + if append: + self._event_handlers.append(event_handler) + else: + self._event_handlers.appendleft(event_handler) def unregister_event_handler(self, event_handler: BaseEventHandler) -> None: """Unregister an event handler.""" diff --git a/launch/launch/launch_service.py b/launch/launch/launch_service.py index 182b50124..d22579d94 100644 --- a/launch/launch/launch_service.py +++ b/launch/launch/launch_service.py @@ -86,6 +86,7 @@ def __init__( # it being set to None by run() as it exits. self.__loop_from_run_thread_lock = threading.RLock() self.__loop_from_run_thread = None + self.__this_task = None # Used to indicate when shutdown() has been called. self.__shutting_down = False @@ -183,6 +184,7 @@ def _prepare_run_loop(self): except AttributeError: this_task = asyncio.Task.current_task(this_loop) + self.__this_task = this_task # Setup custom signal handlers for SIGINT, SIGTERM and maybe SIGQUIT. sigint_received = False @@ -413,3 +415,13 @@ def shutdown(self, force_sync=False) -> Optional[Coroutine]: def context(self): """Getter for context.""" return self.__context + + @property + def event_loop(self): + """Getter for the event loop being used in the thread running the launch service.""" + return self.__loop_from_run_thread + + @property + def task(self): + """Return asyncio task associated with this launch service.""" + return self.__this_task diff --git a/launch_pytest/README.md b/launch_pytest/README.md new file mode 100644 index 000000000..538f5d4fe --- /dev/null +++ b/launch_pytest/README.md @@ -0,0 +1,153 @@ +# launch_pytest + +This is a framework for launch integration testing. For example: + + * The exit codes of all processes are available to the tests. + * Tests can check that all processes shut down normally, or with specific exit codes. + * Tests can fail when a process dies unexpectedly. + * The stdout and stderr of all processes are available to the tests. + * The command-line used to launch the processes are available to the tests. + * Some tests run concurrently with the launch and can interact with the running processes. + +## Differences with launch_testing + +launch_testing is an standalone testing tool, which lacks many features: + * It's impossible to filter test cases by name and run only some. + * It's impossible to mark a test as skipped or xfail. + * The error reporting of the tool was custom, and the output wasn't as nice as the output + generated by other testing frameworks such as unittest and pytest. + +launch_pytest is a really simple pytest plugin leveraging pytest fixtures to manage a launch service lifetime easily. + +## Quick start example + +Start with the [`pytest_hello_world.py`](test/launch_pytest/examples/pytest_hello_world.py) example. + +Run the example by doing: + +```sh +python3 -m pytest test/launch_pytest/examples/pytest_hello_world.py +``` + +The `launch_pytest` plugin will launch the nodes found in the `launch_description` fixture, run the tests from the `test_read_stdout()` class, shut down the launched nodes, and then run the statements after the `yield` statement in `test_read_stdout()`. + +#### launch_pytest fixtures + +```python +@launch_pytest.fixture +def launch_description(hello_world_proc): + """Launch a simple process to print 'hello_world'.""" + return launch.LaunchDescription([ + hello_world_proc, + # Tell launch when to start the test + # If no ReadyToTest action is added, one will be appended automatically. + launch_pytest.actions.ReadyToTest() + ]) +``` + +A `@launch_pytest.fixture` function should return a `launch.LaunchDescription` object, or a sequence of objects whose first item is a `launch.LaunchDescription`. +This launch description will be used in all tests with a mark `@pytest.mark.launch(fixture=)`, in this case `=launch_description`. + +The launch description can include a `ReadyToTest` action to signal to the test framework that it's safe to start the active tests. +If one isn't included, a `ReadyToTest` action will be appended at the end. + +`launch_pytest` fixtures can have `module`, `class` or `function` scope. +The default is `function`. +For example: + +```python +@launch_pytest.fixture(scope=my_scope) +def my_fixture(): + return LaunchDescription(...) + +@pytest.mark.launch(fixture=my_fixture) +def test_case_1(): + pass + +@pytest.mark.launch(fixture=my_fixture) +def test_case_2(): + pass +``` + +If `my_scope=function`, the following happens: + +- A launch service using the `LaunchDescription` returned by `my_fixture()` is started. +- `test_case_1()` is run. +- The launch service is shutdown. +- Another launch service using the `LaunchDescription` returned by `my_fixture()` is started, `my_fixture()` is called again. +- `test_case_2()` is run. +- The launch service is shutdown. + +Whereas when `my_scope=module`, `test case_2()` will run immediately after `test case_1()`, concurrently with the same launch service. + +It's not recommended to mix fixtures with `module` scope with fixtures of `class`/`function` scope in the same file. +It's not recommended to use fixtures with scope larger than `module`. +A test shouldn't depend on more than one `launch_pytest` fixture. +Neither of the three things above automatically generates an error in the current `launch_pytest` implementation, but future versions might. + +#### Active Tests and shutdwon tests + +Test cases marked with `@pytest.mark.launch` will be run concurrently with the launch service or after launch shutdown, depending on the object being marked and the mark arguements. + +- functions: Functions marked with `@pytest.mark.launch` will run concurrently with the launch service, except when `shutdown=True` is passed as an argument to the decorator. + +```python +@pytest.mark.launch(fixture=my_ld_fixture) +def normal_test_case(): + pass + +@pytest.mark.launch(fixture=my_ld_fixture, shutdown=True) +def shutdown_test_case(): + pass +``` + +- coroutine functions: The same rules as normal functions apply. + Coroutines will be run in the same event loop as the launch description, whereas normal functions run concurrently in another thread. + +```python +@pytest.mark.launch(fixture=my_ld_fixture) +async def normal_test_case(): + pass + +@pytest.mark.launch(fixture=my_ld_fixture, shutdown=True) +async def shutdown_test_case(): + pass +``` + +- generators: The first time the generator is called it runs concurrently with the launch service. + The generator will be resumed after the launch service is shutdown. + i.e. This allows to write a test that has a step that runs concurrently with the service and one + that runs after shutdown easily. + The yielded value is ignored. + If the generator doesn't stop iteration after being resumed for a second time, the test will fail. + Passing a `shutdown` argument to the decorator is not allowed in this case. + +```python +@pytest.mark.launch(fixture=my_ld_fixture) +def normal_test_case(): + assert True + yield + assert True +``` + +- async generators: The same rules as for generators apply here as well. + The only difference between the two is that async generator will run in the same event loop as the launch service, whereas a generator will run concurrently in another thread. + +```python +@pytest.mark.launch(fixture=my_ld_fixture) +async def normal_test_case(): + assert True + yield + assert True +``` + +## Fixtures + +The `launch_pytest` plugin will provide the following fixtures. + +- launch_service: The launch service being used to run the tests. + It will have the same scope as the launch_pytest fixture with wider scope in the module. +- launch_context: The launch context being used to run the tests. + It will have the same scope as the launch_pytest fixture with wider scope in the module. +- event_loop: The event loop being used to run the launch service and to run async tests. + It will have the same scope as the launch_pytest fixture with wider scope in the module. diff --git a/launch_pytest/launch_pytest/__init__.py b/launch_pytest/launch_pytest/__init__.py new file mode 100644 index 000000000..4199e9d9a --- /dev/null +++ b/launch_pytest/launch_pytest/__init__.py @@ -0,0 +1,23 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import actions +from . import tools +from .fixture import fixture + +__all__ = [ + 'actions', + 'fixture', + 'tools', +] diff --git a/launch_pytest/launch_pytest/actions/__init__.py b/launch_pytest/launch_pytest/actions/__init__.py new file mode 100644 index 000000000..507c0ef2b --- /dev/null +++ b/launch_pytest/launch_pytest/actions/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from launch_testing.actions import ReadyToTest + +__all__ = [ + 'ReadyToTest', +] diff --git a/launch_pytest/launch_pytest/fixture.py b/launch_pytest/launch_pytest/fixture.py new file mode 100644 index 000000000..aec03c6ba --- /dev/null +++ b/launch_pytest/launch_pytest/fixture.py @@ -0,0 +1,147 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import inspect + +import launch +import pytest + +try: + from _pytest.scope import Scope + + def scope_gt(scope1, scope2): + return Scope(scope1) > Scope(scope2) +except ImportError: + from _pytest.fixtures import scopemismatch as scope_gt + + +def finalize_launch_service(launch_service, eprefix='', auto_shutdown=True): + if auto_shutdown: + launch_service.shutdown(force_sync=True) + loop = launch_service.event_loop + if loop is not None and not loop.is_closed(): + rc = loop.run_until_complete(launch_service.task) + assert rc == 0, f"{eprefix} launch service failed when finishing, return code '{rc}'" + + +def get_launch_service_fixture(*, scope='function', overridable=True): + """Return a launch service fixture.""" + @pytest.fixture(scope=scope) + def launch_service(event_loop): + """Create an instance of the launch service for each test case.""" + ls = launch.LaunchService() + yield ls + finalize_launch_service(ls, eprefix='When tearing down launch_service fixture') + if overridable: + launch_service._launch_pytest_overridable_fixture = True + launch_service._launch_pytest_fixture_scope = scope + return launch_service + + +def get_launch_context_fixture(*, scope='function', overridable=True): + """Return a launch service fixture.""" + @pytest.fixture(scope=scope) + def launch_context(launch_service): + """Create an instance of the launch service for each test case.""" + return launch_service.context + if overridable: + launch_context._launch_pytest_overridable_fixture = True + launch_context._launch_pytest_fixture_scope = scope + return launch_context + + +def get_event_loop_fixture(*, scope='function', overridable=True): + """Return an event loop fixture.""" + # Adapted from: + # https://github.com/pytest-dev/pytest-asyncio/blob/f21e0da345f877755b89ff87b6dcea70815b4497/pytest_asyncio/plugin.py#L224-L229 + # https://github.com/pytest-dev/pytest-asyncio/blob/f21e0da345f877755b89ff87b6dcea70815b4497/pytest_asyncio/plugin.py#L93-L101 + # https://github.com/pytest-dev/pytest-asyncio/blob/f21e0da345f877755b89ff87b6dcea70815b4497/pytest_asyncio/plugin.py#L84. + # See their license https://github.com/pytest-dev/pytest-asyncio/blob/master/LICENSE. + @pytest.fixture(scope=scope) + def event_loop(): + """Create an event loop instance for each test case.""" + loop = asyncio.get_event_loop_policy().new_event_loop() + policy = asyncio.get_event_loop_policy() + try: + old_loop = policy.get_event_loop() + if old_loop is not loop: + old_loop.close() + except RuntimeError: + # Swallow this, since it's probably bad event loop hygiene. + pass + policy.set_event_loop(loop) + yield loop + loop.close() + asyncio.set_event_loop_policy(None) + if overridable: + event_loop._launch_pytest_overridable_fixture = True + event_loop._launch_pytest_fixture_scope = scope + return event_loop + + +def fixture( + decorated=None, + *args, + shutdown_when_idle=True, + auto_shutdown=True, + **kwargs +): + r""" + Decorate launch_test fixtures. + + See also https://docs.pytest.org/en/latest/reference/reference.html#pytest-fixture. + + :param decorated: object to be decorated. + :param \*args: extra positional arguments to be passed to `pytest.fixture()` + :param shutdown_when_idle: when true, the launch service will shutdown when idle. + :param auto_shutdown: when true, the launch service will be shutdown automatically + after all pre-shutdown tests get run. If false, shutdown needs to be signaled in a + different way or the launch fixture should be self terminating. + :param \**kwargs: extra keyword arguments to be passed to pytest.fixture(). + """ + # Automagically override the event_loop, launch_service and launch_context fixtures + # with a fixture of the correct scope. + # This is not done if the user explicitly provided this fixture, + # they might get an ScopeError if not correctly defined. + scope = kwargs.get('scope', 'function') + if scope != 'function': + frame = inspect.stack()[1] + mod = inspect.getmodule(frame[0]) + mod_locals = vars(mod) + for name, getter in ( + ('launch_service', get_launch_service_fixture), + ('event_loop', get_event_loop_fixture), + ('launch_context', get_launch_context_fixture), + ): + if name in mod_locals: + obj = mod_locals[name] + if ( + getattr(obj, '_launch_pytest_overridable_fixture', False) and + scope_gt(scope, obj._launch_pytest_fixture_scope) + ): + mod_locals[name] = getter(scope=scope) + else: + mod_locals[name] = getter(scope=scope) + + def decorator(fixture_function): + fixture_function._launch_pytest_fixture = True + fixture_function._launch_pytest_fixture_options = { + 'shutdown_when_idle': shutdown_when_idle, + 'auto_shutdown': auto_shutdown, + } + return pytest.fixture(*args, **kwargs)(fixture_function) + if decorated is None: + return decorator + return decorator(decorated) diff --git a/launch_pytest/launch_pytest/plugin.py b/launch_pytest/launch_pytest/plugin.py new file mode 100644 index 000000000..57b7a5294 --- /dev/null +++ b/launch_pytest/launch_pytest/plugin.py @@ -0,0 +1,515 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from collections.abc import Sequence +import functools +import inspect + +from _pytest.outcomes import fail +from _pytest.outcomes import skip + +import launch + +import pytest + +from .actions import ReadyToTest +from .fixture import finalize_launch_service +from .fixture import get_event_loop_fixture +from .fixture import get_launch_context_fixture +from .fixture import get_launch_service_fixture + +""" +Native pytest plugin for launch based tests. +""" + + +try: + from _pytest.python import transfer_markers +except ImportError: # Pytest 4.1.0 removes the transfer_marker api (#104) + + def transfer_markers(*args, **kwargs): # noqa + # Noop when over pytest 4.1.0 + pass + + +class LaunchTestWarning(pytest.PytestWarning): + """Raised in this plugin to warn users.""" + + pass + + +def pytest_configure(config): + """Inject launch marker documentation.""" + config.addinivalue_line( + 'markers', + 'launch: ' + 'mark the test as a launch test, it will be ' + 'run using the specified launch_pad.', + ) + + +# TODO(ivanpauno): Deduplicate with launch_testing +def iterate_ready_to_test_actions(entities): + """Search recursively LaunchDescription entities for all ReadyToTest actions.""" + for entity in entities: + if isinstance(entity, ReadyToTest): + yield entity + yield from iterate_ready_to_test_actions( + entity.describe_sub_entities() + ) + for conditional_sub_entity in entity.describe_conditional_sub_entities(): + yield from iterate_ready_to_test_actions( + conditional_sub_entity[1] + ) + + +def get_ready_to_test_action(launch_description): + """Extract the ready to test action from the launch description.""" + gen = (e for e in iterate_ready_to_test_actions(launch_description.entities)) + try: + ready_action = next(gen) + except StopIteration: # No ReadyToTest action found + ready_action = ReadyToTest() + launch_description.entities.append(ready_action) + return ready_action + try: + next(gen) + except StopIteration: # Only one ReadyToTest action must be found + return ready_action + raise RuntimeError( + 'launch_pytest fixtures must return a LaunchDescription ' + 'containing only one ReadyToTest action' + ) + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_fixture_setup(fixturedef, request): + """Set up launch service for all launch_pytest fixtures.""" + if getattr(fixturedef.func, '_launch_pytest_fixture', False): + options = fixturedef.func._launch_pytest_fixture_options + eprefix = f"When running launch_pytest fixture '{fixturedef.func.__name__}':" + ls = request.getfixturevalue('launch_service') + event_loop = request.getfixturevalue('event_loop') + # get the result of the launch fixture, we take advantage of other wrappers this way + outcome = yield + ret = outcome.get_result() + wrong_ret_type_error = ( + f'{eprefix} return value must be either a launch description ' + 'or a sequence which first item is a launch description' + ) + ld = ret + if isinstance(ret, Sequence): + assert len(ret) > 0, wrong_ret_type_error + ld = ret[0] + assert isinstance(ld, launch.LaunchDescription), wrong_ret_type_error + ls.include_launch_description(ld) + run_async_task = event_loop.create_task(ls.run_async( + shutdown_when_idle=options['shutdown_when_idle'] + )) + ready = get_ready_to_test_action(ld) + asyncio.set_event_loop(event_loop) + event = asyncio.Event() + ready._add_callback(lambda: event.set()) + fixturedef.addfinalizer(functools.partial( + finalize_launch_service, ls, eprefix=eprefix, auto_shutdown=options['auto_shutdown'])) + run_until_complete(event_loop, event.wait()) + # this is guaranteed by the current run_async() implementation, let's check it just in case + # it changes in the future + assert ls.event_loop is event_loop + assert ls.context.asyncio_loop is event_loop + assert ls.task is run_async_task + return + yield + + +def is_launch_test(item): + """Return `True` if the item is a launch test.""" + mark = item.get_closest_marker('launch') + return mark is not None + + +def is_launch_test_mark_valid(item): + """ + Return `True` if the item is a launch test. + + If not, a warning and a skip mark will be added to the item. + """ + kwargs = item.get_closest_marker('launch').kwargs + return 'fixture' in kwargs and kwargs['fixture'] is not None + + +def has_shutdown_kwarg(item): + """Return `True` if the launch test shutdown kwarg is true.""" + return item.get_closest_marker('launch').kwargs.get('shutdown', False) + + +def get_launch_test_fixture(item): + """Return the launch test fixture name, `None` if this isn't a launch test.""" + mark = item.get_closest_marker('launch') + if mark is None: + return None + return mark.kwargs.get('fixture') + + +def get_launch_test_fixturename(item): + """Return the launch test fixture name, `None` if this isn't a launch test.""" + fixture = get_launch_test_fixture(item) + return None if fixture is None else fixture.__name__ + + +def is_valid_test_item(obj): + """Return true if obj is a valid launch test item.""" + return ( + inspect.iscoroutinefunction(obj) or inspect.isfunction(obj) + or inspect.isgeneratorfunction(obj) or inspect.isasyncgenfunction(obj) + ) + + +def need_shutdown_test_item(obj): + """Return true if we also need to generate a shutdown test item for this object.""" + return inspect.isgeneratorfunction(obj) or inspect.isasyncgenfunction(obj) + + +def generate_test_items(collector, name, obj, fixturename, *, is_shutdown, needs_renaming): + """Return list of test items for the corresponding object and injects the needed fixtures.""" + # Inject all needed fixtures. + # We use the `usefixtures` pytest mark instead of injecting them in fixturenames + # directly, because the second option doesn't handle parameterized launch fixtures + # correctly. + # We also need to inject the mark before calling _genfunctions(), + # so it actually returns many items for parameterized launch fixtures. + fixtures_to_inject = (fixturename, 'launch_service', 'event_loop') + pytest.mark.usefixtures(*fixtures_to_inject)(obj) + items = list(collector._genfunctions(name, obj)) + for item in items: + # Mark shutdown tests correctly + item._launch_pytest_is_shutdown = is_shutdown + if is_shutdown and needs_renaming: + # rename the items, to differentiate them from the normal test stage + item.name = f'{item.name}[shutdown_test]' + return items + + +def from_parent(cls, *args, **kwargs): + # Compatibility with old pytest. + # from_parent() was added in pytest 5.4 + if hasattr(cls, 'from_parent'): + return cls.from_parent(*args, **kwargs) + return cls(*args, **kwargs) + + +# Part of this function was adapted from +# https://github.com/pytest-dev/pytest-asyncio/blob/f21e0da345f877755b89ff87b6dcea70815b4497/pytest_asyncio/plugin.py#L37-L50. +# See their license https://github.com/pytest-dev/pytest-asyncio/blob/master/LICENSE. +@pytest.mark.tryfirst +def pytest_pycollect_makeitem(collector, name, obj): + """Collect coroutine based launch tests.""" + if collector.funcnamefilter(name) and is_valid_test_item(obj): + item = from_parent(pytest.Function, parent=collector, name=name) + + # Due to how pytest test collection works, module-level pytestmarks + # are applied after the collection step. Since this is the collection + # step, we look ourselves. + transfer_markers(obj, item.cls, item.module) + item = from_parent(pytest.Function, parent=collector, name=name) # To reload keywords. + + if is_launch_test(item): + if not is_launch_test_mark_valid(item): + # return an item with a warning that's going to be skipped + msg = ( + '"fixture" keyword argument is required in a pytest.mark.launch() ' + 'decorator') + item.warn(LaunchTestWarning(msg)) + item.add_marker(pytest.mark.skip(msg)) + return [item] + fixture = get_launch_test_fixture(item) + fixturename = fixture.__name__ + scope = fixture._pytestfixturefunction.scope + is_shutdown = has_shutdown_kwarg(item) + items = generate_test_items( + collector, name, obj, fixturename, is_shutdown=is_shutdown, needs_renaming=False) + if need_shutdown_test_item(obj) and scope != 'function': + # for function scope we only need one shutdown item + # if not we're going to use two event loops!!! + shutdown_items = generate_test_items( + collector, name, obj, fixturename, is_shutdown=True, needs_renaming=True) + for item, shutdown_item in zip(items, shutdown_items): + item._launch_pytest_shutdown_item = shutdown_item + items.extend(shutdown_items) + return items + + +def is_shutdown_test(item): + """Return `True` if the item is a launch test.""" + return getattr(item, '_launch_pytest_is_shutdown', False) + + +def is_same_launch_test_fixture(left_item, right_item): + """Return `True` if both items are using the same fixture with the same parameters.""" + lfn = get_launch_test_fixture(left_item) + rfn = get_launch_test_fixture(right_item) + if None in (lfn, rfn): + return False + if lfn is not rfn: + return False + if lfn._pytestfixturefunction.scope == 'function': + return False + name = lfn.__name__ + + def get_fixture_params(item): + if getattr(item, 'callspec', None) is None: + return None + return item.callspec.params.get(name, None) + return get_fixture_params(left_item) == get_fixture_params(right_item) + + +@pytest.mark.trylast +def pytest_collection_modifyitems(session, config, items): + """Move shutdown tests after normal tests.""" + def enumerate_reversed(sequence): + # reversed(enumerate(sequence)), doesn't work + # here a little generator for that + n = len(sequence) - 1 + for elem in sequence[::-1]: + yield n, elem + n -= 1 + + for i, item in enumerate_reversed(items): + # This algo has worst case Nitems * Nlaunchtestitems time complexity. + # We could probably find something better, but it's not that easy because: + # - Tests using the same launch fixture might not be already grouped, + # i.e. there might be a test not using the fixture in the middle. + # TODO(ivanpauno): Check if that's not actually guaranteed. + # If that's the case, we can easily modify this to have Nlaunchtestitems complexity. + # - There's no strict partial order relation we can use. + # + # iterate in reverse order, so the order of shutdown item is stable + if not is_launch_test(item): + continue + new_position = None + for j, other in enumerate(items[i+1:]): + if ( + is_same_launch_test_fixture(item, other) + and is_shutdown_test(item) and not is_shutdown_test(other) + ): + new_position = i + 1 + j + if new_position is not None: + items.insert(new_position, items.pop(i)) + + +@pytest.hookimpl(hookwrapper=True, tryfirst=True) +def pytest_pyfunc_call(pyfuncitem): + """Run launch_pytest test coroutines and functions in an event loop.""" + if not is_launch_test(pyfuncitem): + yield + return + + func = pyfuncitem.obj + if has_shutdown_kwarg(pyfuncitem) and need_shutdown_test_item(func): + error_msg = ( + 'generator or async generator based launch test items cannot be marked with' + ' shutdown=True') + fail(error_msg) + yield + return + shutdown_test = is_shutdown_test(pyfuncitem) + fixture = get_launch_test_fixture(pyfuncitem) + scope = fixture._pytestfixturefunction.scope + event_loop = pyfuncitem.funcargs['event_loop'] + ls = pyfuncitem.funcargs['launch_service'] + auto_shutdown = fixture._launch_pytest_fixture_options['auto_shutdown'] + on_shutdown = functools.partial( + finalize_launch_service, + ls, + eprefix=f'When running test {func.__name__}', + auto_shutdown=auto_shutdown) + before_test = on_shutdown if shutdown_test else None + if inspect.iscoroutinefunction(func): + pyfuncitem.obj = wrap_coroutine(func, event_loop, before_test) + elif inspect.isgeneratorfunction(func): + if scope != 'function': + shutdown_item = pyfuncitem._launch_pytest_shutdown_item + pyfuncitem.obj, shutdown_item.obj = ( + wrap_generator(func, event_loop, on_shutdown) + ) + shutdown_item._fixtureinfo = shutdown_item.session._fixturemanager.getfixtureinfo( + shutdown_item, shutdown_item.obj, shutdown_item.cls, funcargs=True) + else: + pyfuncitem.obj = wrap_generator_fscope(func, event_loop, on_shutdown) + elif inspect.isasyncgenfunction(func): + if scope != 'function': + shutdown_item = pyfuncitem._launch_pytest_shutdown_item + pyfuncitem.obj, shutdown_item.obj = ( + wrap_asyncgen(func, event_loop, on_shutdown) + ) + shutdown_item._fixtureinfo = shutdown_item.session._fixturemanager.getfixtureinfo( + shutdown_item, shutdown_item.obj, shutdown_item.cls, funcargs=True) + else: + pyfuncitem.obj = wrap_asyncgen_fscope(func, event_loop, on_shutdown) + elif not getattr(pyfuncitem.obj, '_launch_pytest_wrapped', False): + pyfuncitem.obj = wrap_func(func, event_loop, before_test) + yield + + +def run_until_complete(loop, future_like): + """Similar to `asyncio.EventLoop.run_until_complete`, but it consumes all exceptions.""" + try: + loop.run_until_complete(future_like) + except BaseException: + if future_like.done() and not future_like.cancelled(): + future_like.exception() + raise + + +def wrap_coroutine(func, event_loop, before_test): + """Return a sync wrapper around an async function to be executed in the event loop.""" + @functools.wraps(func) + def inner(**kwargs): + if before_test is not None: + before_test() + coro = func(**kwargs) + task = asyncio.ensure_future(coro, loop=event_loop) + run_until_complete(event_loop, task) + + return inner + + +def wrap_func(func, event_loop, before_test): + """Return a wrapper that runs the test in a separate thread while driving the event loop.""" + @functools.wraps(func) + def inner(**kwargs): + if before_test is not None: + before_test() + future = event_loop.run_in_executor(None, functools.partial(func, **kwargs)) + run_until_complete(event_loop, future) + return inner + + +def wrap_generator(func, event_loop, on_shutdown): + """Return wrappers for the normal test and the teardown test for a generator function.""" + gen = None + + def shutdown(): + nonlocal gen + if gen is None: + skip('shutdown test skipped because the test failed before') + on_shutdown() + try: + next(gen) + except StopIteration: + return + fail( + 'launch tests using a generator function must stop iteration after yielding once', + pytrace=False + ) + shutdown._launch_pytest_wrapped = True + shutdown.__name__ = f'{func.__name__}[shutdown]' + + @functools.wraps(func) + def inner(**kwargs): + nonlocal gen + local_gen = func(**kwargs) + future = event_loop.run_in_executor(None, lambda: next(local_gen)) + run_until_complete(event_loop, future) + gen = local_gen + + return inner, shutdown + + +def wrap_generator_fscope(func, event_loop, on_shutdown): + """Return wrappers for the normal test and the teardown test for a generator function.""" + @functools.wraps(func) + def inner(**kwargs): + gen = func(**kwargs) + future = event_loop.run_in_executor(None, lambda: next(gen)) + run_until_complete(event_loop, future) + on_shutdown() + try: + next(gen) + except StopIteration: + return + fail( + 'launch tests using a generator function must stop iteration after yielding once', + pytrace=False + ) + + return inner + + +def wrap_asyncgen(func, event_loop, on_shutdown): + """Return wrappers for the normal test and the teardown test for an async gen function.""" + agen = None + + def shutdown(**kwargs): + nonlocal agen + if agen is None: + skip('shutdown test skipped because the test failed before') + on_shutdown() + try: + coro = agen.__anext__() + task = asyncio.ensure_future(coro, loop=event_loop) + run_until_complete(event_loop, task) + except StopAsyncIteration: + return + fail( + 'launch tests using an async gen function must stop iteration after yielding once', + pytrace=False + ) + shutdown.__name__ = f'{func.__name__}[shutdown]' + shutdown._launch_pytest_wrapped = True + + @functools.wraps(func) + def inner(**kwargs): + nonlocal agen + local_agen = func(**kwargs) + coro = local_agen.__anext__() + task = asyncio.ensure_future(coro, loop=event_loop) + run_until_complete(event_loop, task) + agen = local_agen + + return inner, shutdown + + +def wrap_asyncgen_fscope(func, event_loop, on_shutdown): + """Return wrappers for the normal test and the teardown test for an async gen function.""" + @functools.wraps(func) + def inner(**kwargs): + agen = func(**kwargs) + coro = agen.__anext__() + task = asyncio.ensure_future(coro, loop=event_loop) + run_until_complete(event_loop, task) + on_shutdown() + try: + coro = agen.__anext__() + task = asyncio.ensure_future(coro, loop=event_loop) + run_until_complete(event_loop, task) + except StopAsyncIteration: + return + fail( + 'launch tests using an async gen function must stop iteration after yielding once', + pytrace=False + ) + + return inner + + +launch_service = get_launch_service_fixture(overridable=False) +"""Launch service fixture.""" + +launch_context = get_launch_context_fixture(overridable=False) +"""Launch context fixture.""" + +event_loop = get_event_loop_fixture(overridable=False) +"""Event loop fixture.""" diff --git a/launch_pytest/launch_pytest/tools/__init__.py b/launch_pytest/launch_pytest/tools/__init__.py new file mode 100644 index 000000000..14279b69b --- /dev/null +++ b/launch_pytest/launch_pytest/tools/__init__.py @@ -0,0 +1,35 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import process +from .process import wait_for_exit +from .process import wait_for_exit_sync +from .process import wait_for_output +from .process import wait_for_output_sync +from .process import wait_for_start +from .process import wait_for_start_sync +from .process import wait_for_stderr +from .process import wait_for_stderr_sync + +__all__ = [ + 'process', + 'wait_for_exit', + 'wait_for_exit_sync', + 'wait_for_output', + 'wait_for_output_sync', + 'wait_for_start', + 'wait_for_start_sync', + 'wait_for_stderr', + 'wait_for_stderr_sync', +] diff --git a/launch_pytest/launch_pytest/tools/process.py b/launch_pytest/launch_pytest/tools/process.py new file mode 100644 index 000000000..4479984db --- /dev/null +++ b/launch_pytest/launch_pytest/tools/process.py @@ -0,0 +1,200 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import contextlib +import threading +import time + +from launch import event_handlers + + +@contextlib.contextmanager +def register_event_handler(context, event_handler): + # Code to acquire resource, e.g.: + try: + yield context.register_event_handler(event_handler, append=True) + finally: + context.unregister_event_handler(event_handler) + + +async def _wait_for_event( + launch_context, execute_process_action, get_launch_event_handler, timeout=None +): + pyevent = asyncio.Event() + event_handler = get_launch_event_handler(execute_process_action, pyevent) + with register_event_handler(launch_context, event_handler): + try: + await asyncio.wait_for(pyevent.wait(), timeout) + except asyncio.TimeoutError: + return False + return True + + +async def _wait_for_event_with_condition( + launch_context, execute_process_action, get_launch_event_handler, condition, timeout=None +): + pyevent = asyncio.Event() + event_handler = get_launch_event_handler(execute_process_action, pyevent) + cond_value = False + try: + cond_value = condition() + except AssertionError: + pass + with register_event_handler(launch_context, event_handler): + start = time.time() + now = start + while not cond_value and (timeout is None or now < start + timeout): + try: + await asyncio.wait_for(pyevent.wait(), start - now + timeout) + except asyncio.TimeoutError: + break + pyevent.clear() + try: + cond_value = condition() + except AssertionError: + pass + now = time.time() + # Call condition() again, if before it returned False. + # If assertions were being used and the condition is still not satisfied it should raise here, + # pytest renders assertion errors nicely. + return condition() if not cond_value else cond_value + + +def _wait_for_event_sync( + launch_context, execute_process_action, get_launch_event_handler, timeout=None +): + pyevent = threading.Event() + event_handler = get_launch_event_handler(execute_process_action, pyevent) + with register_event_handler(launch_context, event_handler): + return pyevent.wait(timeout) + + +def _wait_for_event_with_condition_sync( + launch_context, execute_process_action, get_launch_event_handler, condition, timeout=None +): + pyevent = threading.Event() + event_handler = get_launch_event_handler(execute_process_action, pyevent) + cond_value = False + try: + cond_value = condition() + except AssertionError: + pass # Allow asserts in the condition closures + with register_event_handler(launch_context, event_handler): + start = time.time() + now = start + while not cond_value and (timeout is None or now < start + timeout): + pyevent.wait(start - now + timeout) + pyevent.clear() + try: + cond_value = condition() + except AssertionError: + pass + now = time.time() + # Call condition() again, if before it returned False. + # If assertions were being used and the condition is still not satisfied it should raise here, + # pytest renders assertion errors nicely. + return condition() if not cond_value else cond_value + + +def _get_stdout_event_handler(action, pyevent): + return event_handlers.OnProcessIO( + target_action=action, on_stdout=lambda _1: pyevent.set()) + + +async def wait_for_output( + launch_context, execute_process_action, validate_output, timeout=None +): + return await _wait_for_event_with_condition( + launch_context, + execute_process_action, + _get_stdout_event_handler, + lambda: validate_output(execute_process_action.get_stdout()), + timeout) + + +def wait_for_output_sync( + launch_context, execute_process_action, validate_output, timeout=None +): + return _wait_for_event_with_condition_sync( + launch_context, + execute_process_action, + _get_stdout_event_handler, + lambda: validate_output(execute_process_action.get_stdout()), + timeout) + + +def _get_stderr_event_handler(action, pyevent): + return event_handlers.OnProcessIO( + target_action=action, on_stderr=lambda _1: pyevent.set()) + + +async def wait_for_stderr( + launch_context, execute_process_action, validate_output, timeout=None +): + return await _wait_for_event_with_condition( + launch_context, + execute_process_action, + _get_stderr_event_handler, + lambda: validate_output(execute_process_action.get_stderr()), + timeout) + + +def wait_for_stderr_sync( + launch_context, execute_process_action, validate_output, timeout=None +): + return _wait_for_event_with_condition_sync( + launch_context, + execute_process_action, + _get_stderr_event_handler, + lambda: validate_output(execute_process_action.get_stderr()), + timeout) + + +def _get_on_process_start_event_handler(execute_process_action, pyevent): + return event_handlers.OnProcessStart( + target_action=execute_process_action, on_start=lambda _1, _2: pyevent.set()) + + +async def wait_for_start( + launch_context, execute_process_action, timeout=None +): + return await _wait_for_event( + launch_context, execute_process_action, _get_on_process_start_event_handler, timeout) + + +def wait_for_start_sync( + launch_context, execute_process_action, timeout=None +): + return _wait_for_event_sync( + launch_context, execute_process_action, _get_on_process_start_event_handler, timeout) + + +def _get_on_process_exit_event_handler(execute_process_action, pyevent): + return event_handlers.OnProcessExit( + target_action=execute_process_action, on_exit=lambda _1, _2: pyevent.set()) + + +async def wait_for_exit( + launch_context, execute_process_action, timeout=None +): + return await _wait_for_event( + launch_context, execute_process_action, _get_on_process_exit_event_handler, timeout) + + +def wait_for_exit_sync( + launch_context, execute_process_action, timeout=None +): + return _wait_for_event_sync( + launch_context, execute_process_action, _get_on_process_exit_event_handler, timeout) diff --git a/launch_pytest/package.xml b/launch_pytest/package.xml new file mode 100644 index 000000000..c324380e1 --- /dev/null +++ b/launch_pytest/package.xml @@ -0,0 +1,27 @@ + + + + launch_pytest + 0.19.0 + A package to create tests which involve launch files and multiple processes. + William Woodall + Michel Hidalgo + Apache License 2.0 + + Ivan Paunovic + + ament_index_python + launch + launch_testing + osrf_pycommon + python3-pytest + + ament_copyright + ament_flake8 + ament_pep257 + launch + + + ament_python + + diff --git a/launch_pytest/pytest.ini b/launch_pytest/pytest.ini new file mode 100644 index 000000000..fe55d2ed6 --- /dev/null +++ b/launch_pytest/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +junit_family=xunit2 diff --git a/launch_pytest/resource/launch_pytest b/launch_pytest/resource/launch_pytest new file mode 100644 index 000000000..e69de29bb diff --git a/launch_pytest/setup.cfg b/launch_pytest/setup.cfg new file mode 100644 index 000000000..2d7cc1c8c --- /dev/null +++ b/launch_pytest/setup.cfg @@ -0,0 +1,4 @@ +[coverage:run] +# This will let coverage find files with 0% coverage (not hit by tests at all) +source = . +omit = setup.py diff --git a/launch_pytest/setup.py b/launch_pytest/setup.py new file mode 100644 index 000000000..f49dc58dc --- /dev/null +++ b/launch_pytest/setup.py @@ -0,0 +1,51 @@ +import glob +from pathlib import Path + +from setuptools import find_packages +from setuptools import setup + + +package_name = 'launch_pytest' + +setup( + name=package_name, + version='0.19.0', + packages=find_packages(exclude=['test']), + data_files=[ + ('share/ament_index/resource_index/packages', [f'resource/{package_name}']), + (f'lib/{package_name}', glob.glob('example_processes/**')), + (f'share/{package_name}', ['package.xml']), + ( + f'share/{package_name}/examples', + [x for x in glob.glob( + f'test/{package_name}/examples/[!_]*') if Path(x).is_file()] + ), + ( + f'share/{package_name}/examples/executables', + glob.glob(f'test/{package_name}/examples/executables/[!_]*') + ), + ], + entry_points={ + 'pytest11': ['launch_pytest = launch_pytest.plugin'], + }, + install_requires=['setuptools'], + zip_safe=True, + author='Ivan Paunovic', + author_email='ivanpauno@ekumenlabs.com', + maintainer='William Woodall, Michel Hidalgo', + maintainer_email='william@osrfoundation.org, michel@ekumenlabs.com', + url='https://github.com/ros2/launch', + download_url='https://github.com/ros2/launch/releases', + keywords=['ROS'], + classifiers=[ + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Topic :: Software Development', + ], + description='Create tests which involve launch files and multiple processes.', + long_description=('A package to create tests which involve' + ' launch files and multiple processes.'), + license='Apache License, Version 2.0', + tests_require=['pytest'], +) diff --git a/launch_pytest/test/conftest.py b/launch_pytest/test/conftest.py new file mode 100644 index 000000000..f9178550d --- /dev/null +++ b/launch_pytest/test/conftest.py @@ -0,0 +1,15 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +pytest_plugins = ['pytester'] diff --git a/launch_pytest/test/launch_pytest/examples/check_node_msgs.py b/launch_pytest/test/launch_pytest/examples/check_node_msgs.py new file mode 100644 index 000000000..bae89e689 --- /dev/null +++ b/launch_pytest/test/launch_pytest/examples/check_node_msgs.py @@ -0,0 +1,78 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path +import sys +from threading import Event +from threading import Thread + +import launch +import launch_pytest +import launch_ros + +import pytest + +import rclpy +from rclpy.node import Node + +from std_msgs.msg import String + + +@launch_pytest.fixture +def generate_test_description(): + path_to_test = Path(__file__).parent + + return launch.LaunchDescription([ + launch_ros.actions.Node( + executable=sys.executable, + arguments=[str(path_to_test / 'executables' / 'talker.py')], + additional_env={'PYTHONUNBUFFERED': '1'}, + name='demo_node_1', + ), + ]) + + +@pytest.mark.launch +def test_check_if_msgs_published(): + rclpy.init() + try: + node = MakeTestNode('test_node') + node.start_subscriber() + msgs_received_flag = node.msg_event_object.wait(timeout=5.0) + assert msgs_received_flag, 'Did not receive msgs !' + finally: + rclpy.shutdown() + + +class MakeTestNode(Node): + + def __init__(self, name='test_node'): + super().__init__(name) + self.msg_event_object = Event() + + def start_subscriber(self): + # Create a subscriber + self.subscription = self.create_subscription( + String, + 'chatter', + self.subscriber_callback, + 10 + ) + + # Add a spin thread + self.ros_spin_thread = Thread(target=lambda node: rclpy.spin(node), args=(self,)) + self.ros_spin_thread.start() + + def subscriber_callback(self, data): + self.msg_event_object.set() diff --git a/launch_pytest/test/launch_pytest/examples/executables/listener.py b/launch_pytest/test/launch_pytest/examples/executables/listener.py new file mode 100644 index 000000000..861ae743d --- /dev/null +++ b/launch_pytest/test/launch_pytest/examples/executables/listener.py @@ -0,0 +1,47 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import rclpy +from rclpy.node import Node + +from std_msgs.msg import String + + +class Listener(Node): + + def __init__(self): + super().__init__('listener') + self.subscription = self.create_subscription( + String, 'chatter', self.callback, 10 + ) + + def callback(self, msg): + self.get_logger().info('I heard: [%s]' % msg.data) + + +def main(args=None): + rclpy.init(args=args) + + node = Listener() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/launch_pytest/test/launch_pytest/examples/pytest_hello_world.py b/launch_pytest/test/launch_pytest/examples/pytest_hello_world.py new file mode 100644 index 000000000..89246a232 --- /dev/null +++ b/launch_pytest/test/launch_pytest/examples/pytest_hello_world.py @@ -0,0 +1,62 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import launch + +import launch_pytest +from launch_pytest.tools import process as process_tools + +import pytest + + +@pytest.fixture +def hello_world_proc(): + # Launch a process to test + return launch.actions.ExecuteProcess( + cmd=['echo', 'hello_world'], + shell=True, + cached_output=True, + ) + + +# This function specifies the processes to be run for our test. +@launch_pytest.fixture +def launch_description(hello_world_proc): + """Launch a simple process to print 'hello_world'.""" + return launch.LaunchDescription([ + hello_world_proc, + # Tell launch when to start the test + # If no ReadyToTest action is added, one will be appended automatically. + launch_pytest.actions.ReadyToTest() + ]) + + +@pytest.mark.launch(fixture=launch_description) +def test_read_stdout(hello_world_proc, launch_context): + """Check if 'hello_world' was found in the stdout.""" + def validate_output(output): + # this function can use assertions to validate the output or return a boolean. + # pytest generates easier to understand failures when assertions are used. + assert output == 'hello_world\n', 'process never printed hello_world' + assert process_tools.wait_for_output_sync( + launch_context, hello_world_proc, validate_output, timeout=5) + + def validate_output(output): + return output == 'this will never happen' + assert not process_tools.wait_for_output_sync( + launch_context, hello_world_proc, validate_output, timeout=0.1) + yield + # this is executed after launch service shutdown + assert hello_world_proc.return_code == 0 diff --git a/launch_pytest/test/launch_pytest/test_function_scope.py b/launch_pytest/test/launch_pytest/test_function_scope.py new file mode 100644 index 000000000..3e3519b4d --- /dev/null +++ b/launch_pytest/test/launch_pytest/test_function_scope.py @@ -0,0 +1,78 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import launch +import launch_pytest +import launch_testing + +import pytest + + +@pytest.fixture(scope='module') +def order(): + return [] + + +@launch_pytest.fixture +def launch_description(): + return launch.LaunchDescription([ + launch_testing.util.KeepAliveProc(), + launch_pytest.actions.ReadyToTest(), + ]) + + +@pytest.mark.launch(fixture=launch_description, shutdown=True) +async def test_after_shutdown(order, launch_service): + order.append('test_after_shutdown') + assert launch_service._is_idle() + assert launch_service.event_loop is None + + +@pytest.mark.launch(fixture=launch_description) +async def test_case_1(order): + order.append('test_case_1') + assert True + + +@pytest.mark.launch(fixture=launch_description) +def test_case_2(order): + order.append('test_case_2') + assert True + + +@pytest.mark.launch(fixture=launch_description) +def test_case_3(order, launch_service): + order.append('test_case_3') + yield + # assert launch_service._finalized + order.append('test_case_3[shutdown]') + + +@pytest.mark.launch(fixture=launch_description) +async def test_case_4(order): + order.append('test_case_4') + yield + order.append('test_case_4[shutdown]') + + +def test_order(order): + assert order == [ + 'test_after_shutdown', + 'test_case_1', + 'test_case_2', + 'test_case_3', + 'test_case_3[shutdown]', + 'test_case_4', + 'test_case_4[shutdown]', + ] diff --git a/launch_pytest/test/launch_pytest/test_module_scope.py b/launch_pytest/test/launch_pytest/test_module_scope.py new file mode 100644 index 000000000..dd4180c6e --- /dev/null +++ b/launch_pytest/test/launch_pytest/test_module_scope.py @@ -0,0 +1,96 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import launch +import launch_pytest +import launch_testing + +import pytest + + +@pytest.fixture(scope='module') +def order(): + print('once') + yield [] + print('end') + + +@launch_pytest.fixture(scope='module', params=['asd', 'bsd']) +def launch_description(request): + return launch.LaunchDescription([ + launch_testing.util.KeepAliveProc(), + launch_pytest.actions.ReadyToTest(), + ]), request.param + + +@pytest.mark.launch(fixture=launch_description, shutdown=True) +def test_after_shutdown(order, launch_service, launch_description): + param = launch_description[1] + order.append(f'test_after_shutdown[{param}]') + assert launch_service._is_idle() + assert launch_service.event_loop is None + + +@pytest.mark.launch(fixture=launch_description) +async def test_case_1(order, launch_description): + param = launch_description[1] + order.append(f'test_case_1[{param}]') + assert True + + +@pytest.mark.launch(fixture=launch_description) +def test_case_2(order, launch_description): + param = launch_description[1] + order.append(f'test_case_2[{param}]') + assert True + + +@pytest.mark.launch(fixture=launch_description) +def test_case_3(order, launch_service, launch_description): + param = launch_description[1] + order.append(f'test_case_3[{param}]') + yield + assert launch_service._is_idle() + assert launch_service.event_loop is None + order.append(f'test_case_3[{param}][shutdown]') + + +@pytest.mark.launch(fixture=launch_description) +async def test_case_4(order, launch_service, launch_description): + param = launch_description[1] + order.append(f'test_case_4[{param}]') + yield + assert launch_service._is_idle() + assert launch_service.event_loop is None + + order.append(f'test_case_4[{param}][shutdown]') + + +def test_order(order): + assert order == [ + 'test_case_1[asd]', + 'test_case_2[asd]', + 'test_case_3[asd]', + 'test_case_4[asd]', + 'test_after_shutdown[asd]', + 'test_case_3[asd][shutdown]', + 'test_case_4[asd][shutdown]', + 'test_case_1[bsd]', + 'test_case_2[bsd]', + 'test_case_3[bsd]', + 'test_case_4[bsd]', + 'test_after_shutdown[bsd]', + 'test_case_3[bsd][shutdown]', + 'test_case_4[bsd][shutdown]', + ] diff --git a/launch_pytest/test/launch_pytest/test_plugin.py b/launch_pytest/test/launch_pytest/test_plugin.py new file mode 100644 index 000000000..deb9fbd12 --- /dev/null +++ b/launch_pytest/test/launch_pytest/test_plugin.py @@ -0,0 +1,315 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +def test_launch_fixture_is_not_a_launch_description(testdir): + testdir.makepyfile("""\ +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return object() + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert True +""") + + result = testdir.runpytest() + try: + result.assert_outcomes(errors=1) + except TypeError: + # Compatibility to pytest older than 6.0 + result.assert_outcomes(error=1) + result.stdout.re_match_lines(['.*must be either a launch description.*']) + + +def test_launch_fixture_is_not_a_sequence_starting_with_a_ld(testdir): + testdir.makepyfile("""\ +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return [object, 'asd'] + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert True +""") + + result = testdir.runpytest() + + try: + result.assert_outcomes(errors=1) + except TypeError: + # Compatibility to pytest older than 6.0 + result.assert_outcomes(error=1) + result.stdout.re_match_lines(['.*must be either a launch description.*']) + + +def test_multiple_ready_to_test_actions(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest(), launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert True +""") + + result = testdir.runpytest() + + try: + result.assert_outcomes(errors=1) + except TypeError: + # Compatibility to pytest older than 6.0 + result.assert_outcomes(error=1) + result.stdout.re_match_lines(['.*only one ReadyToTest action.*']) + + +def test_generator_yields_twice(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert True + yield + assert True + yield +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1) + result.stdout.re_match_lines(['.*must stop iteration after yielding once.*']) + + +def test_generator_yields_twice_module_scope(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture(scope='module') +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert True + yield + assert True + yield +""") + + result = testdir.runpytest() + + result.assert_outcomes(passed=1, failed=1) + result.stdout.re_match_lines(['.*must stop iteration after yielding once.*']) + + +def test_asyncgenerator_yields_twice(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +async def test_case(): + assert True + yield + assert True + yield +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1) + result.stdout.re_match_lines(['.*must stop iteration after yielding once.*']) + + +def test_asyncgenerator_yields_twice_module_scope(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture(scope='module') +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +async def test_case(): + assert True + yield + assert True + yield +""") + + result = testdir.runpytest() + + result.assert_outcomes(passed=1, failed=1) + result.stdout.re_match_lines(['.*must stop iteration after yielding once.*']) + + +def test_fixture_kwarg_is_mandatory(LineMatcher, testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch +def test_case(): + pass +""") + + result = testdir.runpytest() + + result.assert_outcomes(skipped=1) + # Warnings can appear in both stdout or stderr, depending on pytest capture mode. + # Test for a match in any of both. + LineMatcher(result.outlines + result.errlines).re_match_lines( + ['.*"fixture" keyword argument is required .*']) + + +def test_generator_shutdown_kwarg_true(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description, shutdown=True) +def test_case(): + assert True + yield + assert True +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1) + result.stdout.re_match_lines(['.*generator or async generator.* shutdown=True.*']) + + +def test_async_generator_shutdown_kwarg_true(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description, shutdown=True) +async def test_case(): + assert True + yield + assert True +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1) + result.stdout.re_match_lines(['.*generator or async generator.* shutdown=True.*']) + + +def test_generator_with_pre_shutdown_failure(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture(scope='module') +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +def test_case(): + assert False + yield + assert True +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1, skipped=1) + + +def test_async_generator_with_pre_shutdown_failure(testdir): + testdir.makepyfile("""\ +from launch import LaunchDescription +import launch_pytest +import pytest + +@launch_pytest.fixture(scope='module') +def launch_description(): + return LaunchDescription( + [launch_pytest.actions.ReadyToTest()] + ) + +@pytest.mark.launch(fixture=launch_description) +async def test_case(): + assert False + yield + assert True +""") + + result = testdir.runpytest() + + result.assert_outcomes(failed=1, skipped=1) diff --git a/launch_pytest/test/launch_pytest/tools/test_process.py b/launch_pytest/test/launch_pytest/tools/test_process.py new file mode 100644 index 000000000..f3281dd91 --- /dev/null +++ b/launch_pytest/test/launch_pytest/tools/test_process.py @@ -0,0 +1,72 @@ +# Copyright 2021 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +import launch +import launch_pytest +from launch_pytest import tools + +import pytest + +PYTHON_SCRIPT = """\ +import sys +import time + +print('hello') +print('world', file=sys.stderr) +time.sleep(5) +""" + + +@pytest.fixture +def dut(): + return launch.actions.ExecuteProcess( + cmd=[sys.executable, '-c', PYTHON_SCRIPT], + cached_output=True, + output='screen' + ) + + +@launch_pytest.fixture +def launch_description(dut): + return launch.LaunchDescription([ + dut, + ]) + + +@pytest.mark.launch(fixture=launch_description) +async def test_async_process_tools(dut, launch_context): + await tools.wait_for_start(launch_context, dut, timeout=10) + def check_output(output): assert output.splitlines() == ['hello'] + await tools.wait_for_output( + launch_context, dut, check_output, timeout=10) + + def check_stderr(err): assert err.splitlines() == ['world'] + await tools.wait_for_stderr( + launch_context, dut, check_stderr, timeout=10) + await tools.wait_for_exit(launch_context, dut, timeout=10) + + +@pytest.mark.launch(fixture=launch_description) +def test_sync_process_tools(dut, launch_context): + tools.wait_for_start_sync(launch_context, dut, timeout=10) + def check_output(output): assert output.splitlines() == ['hello'] + tools.wait_for_output_sync( + launch_context, dut, check_output, timeout=10) + + def check_stderr(err): assert err.splitlines() == ['world'] + tools.wait_for_stderr_sync( + launch_context, dut, check_stderr, timeout=10) + tools.wait_for_exit_sync(launch_context, dut, timeout=10) diff --git a/launch_pytest/test/test_copyright.py b/launch_pytest/test/test_copyright.py new file mode 100644 index 000000000..cf0fae31f --- /dev/null +++ b/launch_pytest/test/test_copyright.py @@ -0,0 +1,23 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_copyright.main import main +import pytest + + +@pytest.mark.copyright +@pytest.mark.linter +def test_copyright(): + rc = main(argv=['.', 'test']) + assert rc == 0, 'Found errors' diff --git a/launch_pytest/test/test_flake8.py b/launch_pytest/test/test_flake8.py new file mode 100644 index 000000000..27ee1078f --- /dev/null +++ b/launch_pytest/test/test_flake8.py @@ -0,0 +1,25 @@ +# Copyright 2017 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_flake8.main import main_with_errors +import pytest + + +@pytest.mark.flake8 +@pytest.mark.linter +def test_flake8(): + rc, errors = main_with_errors(argv=[]) + assert rc == 0, \ + 'Found %d code style errors / warnings:\n' % len(errors) + \ + '\n'.join(errors) diff --git a/launch_pytest/test/test_pep257.py b/launch_pytest/test/test_pep257.py new file mode 100644 index 000000000..3aeb4d348 --- /dev/null +++ b/launch_pytest/test/test_pep257.py @@ -0,0 +1,23 @@ +# Copyright 2015 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ament_pep257.main import main +import pytest + + +@pytest.mark.linter +@pytest.mark.pep257 +def test_pep257(): + rc = main(argv=[]) + assert rc == 0, 'Found code style errors / warnings' diff --git a/launch_testing/launch_testing/pytest/hooks.py b/launch_testing/launch_testing/pytest/hooks.py index 4e55f5d2d..5b4cebaf7 100644 --- a/launch_testing/launch_testing/pytest/hooks.py +++ b/launch_testing/launch_testing/pytest/hooks.py @@ -189,7 +189,7 @@ def pytest_launch_collect_makemodule(path, parent, entrypoint): def pytest_addhooks(pluginmanager): - import launch_testing.pytest.hookspecs as hookspecs + from launch_testing.pytest import hookspecs pluginmanager.add_hookspecs(hookspecs) diff --git a/launch_testing/launch_testing/util/__init__.py b/launch_testing/launch_testing/util/__init__.py index fb9b83b05..aadc5e841 100644 --- a/launch_testing/launch_testing/util/__init__.py +++ b/launch_testing/launch_testing/util/__init__.py @@ -30,9 +30,12 @@ def KeepAliveProc(): another process to keep the launch service alive while the tests are running. """ script = """ +import signal +import time + try: while True: - pass + time.sleep(1) except KeyboardInterrupt: pass """ diff --git a/launch_testing/package.xml b/launch_testing/package.xml index 4c414847c..bd4d70828 100644 --- a/launch_testing/package.xml +++ b/launch_testing/package.xml @@ -15,12 +15,12 @@ ament_index_python launch osrf_pycommon + python3-pytest ament_copyright ament_flake8 ament_pep257 launch - python3-pytest ament_python diff --git a/launch_testing/setup.py b/launch_testing/setup.py index 14e72b01a..b55254998 100644 --- a/launch_testing/setup.py +++ b/launch_testing/setup.py @@ -16,7 +16,7 @@ ], entry_points={ 'console_scripts': ['launch_test=launch_testing.launch_test:main'], - 'pytest11': ['launch = launch_testing.pytest.hooks'], + 'pytest11': ['launch_testing = launch_testing.pytest.hooks'], }, install_requires=['setuptools'], zip_safe=True,