diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..b4b7c5c3a26 100644 --- a/AUTHORS +++ b/AUTHORS @@ -439,6 +439,7 @@ Russell Martin Ryan Puddephatt Ryan Wooden Sadra Barikbin +Sagar Saiprasad Kale Samuel Colvin Samuel Dion-Girardeau diff --git a/changelog/3042.bugfix.rst b/changelog/3042.bugfix.rst new file mode 100644 index 00000000000..fbab3303cdf --- /dev/null +++ b/changelog/3042.bugfix.rst @@ -0,0 +1 @@ +Allow ``--fixtures``, ``--funcargs``, ``--fixtures-per-test``, and ``--markers`` to run without supplying required plugin options. Required options remain enforced when running tests. diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index 7180cde2c4c..ffc121d926f 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -3,7 +3,10 @@ import argparse from collections.abc import Callable +from collections.abc import Iterable +from collections.abc import Iterator from collections.abc import Sequence +import contextlib import dataclasses import os import re @@ -512,6 +515,42 @@ def __init__( # an usage error to provide more contextual information to the user. self.extra_info = extra_info + def parse_known_args( + self, + args: Iterable[str] | None = None, + namespace: Any = None, + ) -> tuple[Any, list[str]]: + with self._restore_required_options(namespace): + return super().parse_known_args(args, namespace) + + def parse_known_intermixed_args( + self, + args: Iterable[str] | None = None, + namespace: Any = None, + ) -> tuple[Any, list[str]]: + with self._restore_required_options(namespace): + return super().parse_known_intermixed_args(args, namespace) + + @contextlib.contextmanager + def _restore_required_options( + self, namespace: argparse.Namespace | None + ) -> Iterator[None]: + # Informational actions temporarily relax required options. Restore + # them even if parsing fails, so subsequent parses still enforce them. + required_actions = [action for action in self._actions if action.required] + if namespace is not None and any( + isinstance(action, InformationalAction) + and getattr(namespace, action.dest, False) + for action in self._actions + ): + for action in required_actions: + action.required = False + try: + yield + finally: + for action in required_actions: + action.required = True + def error(self, message: str) -> NoReturn: """Transform argparse error message into UsageError.""" # TODO(py313): Replace with `exit_on_error=False`. Note that while it @@ -657,3 +696,15 @@ def __call__( current_overrides = [] current_overrides.append(f"{self.ini_option}={self.ini_value}") setattr(namespace, "override_ini", current_overrides) + + +class InformationalAction(argparse.Action): + """Set a listing flag without requiring options used to run tests.""" + + def __init__(self, option_strings, dest, default=False, **kwargs): + super().__init__(option_strings, dest, nargs=0, default=default, **kwargs) + + def __call__(self, parser, namespace, values, option_string=None): + setattr(namespace, self.dest, True) + for action in parser._actions: + action.required = False diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 20e43f89132..9dc927535c4 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -54,6 +54,7 @@ from _pytest.config import Config from _pytest.config import ExitCode from _pytest.config import hookimpl +from _pytest.config.argparsing import InformationalAction from _pytest.config.argparsing import Parser from _pytest.deprecated import check_ispytest from _pytest.deprecated import CLASS_FIXTURE_INSTANCE_METHOD @@ -1658,7 +1659,7 @@ def pytest_addoption(parser: Parser) -> None: group.addoption( "--fixtures", "--funcargs", - action="store_true", + action=InformationalAction, dest="showfixtures", default=False, help="Show available fixtures, sorted by plugin appearance " @@ -1666,7 +1667,7 @@ def pytest_addoption(parser: Parser) -> None: ) group.addoption( "--fixtures-per-test", - action="store_true", + action=InformationalAction, dest="show_fixtures_per_test", default=False, help="Show fixtures per test", diff --git a/src/_pytest/mark/__init__.py b/src/_pytest/mark/__init__.py index 996322d93f1..ebbd1f6f328 100644 --- a/src/_pytest/mark/__init__.py +++ b/src/_pytest/mark/__init__.py @@ -25,6 +25,7 @@ from _pytest.config import ExitCode from _pytest.config import hookimpl from _pytest.config import UsageError +from _pytest.config.argparsing import InformationalAction from _pytest.config.argparsing import Parser from _pytest.stash import StashKey @@ -118,7 +119,7 @@ def pytest_addoption(parser: Parser) -> None: group.addoption( "--markers", - action="store_true", + action=InformationalAction, help="show markers (builtin, plugin and per-project ones).", ) diff --git a/testing/test_config.py b/testing/test_config.py index 282e66409f7..df66bdeec93 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -3645,3 +3645,53 @@ def test_get_verbosity(self, pytester: Pytester) -> None: pytester.maketoml("[pytest]\nverbosity_assertions = 2\n") config = pytester.parseconfig() assert config.get_verbosity(Config.VERBOSITY_ASSERTIONS) == 2 + + +@pytest.mark.parametrize( + "flag", ["--fixtures", "--funcargs", "--fixtures-per-test", "--markers"] +) +@pytest.mark.parametrize("action", ["store", "store_true"]) +def test_information_with_required_option( + pytester: Pytester, flag: str, action: str +) -> None: + pytester.makeconftest( + f""" + import pytest + + def pytest_addoption(parser): + parser.addoption("--required", action="{action}", required=True) + + @pytest.fixture + def example_fixture(): + return 1 + """ + ) + pytester.makepyfile( + """ + def test_example(example_fixture): + assert example_fixture == 1 + """ + ) + result = pytester.runpytest(flag) + assert result.ret == pytest.ExitCode.OK + expected = "@pytest.mark.skip" if flag == "--markers" else "example_fixture" + assert expected in result.stdout.str() + + +@pytest.mark.parametrize("args", [[], ["--required", "present"]]) +def test_required_option_still_enforced(pytester: Pytester, args: list[str]) -> None: + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addoption("--required", required=True) + """ + ) + pytester.makepyfile("def test_example(): pass") + result = pytester.runpytest(*args) + if args: + result.assert_outcomes(passed=1) + else: + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*the following arguments are required: --required*"] + ) diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 8457cc6e6ba..d7f15b86df8 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -506,3 +506,50 @@ def test_does_not_break_on_hyphens(self) -> None: def test_empty_help(self) -> None: assert parseopt._split_help_text("", 40) == [] + + +@pytest.mark.parametrize("args", [["--list"], ["--list", "--required", "value"]]) +def test_informational_action_restores_required_options(parser, args): + parser.addoption("--list", action=parseopt.InformationalAction) + parser.addoption("--required", required=True) + namespace = parser.parse(args) + assert namespace.list is True + with pytest.raises( + UsageError, match="the following arguments are required: --required" + ): + parser.parse([]) + assert parser.parse(["--required", "value"]).required == "value" + + +@pytest.mark.parametrize( + "args", [["--list", "--choice", "bad"], ["--choice", "bad", "--list"]] +) +def test_informational_action_keeps_value_validation(parser, args): + parser.addoption("--list", action=parseopt.InformationalAction) + parser.addoption("--required", required=True) + parser.addoption("--choice", choices=["good"]) + with pytest.raises(UsageError, match="invalid choice"): + parser.parse(args) + with pytest.raises( + UsageError, match="the following arguments are required: --required" + ): + parser.parse([]) + + +def test_informational_action_after_option_terminator(parser): + parser.addoption("--list", action=parseopt.InformationalAction) + parser.addoption("--required", required=True) + with pytest.raises( + UsageError, match="the following arguments are required: --required" + ): + parser.parse(["--", "--list"]) + + +# Older argparse versions read argument files without an explicit encoding. +@pytest.mark.filterwarnings("ignore:'encoding' argument not specified:EncodingWarning") +def test_informational_action_from_argument_file(parser, tmp_path): + parser.addoption("--list", action=parseopt.InformationalAction) + parser.addoption("--required", required=True) + args_file = tmp_path / "args.txt" + args_file.write_text("--list\n", encoding="utf-8") + assert parser.parse([f"@{args_file}"]).list is True