Skip to content

Commit f5fd2fb

Browse files
committed
Improve UX during errors while parsing warning filters
Fix #7864 Fix #9218 Closes #8343 Closes #7877
1 parent 4a38341 commit f5fd2fb

File tree

3 files changed

+93
-12
lines changed

3 files changed

+93
-12
lines changed

changelog/7864.improvement.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Improved error messages when parsing warning filters.
2+
3+
Previously pytest would show an internal traceback, which besides ugly sometimes would hide the cause
4+
of the problem (for example an ``ImportError`` while importing a specific warning type).

src/_pytest/config/__init__.py

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
import warnings
1414
from functools import lru_cache
1515
from pathlib import Path
16+
from textwrap import dedent
1617
from types import TracebackType
1718
from typing import Any
1819
from typing import Callable
20+
from typing import cast
1921
from typing import Dict
2022
from typing import Generator
2123
from typing import IO
@@ -1612,17 +1614,54 @@ def parse_warning_filter(
16121614
) -> Tuple[str, str, Type[Warning], str, int]:
16131615
"""Parse a warnings filter string.
16141616
1615-
This is copied from warnings._setoption, but does not apply the filter,
1616-
only parses it, and makes the escaping optional.
1617+
This is copied from warnings._setoption with the following changes:
1618+
1619+
* Does not apply the filter.
1620+
* Escaping is optional.
1621+
* Raises UsageError so we get nice error messages on failure.
16171622
"""
1623+
__tracebackhide__ = True
1624+
error_template = dedent(
1625+
f"""\
1626+
while parsing the following warning configuration:
1627+
1628+
{arg}
1629+
1630+
This error occurred:
1631+
1632+
{{error}}
1633+
"""
1634+
)
1635+
16181636
parts = arg.split(":")
16191637
if len(parts) > 5:
1620-
raise warnings._OptionError(f"too many fields (max 5): {arg!r}")
1638+
doc_url = (
1639+
"https://docs.python.org/3/library/warnings.html#describing-warning-filters"
1640+
)
1641+
error = dedent(
1642+
f"""\
1643+
Too many fields ({len(parts)}), expected at most 5 separated by colons:
1644+
1645+
action:message:category:module:line
1646+
1647+
For more information please consult: {doc_url}
1648+
"""
1649+
)
1650+
raise UsageError(error_template.format(error=error))
1651+
16211652
while len(parts) < 5:
16221653
parts.append("")
16231654
action_, message, category_, module, lineno_ = (s.strip() for s in parts)
1624-
action: str = warnings._getaction(action_) # type: ignore[attr-defined]
1625-
category: Type[Warning] = warnings._getcategory(category_) # type: ignore[attr-defined]
1655+
try:
1656+
action: str = warnings._getaction(action_) # type: ignore[attr-defined]
1657+
except warnings._OptionError as e:
1658+
raise UsageError(error_template.format(error=str(e)))
1659+
try:
1660+
category: Type[Warning] = _resolve_warning_category(category_)
1661+
except Exception:
1662+
exc_info = ExceptionInfo.from_current()
1663+
exception_text = exc_info.getrepr(style="native")
1664+
raise UsageError(error_template.format(error=exception_text))
16261665
if message and escape:
16271666
message = re.escape(message)
16281667
if module and escape:
@@ -1631,14 +1670,38 @@ def parse_warning_filter(
16311670
try:
16321671
lineno = int(lineno_)
16331672
if lineno < 0:
1634-
raise ValueError
1635-
except (ValueError, OverflowError) as e:
1636-
raise warnings._OptionError(f"invalid lineno {lineno_!r}") from e
1673+
raise ValueError("number is negative")
1674+
except ValueError as e:
1675+
raise UsageError(
1676+
error_template.format(error=f"invalid lineno {lineno_!r}: {e}")
1677+
)
16371678
else:
16381679
lineno = 0
16391680
return action, message, category, module, lineno
16401681

16411682

1683+
def _resolve_warning_category(category: str) -> Type[Warning]:
1684+
"""
1685+
Copied from warnings._getcategory, but changed so it lets exceptions (specially ImportErrors)
1686+
propagate so we can get access to their tracebacks (#9218).
1687+
"""
1688+
__tracebackhide__ = True
1689+
if not category:
1690+
return Warning
1691+
1692+
if "." not in category:
1693+
import builtins as m
1694+
1695+
klass = category
1696+
else:
1697+
module, _, klass = category.rpartition(".")
1698+
m = __import__(module, None, None, [klass])
1699+
cat = getattr(m, klass)
1700+
if not issubclass(cat, Warning):
1701+
raise UsageError(f"{cat} is not a Warning subclass")
1702+
return cast(Type[Warning], cat)
1703+
1704+
16421705
def apply_warning_filters(
16431706
config_filters: Iterable[str], cmdline_filters: Iterable[str]
16441707
) -> None:

testing/test_config.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2042,11 +2042,25 @@ def test_parse_warning_filter(
20422042
assert parse_warning_filter(arg, escape=escape) == expected
20432043

20442044

2045-
@pytest.mark.parametrize("arg", [":" * 5, "::::-1", "::::not-a-number"])
2045+
@pytest.mark.parametrize(
2046+
"arg",
2047+
[
2048+
# Too much parts.
2049+
":" * 5,
2050+
# Invalid action.
2051+
"FOO::",
2052+
# ImportError when importing the warning class.
2053+
"::test_parse_warning_filter_failure.NonExistentClass::",
2054+
# Class is not a Warning subclass.
2055+
"::list::",
2056+
# Negative line number.
2057+
"::::-1",
2058+
# Not a line number.
2059+
"::::not-a-number",
2060+
],
2061+
)
20462062
def test_parse_warning_filter_failure(arg: str) -> None:
2047-
import warnings
2048-
2049-
with pytest.raises(warnings._OptionError):
2063+
with pytest.raises(pytest.UsageError):
20502064
parse_warning_filter(arg, escape=True)
20512065

20522066

0 commit comments

Comments
 (0)