Skip to content
Closed
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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ Russell Martin
Ryan Puddephatt
Ryan Wooden
Sadra Barikbin
Sagar
Saiprasad Kale
Samuel Colvin
Samuel Dion-Girardeau
Expand Down
1 change: 1 addition & 0 deletions changelog/3042.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
5 changes: 3 additions & 2 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1658,15 +1659,15 @@ 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 "
"(fixtures with leading '_' are only shown with '-v')",
)
group.addoption(
"--fixtures-per-test",
action="store_true",
action=InformationalAction,
dest="show_fixtures_per_test",
default=False,
help="Show fixtures per test",
Expand Down
3 changes: 2 additions & 1 deletion src/_pytest/mark/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).",
)

Expand Down
50 changes: 50 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]
)
47 changes: 47 additions & 0 deletions testing/test_parseopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading