Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 30 additions & 13 deletions launch/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,33 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from pathlib import PurePath


def pytest_ignore_collect(collection_path=None, path=None):
if collection_path is not None:
path = collection_path
# pytest doctest messes up when trying to import .launch.py packages, ignore them.
# It also messes up when trying to import launch.logging.handlers due to conflicts with
# logging.handlers, ignore that as well.
return str(path).endswith((
'.launch.py',
str(PurePath('logging') / 'handlers.py'),
))
import pathlib

import pytest


def _pytest_version_ge(major, minor=0, patch=0):
"""Return True if pytest version is >= the given version."""
pytest_version = tuple(int(v) for v in pytest.__version__.split('.'))
return pytest_version >= (major, minor, patch)


def _should_ignore(p):
if p is None:
return False
path = pathlib.Path(p)
# Ignore .launch.py files — not valid Python module names.
if path.name.endswith('.launch.py'):
return True
# Ignore launch.logging.handlers — collides with stdlib logging.handlers.
if path.name == 'handlers.py' and path.parent.name == 'logging':
return True
return False


if _pytest_version_ge(8):
def pytest_ignore_collect(collection_path, config):
return _should_ignore(collection_path)
else:
def pytest_ignore_collect(path, config):
return _should_ignore(path)
1 change: 0 additions & 1 deletion launch/launch/event_handlers/on_process_exit.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from ..launch_context import LaunchContext
from ..some_entities_type import SomeEntitiesType


if TYPE_CHECKING:
from ..action import Action # noqa: F401
from ..actions import ExecuteLocal # noqa: F401
Expand Down
2 changes: 1 addition & 1 deletion launch/launch/logging/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2019 Open Source Robotics Foundation, Inc.
# Copyright 2019 Open Source Robotics Foundation, Inc. # noqa: A005
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down
4 changes: 3 additions & 1 deletion launch/pytest.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
[pytest]
junit_family=xunit2
addopts = --doctest-modules
# Disable doctest modules temporarily to avoid ImportPathMismatchError with logging.handlers
# addopts = --doctest-modules
timeout=900
timeout_method=thread
pythonpath = test
98 changes: 68 additions & 30 deletions launch_testing/launch_testing/pytest/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,53 @@ def collect(self):


def find_launch_test_entrypoint(path):
path_obj = pathlib.Path(path)
# Skip files that are known to cause conflicts or are definitely not tests
if path_obj.name == 'handlers.py' and path_obj.parent.name == 'logging':
return None

# Only attempt to import files that look like Python modules and are not launch files
# with double extensions (which Pytest 8+ import_path might struggle with)
if not path_obj.name.endswith('.py') or path_obj.name.endswith('.launch.py'):
return None

try:
if _pytest_version_ge(8, 1, 0):
from _pytest.pathlib import import_path
module = import_path(path, root=None, consider_namespace_packages=False)
module = import_path(path_obj, root=None, consider_namespace_packages=False)
elif _pytest_version_ge(7, 0, 0):
from _pytest.pathlib import import_path
module = import_path(path, root=None)
module = import_path(path_obj, root=None)
else:
# Assume we got legacy path in earlier versions of pytest
module = path.pyimport()
return getattr(module, 'generate_test_description', None)
except SyntaxError:
except Exception:
# Catch all exceptions during import to avoid breaking collection
return None


@pytest.hookimpl(tryfirst=True)
def pytest_ignore_collect(collection_path=None, path=None, config=None):
# Pytest 8.x signature: (collection_path, path, config)
# Pytest < 8 signature: (path, config)
p = collection_path or path
if p is None:
return False

path_obj = pathlib.Path(p)

# Ignore .launch.py files to avoid collection failures for launch_pytest tests
if path_obj.name.endswith('.launch.py'):
return True

# Ignore standard library collisions (launch.logging.handlers vs logging.handlers)
if path_obj.name == 'handlers.py' and path_obj.parent.name == 'logging':
return True

return False


if _pytest_version_ge(8):
def pytest_pycollect_makemodule(module_path, parent):
return _pytest_pycollect_makemodule(module_path, parent)
Expand All @@ -202,41 +234,47 @@ def _pytest_pycollect_makemodule(path, parent):
if entrypoint is not None:
ihook = parent.session.gethookproxy(path)
module = ihook.pytest_launch_collect_makemodule(
module_path=path, parent=parent, entrypoint=entrypoint
module_path=path, path=path, parent=parent, entrypoint=entrypoint
)
if module is not None:
return module

if _pytest_version_ge(7):
path = pathlib.Path(path)
if path.name == '__init__.py':
return pytest.Package.from_parent(parent, path=path)
return pytest.Module.from_parent(parent=parent, path=path)
elif _pytest_version_ge(5, 4):
if path.basename == '__init__.py':
return pytest.Package.from_parent(parent, fspath=path)
return pytest.Module.from_parent(parent, fspath=path)
else:
# todo: remove fallback once all platforms use pytest >=5.4
if path.basename == '__init__.py':
return pytest.Package(path, parent)
return pytest.Module(path, parent)
# If it's not a launch test, still return a standard Module/Package
# but only if it matches standard test patterns to avoid aggressive collection.
path_obj = pathlib.Path(path)
if path_obj.name.startswith('test_') or path_obj.name.endswith('_test.py') or \
path_obj.name == '__init__.py':
if _pytest_version_ge(7):
if path_obj.name == '__init__.py':
return pytest.Package.from_parent(parent, path=path_obj)
return pytest.Module.from_parent(parent=parent, path=path_obj)
elif _pytest_version_ge(5, 4):
if path_obj.name == '__init__.py':
return pytest.Package.from_parent(parent, fspath=path)
return pytest.Module.from_parent(parent, fspath=path)
else:
if path_obj.name == '__init__.py':
return pytest.Package(path, parent)
return pytest.Module(path, parent)

return None


@pytest.hookimpl(trylast=True)
def pytest_launch_collect_makemodule(module_path, parent, entrypoint):
def pytest_launch_collect_makemodule(module_path, path, parent, entrypoint):
p = module_path or path
if _pytest_version_ge(7):
path_obj = pathlib.Path(p)
module = LaunchTestModule.from_parent(parent=parent, path=path_obj)
else:
module = LaunchTestModule.from_parent(parent=parent, fspath=p)

marks = getattr(entrypoint, 'pytestmark', [])
if marks and any(m.name == 'launch_test' for m in marks):
if _pytest_version_ge(7):
path = pathlib.Path(module_path)
module = LaunchTestModule.from_parent(parent=parent, path=path)
else:
module = LaunchTestModule.from_parent(parent=parent, fspath=module_path)
for mark in marks:
decorator = getattr(pytest.mark, mark.name)
decorator = decorator.with_args(*mark.args, **mark.kwargs)
module.add_marker(decorator)
return module
for mark in marks:
decorator = getattr(pytest.mark, mark.name)
decorator = decorator.with_args(*mark.args, **mark.kwargs)
module.add_marker(decorator)
return module


def pytest_addhooks(pluginmanager):
Expand Down
2 changes: 1 addition & 1 deletion launch_testing/launch_testing/pytest/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@


@pytest.hookspec(firstresult=True)
def pytest_launch_collect_makemodule(module_path, parent, entrypoint):
def pytest_launch_collect_makemodule(module_path, path, parent, entrypoint):
"""Make launch test module appropriate for the found test entrypoint."""
pass