Improve UX during errors while parsing warning filters

Fix #7864
Fix #9218

Closes #8343
Closes #7877
This commit is contained in:
Bruno Oliveira 2021-10-21 12:05:39 -03:00
parent 4a3834101e
commit f5fd2fb176
3 changed files with 93 additions and 12 deletions

View File

@ -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).

View File

@ -13,9 +13,11 @@ import types
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
@ -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:
@ -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:

View File

@ -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)