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
4 changes: 4 additions & 0 deletions changelog/7864.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Improved error messages when parsing warning filters.

Previously pytest would show an internal traceback, which besides ugly sometimes would hide the cause
of the problem (for example an ``ImportError`` while importing a specific warning type).
79 changes: 71 additions & 8 deletions src/_pytest/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
import warnings
from functools import lru_cache
from pathlib import Path
from textwrap import dedent
from types import TracebackType
from typing import Any
from typing import Callable
from typing import cast
from typing import Dict
from typing import Generator
from typing import IO
Expand Down Expand Up @@ -1612,17 +1614,54 @@ def parse_warning_filter(
) -> Tuple[str, str, Type[Warning], str, int]:
"""Parse a warnings filter string.

This is copied from warnings._setoption, but does not apply the filter,
only parses it, and makes the escaping optional.
This is copied from warnings._setoption with the following changes:

* Does not apply the filter.
* Escaping is optional.
* Raises UsageError so we get nice error messages on failure.
"""
__tracebackhide__ = True
error_template = dedent(
f"""\
while parsing the following warning configuration:

{arg}

This error occurred:

{{error}}
"""
)

parts = arg.split(":")
if len(parts) > 5:
raise warnings._OptionError(f"too many fields (max 5): {arg!r}")
doc_url = (
"https://docs.python.org/3/library/warnings.html#describing-warning-filters"
)
error = dedent(
f"""\
Too many fields ({len(parts)}), expected at most 5 separated by colons:

action:message:category:module:line

For more information please consult: {doc_url}
"""
)
raise UsageError(error_template.format(error=error))

while len(parts) < 5:
parts.append("")
action_, message, category_, module, lineno_ = (s.strip() for s in parts)
action: str = warnings._getaction(action_) # type: ignore[attr-defined]
category: Type[Warning] = warnings._getcategory(category_) # type: ignore[attr-defined]
try:
action: str = warnings._getaction(action_) # type: ignore[attr-defined]
except warnings._OptionError as e:
raise UsageError(error_template.format(error=str(e)))
try:
category: Type[Warning] = _resolve_warning_category(category_)
except Exception:
exc_info = ExceptionInfo.from_current()
exception_text = exc_info.getrepr(style="native")
raise UsageError(error_template.format(error=exception_text))
if message and escape:
message = re.escape(message)
if module and escape:
Expand All @@ -1631,14 +1670,38 @@ def parse_warning_filter(
try:
lineno = int(lineno_)
if lineno < 0:
raise ValueError
except (ValueError, OverflowError) as e:
raise warnings._OptionError(f"invalid lineno {lineno_!r}") from e
raise ValueError("number is negative")
except ValueError as e:
raise UsageError(
error_template.format(error=f"invalid lineno {lineno_!r}: {e}")
)
else:
lineno = 0
return action, message, category, module, lineno


def _resolve_warning_category(category: str) -> Type[Warning]:
"""
Copied from warnings._getcategory, but changed so it lets exceptions (specially ImportErrors)
propagate so we can get access to their tracebacks (#9218).
"""
__tracebackhide__ = True
if not category:
return Warning

if "." not in category:
import builtins as m

klass = category
else:
module, _, klass = category.rpartition(".")
m = __import__(module, None, None, [klass])
cat = getattr(m, klass)
if not issubclass(cat, Warning):
raise UsageError(f"{cat} is not a Warning subclass")
return cast(Type[Warning], cat)


def apply_warning_filters(
config_filters: Iterable[str], cmdline_filters: Iterable[str]
) -> None:
Expand Down
22 changes: 18 additions & 4 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2042,11 +2042,25 @@ def test_parse_warning_filter(
assert parse_warning_filter(arg, escape=escape) == expected


@pytest.mark.parametrize("arg", [":" * 5, "::::-1", "::::not-a-number"])
@pytest.mark.parametrize(
"arg",
[
# Too much parts.
":" * 5,
# Invalid action.
"FOO::",
# ImportError when importing the warning class.
"::test_parse_warning_filter_failure.NonExistentClass::",
# Class is not a Warning subclass.
"::list::",
# Negative line number.
"::::-1",
# Not a line number.
"::::not-a-number",
],
)
def test_parse_warning_filter_failure(arg: str) -> None:
import warnings

with pytest.raises(warnings._OptionError):
with pytest.raises(pytest.UsageError):
parse_warning_filter(arg, escape=True)


Expand Down