This commit is contained in:
Iliyan Angelov
2025-12-06 03:27:35 +02:00
parent 7667eb5eda
commit 5a8ca3c475
2211 changed files with 28086 additions and 37066 deletions

View File

@@ -1,13 +1,9 @@
from __future__ import annotations
__all__ = ["__version__", "version_tuple"]
try:
from ._version import version as __version__
from ._version import version_tuple
from ._version import version as __version__, version_tuple
except ImportError: # pragma: no cover
# broken installation, we don't even try
# unknown only works because we do poor mans version compare
__version__ = "unknown"
version_tuple = (0, 0, "unknown")
version_tuple = (0, 0, "unknown") # type:ignore[assignment]

View File

@@ -61,14 +61,13 @@ If things do not work right away:
which should throw a KeyError: 'COMPLINE' (which is properly set by the
global argcomplete script).
"""
from __future__ import annotations
import argparse
from glob import glob
import os
import sys
from glob import glob
from typing import Any
from typing import List
from typing import Optional
class FastFilesCompleter:
@@ -77,7 +76,7 @@ class FastFilesCompleter:
def __init__(self, directories: bool = True) -> None:
self.directories = directories
def __call__(self, prefix: str, **kwargs: Any) -> list[str]:
def __call__(self, prefix: str, **kwargs: Any) -> List[str]:
# Only called on non option completions.
if os.sep in prefix[1:]:
prefix_dir = len(os.path.dirname(prefix) + os.sep)
@@ -104,7 +103,7 @@ if os.environ.get("_ARGCOMPLETE"):
import argcomplete.completers
except ImportError:
sys.exit(-1)
filescompleter: FastFilesCompleter | None = FastFilesCompleter()
filescompleter: Optional[FastFilesCompleter] = FastFilesCompleter()
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
argcomplete.autocomplete(parser, always_complete_options=False)

View File

@@ -1,7 +1,4 @@
"""Python inspection/code generation API."""
from __future__ import annotations
from .code import Code
from .code import ExceptionInfo
from .code import filter_traceback
@@ -12,15 +9,14 @@ from .code import TracebackEntry
from .source import getrawcode
from .source import Source
__all__ = [
"Code",
"ExceptionInfo",
"Frame",
"Source",
"Traceback",
"TracebackEntry",
"filter_traceback",
"Frame",
"getfslineno",
"getrawcode",
"Traceback",
"TracebackEntry",
"Source",
]

View File

@@ -1,16 +1,17 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import ast
from bisect import bisect_right
from collections.abc import Iterable
from collections.abc import Iterator
import inspect
import textwrap
import tokenize
import types
from typing import overload
import warnings
from bisect import bisect_right
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Optional
from typing import overload
from typing import Tuple
from typing import Union
class Source:
@@ -21,17 +22,13 @@ class Source:
def __init__(self, obj: object = None) -> None:
if not obj:
self.lines: list[str] = []
self.raw_lines: list[str] = []
self.lines: List[str] = []
elif isinstance(obj, Source):
self.lines = obj.lines
self.raw_lines = obj.raw_lines
elif isinstance(obj, tuple | list):
elif isinstance(obj, (tuple, list)):
self.lines = deindent(x.rstrip("\n") for x in obj)
self.raw_lines = list(x.rstrip("\n") for x in obj)
elif isinstance(obj, str):
self.lines = deindent(obj.split("\n"))
self.raw_lines = obj.split("\n")
else:
try:
rawcode = getrawcode(obj)
@@ -39,7 +36,6 @@ class Source:
except TypeError:
src = inspect.getsource(obj) # type: ignore[arg-type]
self.lines = deindent(src.split("\n"))
self.raw_lines = src.split("\n")
def __eq__(self, other: object) -> bool:
if not isinstance(other, Source):
@@ -50,12 +46,14 @@ class Source:
__hash__ = None # type: ignore
@overload
def __getitem__(self, key: int) -> str: ...
def __getitem__(self, key: int) -> str:
...
@overload
def __getitem__(self, key: slice) -> Source: ...
def __getitem__(self, key: slice) -> "Source":
...
def __getitem__(self, key: int | slice) -> str | Source:
def __getitem__(self, key: Union[int, slice]) -> Union[str, "Source"]:
if isinstance(key, int):
return self.lines[key]
else:
@@ -63,7 +61,6 @@ class Source:
raise IndexError("cannot slice a Source with a step")
newsource = Source()
newsource.lines = self.lines[key.start : key.stop]
newsource.raw_lines = self.raw_lines[key.start : key.stop]
return newsource
def __iter__(self) -> Iterator[str]:
@@ -72,7 +69,7 @@ class Source:
def __len__(self) -> int:
return len(self.lines)
def strip(self) -> Source:
def strip(self) -> "Source":
"""Return new Source object with trailing and leading blank lines removed."""
start, end = 0, len(self)
while start < end and not self.lines[start].strip():
@@ -80,37 +77,34 @@ class Source:
while end > start and not self.lines[end - 1].strip():
end -= 1
source = Source()
source.raw_lines = self.raw_lines
source.lines[:] = self.lines[start:end]
return source
def indent(self, indent: str = " " * 4) -> Source:
def indent(self, indent: str = " " * 4) -> "Source":
"""Return a copy of the source object with all lines indented by the
given indent-string."""
newsource = Source()
newsource.raw_lines = self.raw_lines
newsource.lines = [(indent + line) for line in self.lines]
return newsource
def getstatement(self, lineno: int) -> Source:
def getstatement(self, lineno: int) -> "Source":
"""Return Source statement which contains the given linenumber
(counted from 0)."""
start, end = self.getstatementrange(lineno)
return self[start:end]
def getstatementrange(self, lineno: int) -> tuple[int, int]:
def getstatementrange(self, lineno: int) -> Tuple[int, int]:
"""Return (start, end) tuple which spans the minimal statement region
which containing the given lineno."""
if not (0 <= lineno < len(self)):
raise IndexError("lineno out of range")
_ast, start, end = getstatementrange_ast(lineno, self)
ast, start, end = getstatementrange_ast(lineno, self)
return start, end
def deindent(self) -> Source:
def deindent(self) -> "Source":
"""Return a new Source object deindented."""
newsource = Source()
newsource.lines[:] = deindent(self.lines)
newsource.raw_lines = self.raw_lines
return newsource
def __str__(self) -> str:
@@ -122,14 +116,13 @@ class Source:
#
def findsource(obj) -> tuple[Source | None, int]:
def findsource(obj) -> Tuple[Optional[Source], int]:
try:
sourcelines, lineno = inspect.findsource(obj)
except Exception:
return None, -1
source = Source()
source.lines = [line.rstrip() for line in sourcelines]
source.raw_lines = sourcelines
return source, lineno
@@ -146,23 +139,24 @@ def getrawcode(obj: object, trycall: bool = True) -> types.CodeType:
raise TypeError(f"could not get code object for {obj!r}")
def deindent(lines: Iterable[str]) -> list[str]:
def deindent(lines: Iterable[str]) -> List[str]:
return textwrap.dedent("\n".join(lines)).splitlines()
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
def get_statement_startend2(lineno: int, node: ast.AST) -> Tuple[int, Optional[int]]:
# Flatten all statements and except handlers into one lineno-list.
# AST's line numbers start indexing at 1.
values: list[int] = []
values: List[int] = []
for x in ast.walk(node):
if isinstance(x, ast.stmt | ast.ExceptHandler):
# The lineno points to the class/def, so need to include the decorators.
if isinstance(x, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
if isinstance(x, (ast.stmt, ast.ExceptHandler)):
# Before Python 3.8, the lineno of a decorated class or function pointed at the decorator.
# Since Python 3.8, the lineno points to the class/def, so need to include the decorators.
if isinstance(x, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
for d in x.decorator_list:
values.append(d.lineno - 1)
values.append(x.lineno - 1)
for name in ("finalbody", "orelse"):
val: list[ast.stmt] | None = getattr(x, name, None)
val: Optional[List[ast.stmt]] = getattr(x, name, None)
if val:
# Treat the finally/orelse part as its own statement.
values.append(val[0].lineno - 1 - 1)
@@ -180,8 +174,8 @@ def getstatementrange_ast(
lineno: int,
source: Source,
assertion: bool = False,
astnode: ast.AST | None = None,
) -> tuple[ast.AST, int, int]:
astnode: Optional[ast.AST] = None,
) -> Tuple[ast.AST, int, int]:
if astnode is None:
content = str(source)
# See #4260:
@@ -203,9 +197,7 @@ def getstatementrange_ast(
# by using the BlockFinder helper used which inspect.getsource() uses itself.
block_finder = inspect.BlockFinder()
# If we start with an indented line, put blockfinder to "started" mode.
block_finder.started = (
bool(source.lines[start]) and source.lines[start][0].isspace()
)
block_finder.started = source.lines[start][0].isspace()
it = ((x + "\n") for x in source.lines[start:end])
try:
for tok in tokenize.generate_tokens(lambda: next(it)):

View File

@@ -1,5 +1,3 @@
from __future__ import annotations
from .terminalwriter import get_terminal_width
from .terminalwriter import TerminalWriter

View File

@@ -1,673 +0,0 @@
# mypy: allow-untyped-defs
# This module was imported from the cpython standard library
# (https://github.com/python/cpython/) at commit
# c5140945c723ae6c4b7ee81ff720ac8ea4b52cfd (python3.12).
#
#
# Original Author: Fred L. Drake, Jr.
# fdrake@acm.org
#
# This is a simple little module I wrote to make life easier. I didn't
# see anything quite like it in the library, though I may have overlooked
# something. I wrote this when I was trying to read some heavily nested
# tuples with fairly non-descriptive content. This is modeled very much
# after Lisp/Scheme - style pretty-printing of lists. If you find it
# useful, thank small children who sleep at night.
from __future__ import annotations
import collections as _collections
from collections.abc import Callable
from collections.abc import Iterator
import dataclasses as _dataclasses
from io import StringIO as _StringIO
import re
import types as _types
from typing import Any
from typing import IO
class _safe_key:
"""Helper function for key functions when sorting unorderable objects.
The wrapped-object will fallback to a Py2.x style comparison for
unorderable types (sorting first comparing the type name and then by
the obj ids). Does not work recursively, so dict.items() must have
_safe_key applied to both the key and the value.
"""
__slots__ = ["obj"]
def __init__(self, obj):
self.obj = obj
def __lt__(self, other):
try:
return self.obj < other.obj
except TypeError:
return (str(type(self.obj)), id(self.obj)) < (
str(type(other.obj)),
id(other.obj),
)
def _safe_tuple(t):
"""Helper function for comparing 2-tuples"""
return _safe_key(t[0]), _safe_key(t[1])
class PrettyPrinter:
def __init__(
self,
indent: int = 4,
width: int = 80,
depth: int | None = None,
) -> None:
"""Handle pretty printing operations onto a stream using a set of
configured parameters.
indent
Number of spaces to indent for each level of nesting.
width
Attempted maximum number of columns in the output.
depth
The maximum depth to print out nested structures.
"""
if indent < 0:
raise ValueError("indent must be >= 0")
if depth is not None and depth <= 0:
raise ValueError("depth must be > 0")
if not width:
raise ValueError("width must be != 0")
self._depth = depth
self._indent_per_level = indent
self._width = width
def pformat(self, object: Any) -> str:
sio = _StringIO()
self._format(object, sio, 0, 0, set(), 0)
return sio.getvalue()
def _format(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
objid = id(object)
if objid in context:
stream.write(_recursion(object))
return
p = self._dispatch.get(type(object).__repr__, None)
if p is not None:
context.add(objid)
p(self, object, stream, indent, allowance, context, level + 1)
context.remove(objid)
elif (
_dataclasses.is_dataclass(object)
and not isinstance(object, type)
and object.__dataclass_params__.repr # type:ignore[attr-defined]
and
# Check dataclass has generated repr method.
hasattr(object.__repr__, "__wrapped__")
and "__create_fn__" in object.__repr__.__wrapped__.__qualname__
):
context.add(objid)
self._pprint_dataclass(
object, stream, indent, allowance, context, level + 1
)
context.remove(objid)
else:
stream.write(self._repr(object, context, level))
def _pprint_dataclass(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
cls_name = object.__class__.__name__
items = [
(f.name, getattr(object, f.name))
for f in _dataclasses.fields(object)
if f.repr
]
stream.write(cls_name + "(")
self._format_namespace_items(items, stream, indent, allowance, context, level)
stream.write(")")
_dispatch: dict[
Callable[..., str],
Callable[[PrettyPrinter, Any, IO[str], int, int, set[int], int], None],
] = {}
def _pprint_dict(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
write = stream.write
write("{")
items = sorted(object.items(), key=_safe_tuple)
self._format_dict_items(items, stream, indent, allowance, context, level)
write("}")
_dispatch[dict.__repr__] = _pprint_dict
def _pprint_ordered_dict(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not len(object):
stream.write(repr(object))
return
cls = object.__class__
stream.write(cls.__name__ + "(")
self._pprint_dict(object, stream, indent, allowance, context, level)
stream.write(")")
_dispatch[_collections.OrderedDict.__repr__] = _pprint_ordered_dict
def _pprint_list(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
stream.write("[")
self._format_items(object, stream, indent, allowance, context, level)
stream.write("]")
_dispatch[list.__repr__] = _pprint_list
def _pprint_tuple(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
stream.write("(")
self._format_items(object, stream, indent, allowance, context, level)
stream.write(")")
_dispatch[tuple.__repr__] = _pprint_tuple
def _pprint_set(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not len(object):
stream.write(repr(object))
return
typ = object.__class__
if typ is set:
stream.write("{")
endchar = "}"
else:
stream.write(typ.__name__ + "({")
endchar = "})"
object = sorted(object, key=_safe_key)
self._format_items(object, stream, indent, allowance, context, level)
stream.write(endchar)
_dispatch[set.__repr__] = _pprint_set
_dispatch[frozenset.__repr__] = _pprint_set
def _pprint_str(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
write = stream.write
if not len(object):
write(repr(object))
return
chunks = []
lines = object.splitlines(True)
if level == 1:
indent += 1
allowance += 1
max_width1 = max_width = self._width - indent
for i, line in enumerate(lines):
rep = repr(line)
if i == len(lines) - 1:
max_width1 -= allowance
if len(rep) <= max_width1:
chunks.append(rep)
else:
# A list of alternating (non-space, space) strings
parts = re.findall(r"\S*\s*", line)
assert parts
assert not parts[-1]
parts.pop() # drop empty last part
max_width2 = max_width
current = ""
for j, part in enumerate(parts):
candidate = current + part
if j == len(parts) - 1 and i == len(lines) - 1:
max_width2 -= allowance
if len(repr(candidate)) > max_width2:
if current:
chunks.append(repr(current))
current = part
else:
current = candidate
if current:
chunks.append(repr(current))
if len(chunks) == 1:
write(rep)
return
if level == 1:
write("(")
for i, rep in enumerate(chunks):
if i > 0:
write("\n" + " " * indent)
write(rep)
if level == 1:
write(")")
_dispatch[str.__repr__] = _pprint_str
def _pprint_bytes(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
write = stream.write
if len(object) <= 4:
write(repr(object))
return
parens = level == 1
if parens:
indent += 1
allowance += 1
write("(")
delim = ""
for rep in _wrap_bytes_repr(object, self._width - indent, allowance):
write(delim)
write(rep)
if not delim:
delim = "\n" + " " * indent
if parens:
write(")")
_dispatch[bytes.__repr__] = _pprint_bytes
def _pprint_bytearray(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
write = stream.write
write("bytearray(")
self._pprint_bytes(
bytes(object), stream, indent + 10, allowance + 1, context, level + 1
)
write(")")
_dispatch[bytearray.__repr__] = _pprint_bytearray
def _pprint_mappingproxy(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
stream.write("mappingproxy(")
self._format(object.copy(), stream, indent, allowance, context, level)
stream.write(")")
_dispatch[_types.MappingProxyType.__repr__] = _pprint_mappingproxy
def _pprint_simplenamespace(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if type(object) is _types.SimpleNamespace:
# The SimpleNamespace repr is "namespace" instead of the class
# name, so we do the same here. For subclasses; use the class name.
cls_name = "namespace"
else:
cls_name = object.__class__.__name__
items = object.__dict__.items()
stream.write(cls_name + "(")
self._format_namespace_items(items, stream, indent, allowance, context, level)
stream.write(")")
_dispatch[_types.SimpleNamespace.__repr__] = _pprint_simplenamespace
def _format_dict_items(
self,
items: list[tuple[Any, Any]],
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not items:
return
write = stream.write
item_indent = indent + self._indent_per_level
delimnl = "\n" + " " * item_indent
for key, ent in items:
write(delimnl)
write(self._repr(key, context, level))
write(": ")
self._format(ent, stream, item_indent, 1, context, level)
write(",")
write("\n" + " " * indent)
def _format_namespace_items(
self,
items: list[tuple[Any, Any]],
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not items:
return
write = stream.write
item_indent = indent + self._indent_per_level
delimnl = "\n" + " " * item_indent
for key, ent in items:
write(delimnl)
write(key)
write("=")
if id(ent) in context:
# Special-case representation of recursion to match standard
# recursive dataclass repr.
write("...")
else:
self._format(
ent,
stream,
item_indent + len(key) + 1,
1,
context,
level,
)
write(",")
write("\n" + " " * indent)
def _format_items(
self,
items: list[Any],
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not items:
return
write = stream.write
item_indent = indent + self._indent_per_level
delimnl = "\n" + " " * item_indent
for item in items:
write(delimnl)
self._format(item, stream, item_indent, 1, context, level)
write(",")
write("\n" + " " * indent)
def _repr(self, object: Any, context: set[int], level: int) -> str:
return self._safe_repr(object, context.copy(), self._depth, level)
def _pprint_default_dict(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
rdf = self._repr(object.default_factory, context, level)
stream.write(f"{object.__class__.__name__}({rdf}, ")
self._pprint_dict(object, stream, indent, allowance, context, level)
stream.write(")")
_dispatch[_collections.defaultdict.__repr__] = _pprint_default_dict
def _pprint_counter(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
stream.write(object.__class__.__name__ + "(")
if object:
stream.write("{")
items = object.most_common()
self._format_dict_items(items, stream, indent, allowance, context, level)
stream.write("}")
stream.write(")")
_dispatch[_collections.Counter.__repr__] = _pprint_counter
def _pprint_chain_map(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
if not len(object.maps) or (len(object.maps) == 1 and not len(object.maps[0])):
stream.write(repr(object))
return
stream.write(object.__class__.__name__ + "(")
self._format_items(object.maps, stream, indent, allowance, context, level)
stream.write(")")
_dispatch[_collections.ChainMap.__repr__] = _pprint_chain_map
def _pprint_deque(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
stream.write(object.__class__.__name__ + "(")
if object.maxlen is not None:
stream.write(f"maxlen={object.maxlen}, ")
stream.write("[")
self._format_items(object, stream, indent, allowance + 1, context, level)
stream.write("])")
_dispatch[_collections.deque.__repr__] = _pprint_deque
def _pprint_user_dict(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
self._format(object.data, stream, indent, allowance, context, level - 1)
_dispatch[_collections.UserDict.__repr__] = _pprint_user_dict
def _pprint_user_list(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
self._format(object.data, stream, indent, allowance, context, level - 1)
_dispatch[_collections.UserList.__repr__] = _pprint_user_list
def _pprint_user_string(
self,
object: Any,
stream: IO[str],
indent: int,
allowance: int,
context: set[int],
level: int,
) -> None:
self._format(object.data, stream, indent, allowance, context, level - 1)
_dispatch[_collections.UserString.__repr__] = _pprint_user_string
def _safe_repr(
self, object: Any, context: set[int], maxlevels: int | None, level: int
) -> str:
typ = type(object)
if typ in _builtin_scalars:
return repr(object)
r = getattr(typ, "__repr__", None)
if issubclass(typ, dict) and r is dict.__repr__:
if not object:
return "{}"
objid = id(object)
if maxlevels and level >= maxlevels:
return "{...}"
if objid in context:
return _recursion(object)
context.add(objid)
components: list[str] = []
append = components.append
level += 1
for k, v in sorted(object.items(), key=_safe_tuple):
krepr = self._safe_repr(k, context, maxlevels, level)
vrepr = self._safe_repr(v, context, maxlevels, level)
append(f"{krepr}: {vrepr}")
context.remove(objid)
return "{{{}}}".format(", ".join(components))
if (issubclass(typ, list) and r is list.__repr__) or (
issubclass(typ, tuple) and r is tuple.__repr__
):
if issubclass(typ, list):
if not object:
return "[]"
format = "[%s]"
elif len(object) == 1:
format = "(%s,)"
else:
if not object:
return "()"
format = "(%s)"
objid = id(object)
if maxlevels and level >= maxlevels:
return format % "..."
if objid in context:
return _recursion(object)
context.add(objid)
components = []
append = components.append
level += 1
for o in object:
orepr = self._safe_repr(o, context, maxlevels, level)
append(orepr)
context.remove(objid)
return format % ", ".join(components)
return repr(object)
_builtin_scalars = frozenset(
{str, bytes, bytearray, float, complex, bool, type(None), int}
)
def _recursion(object: Any) -> str:
return f"<Recursion on {type(object).__name__} with id={id(object)}>"
def _wrap_bytes_repr(object: Any, width: int, allowance: int) -> Iterator[str]:
current = b""
last = len(object) // 4 * 4
for i in range(0, len(object), 4):
part = object[i : i + 4]
candidate = current + part
if i == last:
width -= allowance
if len(repr(candidate)) > width:
if current:
yield repr(current)
current = part
else:
current = candidate
if current:
yield repr(current)

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import pprint
import reprlib
from typing import Any
from typing import Dict
from typing import IO
from typing import Optional
def _try_repr_or_str(obj: object) -> str:
@@ -18,10 +20,10 @@ def _format_repr_exception(exc: BaseException, obj: object) -> str:
exc_info = _try_repr_or_str(exc)
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as inner_exc:
exc_info = f"unpresentable exception ({_try_repr_or_str(inner_exc)})"
return (
f"<[{exc_info} raised in repr()] {type(obj).__name__} object at 0x{id(obj):x}>"
except BaseException as exc:
exc_info = f"unpresentable exception ({_try_repr_or_str(exc)})"
return "<[{} raised in repr()] {} object at 0x{:x}>".format(
exc_info, type(obj).__name__, id(obj)
)
@@ -39,7 +41,7 @@ class SafeRepr(reprlib.Repr):
information on exceptions raised during the call.
"""
def __init__(self, maxsize: int | None, use_ascii: bool = False) -> None:
def __init__(self, maxsize: Optional[int], use_ascii: bool = False) -> None:
"""
:param maxsize:
If not None, will truncate the resulting repr to that specific size, using ellipsis
@@ -60,6 +62,7 @@ class SafeRepr(reprlib.Repr):
s = ascii(x)
else:
s = super().repr(x)
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as exc:
@@ -97,7 +100,7 @@ DEFAULT_REPR_MAX_SIZE = 240
def saferepr(
obj: object, maxsize: int | None = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
) -> str:
"""Return a size-limited safe repr-string for the given object.
@@ -108,6 +111,7 @@ def saferepr(
This function is a wrapper around the Repr/reprlib functionality of the
stdlib.
"""
return SafeRepr(maxsize, use_ascii).repr(obj)
@@ -128,3 +132,49 @@ def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str:
return repr(obj)
except Exception as exc:
return _format_repr_exception(exc, obj)
class AlwaysDispatchingPrettyPrinter(pprint.PrettyPrinter):
"""PrettyPrinter that always dispatches (regardless of width)."""
def _format(
self,
object: object,
stream: IO[str],
indent: int,
allowance: int,
context: Dict[int, Any],
level: int,
) -> None:
# Type ignored because _dispatch is private.
p = self._dispatch.get(type(object).__repr__, None) # type: ignore[attr-defined]
objid = id(object)
if objid in context or p is None:
# Type ignored because _format is private.
super()._format( # type: ignore[misc]
object,
stream,
indent,
allowance,
context,
level,
)
return
context[objid] = 1
p(self, object, stream, indent, allowance, context, level + 1)
del context[objid]
def _pformat_dispatch(
object: object,
indent: int = 1,
width: int = 80,
depth: Optional[int] = None,
*,
compact: bool = False,
) -> str:
return AlwaysDispatchingPrettyPrinter(
indent=indent, width=width, depth=depth, compact=compact
).pformat(object)

View File

@@ -1,23 +1,13 @@
"""Helper functions for writing to terminals and files."""
from __future__ import annotations
from collections.abc import Sequence
import os
import shutil
import sys
from typing import final
from typing import Literal
from typing import Optional
from typing import Sequence
from typing import TextIO
import pygments
from pygments.formatters.terminal import TerminalFormatter
from pygments.lexer import Lexer
from pygments.lexers.diff import DiffLexer
from pygments.lexers.python import PythonLexer
from ..compat import assert_never
from .wcwidth import wcswidth
from _pytest.compat import final
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
@@ -38,9 +28,9 @@ def should_do_markup(file: TextIO) -> bool:
return True
if os.environ.get("PY_COLORS") == "0":
return False
if os.environ.get("NO_COLOR"):
if "NO_COLOR" in os.environ:
return False
if os.environ.get("FORCE_COLOR"):
if "FORCE_COLOR" in os.environ:
return True
return (
hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
@@ -72,7 +62,7 @@ class TerminalWriter:
invert=7,
)
def __init__(self, file: TextIO | None = None) -> None:
def __init__(self, file: Optional[TextIO] = None) -> None:
if file is None:
file = sys.stdout
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
@@ -86,7 +76,7 @@ class TerminalWriter:
self._file = file
self.hasmarkup = should_do_markup(file)
self._current_line = ""
self._terminal_width: int | None = None
self._terminal_width: Optional[int] = None
self.code_highlight = True
@property
@@ -111,14 +101,14 @@ class TerminalWriter:
if self.hasmarkup:
esc = [self._esctable[name] for name, on in markup.items() if on]
if esc:
text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m"
return text
def sep(
self,
sepchar: str,
title: str | None = None,
fullwidth: int | None = None,
title: Optional[str] = None,
fullwidth: Optional[int] = None,
**markup: bool,
) -> None:
if fullwidth is None:
@@ -161,23 +151,20 @@ class TerminalWriter:
msg = self.markup(msg, **markup)
self.write_raw(msg, flush=flush)
try:
self._file.write(msg)
except UnicodeEncodeError:
# Some environments don't support printing general Unicode
# strings, due to misconfiguration or otherwise; in that case,
# print the string escaped to ASCII.
# When the Unicode situation improves we should consider
# letting the error propagate instead of masking it (see #7475
# for one brief attempt).
msg = msg.encode("unicode-escape").decode("ascii")
self._file.write(msg)
def write_raw(self, msg: str, *, flush: bool = False) -> None:
try:
self._file.write(msg)
except UnicodeEncodeError:
# Some environments don't support printing general Unicode
# strings, due to misconfiguration or otherwise; in that case,
# print the string escaped to ASCII.
# When the Unicode situation improves we should consider
# letting the error propagate instead of masking it (see #7475
# for one brief attempt).
msg = msg.encode("unicode-escape").decode("ascii")
self._file.write(msg)
if flush:
self.flush()
if flush:
self.flush()
def line(self, s: str = "", **markup: bool) -> None:
self.write(s, **markup)
@@ -195,64 +182,52 @@ class TerminalWriter:
"""
if indents and len(indents) != len(lines):
raise ValueError(
f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
"indents size ({}) should have same size as lines ({})".format(
len(indents), len(lines)
)
)
if not indents:
indents = [""] * len(lines)
source = "\n".join(lines)
new_lines = self._highlight(source).splitlines()
# Would be better to strict=True but that fails some CI jobs.
for indent, new_line in zip(indents, new_lines, strict=False):
for indent, new_line in zip(indents, new_lines):
self.line(indent + new_line)
def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer:
if lexer == "python":
return PythonLexer()
elif lexer == "diff":
return DiffLexer()
else:
assert_never(lexer)
def _get_pygments_formatter(self) -> TerminalFormatter:
def _highlight(self, source: str) -> str:
"""Highlight the given source code if we have markup support."""
from _pytest.config.exceptions import UsageError
theme = os.getenv("PYTEST_THEME")
theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
try:
return TerminalFormatter(bg=theme_mode, style=theme)
except pygments.util.ClassNotFound as e:
raise UsageError(
f"PYTEST_THEME environment variable has an invalid value: '{theme}'. "
"Hint: See available pygments styles with `pygmentize -L styles`."
) from e
except pygments.util.OptionError as e:
raise UsageError(
f"PYTEST_THEME_MODE environment variable has an invalid value: '{theme_mode}'. "
"The allowed values are 'dark' (default) and 'light'."
) from e
def _highlight(
self, source: str, lexer: Literal["diff", "python"] = "python"
) -> str:
"""Highlight the given source if we have markup support."""
if not source or not self.hasmarkup or not self.code_highlight:
if not self.hasmarkup or not self.code_highlight:
return source
pygments_lexer = self._get_pygments_lexer(lexer)
pygments_formatter = self._get_pygments_formatter()
highlighted: str = pygments.highlight(
source, pygments_lexer, pygments_formatter
)
# pygments terminal formatter may add a newline when there wasn't one.
# We don't want this, remove.
if highlighted[-1] == "\n" and source[-1] != "\n":
highlighted = highlighted[:-1]
# Some lexers will not set the initial color explicitly
# which may lead to the previous color being propagated to the
# start of the expression, so reset first.
highlighted = "\x1b[0m" + highlighted
return highlighted
try:
from pygments.formatters.terminal import TerminalFormatter
from pygments.lexers.python import PythonLexer
from pygments import highlight
import pygments.util
except ImportError:
return source
else:
try:
highlighted: str = highlight(
source,
PythonLexer(),
TerminalFormatter(
bg=os.getenv("PYTEST_THEME_MODE", "dark"),
style=os.getenv("PYTEST_THEME"),
),
)
return highlighted
except pygments.util.ClassNotFound:
raise UsageError(
"PYTEST_THEME environment variable had an invalid value: '{}'. "
"Only valid pygment styles are allowed.".format(
os.getenv("PYTEST_THEME")
)
)
except pygments.util.OptionError:
raise UsageError(
"PYTEST_THEME_MODE environment variable had an invalid value: '{}'. "
"The only allowed values are 'dark' and 'light'.".format(
os.getenv("PYTEST_THEME_MODE")
)
)

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from functools import lru_cache
import unicodedata
from functools import lru_cache
@lru_cache(100)

View File

@@ -1,15 +1,13 @@
"""create errno-specific classes for IO or os calls."""
from __future__ import annotations
from collections.abc import Callable
import errno
import os
import sys
from typing import Callable
from typing import TYPE_CHECKING
from typing import TypeVar
if TYPE_CHECKING:
from typing_extensions import ParamSpec
@@ -41,7 +39,7 @@ _winerrnomap = {
3: errno.ENOENT,
17: errno.EEXIST,
18: errno.EXDEV,
13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailable
13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailiable
22: errno.ENOTDIR,
20: errno.ENOTDIR,
267: errno.ENOTDIR,
@@ -69,7 +67,7 @@ class ErrorMaker:
try:
return self._errno2class[eno]
except KeyError:
clsname = errno.errorcode.get(eno, f"UnknownErrno{eno}")
clsname = errno.errorcode.get(eno, "UnknownErrno%d" % (eno,))
errorcls = type(
clsname,
(Error,),
@@ -90,23 +88,15 @@ class ErrorMaker:
except OSError as value:
if not hasattr(value, "errno"):
raise
errno = value.errno
if sys.platform == "win32":
try:
# error: Invalid index type "Optional[int]" for "dict[int, int]"; expected type "int" [index]
# OK to ignore because we catch the KeyError below.
cls = self._geterrnoclass(_winerrnomap[value.errno]) # type:ignore[index]
cls = self._geterrnoclass(_winerrnomap[errno])
except KeyError:
raise value
else:
# we are not on Windows, or we got a proper OSError
if value.errno is None:
cls = type(
"UnknownErrnoNone",
(Error,),
{"__module__": "py.error", "__doc__": None},
)
else:
cls = self._geterrnoclass(value.errno)
cls = self._geterrnoclass(errno)
raise cls(f"{func.__name__}{args!r}")

View File

@@ -1,15 +1,16 @@
# mypy: allow-untyped-defs
"""local path implementation."""
from __future__ import annotations
import atexit
from collections.abc import Callable
from contextlib import contextmanager
import fnmatch
import importlib.util
import io
import os
import posixpath
import sys
import uuid
import warnings
from contextlib import contextmanager
from os.path import abspath
from os.path import dirname
from os.path import exists
@@ -18,21 +19,19 @@ from os.path import isdir
from os.path import isfile
from os.path import islink
from os.path import normpath
import posixpath
from stat import S_ISDIR
from stat import S_ISLNK
from stat import S_ISREG
import sys
from typing import Any
from typing import Callable
from typing import cast
from typing import Literal
from typing import overload
from typing import TYPE_CHECKING
import uuid
import warnings
from . import error
if TYPE_CHECKING:
from typing import Literal
# Moved from local.py.
iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt")
@@ -161,13 +160,15 @@ class Visitor:
)
if not self.breadthfirst:
for subdir in dirs:
yield from self.gen(subdir)
for p in self.gen(subdir):
yield p
for p in self.optsort(entries):
if self.fil is None or self.fil(p):
yield p
if self.breadthfirst:
for subdir in dirs:
yield from self.gen(subdir)
for p in self.gen(subdir):
yield p
class FNMatcher:
@@ -204,10 +205,12 @@ class Stat:
if TYPE_CHECKING:
@property
def size(self) -> int: ...
def size(self) -> int:
...
@property
def mtime(self) -> float: ...
def mtime(self) -> float:
...
def __getattr__(self, name: str) -> Any:
return getattr(self._osstatresult, "st_" + name)
@@ -222,7 +225,7 @@ class Stat:
raise NotImplementedError("XXX win32")
import pwd
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined,unused-ignore]
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined]
return entry[0]
@property
@@ -232,7 +235,7 @@ class Stat:
raise NotImplementedError("XXX win32")
import grp
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined,unused-ignore]
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined]
return entry[0]
def isdir(self):
@@ -250,7 +253,7 @@ def getuserid(user):
import pwd
if not isinstance(user, int):
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined,unused-ignore]
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined]
return user
@@ -258,7 +261,7 @@ def getgroupid(group):
import grp
if not isinstance(group, int):
group = grp.getgrnam(group)[2] # type:ignore[attr-defined,unused-ignore]
group = grp.getgrnam(group)[2] # type:ignore[attr-defined]
return group
@@ -315,7 +318,7 @@ class LocalPath:
def readlink(self) -> str:
"""Return value of a symbolic link."""
# https://github.com/python/mypy/issues/12278
return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value,unused-ignore]
return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value]
def mklinkto(self, oldname):
"""Posix style hard link to another name."""
@@ -432,7 +435,7 @@ class LocalPath:
"""Return a string which is the relative part of the path
to the given 'relpath'.
"""
if not isinstance(relpath, str | LocalPath):
if not isinstance(relpath, (str, LocalPath)):
raise TypeError(f"{relpath!r}: not a string or path object")
strrelpath = str(relpath)
if strrelpath and strrelpath[-1] != self.sep:
@@ -449,7 +452,7 @@ class LocalPath:
def ensure_dir(self, *args):
"""Ensure the path joined with args is a directory."""
return self.ensure(*args, dir=True)
return self.ensure(*args, **{"dir": True})
def bestrelpath(self, dest):
"""Return a string which is a relative path from self
@@ -652,12 +655,12 @@ class LocalPath:
if not kw:
obj.strpath = self.strpath
return obj
drive, dirname, _basename, purebasename, ext = self._getbyspec(
drive, dirname, basename, purebasename, ext = self._getbyspec(
"drive,dirname,basename,purebasename,ext"
)
if "basename" in kw:
if "purebasename" in kw or "ext" in kw:
raise ValueError(f"invalid specification {kw!r}")
raise ValueError("invalid specification %r" % kw)
else:
pb = kw.setdefault("purebasename", purebasename)
try:
@@ -674,7 +677,7 @@ class LocalPath:
else:
kw.setdefault("dirname", dirname)
kw.setdefault("sep", self.sep)
obj.strpath = normpath("{dirname}{sep}{basename}".format(**kw))
obj.strpath = normpath("%(dirname)s%(sep)s%(basename)s" % kw)
return obj
def _getbyspec(self, spec: str) -> list[str]:
@@ -703,7 +706,7 @@ class LocalPath:
elif name == "ext":
res.append(ext)
else:
raise ValueError(f"invalid part specification {name!r}")
raise ValueError("invalid part specification %r" % name)
return res
def dirpath(self, *args, **kwargs):
@@ -754,12 +757,7 @@ class LocalPath:
if ensure:
self.dirpath().ensure(dir=1)
if encoding:
return error.checked_call(
io.open,
self.strpath,
mode,
encoding=encoding,
)
return error.checked_call(io.open, self.strpath, mode, encoding=encoding)
return error.checked_call(open, self.strpath, mode)
def _fastjoin(self, name):
@@ -777,11 +775,11 @@ class LocalPath:
valid checkers::
file = 1 # is a file
file = 0 # is not a file (may not even exist)
dir = 1 # is a dir
link = 1 # is a link
exists = 1 # exists
file=1 # is a file
file=0 # is not a file (may not even exist)
dir=1 # is a dir
link=1 # is a link
exists=1 # exists
You can specify multiple checker definitions, for example::
@@ -834,7 +832,7 @@ class LocalPath:
def copy(self, target, mode=False, stat=False):
"""Copy path to target.
If mode is True, will copy permission from path to target.
If mode is True, will copy copy permission from path to target.
If stat is True, copy permission, last modification
time, last access time, and flags from path to target.
"""
@@ -959,10 +957,12 @@ class LocalPath:
return p
@overload
def stat(self, raising: Literal[True] = ...) -> Stat: ...
def stat(self, raising: Literal[True] = ...) -> Stat:
...
@overload
def stat(self, raising: Literal[False]) -> Stat | None: ...
def stat(self, raising: Literal[False]) -> Stat | None:
...
def stat(self, raising: bool = True) -> Stat | None:
"""Return an os.stat() tuple."""
@@ -1024,7 +1024,7 @@ class LocalPath:
return self.stat().atime
def __repr__(self):
return f"local({self.strpath!r})"
return "local(%r)" % self.strpath
def __str__(self):
"""Return string representation of the Path."""
@@ -1045,7 +1045,7 @@ class LocalPath:
def pypkgpath(self):
"""Return the Python package path by looking for the last
directory upwards which still contains an __init__.py.
Return None if a pkgpath cannot be determined.
Return None if a pkgpath can not be determined.
"""
pkgpath = None
for parent in self.parts(reverse=True):
@@ -1096,7 +1096,9 @@ class LocalPath:
modname = self.purebasename
spec = importlib.util.spec_from_file_location(modname, str(self))
if spec is None or spec.loader is None:
raise ImportError(f"Can't find module {modname} at location {self!s}")
raise ImportError(
f"Can't find module {modname} at location {str(self)}"
)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@@ -1161,8 +1163,7 @@ class LocalPath:
where the 'self' path points to executable.
The process is directly invoked and not through a system shell.
"""
from subprocess import PIPE
from subprocess import Popen
from subprocess import Popen, PIPE
popen_opts.pop("stdout", None)
popen_opts.pop("stderr", None)
@@ -1262,14 +1263,13 @@ class LocalPath:
@classmethod
def mkdtemp(cls, rootdir=None):
"""Return a Path object pointing to a fresh new temporary directory
(which we created ourselves).
(which we created ourself).
"""
import tempfile
if rootdir is None:
rootdir = cls.get_temproot()
path = error.checked_call(tempfile.mkdtemp, dir=str(rootdir))
return cls(path)
return cls(error.checked_call(tempfile.mkdtemp, dir=str(rootdir)))
@classmethod
def make_numbered_dir(

View File

@@ -1,34 +1,16 @@
# file generated by setuptools-scm
# file generated by setuptools_scm
# don't change, don't track in version control
__all__ = [
"__version__",
"__version_tuple__",
"version",
"version_tuple",
"__commit_id__",
"commit_id",
]
TYPE_CHECKING = False
if TYPE_CHECKING:
from typing import Tuple
from typing import Union
from typing import Tuple, Union
VERSION_TUPLE = Tuple[Union[int, str], ...]
COMMIT_ID = Union[str, None]
else:
VERSION_TUPLE = object
COMMIT_ID = object
version: str
__version__: str
__version_tuple__: VERSION_TUPLE
version_tuple: VERSION_TUPLE
commit_id: COMMIT_ID
__commit_id__: COMMIT_ID
__version__ = version = '9.0.1'
__version_tuple__ = version_tuple = (9, 0, 1)
__commit_id__ = commit_id = None
__version__ = version = '7.4.3'
__version_tuple__ = version_tuple = (7, 4, 3)

View File

@@ -1,12 +1,9 @@
# mypy: allow-untyped-defs
"""Support for presenting detailed information in failing assertions."""
from __future__ import annotations
from collections.abc import Generator
import sys
from typing import Any
from typing import Protocol
from typing import Generator
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
from _pytest.assertion import rewrite
@@ -18,7 +15,6 @@ from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
if TYPE_CHECKING:
from _pytest.main import Session
@@ -47,26 +43,6 @@ def pytest_addoption(parser: Parser) -> None:
"Make sure to delete any previously generated pyc cache files.",
)
parser.addini(
"truncation_limit_lines",
default=None,
help="Set threshold of LINES after which truncation will take effect",
)
parser.addini(
"truncation_limit_chars",
default=None,
help=("Set threshold of CHARS after which truncation will take effect"),
)
Config._add_verbosity_ini(
parser,
Config.VERBOSITY_ASSERTIONS,
help=(
"Specify a verbosity level for assertions, overriding the main level. "
"Higher levels will provide more detailed explanation when an assertion fails."
),
)
def register_assert_rewrite(*names: str) -> None:
"""Register one or more module names to be rewritten on import.
@@ -83,18 +59,15 @@ def register_assert_rewrite(*names: str) -> None:
if not isinstance(name, str):
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
raise TypeError(msg.format(repr(names)))
rewrite_hook: RewriteHook
for hook in sys.meta_path:
if isinstance(hook, rewrite.AssertionRewritingHook):
rewrite_hook = hook
importhook = hook
break
else:
rewrite_hook = DummyRewriteHook()
rewrite_hook.mark_rewrite(*names)
class RewriteHook(Protocol):
def mark_rewrite(self, *names: str) -> None: ...
# TODO(typing): Add a protocol for mark_rewrite() and use it
# for importhook and for PytestPluginManager.rewrite_hook.
importhook = DummyRewriteHook() # type: ignore
importhook.mark_rewrite(*names)
class DummyRewriteHook:
@@ -110,7 +83,7 @@ class AssertionState:
def __init__(self, config: Config, mode) -> None:
self.mode = mode
self.trace = config.trace.root.get("assertion")
self.hook: rewrite.AssertionRewritingHook | None = None
self.hook: Optional[rewrite.AssertionRewritingHook] = None
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
@@ -129,7 +102,7 @@ def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
return hook
def pytest_collection(session: Session) -> None:
def pytest_collection(session: "Session") -> None:
# This hook is only called when test modules are collected
# so for example not in the managing process of pytest-xdist
# (which does not collect test modules).
@@ -139,17 +112,18 @@ def pytest_collection(session: Session) -> None:
assertstate.hook.set_session(session)
@hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
@hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
The rewrite module will use util._reprcompare if it exists to use custom
reporting via the pytest_assertrepr_compare hook. This sets up this custom
comparison for the test.
"""
ihook = item.ihook
def callbinrepr(op, left: object, right: object) -> str | None:
def callbinrepr(op, left: object, right: object) -> Optional[str]:
"""Call the pytest_assertrepr_compare hook and prepare the result.
This uses the first result from the hook and then ensures the
@@ -188,14 +162,13 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
util._assertion_pass = call_assertion_pass_hook
try:
return (yield)
finally:
util._reprcompare, util._assertion_pass = saved_assert_hooks
util._config = None
yield
util._reprcompare, util._assertion_pass = saved_assert_hooks
util._config = None
def pytest_sessionfinish(session: Session) -> None:
def pytest_sessionfinish(session: "Session") -> None:
assertstate = session.config.stash.get(assertstate_key, None)
if assertstate:
if assertstate.hook is not None:
@@ -204,5 +177,5 @@ def pytest_sessionfinish(session: Session) -> None:
def pytest_assertrepr_compare(
config: Config, op: str, left: Any, right: Any
) -> list[str] | None:
) -> Optional[List[str]]:
return util.assertrepr_compare(config=config, op=op, left=left, right=right)

View File

@@ -1,13 +1,5 @@
"""Rewrite assertion AST to produce nice error messages."""
from __future__ import annotations
import ast
from collections import defaultdict
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Sequence
import errno
import functools
import importlib.abc
@@ -17,46 +9,53 @@ import io
import itertools
import marshal
import os
from pathlib import Path
from pathlib import PurePath
import struct
import sys
import tokenize
import types
from collections import defaultdict
from pathlib import Path
from pathlib import PurePath
from typing import Callable
from typing import Dict
from typing import IO
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Optional
from typing import Sequence
from typing import Set
from typing import Tuple
from typing import TYPE_CHECKING
if sys.version_info >= (3, 12):
from importlib.resources.abc import TraversableResources
else:
from importlib.abc import TraversableResources
if sys.version_info < (3, 11):
from importlib.readers import FileReader
else:
from importlib.resources.readers import FileReader
from typing import Union
from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE
from _pytest._io.saferepr import saferepr
from _pytest._io.saferepr import saferepr_unlimited
from _pytest._version import version
from _pytest.assertion import util
from _pytest.assertion.util import ( # noqa: F401
format_explanation as _format_explanation,
)
from _pytest.config import Config
from _pytest.fixtures import FixtureFunctionDefinition
from _pytest.main import Session
from _pytest.pathlib import absolutepath
from _pytest.pathlib import fnmatch_ex
from _pytest.stash import StashKey
# fmt: off
from _pytest.assertion.util import format_explanation as _format_explanation # noqa:F401, isort:skip
# fmt:on
if TYPE_CHECKING:
from _pytest.assertion import AssertionState
if sys.version_info >= (3, 8):
namedExpr = ast.NamedExpr
astNameConstant = ast.Constant
astStr = ast.Constant
astNum = ast.Constant
else:
namedExpr = ast.Expr
astNameConstant = ast.NameConstant
astStr = ast.Str
astNum = ast.Num
class Sentinel:
pass
@@ -66,7 +65,7 @@ assertstate_key = StashKey["AssertionState"]()
# pytest caches rewritten pycs in pycache dirs
PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}"
PYC_EXT = ".py" + ((__debug__ and "c") or "o")
PYC_EXT = ".py" + (__debug__ and "c" or "o")
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
# Special marker that denotes we have just left a scope definition
@@ -82,17 +81,17 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
self.fnpats = config.getini("python_files")
except ValueError:
self.fnpats = ["test_*.py", "*_test.py"]
self.session: Session | None = None
self._rewritten_names: dict[str, Path] = {}
self._must_rewrite: set[str] = set()
self.session: Optional[Session] = None
self._rewritten_names: Dict[str, Path] = {}
self._must_rewrite: Set[str] = set()
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
# which might result in infinite recursion (#3506)
self._writing_pyc = False
self._basenames_to_check_rewrite = {"conftest"}
self._marked_for_rewrite_cache: dict[str, bool] = {}
self._marked_for_rewrite_cache: Dict[str, bool] = {}
self._session_paths_checked = False
def set_session(self, session: Session | None) -> None:
def set_session(self, session: Optional[Session]) -> None:
self.session = session
self._session_paths_checked = False
@@ -102,28 +101,18 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
def find_spec(
self,
name: str,
path: Sequence[str | bytes] | None = None,
target: types.ModuleType | None = None,
) -> importlib.machinery.ModuleSpec | None:
path: Optional[Sequence[Union[str, bytes]]] = None,
target: Optional[types.ModuleType] = None,
) -> Optional[importlib.machinery.ModuleSpec]:
if self._writing_pyc:
return None
state = self.config.stash[assertstate_key]
if self._early_rewrite_bailout(name, state):
return None
state.trace(f"find_module called for: {name}")
state.trace("find_module called for: %s" % name)
# Type ignored because mypy is confused about the `self` binding here.
spec = self._find_spec(name, path) # type: ignore
if spec is None and path is not None:
# With --import-mode=importlib, PathFinder cannot find spec without modifying `sys.path`,
# causing inability to assert rewriting (#12659).
# At this point, try using the file path to find the module spec.
for _path_str in path:
spec = importlib.util.spec_from_file_location(name, _path_str)
if spec is not None:
break
if (
# the import machinery could not find a file to import
spec is None
@@ -151,7 +140,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
def create_module(
self, spec: importlib.machinery.ModuleSpec
) -> types.ModuleType | None:
) -> Optional[types.ModuleType]:
return None # default behaviour is fine
def exec_module(self, module: types.ModuleType) -> None:
@@ -196,7 +185,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
state.trace(f"found cached rewritten pyc for {fn}")
exec(co, module.__dict__)
def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool:
def _early_rewrite_bailout(self, name: str, state: "AssertionState") -> bool:
"""A fast way to get out of rewriting modules.
Profiling has shown that the call to PathFinder.find_spec (inside of
@@ -235,7 +224,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
state.trace(f"early skip of rewriting module: {name}")
return True
def _should_rewrite(self, name: str, fn: str, state: AssertionState) -> bool:
def _should_rewrite(self, name: str, fn: str, state: "AssertionState") -> bool:
# always rewrite conftest files
if os.path.basename(fn) == "conftest.py":
state.trace(f"rewriting conftest file: {fn!r}")
@@ -256,7 +245,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
return self._is_marked_for_rewrite(name, state)
def _is_marked_for_rewrite(self, name: str, state: AssertionState) -> bool:
def _is_marked_for_rewrite(self, name: str, state: "AssertionState") -> bool:
try:
return self._marked_for_rewrite_cache[name]
except KeyError:
@@ -292,18 +281,31 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
self.config.issue_config_time_warning(
PytestAssertRewriteWarning(
f"Module already imported so cannot be rewritten; {name}"
"Module already imported so cannot be rewritten: %s" % name
),
stacklevel=5,
)
def get_data(self, pathname: str | bytes) -> bytes:
def get_data(self, pathname: Union[str, bytes]) -> bytes:
"""Optional PEP302 get_data API."""
with open(pathname, "rb") as f:
return f.read()
def get_resource_reader(self, name: str) -> TraversableResources:
return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) # type: ignore[arg-type]
if sys.version_info >= (3, 10):
if sys.version_info >= (3, 12):
from importlib.resources.abc import TraversableResources
else:
from importlib.abc import TraversableResources
def get_resource_reader(self, name: str) -> TraversableResources: # type: ignore
if sys.version_info < (3, 11):
from importlib.readers import FileReader
else:
from importlib.resources.readers import FileReader
return FileReader( # type:ignore[no-any-return]
types.SimpleNamespace(path=self._rewritten_names[name])
)
def _write_pyc_fp(
@@ -325,7 +327,7 @@ def _write_pyc_fp(
def _write_pyc(
state: AssertionState,
state: "AssertionState",
co: types.CodeType,
source_stat: os.stat_result,
pyc: Path,
@@ -349,7 +351,7 @@ def _write_pyc(
return True
def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeType]:
def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeType]:
"""Read and rewrite *fn* and return the code object."""
stat = os.stat(fn)
source = fn.read_bytes()
@@ -362,7 +364,7 @@ def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeT
def _read_pyc(
source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None
) -> types.CodeType | None:
) -> Optional[types.CodeType]:
"""Possibly read a pytest pyc containing rewritten code.
Return rewritten code if successful or None if not.
@@ -382,21 +384,21 @@ def _read_pyc(
return None
# Check for invalid or out of date pyc file.
if len(data) != (16):
trace(f"_read_pyc({source}): invalid pyc (too short)")
trace("_read_pyc(%s): invalid pyc (too short)" % source)
return None
if data[:4] != importlib.util.MAGIC_NUMBER:
trace(f"_read_pyc({source}): invalid pyc (bad magic number)")
trace("_read_pyc(%s): invalid pyc (bad magic number)" % source)
return None
if data[4:8] != b"\x00\x00\x00\x00":
trace(f"_read_pyc({source}): invalid pyc (unsupported flags)")
trace("_read_pyc(%s): invalid pyc (unsupported flags)" % source)
return None
mtime_data = data[8:12]
if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF:
trace(f"_read_pyc({source}): out of date")
trace("_read_pyc(%s): out of date" % source)
return None
size_data = data[12:16]
if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF:
trace(f"_read_pyc({source}): invalid pyc (incorrect size)")
trace("_read_pyc(%s): invalid pyc (incorrect size)" % source)
return None
try:
co = marshal.load(fp)
@@ -404,7 +406,7 @@ def _read_pyc(
trace(f"_read_pyc({source}): marshal.load error {e}")
return None
if not isinstance(co, types.CodeType):
trace(f"_read_pyc({source}): not a code object")
trace("_read_pyc(%s): not a code object" % source)
return None
return co
@@ -412,8 +414,8 @@ def _read_pyc(
def rewrite_asserts(
mod: ast.Module,
source: bytes,
module_path: str | None = None,
config: Config | None = None,
module_path: Optional[str] = None,
config: Optional[Config] = None,
) -> None:
"""Rewrite the assert statements in mod."""
AssertionRewriter(module_path, config, source).run(mod)
@@ -429,22 +431,13 @@ def _saferepr(obj: object) -> str:
sequences, especially '\n{' and '\n}' are likely to be present in
JSON reprs.
"""
if isinstance(obj, types.MethodType):
# for bound methods, skip redundant <bound method ...> information
return obj.__name__
maxsize = _get_maxsize_for_saferepr(util._config)
if not maxsize:
return saferepr_unlimited(obj).replace("\n", "\\n")
return saferepr(obj, maxsize=maxsize).replace("\n", "\\n")
def _get_maxsize_for_saferepr(config: Config | None) -> int | None:
def _get_maxsize_for_saferepr(config: Optional[Config]) -> Optional[int]:
"""Get `maxsize` configuration for saferepr based on the given config object."""
if config is None:
verbosity = 0
else:
verbosity = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
verbosity = config.getoption("verbose") if config is not None else 0
if verbosity >= 2:
return None
if verbosity >= 1:
@@ -465,7 +458,7 @@ def _format_assertmsg(obj: object) -> str:
# However in either case we want to preserve the newline.
replaces = [("\n", "\n~"), ("%", "%%")]
if not isinstance(obj, str):
obj = saferepr(obj, _get_maxsize_for_saferepr(util._config))
obj = saferepr(obj)
replaces.append(("\\n", "\n~"))
for r1, r2 in replaces:
@@ -476,8 +469,7 @@ def _format_assertmsg(obj: object) -> str:
def _should_repr_global_name(obj: object) -> bool:
if callable(obj):
# For pytest fixtures the __repr__ method provides more information than the function name.
return isinstance(obj, FixtureFunctionDefinition)
return False
try:
return not hasattr(obj, "__name__")
@@ -486,7 +478,7 @@ def _should_repr_global_name(obj: object) -> bool:
def _format_boolop(explanations: Iterable[str], is_or: bool) -> str:
explanation = "(" + ((is_or and " or ") or " and ").join(explanations) + ")"
explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")"
return explanation.replace("%", "%%")
@@ -496,7 +488,7 @@ def _call_reprcompare(
expls: Sequence[str],
each_obj: Sequence[object],
) -> str:
for i, res, expl in zip(range(len(ops)), results, expls, strict=True):
for i, res, expl in zip(range(len(ops)), results, expls):
try:
done = not res
except Exception:
@@ -558,14 +550,14 @@ def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
@functools.lru_cache(maxsize=1)
def _get_assertion_exprs(src: bytes) -> dict[int, str]:
def _get_assertion_exprs(src: bytes) -> Dict[int, str]:
"""Return a mapping from {lineno: "assertion test expression"}."""
ret: dict[int, str] = {}
ret: Dict[int, str] = {}
depth = 0
lines: list[str] = []
assert_lineno: int | None = None
seen_lines: set[int] = set()
lines: List[str] = []
assert_lineno: Optional[int] = None
seen_lines: Set[int] = set()
def _write_and_reset() -> None:
nonlocal depth, lines, assert_lineno, seen_lines
@@ -599,7 +591,7 @@ def _get_assertion_exprs(src: bytes) -> dict[int, str]:
# multi-line assert with message
elif lineno in seen_lines:
lines[-1] = lines[-1][:offset]
# multi line assert with escaped newline before message
# multi line assert with escapd newline before message
else:
lines.append(line[:offset])
_write_and_reset()
@@ -672,7 +664,7 @@ class AssertionRewriter(ast.NodeVisitor):
"""
def __init__(
self, module_path: str | None, config: Config | None, source: bytes
self, module_path: Optional[str], config: Optional[Config], source: bytes
) -> None:
super().__init__()
self.module_path = module_path
@@ -685,9 +677,9 @@ class AssertionRewriter(ast.NodeVisitor):
self.enable_assertion_pass_hook = False
self.source = source
self.scope: tuple[ast.AST, ...] = ()
self.variables_overwrite: defaultdict[tuple[ast.AST, ...], dict[str, str]] = (
defaultdict(dict)
)
self.variables_overwrite: defaultdict[
tuple[ast.AST, ...], Dict[str, str]
] = defaultdict(dict)
def run(self, mod: ast.Module) -> None:
"""Find all assert statements in *mod* and rewrite them."""
@@ -702,18 +694,28 @@ class AssertionRewriter(ast.NodeVisitor):
if doc is not None and self.is_rewrite_disabled(doc):
return
pos = 0
item = None
for item in mod.body:
match item:
case ast.Expr(value=ast.Constant(value=str() as doc)) if (
expect_docstring
):
if self.is_rewrite_disabled(doc):
return
expect_docstring = False
case ast.ImportFrom(level=0, module="__future__"):
pass
case _:
break
if (
expect_docstring
and isinstance(item, ast.Expr)
and isinstance(item.value, astStr)
):
if sys.version_info >= (3, 8):
doc = item.value.value
else:
doc = item.value.s
if self.is_rewrite_disabled(doc):
return
expect_docstring = False
elif (
isinstance(item, ast.ImportFrom)
and item.level == 0
and item.module == "__future__"
):
pass
else:
break
pos += 1
# Special case: for a decorated function, set the lineno to that of the
# first decorator, not the `def`. Issue #4984.
@@ -722,15 +724,21 @@ class AssertionRewriter(ast.NodeVisitor):
else:
lineno = item.lineno
# Now actually insert the special imports.
aliases = [
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
ast.alias(
"_pytest.assertion.rewrite",
"@pytest_ar",
lineno=lineno,
col_offset=0,
),
]
if sys.version_info >= (3, 10):
aliases = [
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
ast.alias(
"_pytest.assertion.rewrite",
"@pytest_ar",
lineno=lineno,
col_offset=0,
),
]
else:
aliases = [
ast.alias("builtins", "@py_builtins"),
ast.alias("_pytest.assertion.rewrite", "@pytest_ar"),
]
imports = [
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
]
@@ -738,10 +746,10 @@ class AssertionRewriter(ast.NodeVisitor):
# Collect asserts.
self.scope = (mod,)
nodes: list[ast.AST | Sentinel] = [mod]
nodes: List[Union[ast.AST, Sentinel]] = [mod]
while nodes:
node = nodes.pop()
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
self.scope = tuple((*self.scope, node))
nodes.append(_SCOPE_END_MARKER)
if node == _SCOPE_END_MARKER:
@@ -750,7 +758,7 @@ class AssertionRewriter(ast.NodeVisitor):
assert isinstance(node, ast.AST)
for name, field in ast.iter_fields(node):
if isinstance(field, list):
new: list[ast.AST] = []
new: List[ast.AST] = []
for i, child in enumerate(field):
if isinstance(child, ast.Assert):
# Transform assert.
@@ -783,7 +791,7 @@ class AssertionRewriter(ast.NodeVisitor):
"""Give *expr* a name."""
name = self.variable()
self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr))
return ast.copy_location(ast.Name(name, ast.Load()), expr)
return ast.Name(name, ast.Load())
def display(self, expr: ast.expr) -> ast.expr:
"""Call saferepr on the expression."""
@@ -822,7 +830,7 @@ class AssertionRewriter(ast.NodeVisitor):
to format a string of %-formatted values as added by
.explanation_param().
"""
self.explanation_specifiers: dict[str, ast.expr] = {}
self.explanation_specifiers: Dict[str, ast.expr] = {}
self.stack.append(self.explanation_specifiers)
def pop_format_context(self, expl_expr: ast.expr) -> ast.Name:
@@ -836,7 +844,7 @@ class AssertionRewriter(ast.NodeVisitor):
current = self.stack.pop()
if self.stack:
self.explanation_specifiers = self.stack[-1]
keys: list[ast.expr | None] = [ast.Constant(key) for key in current.keys()]
keys = [astStr(key) for key in current.keys()]
format_dict = ast.Dict(keys, list(current.values()))
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
name = "@py_format" + str(next(self.variable_counter))
@@ -845,13 +853,13 @@ class AssertionRewriter(ast.NodeVisitor):
self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form))
return ast.Name(name, ast.Load())
def generic_visit(self, node: ast.AST) -> tuple[ast.Name, str]:
def generic_visit(self, node: ast.AST) -> Tuple[ast.Name, str]:
"""Handle expressions we don't have custom code for."""
assert isinstance(node, ast.expr)
res = self.assign(node)
return res, self.explanation_param(self.display(res))
def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]:
def visit_Assert(self, assert_: ast.Assert) -> List[ast.stmt]:
"""Return the AST statements to replace the ast.Assert instance.
This rewrites the test of an assertion to provide
@@ -860,9 +868,8 @@ class AssertionRewriter(ast.NodeVisitor):
the expression is false.
"""
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
import warnings
from _pytest.warning_types import PytestAssertRewriteWarning
import warnings
# TODO: This assert should not be needed.
assert self.module_path is not None
@@ -875,15 +882,15 @@ class AssertionRewriter(ast.NodeVisitor):
lineno=assert_.lineno,
)
self.statements: list[ast.stmt] = []
self.variables: list[str] = []
self.statements: List[ast.stmt] = []
self.variables: List[str] = []
self.variable_counter = itertools.count()
if self.enable_assertion_pass_hook:
self.format_variables: list[str] = []
self.format_variables: List[str] = []
self.stack: list[dict[str, ast.expr]] = []
self.expl_stmts: list[ast.stmt] = []
self.stack: List[Dict[str, ast.expr]] = []
self.expl_stmts: List[ast.stmt] = []
self.push_format_context()
# Rewrite assert into a bunch of statements.
top_condition, explanation = self.visit(assert_.test)
@@ -891,16 +898,16 @@ class AssertionRewriter(ast.NodeVisitor):
negation = ast.UnaryOp(ast.Not(), top_condition)
if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook
msg = self.pop_format_context(ast.Constant(explanation))
msg = self.pop_format_context(astStr(explanation))
# Failed
if assert_.msg:
assertmsg = self.helper("_format_assertmsg", assert_.msg)
gluestr = "\n>assert "
else:
assertmsg = ast.Constant("")
assertmsg = astStr("")
gluestr = "assert "
err_explanation = ast.BinOp(ast.Constant(gluestr), ast.Add(), msg)
err_explanation = ast.BinOp(astStr(gluestr), ast.Add(), msg)
err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
err_name = ast.Name("AssertionError", ast.Load())
fmt = self.helper("_format_explanation", err_msg)
@@ -916,27 +923,27 @@ class AssertionRewriter(ast.NodeVisitor):
hook_call_pass = ast.Expr(
self.helper(
"_call_assertion_pass",
ast.Constant(assert_.lineno),
ast.Constant(orig),
astNum(assert_.lineno),
astStr(orig),
fmt_pass,
)
)
# If any hooks implement assert_pass hook
hook_impl_test = ast.If(
self.helper("_check_if_assertion_pass_impl"),
[*self.expl_stmts, hook_call_pass],
self.expl_stmts + [hook_call_pass],
[],
)
statements_pass: list[ast.stmt] = [hook_impl_test]
statements_pass = [hook_impl_test]
# Test for assertion condition
main_test = ast.If(negation, statements_fail, statements_pass)
self.statements.append(main_test)
if self.format_variables:
variables: list[ast.expr] = [
variables = [
ast.Name(name, ast.Store()) for name in self.format_variables
]
clear_format = ast.Assign(variables, ast.Constant(None))
clear_format = ast.Assign(variables, astNameConstant(None))
self.statements.append(clear_format)
else: # Original assertion rewriting
@@ -947,9 +954,9 @@ class AssertionRewriter(ast.NodeVisitor):
assertmsg = self.helper("_format_assertmsg", assert_.msg)
explanation = "\n>assert " + explanation
else:
assertmsg = ast.Constant("")
assertmsg = astStr("")
explanation = "assert " + explanation
template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation))
template = ast.BinOp(assertmsg, ast.Add(), astStr(explanation))
msg = self.pop_format_context(template)
fmt = self.helper("_format_explanation", msg)
err_name = ast.Name("AssertionError", ast.Load())
@@ -961,40 +968,37 @@ class AssertionRewriter(ast.NodeVisitor):
# Clear temporary variables by setting them to None.
if self.variables:
variables = [ast.Name(name, ast.Store()) for name in self.variables]
clear = ast.Assign(variables, ast.Constant(None))
clear = ast.Assign(variables, astNameConstant(None))
self.statements.append(clear)
# Fix locations (line numbers/column offsets).
for stmt in self.statements:
for node in traverse_node(stmt):
if getattr(node, "lineno", None) is None:
# apply the assertion location to all generated ast nodes without source location
# and preserve the location of existing nodes or generated nodes with an correct location.
ast.copy_location(node, assert_)
ast.copy_location(node, assert_)
return self.statements
def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]:
def visit_NamedExpr(self, name: namedExpr) -> Tuple[namedExpr, str]:
# This method handles the 'walrus operator' repr of the target
# name if it's a local variable or _should_repr_global_name()
# thinks it's acceptable.
locs = ast.Call(self.builtin("locals"), [], [])
target_id = name.target.id
inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs])
target_id = name.target.id # type: ignore[attr-defined]
inlocs = ast.Compare(astStr(target_id), [ast.In()], [locs])
dorepr = self.helper("_should_repr_global_name", name)
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
expr = ast.IfExp(test, self.display(name), ast.Constant(target_id))
expr = ast.IfExp(test, self.display(name), astStr(target_id))
return name, self.explanation_param(expr)
def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]:
def visit_Name(self, name: ast.Name) -> Tuple[ast.Name, str]:
# Display the repr of the name if it's a local variable or
# _should_repr_global_name() thinks it's acceptable.
locs = ast.Call(self.builtin("locals"), [], [])
inlocs = ast.Compare(ast.Constant(name.id), [ast.In()], [locs])
inlocs = ast.Compare(astStr(name.id), [ast.In()], [locs])
dorepr = self.helper("_should_repr_global_name", name)
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
expr = ast.IfExp(test, self.display(name), ast.Constant(name.id))
expr = ast.IfExp(test, self.display(name), astStr(name.id))
return name, self.explanation_param(expr)
def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
def visit_BoolOp(self, boolop: ast.BoolOp) -> Tuple[ast.Name, str]:
res_var = self.variable()
expl_list = self.assign(ast.List([], ast.Load()))
app = ast.Attribute(expl_list, "append", ast.Load())
@@ -1006,57 +1010,60 @@ class AssertionRewriter(ast.NodeVisitor):
# Process each operand, short-circuiting if needed.
for i, v in enumerate(boolop.values):
if i:
fail_inner: list[ast.stmt] = []
fail_inner: List[ast.stmt] = []
# cond is set in a prior loop iteration below
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa
self.expl_stmts = fail_inner
match v:
# Check if the left operand is an ast.NamedExpr and the value has already been visited
case ast.Compare(
left=ast.NamedExpr(target=ast.Name(id=target_id))
) if target_id in [
e.id for e in boolop.values[:i] if hasattr(e, "id")
]:
pytest_temp = self.variable()
self.variables_overwrite[self.scope][target_id] = v.left # type:ignore[assignment]
# mypy's false positive, we're checking that the 'target' attribute exists.
v.left.target.id = pytest_temp # type:ignore[attr-defined]
# Check if the left operand is a namedExpr and the value has already been visited
if (
isinstance(v, ast.Compare)
and isinstance(v.left, namedExpr)
and v.left.target.id
in [
ast_expr.id
for ast_expr in boolop.values[:i]
if hasattr(ast_expr, "id")
]
):
pytest_temp = self.variable()
self.variables_overwrite[self.scope][
v.left.target.id
] = v.left # type:ignore[assignment]
v.left.target.id = pytest_temp
self.push_format_context()
res, expl = self.visit(v)
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
expl_format = self.pop_format_context(ast.Constant(expl))
expl_format = self.pop_format_context(astStr(expl))
call = ast.Call(app, [expl_format], [])
self.expl_stmts.append(ast.Expr(call))
if i < levels:
cond: ast.expr = res
if is_or:
cond = ast.UnaryOp(ast.Not(), cond)
inner: list[ast.stmt] = []
inner: List[ast.stmt] = []
self.statements.append(ast.If(cond, inner, []))
self.statements = body = inner
self.statements = save
self.expl_stmts = fail_save
expl_template = self.helper("_format_boolop", expl_list, ast.Constant(is_or))
expl_template = self.helper("_format_boolop", expl_list, astNum(is_or))
expl = self.pop_format_context(expl_template)
return ast.Name(res_var, ast.Load()), self.explanation_param(expl)
def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]:
def visit_UnaryOp(self, unary: ast.UnaryOp) -> Tuple[ast.Name, str]:
pattern = UNARY_MAP[unary.op.__class__]
operand_res, operand_expl = self.visit(unary.operand)
res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary))
res = self.assign(ast.UnaryOp(unary.op, operand_res))
return res, pattern % (operand_expl,)
def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]:
def visit_BinOp(self, binop: ast.BinOp) -> Tuple[ast.Name, str]:
symbol = BINOP_MAP[binop.op.__class__]
left_expr, left_expl = self.visit(binop.left)
right_expr, right_expl = self.visit(binop.right)
explanation = f"({left_expl} {symbol} {right_expl})"
res = self.assign(
ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop)
)
res = self.assign(ast.BinOp(left_expr, binop.op, right_expr))
return res, explanation
def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]:
def visit_Call(self, call: ast.Call) -> Tuple[ast.Name, str]:
new_func, func_expl = self.visit(call.func)
arg_expls = []
new_args = []
@@ -1065,16 +1072,19 @@ class AssertionRewriter(ast.NodeVisitor):
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get(
self.scope, {}
):
arg = self.variables_overwrite[self.scope][arg.id] # type:ignore[assignment]
arg = self.variables_overwrite[self.scope][
arg.id
] # type:ignore[assignment]
res, expl = self.visit(arg)
arg_expls.append(expl)
new_args.append(res)
for keyword in call.keywords:
match keyword.value:
case ast.Name(id=id) if id in self.variables_overwrite.get(
self.scope, {}
):
keyword.value = self.variables_overwrite[self.scope][id] # type:ignore[assignment]
if isinstance(
keyword.value, ast.Name
) and keyword.value.id in self.variables_overwrite.get(self.scope, {}):
keyword.value = self.variables_overwrite[self.scope][
keyword.value.id
] # type:ignore[assignment]
res, expl = self.visit(keyword.value)
new_kwargs.append(ast.keyword(keyword.arg, res))
if keyword.arg:
@@ -1083,68 +1093,70 @@ class AssertionRewriter(ast.NodeVisitor):
arg_expls.append("**" + expl)
expl = "{}({})".format(func_expl, ", ".join(arg_expls))
new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call)
new_call = ast.Call(new_func, new_args, new_kwargs)
res = self.assign(new_call)
res_expl = self.explanation_param(self.display(res))
outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}"
return res, outer_expl
def visit_Starred(self, starred: ast.Starred) -> tuple[ast.Starred, str]:
def visit_Starred(self, starred: ast.Starred) -> Tuple[ast.Starred, str]:
# A Starred node can appear in a function call.
res, expl = self.visit(starred.value)
new_starred = ast.Starred(res, starred.ctx)
return new_starred, "*" + expl
def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]:
def visit_Attribute(self, attr: ast.Attribute) -> Tuple[ast.Name, str]:
if not isinstance(attr.ctx, ast.Load):
return self.generic_visit(attr)
value, value_expl = self.visit(attr.value)
res = self.assign(
ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr)
)
res = self.assign(ast.Attribute(value, attr.attr, ast.Load()))
res_expl = self.explanation_param(self.display(res))
pat = "%s\n{%s = %s.%s\n}"
expl = pat % (res_expl, res_expl, value_expl, attr.attr)
return res, expl
def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
def visit_Compare(self, comp: ast.Compare) -> Tuple[ast.expr, str]:
self.push_format_context()
# We first check if we have overwritten a variable in the previous assert
match comp.left:
case ast.Name(id=name_id) if name_id in self.variables_overwrite.get(
self.scope, {}
):
comp.left = self.variables_overwrite[self.scope][name_id] # type: ignore[assignment]
case ast.NamedExpr(target=ast.Name(id=target_id)):
self.variables_overwrite[self.scope][target_id] = comp.left # type: ignore[assignment]
if isinstance(
comp.left, ast.Name
) and comp.left.id in self.variables_overwrite.get(self.scope, {}):
comp.left = self.variables_overwrite[self.scope][
comp.left.id
] # type:ignore[assignment]
if isinstance(comp.left, namedExpr):
self.variables_overwrite[self.scope][
comp.left.target.id
] = comp.left # type:ignore[assignment]
left_res, left_expl = self.visit(comp.left)
if isinstance(comp.left, ast.Compare | ast.BoolOp):
if isinstance(comp.left, (ast.Compare, ast.BoolOp)):
left_expl = f"({left_expl})"
res_variables = [self.variable() for i in range(len(comp.ops))]
load_names: list[ast.expr] = [ast.Name(v, ast.Load()) for v in res_variables]
load_names = [ast.Name(v, ast.Load()) for v in res_variables]
store_names = [ast.Name(v, ast.Store()) for v in res_variables]
it = zip(range(len(comp.ops)), comp.ops, comp.comparators, strict=True)
expls: list[ast.expr] = []
syms: list[ast.expr] = []
it = zip(range(len(comp.ops)), comp.ops, comp.comparators)
expls = []
syms = []
results = [left_res]
for i, op, next_operand in it:
match (next_operand, left_res):
case (
ast.NamedExpr(target=ast.Name(id=target_id)),
ast.Name(id=name_id),
) if target_id == name_id:
next_operand.target.id = self.variable()
self.variables_overwrite[self.scope][name_id] = next_operand # type: ignore[assignment]
if (
isinstance(next_operand, namedExpr)
and isinstance(left_res, ast.Name)
and next_operand.target.id == left_res.id
):
next_operand.target.id = self.variable()
self.variables_overwrite[self.scope][
left_res.id
] = next_operand # type:ignore[assignment]
next_res, next_expl = self.visit(next_operand)
if isinstance(next_operand, ast.Compare | ast.BoolOp):
if isinstance(next_operand, (ast.Compare, ast.BoolOp)):
next_expl = f"({next_expl})"
results.append(next_res)
sym = BINOP_MAP[op.__class__]
syms.append(ast.Constant(sym))
syms.append(astStr(sym))
expl = f"{left_expl} {sym} {next_expl}"
expls.append(ast.Constant(expl))
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
expls.append(astStr(expl))
res_expr = ast.Compare(left_res, [op], [next_res])
self.statements.append(ast.Assign([store_names[i]], res_expr))
left_res, left_expl = next_res, next_expl
# Use pytest.assertion.util._reprcompare if that's available.
@@ -1179,10 +1191,7 @@ def try_makedirs(cache_dir: Path) -> bool:
return False
except OSError as e:
# as of now, EROFS doesn't have an equivalent OSError-subclass
#
# squashfuse_ll returns ENOSYS "OSError: [Errno 38] Function not
# implemented" for a read-only error
if e.errno in {errno.EROFS, errno.ENOSYS}:
if e.errno == errno.EROFS:
return False
raise
return True
@@ -1190,7 +1199,7 @@ def try_makedirs(cache_dir: Path) -> bool:
def get_cache_dir(file_path: Path) -> Path:
"""Return the cache directory to write .pyc files for the given .py file path."""
if sys.pycache_prefix:
if sys.version_info >= (3, 8) and sys.pycache_prefix:
# given:
# prefix = '/tmp/pycs'
# path = '/home/user/proj/test_app.py'

View File

@@ -1,65 +1,51 @@
"""Utilities for truncating assertion output.
Current default behaviour is to truncate assertion explanations at
terminal lines, unless running with an assertions verbosity level of at least 2 or running on CI.
~8 terminal lines, unless running in "-vv" mode or running on CI.
"""
from typing import List
from typing import Optional
from __future__ import annotations
from _pytest.compat import running_on_ci
from _pytest.config import Config
from _pytest.assertion import util
from _pytest.nodes import Item
DEFAULT_MAX_LINES = 8
DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80
DEFAULT_MAX_CHARS = 8 * 80
USAGE_MSG = "use '-vv' to show"
def truncate_if_required(explanation: list[str], item: Item) -> list[str]:
def truncate_if_required(
explanation: List[str], item: Item, max_length: Optional[int] = None
) -> List[str]:
"""Truncate this assertion explanation if the given test item is eligible."""
should_truncate, max_lines, max_chars = _get_truncation_parameters(item)
if should_truncate:
return _truncate_explanation(
explanation,
max_lines=max_lines,
max_chars=max_chars,
)
if _should_truncate_item(item):
return _truncate_explanation(explanation)
return explanation
def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]:
"""Return the truncation parameters related to the given item, as (should truncate, max lines, max chars)."""
# We do not need to truncate if one of conditions is met:
# 1. Verbosity level is 2 or more;
# 2. Test is being run in CI environment;
# 3. Both truncation_limit_lines and truncation_limit_chars
# .ini parameters are set to 0 explicitly.
max_lines = item.config.getini("truncation_limit_lines")
max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES)
max_chars = item.config.getini("truncation_limit_chars")
max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS)
verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
should_truncate = verbose < 2 and not running_on_ci()
should_truncate = should_truncate and (max_lines > 0 or max_chars > 0)
return should_truncate, max_lines, max_chars
def _should_truncate_item(item: Item) -> bool:
"""Whether or not this test item is eligible for truncation."""
verbose = item.config.option.verbose
return verbose < 2 and not util.running_on_ci()
def _truncate_explanation(
input_lines: list[str],
max_lines: int,
max_chars: int,
) -> list[str]:
input_lines: List[str],
max_lines: Optional[int] = None,
max_chars: Optional[int] = None,
) -> List[str]:
"""Truncate given list of strings that makes up the assertion explanation.
Truncates to either max_lines, or max_chars - whichever the input reaches
Truncates to either 8 lines, or 640 characters - whichever the input reaches
first, taking the truncation explanation into account. The remaining lines
will be replaced by a usage message.
"""
if max_lines is None:
max_lines = DEFAULT_MAX_LINES
if max_chars is None:
max_chars = DEFAULT_MAX_CHARS
# Check if truncation required
input_char_count = len("".join(input_lines))
# The length of the truncation explanation depends on the number of lines
@@ -84,23 +70,16 @@ def _truncate_explanation(
):
return input_lines
# Truncate first to max_lines, and then truncate to max_chars if necessary
if max_lines > 0:
truncated_explanation = input_lines[:max_lines]
else:
truncated_explanation = input_lines
truncated_explanation = input_lines[:max_lines]
truncated_char = True
# We reevaluate the need to truncate chars following removal of some lines
if len("".join(truncated_explanation)) > tolerable_max_chars and max_chars > 0:
if len("".join(truncated_explanation)) > tolerable_max_chars:
truncated_explanation = _truncate_by_char_count(
truncated_explanation, max_chars
)
else:
truncated_char = False
if truncated_explanation == input_lines:
# No truncation happened, so we do not need to add any explanations
return truncated_explanation
truncated_line_count = len(input_lines) - len(truncated_explanation)
if truncated_explanation[-1]:
# Add ellipsis and take into account part-truncated final line
@@ -111,15 +90,14 @@ def _truncate_explanation(
else:
# Add proper ellipsis when we were able to fit a full line exactly
truncated_explanation[-1] = "..."
return [
*truncated_explanation,
return truncated_explanation + [
"",
f"...Full output truncated ({truncated_line_count} line"
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
]
def _truncate_by_char_count(input_lines: list[str], max_chars: int) -> list[str]:
def _truncate_by_char_count(input_lines: List[str], max_chars: int) -> List[str]:
# Find point at which input length exceeds total allowed length
iterated_char_count = 0
for iterated_index, input_line in enumerate(input_lines):

View File

@@ -1,54 +1,36 @@
# mypy: allow-untyped-defs
"""Utilities for assertion debugging."""
from __future__ import annotations
import collections.abc
from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Mapping
from collections.abc import Sequence
from collections.abc import Set as AbstractSet
import os
import pprint
from typing import AbstractSet
from typing import Any
from typing import Literal
from typing import Protocol
from typing import Callable
from typing import Iterable
from typing import List
from typing import Mapping
from typing import Optional
from typing import Sequence
from unicodedata import normalize
from _pytest import outcomes
import _pytest._code
from _pytest._io.pprint import PrettyPrinter
from _pytest import outcomes
from _pytest._io.saferepr import _pformat_dispatch
from _pytest._io.saferepr import saferepr
from _pytest._io.saferepr import saferepr_unlimited
from _pytest.compat import running_on_ci
from _pytest.config import Config
# The _reprcompare attribute on the util module is used by the new assertion
# interpretation code and assertion rewriter to detect this plugin was
# loaded and in turn call the hooks defined here as part of the
# DebugInterpreter.
_reprcompare: Callable[[str, object, object], str | None] | None = None
_reprcompare: Optional[Callable[[str, object, object], Optional[str]]] = None
# Works similarly as _reprcompare attribute. Is populated with the hook call
# when pytest_runtest_setup is called.
_assertion_pass: Callable[[int, str, str], None] | None = None
_assertion_pass: Optional[Callable[[int, str, str], None]] = None
# Config object which is assigned during pytest_runtest_protocol.
_config: Config | None = None
class _HighlightFunc(Protocol):
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Apply highlighting to the given source."""
def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Dummy highlighter that returns the text unprocessed.
Needed for _notin_text, as the diff gets post-processed to only show the "+" part.
"""
return source
_config: Optional[Config] = None
def format_explanation(explanation: str) -> str:
@@ -66,7 +48,7 @@ def format_explanation(explanation: str) -> str:
return "\n".join(result)
def _split_explanation(explanation: str) -> list[str]:
def _split_explanation(explanation: str) -> List[str]:
r"""Return a list of individual lines in the explanation.
This will return a list of lines split on '\n{', '\n}' and '\n~'.
@@ -83,7 +65,7 @@ def _split_explanation(explanation: str) -> list[str]:
return lines
def _format_lines(lines: Sequence[str]) -> list[str]:
def _format_lines(lines: Sequence[str]) -> List[str]:
"""Format the individual lines.
This will replace the '{', '}' and '~' characters of our mini formatting
@@ -131,7 +113,7 @@ def isdict(x: Any) -> bool:
def isset(x: Any) -> bool:
return isinstance(x, set | frozenset)
return isinstance(x, (set, frozenset))
def isnamedtuple(obj: Any) -> bool:
@@ -150,7 +132,7 @@ def isiterable(obj: Any) -> bool:
try:
iter(obj)
return not istext(obj)
except Exception:
except TypeError:
return False
@@ -169,7 +151,7 @@ def has_default_eq(
code_filename = obj.__eq__.__code__.co_filename
if isattrs(obj):
return "attrs generated " in code_filename
return "attrs generated eq" in code_filename
return code_filename == "<string>" # data class
return True
@@ -177,9 +159,9 @@ def has_default_eq(
def assertrepr_compare(
config, op: str, left: Any, right: Any, use_ascii: bool = False
) -> list[str] | None:
) -> Optional[List[str]]:
"""Return specialised explanations for some operators/operands."""
verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
verbose = config.getoption("verbose")
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
# See issue #3246.
@@ -203,54 +185,34 @@ def assertrepr_compare(
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
summary = f"{left_repr} {op} {right_repr}"
highlighter = config.get_terminal_writer()._highlight
explanation = None
try:
if op == "==":
explanation = _compare_eq_any(left, right, highlighter, verbose)
explanation = _compare_eq_any(left, right, verbose)
elif op == "not in":
if istext(left) and istext(right):
explanation = _notin_text(left, right, verbose)
elif op == "!=":
if isset(left) and isset(right):
explanation = ["Both sets are equal"]
elif op == ">=":
if isset(left) and isset(right):
explanation = _compare_gte_set(left, right, highlighter, verbose)
elif op == "<=":
if isset(left) and isset(right):
explanation = _compare_lte_set(left, right, highlighter, verbose)
elif op == ">":
if isset(left) and isset(right):
explanation = _compare_gt_set(left, right, highlighter, verbose)
elif op == "<":
if isset(left) and isset(right):
explanation = _compare_lt_set(left, right, highlighter, verbose)
except outcomes.Exit:
raise
except Exception:
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
explanation = [
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
"(pytest_assertion plugin: representation of details failed: {}.".format(
_pytest._code.ExceptionInfo.from_current()._getreprcrash()
),
" Probably an object has a faulty __repr__.)",
]
if not explanation:
return None
if explanation[0] != "":
explanation = ["", *explanation]
return [summary, *explanation]
return [summary] + explanation
def _compare_eq_any(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
) -> list[str]:
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
explanation = []
if istext(left) and istext(right):
explanation = _diff_text(left, right, highlighter, verbose)
explanation = _diff_text(left, right, verbose)
else:
from _pytest.python_api import ApproxBase
@@ -260,31 +222,29 @@ def _compare_eq_any(
other_side = right if isinstance(left, ApproxBase) else left
explanation = approx_side._repr_compare(other_side)
elif type(left) is type(right) and (
elif type(left) == type(right) and (
isdatacls(left) or isattrs(left) or isnamedtuple(left)
):
# Note: unlike dataclasses/attrs, namedtuples compare only the
# field values, not the type or field names. But this branch
# intentionally only handles the same-type case, which was often
# used in older code bases before dataclasses/attrs were available.
explanation = _compare_eq_cls(left, right, highlighter, verbose)
explanation = _compare_eq_cls(left, right, verbose)
elif issequence(left) and issequence(right):
explanation = _compare_eq_sequence(left, right, highlighter, verbose)
explanation = _compare_eq_sequence(left, right, verbose)
elif isset(left) and isset(right):
explanation = _compare_eq_set(left, right, highlighter, verbose)
explanation = _compare_eq_set(left, right, verbose)
elif isdict(left) and isdict(right):
explanation = _compare_eq_dict(left, right, highlighter, verbose)
explanation = _compare_eq_dict(left, right, verbose)
if isiterable(left) and isiterable(right):
expl = _compare_eq_iterable(left, right, highlighter, verbose)
expl = _compare_eq_iterable(left, right, verbose)
explanation.extend(expl)
return explanation
def _diff_text(
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
) -> list[str]:
def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
"""Return the explanation for the diff between text.
Unless --verbose is used this will skip leading and trailing
@@ -292,7 +252,7 @@ def _diff_text(
"""
from difflib import ndiff
explanation: list[str] = []
explanation: List[str] = []
if verbose < 1:
i = 0 # just in case left or right has zero length
@@ -302,7 +262,7 @@ def _diff_text(
if i > 42:
i -= 10 # Provide some context
explanation = [
f"Skipping {i} identical leading characters in diff, use -v to show"
"Skipping %s identical leading characters in diff, use -v to show" % i
]
left = left[i:]
right = right[i:]
@@ -313,8 +273,8 @@ def _diff_text(
if i > 42:
i -= 10 # Provide some context
explanation += [
f"Skipping {i} identical trailing "
"characters in diff, use -v to show"
"Skipping {} identical trailing "
"characters in diff, use -v to show".format(i)
]
left = left[:-i]
right = right[:-i]
@@ -325,55 +285,61 @@ def _diff_text(
explanation += ["Strings contain only whitespace, escaping them using repr()"]
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
explanation.extend(
highlighter(
"\n".join(
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
),
lexer="diff",
).splitlines()
)
explanation += [
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
]
return explanation
def _surrounding_parens_on_own_lines(lines: List[str]) -> None:
"""Move opening/closing parenthesis/bracket to own lines."""
opening = lines[0][:1]
if opening in ["(", "[", "{"]:
lines[0] = " " + lines[0][1:]
lines[:] = [opening] + lines
closing = lines[-1][-1:]
if closing in [")", "]", "}"]:
lines[-1] = lines[-1][:-1] + ","
lines[:] = lines + [closing]
def _compare_eq_iterable(
left: Iterable[Any],
right: Iterable[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
left: Iterable[Any], right: Iterable[Any], verbose: int = 0
) -> List[str]:
if verbose <= 0 and not running_on_ci():
return ["Use -v to get more diff"]
# dynamic import to speedup pytest
import difflib
left_formatting = PrettyPrinter().pformat(left).splitlines()
right_formatting = PrettyPrinter().pformat(right).splitlines()
left_formatting = pprint.pformat(left).splitlines()
right_formatting = pprint.pformat(right).splitlines()
explanation = ["", "Full diff:"]
# Re-format for different output lengths.
lines_left = len(left_formatting)
lines_right = len(right_formatting)
if lines_left != lines_right:
left_formatting = _pformat_dispatch(left).splitlines()
right_formatting = _pformat_dispatch(right).splitlines()
if lines_left > 1 or lines_right > 1:
_surrounding_parens_on_own_lines(left_formatting)
_surrounding_parens_on_own_lines(right_formatting)
explanation = ["Full diff:"]
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
explanation.extend(
highlighter(
"\n".join(
line.rstrip()
for line in difflib.ndiff(right_formatting, left_formatting)
),
lexer="diff",
).splitlines()
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
)
return explanation
def _compare_eq_sequence(
left: Sequence[Any],
right: Sequence[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
left: Sequence[Any], right: Sequence[Any], verbose: int = 0
) -> List[str]:
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
explanation: list[str] = []
explanation: List[str] = []
len_left = len(left)
len_right = len(right)
for i in range(min(len_left, len_right)):
@@ -393,10 +359,7 @@ def _compare_eq_sequence(
left_value = left[i]
right_value = right[i]
explanation.append(
f"At index {i} diff:"
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
)
explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"]
break
if comparing_bytes:
@@ -416,134 +379,74 @@ def _compare_eq_sequence(
extra = saferepr(right[len_left])
if len_diff == 1:
explanation += [
f"{dir_with_more} contains one more item: {highlighter(extra)}"
]
explanation += [f"{dir_with_more} contains one more item: {extra}"]
else:
explanation += [
f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
"%s contains %d more items, first extra item: %s"
% (dir_with_more, len_diff, extra)
]
return explanation
def _compare_eq_set(
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0
) -> List[str]:
explanation = []
explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
return explanation
def _compare_gt_set(
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = _compare_gte_set(left, right, highlighter)
if not explanation:
return ["Both sets are equal"]
return explanation
def _compare_lt_set(
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = _compare_lte_set(left, right, highlighter)
if not explanation:
return ["Both sets are equal"]
return explanation
def _compare_gte_set(
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
return _set_one_sided_diff("right", right, left, highlighter)
def _compare_lte_set(
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
return _set_one_sided_diff("left", left, right, highlighter)
def _set_one_sided_diff(
posn: str,
set1: AbstractSet[Any],
set2: AbstractSet[Any],
highlighter: _HighlightFunc,
) -> list[str]:
explanation = []
diff = set1 - set2
if diff:
explanation.append(f"Extra items in the {posn} set:")
for item in diff:
explanation.append(highlighter(saferepr(item)))
diff_left = left - right
diff_right = right - left
if diff_left:
explanation.append("Extra items in the left set:")
for item in diff_left:
explanation.append(saferepr(item))
if diff_right:
explanation.append("Extra items in the right set:")
for item in diff_right:
explanation.append(saferepr(item))
return explanation
def _compare_eq_dict(
left: Mapping[Any, Any],
right: Mapping[Any, Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation: list[str] = []
left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0
) -> List[str]:
explanation: List[str] = []
set_left = set(left)
set_right = set(right)
common = set_left.intersection(set_right)
same = {k: left[k] for k in common if left[k] == right[k]}
if same and verbose < 2:
explanation += [f"Omitting {len(same)} identical items, use -vv to show"]
explanation += ["Omitting %s identical items, use -vv to show" % len(same)]
elif same:
explanation += ["Common items:"]
explanation += highlighter(pprint.pformat(same)).splitlines()
explanation += pprint.pformat(same).splitlines()
diff = {k for k in common if left[k] != right[k]}
if diff:
explanation += ["Differing items:"]
for k in diff:
explanation += [
highlighter(saferepr({k: left[k]}))
+ " != "
+ highlighter(saferepr({k: right[k]}))
]
explanation += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})]
extra_left = set_left - set_right
len_extra_left = len(extra_left)
if len_extra_left:
explanation.append(
f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
"Left contains %d more item%s:"
% (len_extra_left, "" if len_extra_left == 1 else "s")
)
explanation.extend(
highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines()
pprint.pformat({k: left[k] for k in extra_left}).splitlines()
)
extra_right = set_right - set_left
len_extra_right = len(extra_right)
if len_extra_right:
explanation.append(
f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
"Right contains %d more item%s:"
% (len_extra_right, "" if len_extra_right == 1 else "s")
)
explanation.extend(
highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines()
pprint.pformat({k: right[k] for k in extra_right}).splitlines()
)
return explanation
def _compare_eq_cls(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
) -> list[str]:
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
if not has_default_eq(left):
return []
if isdatacls(left):
@@ -572,37 +475,35 @@ def _compare_eq_cls(
if same or diff:
explanation += [""]
if same and verbose < 2:
explanation.append(f"Omitting {len(same)} identical items, use -vv to show")
explanation.append("Omitting %s identical items, use -vv to show" % len(same))
elif same:
explanation += ["Matching attributes:"]
explanation += highlighter(pprint.pformat(same)).splitlines()
explanation += pprint.pformat(same).splitlines()
if diff:
explanation += ["Differing attributes:"]
explanation += highlighter(pprint.pformat(diff)).splitlines()
explanation += pprint.pformat(diff).splitlines()
for field in diff:
field_left = getattr(left, field)
field_right = getattr(right, field)
explanation += [
"",
f"Drill down into differing attribute {field}:",
f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}",
"Drill down into differing attribute %s:" % field,
("%s%s: %r != %r") % (indent, field, field_left, field_right),
]
explanation += [
indent + line
for line in _compare_eq_any(
field_left, field_right, highlighter, verbose
)
for line in _compare_eq_any(field_left, field_right, verbose)
]
return explanation
def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]:
def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]:
index = text.find(term)
head = text[:index]
tail = text[index + len(term) :]
correct_text = head + tail
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"]
diff = _diff_text(text, correct_text, verbose)
newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)]
for line in diff:
if line.startswith("Skipping"):
continue
@@ -613,3 +514,9 @@ def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]:
else:
newdiff.append(line)
return newdiff
def running_on_ci() -> bool:
"""Check if we're currently running on a CI system."""
env_vars = ["CI", "BUILD_NUMBER"]
return any(var in os.environ for var in env_vars)

View File

@@ -1,25 +1,24 @@
# mypy: allow-untyped-defs
"""Implementation of the cache provider."""
# This plugin was not named "cache" to avoid conflicts with the external
# pytest-cache version.
from __future__ import annotations
from collections.abc import Generator
from collections.abc import Iterable
import dataclasses
import errno
import json
import os
from pathlib import Path
import tempfile
from typing import final
from typing import Dict
from typing import Generator
from typing import Iterable
from typing import List
from typing import Optional
from typing import Set
from typing import Union
from .pathlib import resolve_from_str
from .pathlib import rm_rf
from .reports import CollectReport
from _pytest import nodes
from _pytest._io import TerminalWriter
from _pytest.compat import final
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl
@@ -28,11 +27,10 @@ from _pytest.deprecated import check_ispytest
from _pytest.fixtures import fixture
from _pytest.fixtures import FixtureRequest
from _pytest.main import Session
from _pytest.nodes import Directory
from _pytest.nodes import File
from _pytest.python import Package
from _pytest.reports import TestReport
README_CONTENT = """\
# pytest cache directory #
@@ -74,7 +72,7 @@ class Cache:
self._config = config
@classmethod
def for_config(cls, config: Config, *, _ispytest: bool = False) -> Cache:
def for_config(cls, config: Config, *, _ispytest: bool = False) -> "Cache":
"""Create the Cache instance for a Config.
:meta private:
@@ -113,7 +111,6 @@ class Cache:
"""
check_ispytest(_ispytest)
import warnings
from _pytest.warning_types import PytestCacheWarning
warnings.warn(
@@ -122,10 +119,6 @@ class Cache:
stacklevel=3,
)
def _mkdir(self, path: Path) -> None:
self._ensure_cache_dir_and_supporting_files()
path.mkdir(exist_ok=True, parents=True)
def mkdir(self, name: str) -> Path:
"""Return a directory path object with the given name.
@@ -144,7 +137,7 @@ class Cache:
if len(path.parts) > 1:
raise ValueError("name is not allowed to contain path separators")
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
self._mkdir(res)
res.mkdir(exist_ok=True, parents=True)
return res
def _getvaluepath(self, key: str) -> Path:
@@ -181,13 +174,19 @@ class Cache:
"""
path = self._getvaluepath(key)
try:
self._mkdir(path.parent)
if path.parent.is_dir():
cache_dir_exists_already = True
else:
cache_dir_exists_already = self._cachedir.exists()
path.parent.mkdir(exist_ok=True, parents=True)
except OSError as exc:
self.warn(
f"could not create cache path {path}: {exc}",
_ispytest=True,
)
return
if not cache_dir_exists_already:
self._ensure_supporting_files()
data = json.dumps(value, ensure_ascii=False, indent=2)
try:
f = path.open("w", encoding="UTF-8")
@@ -200,85 +199,60 @@ class Cache:
with f:
f.write(data)
def _ensure_cache_dir_and_supporting_files(self) -> None:
"""Create the cache dir and its supporting files."""
if self._cachedir.is_dir():
return
def _ensure_supporting_files(self) -> None:
"""Create supporting files in the cache dir that are not really part of the cache."""
readme_path = self._cachedir / "README.md"
readme_path.write_text(README_CONTENT, encoding="UTF-8")
self._cachedir.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(
prefix="pytest-cache-files-",
dir=self._cachedir.parent,
) as newpath:
path = Path(newpath)
gitignore_path = self._cachedir.joinpath(".gitignore")
msg = "# Created by pytest automatically.\n*\n"
gitignore_path.write_text(msg, encoding="UTF-8")
# Reset permissions to the default, see #12308.
# Note: there's no way to get the current umask atomically, eek.
umask = os.umask(0o022)
os.umask(umask)
path.chmod(0o777 - umask)
with open(path.joinpath("README.md"), "x", encoding="UTF-8") as f:
f.write(README_CONTENT)
with open(path.joinpath(".gitignore"), "x", encoding="UTF-8") as f:
f.write("# Created by pytest automatically.\n*\n")
with open(path.joinpath("CACHEDIR.TAG"), "xb") as f:
f.write(CACHEDIR_TAG_CONTENT)
try:
path.rename(self._cachedir)
except OSError as e:
# If 2 concurrent pytests both race to the rename, the loser
# gets "Directory not empty" from the rename. In this case,
# everything is handled so just continue (while letting the
# temporary directory be cleaned up).
# On Windows, the error is a FileExistsError which translates to EEXIST.
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
raise
else:
# Create a directory in place of the one we just moved so that
# `TemporaryDirectory`'s cleanup doesn't complain.
#
# TODO: pass ignore_cleanup_errors=True when we no longer support python < 3.10.
# See https://github.com/python/cpython/issues/74168. Note that passing
# delete=False would do the wrong thing in case of errors and isn't supported
# until python 3.12.
path.mkdir()
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG")
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
class LFPluginCollWrapper:
def __init__(self, lfplugin: LFPlugin) -> None:
def __init__(self, lfplugin: "LFPlugin") -> None:
self.lfplugin = lfplugin
self._collected_at_least_one_failure = False
@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: nodes.Collector
) -> Generator[None, CollectReport, CollectReport]:
res = yield
if isinstance(collector, Session | Directory):
@hookimpl(hookwrapper=True)
def pytest_make_collect_report(self, collector: nodes.Collector):
if isinstance(collector, (Session, Package)):
out = yield
res: CollectReport = out.get_result()
# Sort any lf-paths to the beginning.
lf_paths = self.lfplugin._last_failed_paths
# Use stable sort to prioritize last failed.
def sort_key(node: nodes.Item | nodes.Collector) -> bool:
return node.path in lf_paths
# Use stable sort to priorize last failed.
def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
# Package.path is the __init__.py file, we need the directory.
if isinstance(node, Package):
path = node.path.parent
else:
path = node.path
return path in lf_paths
res.result = sorted(
res.result,
key=sort_key,
reverse=True,
)
return
elif isinstance(collector, File):
if collector.path in self.lfplugin._last_failed_paths:
out = yield
res = out.get_result()
result = res.result
lastfailed = self.lfplugin.lastfailed
# Only filter with known failures.
if not self._collected_at_least_one_failure:
if not any(x.nodeid in lastfailed for x in result):
return res
return
self.lfplugin.config.pluginmanager.register(
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
)
@@ -294,19 +268,21 @@ class LFPluginCollWrapper:
# Keep all sub-collectors.
or isinstance(x, nodes.Collector)
]
return res
return
yield
class LFPluginCollSkipfiles:
def __init__(self, lfplugin: LFPlugin) -> None:
def __init__(self, lfplugin: "LFPlugin") -> None:
self.lfplugin = lfplugin
@hookimpl
def pytest_make_collect_report(
self, collector: nodes.Collector
) -> CollectReport | None:
if isinstance(collector, File):
) -> Optional[CollectReport]:
# Packages are Files, but we only want to skip test-bearing Files,
# so don't filter Packages.
if isinstance(collector, File) and not isinstance(collector, Package):
if collector.path not in self.lfplugin._last_failed_paths:
self.lfplugin._skipped_files += 1
@@ -324,9 +300,9 @@ class LFPlugin:
active_keys = "lf", "failedfirst"
self.active = any(config.getoption(key) for key in active_keys)
assert config.cache
self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {})
self._previously_failed_count: int | None = None
self._report_status: str | None = None
self.lastfailed: Dict[str, bool] = config.cache.get("cache/lastfailed", {})
self._previously_failed_count: Optional[int] = None
self._report_status: Optional[str] = None
self._skipped_files = 0 # count skipped files during collection due to --lf
if config.getoption("lf"):
@@ -335,7 +311,7 @@ class LFPlugin:
LFPluginCollWrapper(self), "lfplugin-collwrapper"
)
def get_last_failed_paths(self) -> set[Path]:
def get_last_failed_paths(self) -> Set[Path]:
"""Return a set with all Paths of the previously failed nodeids and
their parents."""
rootpath = self.config.rootpath
@@ -346,9 +322,9 @@ class LFPlugin:
result.update(path.parents)
return {x for x in result if x.exists()}
def pytest_report_collectionfinish(self) -> str | None:
if self.active and self.config.get_verbosity() >= 0:
return f"run-last-failure: {self._report_status}"
def pytest_report_collectionfinish(self) -> Optional[str]:
if self.active and self.config.getoption("verbose") >= 0:
return "run-last-failure: %s" % self._report_status
return None
def pytest_runtest_logreport(self, report: TestReport) -> None:
@@ -366,14 +342,14 @@ class LFPlugin:
else:
self.lastfailed[report.nodeid] = True
@hookimpl(wrapper=True, tryfirst=True)
@hookimpl(hookwrapper=True, tryfirst=True)
def pytest_collection_modifyitems(
self, config: Config, items: list[nodes.Item]
) -> Generator[None]:
res = yield
self, config: Config, items: List[nodes.Item]
) -> Generator[None, None, None]:
yield
if not self.active:
return res
return
if self.lastfailed:
previously_failed = []
@@ -388,8 +364,8 @@ class LFPlugin:
if not previously_failed:
# Running a subset of all tests with recorded failures
# only outside of it.
self._report_status = (
f"{len(self.lastfailed)} known failures not in selected tests"
self._report_status = "%d known failures not in selected tests" % (
len(self.lastfailed),
)
else:
if self.config.getoption("lf"):
@@ -400,13 +376,15 @@ class LFPlugin:
noun = "failure" if self._previously_failed_count == 1 else "failures"
suffix = " first" if self.config.getoption("failedfirst") else ""
self._report_status = (
f"rerun previous {self._previously_failed_count} {noun}{suffix}"
self._report_status = "rerun previous {count} {noun}{suffix}".format(
count=self._previously_failed_count, suffix=suffix, noun=noun
)
if self._skipped_files > 0:
files_noun = "file" if self._skipped_files == 1 else "files"
self._report_status += f" (skipped {self._skipped_files} {files_noun})"
self._report_status += " (skipped {files} {files_noun})".format(
files=self._skipped_files, files_noun=files_noun
)
else:
self._report_status = "no previously failed tests, "
if self.config.getoption("last_failed_no_failures") == "none":
@@ -416,8 +394,6 @@ class LFPlugin:
else:
self._report_status += "not deselecting items."
return res
def pytest_sessionfinish(self, session: Session) -> None:
config = self.config
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
@@ -438,13 +414,15 @@ class NFPlugin:
assert config.cache is not None
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
@hookimpl(wrapper=True, tryfirst=True)
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]:
res = yield
@hookimpl(hookwrapper=True, tryfirst=True)
def pytest_collection_modifyitems(
self, items: List[nodes.Item]
) -> Generator[None, None, None]:
yield
if self.active:
new_items: dict[str, nodes.Item] = {}
other_items: dict[str, nodes.Item] = {}
new_items: Dict[str, nodes.Item] = {}
other_items: Dict[str, nodes.Item] = {}
for item in items:
if item.nodeid not in self.cached_nodeids:
new_items[item.nodeid] = item
@@ -458,10 +436,8 @@ class NFPlugin:
else:
self.cached_nodeids.update(item.nodeid for item in items)
return res
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> list[nodes.Item]:
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True)
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]:
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return]
def pytest_sessionfinish(self) -> None:
config = self.config
@@ -476,17 +452,14 @@ class NFPlugin:
def pytest_addoption(parser: Parser) -> None:
"""Add command-line options for cache functionality.
:param parser: Parser object to add command-line options to.
"""
group = parser.getgroup("general")
group.addoption(
"--lf",
"--last-failed",
action="store_true",
dest="lf",
help="Rerun only the tests that failed at the last run (or all if none failed)",
help="Rerun only the tests that failed "
"at the last run (or all if none failed)",
)
group.addoption(
"--ff",
@@ -540,7 +513,7 @@ def pytest_addoption(parser: Parser) -> None:
)
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
if config.option.cacheshow and not config.option.help:
from _pytest.main import wrap_session
@@ -550,13 +523,6 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
@hookimpl(tryfirst=True)
def pytest_configure(config: Config) -> None:
"""Configure cache system and register related plugins.
Creates the Cache instance and registers the last-failed (LFPlugin)
and new-first (NFPlugin) plugins with the plugin manager.
:param config: pytest configuration object.
"""
config.cache = Cache.for_config(config, _ispytest=True)
config.pluginmanager.register(LFPlugin(config), "lfplugin")
config.pluginmanager.register(NFPlugin(config), "nfplugin")
@@ -578,7 +544,7 @@ def cache(request: FixtureRequest) -> Cache:
return request.config.cache
def pytest_report_header(config: Config) -> str | None:
def pytest_report_header(config: Config) -> Optional[str]:
"""Display cachedir with --cache-show and if non-default."""
if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache":
assert config.cache is not None
@@ -595,16 +561,6 @@ def pytest_report_header(config: Config) -> str | None:
def cacheshow(config: Config, session: Session) -> int:
"""Display cache contents when --cache-show is used.
Shows cached values and directories matching the specified glob pattern
(default: '*'). Displays cache location, cached test results, and
any cached directories created by plugins.
:param config: pytest configuration object.
:param session: pytest session object.
:returns: Exit code (0 for success).
"""
from pprint import pformat
assert config.cache is not None
@@ -622,25 +578,25 @@ def cacheshow(config: Config, session: Session) -> int:
dummy = object()
basedir = config.cache._cachedir
vdir = basedir / Cache._CACHE_PREFIX_VALUES
tw.sep("-", f"cache values for {glob!r}")
tw.sep("-", "cache values for %r" % glob)
for valpath in sorted(x for x in vdir.rglob(glob) if x.is_file()):
key = str(valpath.relative_to(vdir))
val = config.cache.get(key, dummy)
if val is dummy:
tw.line(f"{key} contains unreadable content, will be ignored")
tw.line("%s contains unreadable content, will be ignored" % key)
else:
tw.line(f"{key} contains:")
tw.line("%s contains:" % key)
for line in pformat(val).splitlines():
tw.line(" " + line)
ddir = basedir / Cache._CACHE_PREFIX_DIRS
if ddir.is_dir():
contents = sorted(ddir.rglob(glob))
tw.sep("-", f"cache directories for {glob!r}")
tw.sep("-", "cache directories for %r" % glob)
for p in contents:
# if p.is_dir():
# print("%s/" % p.relative_to(basedir))
if p.is_file():
key = str(p.relative_to(basedir))
tw.line(f"{key} is a file of length {p.stat().st_size}")
tw.line(f"{key} is a file of length {p.stat().st_size:d}")
return 0

View File

@@ -1,36 +1,30 @@
# mypy: allow-untyped-defs
"""Per-test stdout/stderr capturing mechanism."""
from __future__ import annotations
import abc
import collections
from collections.abc import Generator
from collections.abc import Iterable
from collections.abc import Iterator
import contextlib
import io
from io import UnsupportedOperation
import os
import sys
from io import UnsupportedOperation
from tempfile import TemporaryFile
from types import TracebackType
from typing import Any
from typing import AnyStr
from typing import BinaryIO
from typing import cast
from typing import Final
from typing import final
from typing import Generator
from typing import Generic
from typing import Literal
from typing import Iterable
from typing import Iterator
from typing import List
from typing import NamedTuple
from typing import Optional
from typing import TextIO
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
if TYPE_CHECKING:
from typing_extensions import Self
from _pytest.compat import final
from _pytest.config import Config
from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
@@ -40,15 +34,17 @@ from _pytest.fixtures import SubRequest
from _pytest.nodes import Collector
from _pytest.nodes import File
from _pytest.nodes import Item
from _pytest.reports import CollectReport
if TYPE_CHECKING:
from typing_extensions import Final
from typing_extensions import Literal
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general")
group.addoption(
group._addoption(
"--capture",
action="store",
default="fd",
@@ -56,7 +52,7 @@ def pytest_addoption(parser: Parser) -> None:
choices=["fd", "sys", "no", "tee-sys"],
help="Per-test capturing method: one of fd|sys|no|tee-sys",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-s",
action="store_const",
const="no",
@@ -80,23 +76,6 @@ def _colorama_workaround() -> None:
pass
def _readline_workaround() -> None:
"""Ensure readline is imported early so it attaches to the correct stdio handles.
This isn't a problem with the default GNU readline implementation, but in
some configurations, Python uses libedit instead (on macOS, and for prebuilt
binaries such as used by uv).
In theory this is only needed if readline.backend == "libedit", but the
workaround consists of importing readline here, so we already worked around
the issue by the time we could check if we need to.
"""
try:
import readline # noqa: F401
except ImportError:
pass
def _windowsconsoleio_workaround(stream: TextIO) -> None:
"""Workaround for Windows Unicode console handling.
@@ -125,16 +104,17 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
return
# Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666).
if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore]
if not hasattr(stream, "buffer"): # type: ignore[unreachable]
return
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
buffered = hasattr(stream.buffer, "raw")
raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined]
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined]
return
def _reopen_stdio(f, mode):
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
if not buffered and mode[0] == "w":
buffering = 0
else:
buffering = -1
@@ -152,13 +132,12 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
sys.stderr = _reopen_stdio(sys.stderr, "wb")
@hookimpl(wrapper=True)
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_load_initial_conftests(early_config: Config):
ns = early_config.known_args_namespace
if ns.capture == "fd":
_windowsconsoleio_workaround(sys.stdout)
_colorama_workaround()
_readline_workaround()
pluginmanager = early_config.pluginmanager
capman = CaptureManager(ns.capture)
pluginmanager.register(capman, "capturemanager")
@@ -168,16 +147,12 @@ def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
# Finally trigger conftest loading but while capturing (issue #93).
capman.start_global_capturing()
try:
try:
yield
finally:
capman.suspend_global_capture()
except BaseException:
outcome = yield
capman.suspend_global_capture()
if outcome.excinfo is not None:
out, err = capman.read_global_capture()
sys.stdout.write(out)
sys.stderr.write(err)
raise
# IO Helpers.
@@ -196,8 +171,7 @@ class EncodedFile(io.TextIOWrapper):
def mode(self) -> str:
# TextIOWrapper doesn't expose a mode, but at least some of our
# tests check it.
assert hasattr(self.buffer, "mode")
return cast(str, self.buffer.mode.replace("b", ""))
return self.buffer.mode.replace("b", "")
class CaptureIO(io.TextIOWrapper):
@@ -222,7 +196,6 @@ class TeeCaptureIO(CaptureIO):
class DontReadFromInput(TextIO):
@property
def encoding(self) -> str:
assert sys.__stdin__ is not None
return sys.__stdin__.encoding
def read(self, size: int = -1) -> str:
@@ -235,7 +208,7 @@ class DontReadFromInput(TextIO):
def __next__(self) -> str:
return self.readline()
def readlines(self, hint: int | None = -1) -> list[str]:
def readlines(self, hint: Optional[int] = -1) -> List[str]:
raise OSError(
"pytest: reading from stdin while output is captured! Consider using `-s`."
)
@@ -267,7 +240,7 @@ class DontReadFromInput(TextIO):
def tell(self) -> int:
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
def truncate(self, size: int | None = None) -> int:
def truncate(self, size: Optional[int] = None) -> int:
raise UnsupportedOperation("cannot truncate stdin")
def write(self, data: str) -> int:
@@ -279,14 +252,14 @@ class DontReadFromInput(TextIO):
def writable(self) -> bool:
return False
def __enter__(self) -> Self:
def __enter__(self) -> "DontReadFromInput":
return self
def __exit__(
self,
type: type[BaseException] | None,
value: BaseException | None,
traceback: TracebackType | None,
type: Optional[Type[BaseException]],
value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> None:
pass
@@ -361,7 +334,7 @@ class NoCapture(CaptureBase[str]):
class SysCaptureBase(CaptureBase[AnyStr]):
def __init__(
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False
) -> None:
name = patchsysdict[fd]
self._old: TextIO = getattr(sys, name)
@@ -378,7 +351,7 @@ class SysCaptureBase(CaptureBase[AnyStr]):
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
class_name,
self.name,
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
self._state,
self.tmpfile,
)
@@ -387,16 +360,16 @@ class SysCaptureBase(CaptureBase[AnyStr]):
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__,
self.name,
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
self._state,
self.tmpfile,
)
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
assert self._state in states, (
"cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
assert (
self._state in states
), "cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def start(self) -> None:
@@ -479,7 +452,7 @@ class FDCaptureBase(CaptureBase[AnyStr]):
# Further complications are the need to support suspend() and the
# possibility of FD reuse (e.g. the tmpfile getting the very same
# target FD). The following approach is robust, I believe.
self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR)
self.targetfd_invalid: Optional[int] = os.open(os.devnull, os.O_RDWR)
os.dup2(self.targetfd_invalid, targetfd)
else:
self.targetfd_invalid = None
@@ -504,16 +477,19 @@ class FDCaptureBase(CaptureBase[AnyStr]):
self._state = "initialized"
def __repr__(self) -> str:
return (
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__,
self.targetfd,
self.targetfd_save,
self._state,
self.tmpfile,
)
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
assert self._state in states, (
"cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
assert (
self._state in states
), "cannot {} in state {!r}: expected one of {}".format(
op, self._state, ", ".join(states)
)
def start(self) -> None:
@@ -570,7 +546,7 @@ class FDCaptureBinary(FDCaptureBase[bytes]):
res = self.tmpfile.buffer.read()
self.tmpfile.seek(0)
self.tmpfile.truncate()
return res # type: ignore[return-value]
return res
def writeorg(self, data: bytes) -> None:
"""Write to original file descriptor."""
@@ -609,7 +585,7 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
@final
class CaptureResult(NamedTuple, Generic[AnyStr]):
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
"""The result of :method:`CaptureFixture.readouterr`."""
out: AnyStr
err: AnyStr
@@ -617,10 +593,9 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
else:
class CaptureResult(
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
Generic[AnyStr],
collections.namedtuple("CaptureResult", ["out", "err"]), Generic[AnyStr]
):
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
"""The result of :method:`CaptureFixture.readouterr`."""
__slots__ = ()
@@ -631,18 +606,21 @@ class MultiCapture(Generic[AnyStr]):
def __init__(
self,
in_: CaptureBase[AnyStr] | None,
out: CaptureBase[AnyStr] | None,
err: CaptureBase[AnyStr] | None,
in_: Optional[CaptureBase[AnyStr]],
out: Optional[CaptureBase[AnyStr]],
err: Optional[CaptureBase[AnyStr]],
) -> None:
self.in_: CaptureBase[AnyStr] | None = in_
self.out: CaptureBase[AnyStr] | None = out
self.err: CaptureBase[AnyStr] | None = err
self.in_: Optional[CaptureBase[AnyStr]] = in_
self.out: Optional[CaptureBase[AnyStr]] = out
self.err: Optional[CaptureBase[AnyStr]] = err
def __repr__(self) -> str:
return (
f"<MultiCapture out={self.out!r} err={self.err!r} in_={self.in_!r} "
f"_state={self._state!r} _in_suspended={self._in_suspended!r}>"
return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format(
self.out,
self.err,
self.in_,
self._state,
self._in_suspended,
)
def start_capturing(self) -> None:
@@ -654,7 +632,7 @@ class MultiCapture(Generic[AnyStr]):
if self.err:
self.err.start()
def pop_outerr_to_orig(self) -> tuple[AnyStr, AnyStr]:
def pop_outerr_to_orig(self) -> Tuple[AnyStr, AnyStr]:
"""Pop current snapshot out/err capture and flush to orig streams."""
out, err = self.readouterr()
if out:
@@ -709,7 +687,7 @@ class MultiCapture(Generic[AnyStr]):
return CaptureResult(out, err) # type: ignore[arg-type]
def _get_multicapture(method: _CaptureMethod) -> MultiCapture[str]:
def _get_multicapture(method: "_CaptureMethod") -> MultiCapture[str]:
if method == "fd":
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
elif method == "sys":
@@ -745,22 +723,21 @@ class CaptureManager:
needed to ensure the fixtures take precedence over the global capture.
"""
def __init__(self, method: _CaptureMethod) -> None:
def __init__(self, method: "_CaptureMethod") -> None:
self._method: Final = method
self._global_capturing: MultiCapture[str] | None = None
self._capture_fixture: CaptureFixture[Any] | None = None
self._global_capturing: Optional[MultiCapture[str]] = None
self._capture_fixture: Optional[CaptureFixture[Any]] = None
def __repr__(self) -> str:
return (
f"<CaptureManager _method={self._method!r} _global_capturing={self._global_capturing!r} "
f"_capture_fixture={self._capture_fixture!r}>"
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
self._method, self._global_capturing, self._capture_fixture
)
def is_capturing(self) -> str | bool:
def is_capturing(self) -> Union[str, bool]:
if self.is_globally_capturing():
return "global"
if self._capture_fixture:
return f"fixture {self._capture_fixture.request.fixturename}"
return "fixture %s" % self._capture_fixture.request.fixturename
return False
# Global capturing control
@@ -804,12 +781,14 @@ class CaptureManager:
# Fixture Control
def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None:
def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None:
if self._capture_fixture:
current_fixture = self._capture_fixture.request.fixturename
requested_fixture = capture_fixture.request.fixturename
capture_fixture.request.raiseerror(
f"cannot use {requested_fixture} and {current_fixture} at the same time"
"cannot use {} and {} at the same time".format(
requested_fixture, current_fixture
)
)
self._capture_fixture = capture_fixture
@@ -838,7 +817,7 @@ class CaptureManager:
# Helper context managers
@contextlib.contextmanager
def global_and_fixture_disabled(self) -> Generator[None]:
def global_and_fixture_disabled(self) -> Generator[None, None, None]:
"""Context manager to temporarily disable global and current fixture capturing."""
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
if do_fixture:
@@ -855,7 +834,7 @@ class CaptureManager:
self.resume_fixture()
@contextlib.contextmanager
def item_capture(self, when: str, item: Item) -> Generator[None]:
def item_capture(self, when: str, item: Item) -> Generator[None, None, None]:
self.resume_global_capture()
self.activate_fixture()
try:
@@ -864,45 +843,41 @@ class CaptureManager:
self.deactivate_fixture()
self.suspend_global_capture(in_=False)
out, err = self.read_global_capture()
item.add_report_section(when, "stdout", out)
item.add_report_section(when, "stderr", err)
out, err = self.read_global_capture()
item.add_report_section(when, "stdout", out)
item.add_report_section(when, "stderr", err)
# Hooks
@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: Collector
) -> Generator[None, CollectReport, CollectReport]:
@hookimpl(hookwrapper=True)
def pytest_make_collect_report(self, collector: Collector):
if isinstance(collector, File):
self.resume_global_capture()
try:
rep = yield
finally:
self.suspend_global_capture()
outcome = yield
self.suspend_global_capture()
out, err = self.read_global_capture()
rep = outcome.get_result()
if out:
rep.sections.append(("Captured stdout", out))
if err:
rep.sections.append(("Captured stderr", err))
else:
rep = yield
return rep
yield
@hookimpl(wrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("setup", item):
return (yield)
yield
@hookimpl(wrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("call", item):
return (yield)
yield
@hookimpl(wrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
@hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
with self.item_capture("teardown", item):
return (yield)
yield
@hookimpl(tryfirst=True)
def pytest_keyboard_interrupt(self) -> None:
@@ -919,17 +894,15 @@ class CaptureFixture(Generic[AnyStr]):
def __init__(
self,
captureclass: type[CaptureBase[AnyStr]],
captureclass: Type[CaptureBase[AnyStr]],
request: SubRequest,
*,
config: dict[str, Any] | None = None,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self.captureclass: type[CaptureBase[AnyStr]] = captureclass
self.captureclass: Type[CaptureBase[AnyStr]] = captureclass
self.request = request
self._config = config if config else {}
self._capture: MultiCapture[AnyStr] | None = None
self._capture: Optional[MultiCapture[AnyStr]] = None
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
@@ -937,8 +910,8 @@ class CaptureFixture(Generic[AnyStr]):
if self._capture is None:
self._capture = MultiCapture(
in_=None,
out=self.captureclass(1, **self._config),
err=self.captureclass(2, **self._config),
out=self.captureclass(1),
err=self.captureclass(2),
)
self._capture.start_capturing()
@@ -984,7 +957,7 @@ class CaptureFixture(Generic[AnyStr]):
return False
@contextlib.contextmanager
def disabled(self) -> Generator[None]:
def disabled(self) -> Generator[None, None, None]:
"""Temporarily disable capturing while inside the ``with`` block."""
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
"capturemanager"
@@ -997,7 +970,7 @@ class CaptureFixture(Generic[AnyStr]):
@fixture
def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsys.readouterr()`` method
@@ -1025,42 +998,7 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
@fixture
def capteesys(request: SubRequest) -> Generator[CaptureFixture[str]]:
r"""Enable simultaneous text capturing and pass-through of writes
to ``sys.stdout`` and ``sys.stderr`` as defined by ``--capture=``.
The captured output is made available via ``capteesys.readouterr()`` method
calls, which return a ``(out, err)`` namedtuple.
``out`` and ``err`` will be ``text`` objects.
The output is also passed-through, allowing it to be "live-printed",
reported, or both as defined by ``--capture=``.
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
Example:
.. code-block:: python
def test_output(capteesys):
print("hello")
captured = capteesys.readouterr()
assert captured.out == "hello\n"
"""
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
capture_fixture = CaptureFixture(
SysCapture, request, config=dict(tee=True), _ispytest=True
)
capman.set_fixture(capture_fixture)
capture_fixture._start()
yield capture_fixture
capture_fixture.close()
capman.unset_fixture()
@fixture
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsysbinary.readouterr()``
@@ -1088,7 +1026,7 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
@fixture
def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method
@@ -1116,7 +1054,7 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
@fixture
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method

View File

@@ -1,28 +1,41 @@
# mypy: allow-untyped-defs
"""Python version compatibility code and random general utilities."""
"""Python version compatibility code."""
from __future__ import annotations
from collections.abc import Callable
import dataclasses
import enum
import functools
import inspect
from inspect import Parameter
from inspect import Signature
import os
from pathlib import Path
import sys
from inspect import Parameter
from inspect import signature
from pathlib import Path
from typing import Any
from typing import Final
from typing import Callable
from typing import Generic
from typing import NoReturn
from typing import TYPE_CHECKING
from typing import TypeVar
import py
# fmt: off
# Workaround for https://github.com/sphinx-doc/sphinx/issues/10351.
# If `overload` is imported from `compat` instead of from `typing`,
# Sphinx doesn't recognize it as `overload` and the API docs for
# overloaded functions look good again. But type checkers handle
# it fine.
# fmt: on
if True:
from typing import overload as overload
if sys.version_info >= (3, 14):
from annotationlib import Format
if TYPE_CHECKING:
from typing_extensions import Final
_T = TypeVar("_T")
_S = TypeVar("_S")
#: constant to prepare valuing pylib path replacements/lazy proxies later on
# intended for removal in pytest 8.0 or 9.0
@@ -42,16 +55,32 @@ def legacy_path(path: str | os.PathLike[str]) -> LEGACY_PATH:
# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
class NotSetType(enum.Enum):
token = 0
NOTSET: Final = NotSetType.token
NOTSET: Final = NotSetType.token # noqa: E305
# fmt: on
if sys.version_info >= (3, 8):
import importlib.metadata
importlib_metadata = importlib.metadata
else:
import importlib_metadata as importlib_metadata # noqa: F401
def _format_args(func: Callable[..., Any]) -> str:
return str(signature(func))
def is_generator(func: object) -> bool:
genfunc = inspect.isgeneratorfunction(func)
return genfunc and not iscoroutinefunction(func)
def iscoroutinefunction(func: object) -> bool:
"""Return True if func is a coroutine function (a function defined with async
def syntax, and doesn't contain yield), or a function decorated with
@asyncio.coroutine.
Note: copied and modified from Python 3.5's builtin coroutines.py to avoid
Note: copied and modified from Python 3.5's builtin couroutines.py to avoid
importing asyncio directly, which in turns also initializes the "logging"
module as a side-effect (see issue #8).
"""
@@ -64,14 +93,7 @@ def is_async_function(func: object) -> bool:
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
def signature(obj: Callable[..., Any]) -> Signature:
"""Return signature without evaluating annotations."""
if sys.version_info >= (3, 14):
return inspect.signature(obj, annotation_format=Format.STRING)
return inspect.signature(obj)
def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
def getlocation(function, curdir: str | None = None) -> str:
function = get_real_func(function)
fn = Path(inspect.getfile(function))
lineno = function.__code__.co_firstlineno
@@ -81,8 +103,8 @@ def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
except ValueError:
pass
else:
return f"{relfn}:{lineno + 1}"
return f"{fn}:{lineno + 1}"
return "%s:%d" % (relfn, lineno + 1)
return "%s:%d" % (fn, lineno + 1)
def num_mock_patch_args(function) -> int:
@@ -105,9 +127,10 @@ def num_mock_patch_args(function) -> int:
def getfuncargnames(
function: Callable[..., object],
function: Callable[..., Any],
*,
name: str = "",
is_method: bool = False,
cls: type | None = None,
) -> tuple[str, ...]:
"""Return the names of a function's mandatory arguments.
@@ -118,8 +141,9 @@ def getfuncargnames(
* Aren't bound with functools.partial.
* Aren't replaced with mocks.
The cls arguments indicate that the function should be treated as a bound
method even though it's not unless the function is a static method.
The is_method and cls arguments indicate that the function should
be treated as a bound method even though it's not unless, only in
the case of cls, the function is a static method.
The name parameter should be the original name in which the function was collected.
"""
@@ -133,7 +157,7 @@ def getfuncargnames(
# creates a tuple of the names of the parameters that don't have
# defaults.
try:
parameters = signature(function).parameters.values()
parameters = signature(function).parameters
except (ValueError, TypeError) as e:
from _pytest.outcomes import fail
@@ -144,7 +168,7 @@ def getfuncargnames(
arg_names = tuple(
p.name
for p in parameters
for p in parameters.values()
if (
p.kind is Parameter.POSITIONAL_OR_KEYWORD
or p.kind is Parameter.KEYWORD_ONLY
@@ -155,9 +179,9 @@ def getfuncargnames(
name = function.__name__
# If this function should be treated as a bound method even though
# it's passed as an unbound method or function, and its first parameter
# wasn't defined as positional only, remove the first parameter name.
if not any(p.kind is Parameter.POSITIONAL_ONLY for p in parameters) and (
# it's passed as an unbound method or function, remove the first
# parameter name.
if is_method or (
# Not using `getattr` because we don't want to resolve the staticmethod.
# Not using `cls.__dict__` because we want to check the entire MRO.
cls
@@ -192,13 +216,25 @@ _non_printable_ascii_translate_table.update(
)
def _translate_non_printable(s: str) -> str:
return s.translate(_non_printable_ascii_translate_table)
STRING_TYPES = bytes, str
def _bytes_to_ascii(val: bytes) -> str:
return val.decode("ascii", "backslashreplace")
def ascii_escaped(val: bytes | str) -> str:
r"""If val is pure ASCII, return it as an str, otherwise, escape
bytes objects into a sequence of escaped bytes:
b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6'
and escapes strings into a sequence of escaped unicode ids, e.g.:
and escapes unicode objects into a sequence of escaped unicode
ids, e.g.:
r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944'
@@ -209,22 +245,67 @@ def ascii_escaped(val: bytes | str) -> str:
a UTF-8 string.
"""
if isinstance(val, bytes):
ret = val.decode("ascii", "backslashreplace")
ret = _bytes_to_ascii(val)
else:
ret = val.encode("unicode_escape").decode("ascii")
return ret.translate(_non_printable_ascii_translate_table)
return _translate_non_printable(ret)
@dataclasses.dataclass
class _PytestWrapper:
"""Dummy wrapper around a function object for internal use only.
Used to correctly unwrap the underlying function object when we are
creating fixtures, because we wrap the function object ourselves with a
decorator to issue warnings when the fixture function is called directly.
"""
obj: Any
def get_real_func(obj):
"""Get the real function object of the (possibly) wrapped object by
:func:`functools.wraps`, or :func:`functools.partial`."""
obj = inspect.unwrap(obj)
functools.wraps or functools.partial."""
start_obj = obj
for i in range(100):
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
# to trigger a warning if it gets called directly instead of by pytest: we don't
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
new_obj = getattr(obj, "__pytest_wrapped__", None)
if isinstance(new_obj, _PytestWrapper):
obj = new_obj.obj
break
new_obj = getattr(obj, "__wrapped__", None)
if new_obj is None:
break
obj = new_obj
else:
from _pytest._io.saferepr import saferepr
raise ValueError(
("could not find real function of {start}\nstopped at {current}").format(
start=saferepr(start_obj), current=saferepr(obj)
)
)
if isinstance(obj, functools.partial):
obj = obj.func
return obj
def get_real_method(obj, holder):
"""Attempt to obtain the real function object that might be wrapping
``obj``, while at the same time returning a bound method to ``holder`` if
the original object was a bound method."""
try:
is_method = hasattr(obj, "__func__")
obj = get_real_func(obj)
except Exception: # pragma: no cover
return obj
if is_method and hasattr(obj, "__get__") and callable(obj.__get__):
obj = obj.__get__(holder)
return obj
def getimfunc(func):
try:
return func.__func__
@@ -257,6 +338,47 @@ def safe_isclass(obj: object) -> bool:
return False
if TYPE_CHECKING:
if sys.version_info >= (3, 8):
from typing import final as final
else:
from typing_extensions import final as final
elif sys.version_info >= (3, 8):
from typing import final as final
else:
def final(f):
return f
if sys.version_info >= (3, 8):
from functools import cached_property as cached_property
else:
class cached_property(Generic[_S, _T]):
__slots__ = ("func", "__doc__")
def __init__(self, func: Callable[[_S], _T]) -> None:
self.func = func
self.__doc__ = func.__doc__
@overload
def __get__(
self, instance: None, owner: type[_S] | None = ...
) -> cached_property[_S, _T]:
...
@overload
def __get__(self, instance: _S, owner: type[_S] | None = ...) -> _T:
...
def __get__(self, instance, owner=None):
if instance is None:
return self
value = instance.__dict__[self.func.__name__] = self.func(instance)
return value
def get_user_id() -> int | None:
"""Return the current process's real user id or None if it could not be
determined.
@@ -278,37 +400,36 @@ def get_user_id() -> int | None:
return uid if uid != ERROR else None
if sys.version_info >= (3, 11):
from typing import assert_never
else:
def assert_never(value: NoReturn) -> NoReturn:
assert False, f"Unhandled value: {value} ({type(value).__name__})"
class CallableBool:
"""
A bool-like object that can also be called, returning its true/false value.
Used for backwards compatibility in cases where something was supposed to be a method
but was implemented as a simple attribute by mistake (see `TerminalReporter.isatty`).
Do not use in new code.
"""
def __init__(self, value: bool) -> None:
self._value = value
def __bool__(self) -> bool:
return self._value
def __call__(self) -> bool:
return self._value
def running_on_ci() -> bool:
"""Check if we're currently running on a CI system."""
# Only enable CI mode if one of these env variables is defined and non-empty.
# Note: review `regendoc` tox env in case this list is changed.
env_vars = ["CI", "BUILD_NUMBER"]
return any(os.environ.get(var) for var in env_vars)
# Perform exhaustiveness checking.
#
# Consider this example:
#
# MyUnion = Union[int, str]
#
# def handle(x: MyUnion) -> int {
# if isinstance(x, int):
# return 1
# elif isinstance(x, str):
# return 2
# else:
# raise Exception('unreachable')
#
# Now suppose we add a new variant:
#
# MyUnion = Union[int, str, bytes]
#
# After doing this, we must remember ourselves to go and update the handle
# function to handle the new variant.
#
# With `assert_never` we can do better:
#
# // raise Exception('unreachable')
# return assert_never(x)
#
# Now, if we forget to handle the new variant, the type-checker will emit a
# compile-time error, instead of the runtime error we would have gotten
# previously.
#
# This also work for Enums (if you use `is` to compare) and Literals.
def assert_never(value: NoReturn) -> NoReturn:
assert False, f"Unhandled value: {value} ({type(value).__name__})"

View File

@@ -1,83 +1,69 @@
# mypy: allow-untyped-defs
from __future__ import annotations
import argparse
from collections.abc import Callable
from collections.abc import Mapping
from collections.abc import Sequence
import os
import sys
import warnings
from gettext import gettext
from typing import Any
from typing import final
from typing import Literal
from typing import Callable
from typing import cast
from typing import Dict
from typing import List
from typing import Mapping
from typing import NoReturn
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
from .exceptions import UsageError
import _pytest._io
from _pytest.compat import final
from _pytest.config.exceptions import UsageError
from _pytest.deprecated import ARGUMENT_PERCENT_DEFAULT
from _pytest.deprecated import ARGUMENT_TYPE_STR
from _pytest.deprecated import ARGUMENT_TYPE_STR_CHOICE
from _pytest.deprecated import check_ispytest
if TYPE_CHECKING:
from typing_extensions import Literal
FILE_OR_DIR = "file_or_dir"
class NotSet:
def __repr__(self) -> str:
return "<notset>"
NOT_SET = NotSet()
@final
class Parser:
"""Parser for command line arguments and config-file values.
"""Parser for command line arguments and ini-file values.
:ivar extra_info: Dict of generic param -> value to display in case
there's an error processing the command line arguments.
"""
prog: Optional[str] = None
def __init__(
self,
usage: str | None = None,
processopt: Callable[[Argument], None] | None = None,
usage: Optional[str] = None,
processopt: Optional[Callable[["Argument"], None]] = None,
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
from _pytest._argcomplete import filescompleter
self._anonymous = OptionGroup("Custom options", parser=self, _ispytest=True)
self._groups: List[OptionGroup] = []
self._processopt = processopt
self.extra_info: dict[str, Any] = {}
self.optparser = PytestArgumentParser(self, usage, self.extra_info)
anonymous_arggroup = self.optparser.add_argument_group("Custom options")
self._anonymous = OptionGroup(
anonymous_arggroup, "_anonymous", self, _ispytest=True
)
self._groups = [self._anonymous]
file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*")
file_or_dir_arg.completer = filescompleter # type: ignore
self._usage = usage
self._inidict: Dict[str, Tuple[str, Optional[str], Any]] = {}
self._ininames: List[str] = []
self.extra_info: Dict[str, Any] = {}
self._inidict: dict[str, tuple[str, str, Any]] = {}
# Maps alias -> canonical name.
self._ini_aliases: dict[str, str] = {}
@property
def prog(self) -> str:
return self.optparser.prog
@prog.setter
def prog(self, value: str) -> None:
self.optparser.prog = value
def processoption(self, option: Argument) -> None:
def processoption(self, option: "Argument") -> None:
if self._processopt:
if option.dest:
self._processopt(option)
def getgroup(
self, name: str, description: str = "", after: str | None = None
) -> OptionGroup:
self, name: str, description: str = "", after: Optional[str] = None
) -> "OptionGroup":
"""Get (or create) a named option Group.
:param name: Name of the option group.
@@ -93,17 +79,12 @@ class Parser:
for group in self._groups:
if group.name == name:
return group
arggroup = self.optparser.add_argument_group(description or name)
group = OptionGroup(arggroup, name, self, _ispytest=True)
group = OptionGroup(name, description, parser=self, _ispytest=True)
i = 0
for i, grp in enumerate(self._groups):
if grp.name == after:
break
self._groups.insert(i + 1, group)
# argparse doesn't provide a way to control `--help` order, so must
# access its internals ☹.
self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop())
return group
def addoption(self, *opts: str, **attrs: Any) -> None:
@@ -112,7 +93,7 @@ class Parser:
:param opts:
Option names, can be short or long options.
:param attrs:
Same attributes as the argparse library's :meth:`add_argument()
Same attributes as the argparse library's :py:func:`add_argument()
<argparse.ArgumentParser.add_argument>` function accepts.
After command line parsing, options are available on the pytest config
@@ -124,32 +105,50 @@ class Parser:
def parse(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Parse the arguments.
Unlike ``parse_known_args`` and ``parse_known_and_unknown_args``,
raises PrintHelp on `--help` and UsageError on unknown flags
:meta private:
"""
from _pytest._argcomplete import try_argcomplete
self.optparser = self._getparser()
try_argcomplete(self.optparser)
strargs = [os.fspath(x) for x in args]
if namespace is None:
namespace = argparse.Namespace()
try:
namespace._raise_print_help = True
return self.optparser.parse_intermixed_args(strargs, namespace=namespace)
finally:
del namespace._raise_print_help
return self.optparser.parse_args(strargs, namespace=namespace)
def _getparser(self) -> "MyOptionParser":
from _pytest._argcomplete import filescompleter
optparser = MyOptionParser(self, self.extra_info, prog=self.prog)
groups = self._groups + [self._anonymous]
for group in groups:
if group.options:
desc = group.description or group.name
arggroup = optparser.add_argument_group(desc)
for option in group.options:
n = option.names()
a = option.attrs()
arggroup.add_argument(*n, **a)
file_or_dir_arg = optparser.add_argument(FILE_OR_DIR, nargs="*")
# bash like autocompletion for dirs (appending '/')
# Type ignored because typeshed doesn't know about argcomplete.
file_or_dir_arg.completer = filescompleter # type: ignore
return optparser
def parse_setoption(
self,
args: Sequence[Union[str, "os.PathLike[str]"]],
option: argparse.Namespace,
namespace: Optional[argparse.Namespace] = None,
) -> List[str]:
parsedoption = self.parse(args, namespace=namespace)
for name, value in parsedoption.__dict__.items():
setattr(option, name, value)
return cast(List[str], getattr(parsedoption, FILE_OR_DIR))
def parse_known_args(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Parse the known arguments at this point.
@@ -159,47 +158,35 @@ class Parser:
def parse_known_and_unknown_args(
self,
args: Sequence[str | os.PathLike[str]],
namespace: argparse.Namespace | None = None,
) -> tuple[argparse.Namespace, list[str]]:
args: Sequence[Union[str, "os.PathLike[str]"]],
namespace: Optional[argparse.Namespace] = None,
) -> Tuple[argparse.Namespace, List[str]]:
"""Parse the known arguments at this point, and also return the
remaining unknown flag arguments.
remaining unknown arguments.
:returns:
A tuple containing an argparse namespace object for the known
arguments, and a list of unknown flag arguments.
arguments, and a list of the unknown arguments.
"""
optparser = self._getparser()
strargs = [os.fspath(x) for x in args]
if sys.version_info < (3, 12, 8) or (3, 13) <= sys.version_info < (3, 13, 1):
# Older argparse have a bugged parse_known_intermixed_args.
namespace, unknown = self.optparser.parse_known_args(strargs, namespace)
assert namespace is not None
file_or_dir = getattr(namespace, FILE_OR_DIR)
unknown_flags: list[str] = []
for arg in unknown:
(unknown_flags if arg.startswith("-") else file_or_dir).append(arg)
return namespace, unknown_flags
else:
return self.optparser.parse_known_intermixed_args(strargs, namespace)
return optparser.parse_known_args(strargs, namespace=namespace)
def addini(
self,
name: str,
help: str,
type: Literal[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
]
| None = None,
default: Any = NOT_SET,
*,
aliases: Sequence[str] = (),
type: Optional[
"Literal['string', 'paths', 'pathlist', 'args', 'linelist', 'bool']"
] = None,
default: Any = None,
) -> None:
"""Register a configuration file option.
"""Register an ini-file option.
:param name:
Name of the configuration.
Name of the ini-variable.
:param type:
Type of the configuration. Can be:
Type of the variable. Can be:
* ``string``: a string
* ``bool``: a boolean
@@ -207,90 +194,27 @@ class Parser:
* ``linelist``: a list of strings, separated by line breaks
* ``paths``: a list of :class:`pathlib.Path`, separated as in a shell
* ``pathlist``: a list of ``py.path``, separated as in a shell
* ``int``: an integer
* ``float``: a floating-point number
.. versionadded:: 8.4
The ``float`` and ``int`` types.
For ``paths`` and ``pathlist`` types, they are considered relative to the config-file.
In case the execution is happening without a config-file defined,
they will be considered relative to the current working directory (for example with ``--override-ini``).
.. versionadded:: 7.0
The ``paths`` variable type.
.. versionadded:: 8.1
Use the current working directory to resolve ``paths`` and ``pathlist`` in the absence of a config-file.
Defaults to ``string`` if ``None`` or not passed.
:param default:
Default value if no config-file option exists but is queried.
:param aliases:
Additional names by which this option can be referenced.
Aliases resolve to the canonical name.
Default value if no ini-file option exists but is queried.
.. versionadded:: 9.0
The ``aliases`` parameter.
The value of configuration keys can be retrieved via a call to
The value of ini-variables can be retrieved via a call to
:py:func:`config.getini(name) <pytest.Config.getini>`.
"""
assert type in (
None,
"string",
"paths",
"pathlist",
"args",
"linelist",
"bool",
"int",
"float",
)
if type is None:
type = "string"
if default is NOT_SET:
default = get_ini_default_for_type(type)
assert type in (None, "string", "paths", "pathlist", "args", "linelist", "bool")
self._inidict[name] = (help, type, default)
for alias in aliases:
if alias in self._inidict:
raise ValueError(
f"alias {alias!r} conflicts with existing configuration option"
)
if (already := self._ini_aliases.get(alias)) is not None:
raise ValueError(f"{alias!r} is already an alias of {already!r}")
self._ini_aliases[alias] = name
def get_ini_default_for_type(
type: Literal[
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
],
) -> Any:
"""
Used by addini to get the default value for a given config option type, when
default is not supplied.
"""
if type in ("paths", "pathlist", "args", "linelist"):
return []
elif type == "bool":
return False
elif type == "int":
return 0
elif type == "float":
return 0.0
else:
return ""
self._ininames.append(name)
class ArgumentError(Exception):
"""Raised if an Argument instance is created with invalid or
inconsistent arguments."""
def __init__(self, msg: str, option: Argument | str) -> None:
def __init__(self, msg: str, option: Union["Argument", str]) -> None:
self.msg = msg
self.option_id = str(option)
@@ -310,22 +234,46 @@ class Argument:
https://docs.python.org/3/library/optparse.html#optparse-standard-option-types
"""
_typ_map = {"int": int, "string": str, "float": float, "complex": complex}
def __init__(self, *names: str, **attrs: Any) -> None:
"""Store params in private vars for use in add_argument."""
self._attrs = attrs
self._short_opts: list[str] = []
self._long_opts: list[str] = []
self._short_opts: List[str] = []
self._long_opts: List[str] = []
if "%default" in (attrs.get("help") or ""):
warnings.warn(ARGUMENT_PERCENT_DEFAULT, stacklevel=3)
try:
self.type = attrs["type"]
typ = attrs["type"]
except KeyError:
pass
else:
# This might raise a keyerror as well, don't want to catch that.
if isinstance(typ, str):
if typ == "choice":
warnings.warn(
ARGUMENT_TYPE_STR_CHOICE.format(typ=typ, names=names),
stacklevel=4,
)
# argparse expects a type here take it from
# the type of the first element
attrs["type"] = type(attrs["choices"][0])
else:
warnings.warn(
ARGUMENT_TYPE_STR.format(typ=typ, names=names), stacklevel=4
)
attrs["type"] = Argument._typ_map[typ]
# Used in test_parseopt -> test_parse_defaultgetter.
self.type = attrs["type"]
else:
self.type = typ
try:
# Attribute existence is tested in Config._processopt.
self.default = attrs["default"]
except KeyError:
pass
self._set_opt_strings(names)
dest: str | None = attrs.get("dest")
dest: Optional[str] = attrs.get("dest")
if dest:
self.dest = dest
elif self._long_opts:
@@ -337,16 +285,23 @@ class Argument:
self.dest = "???" # Needed for the error repr.
raise ArgumentError("need a long or short option", self) from e
def names(self) -> list[str]:
def names(self) -> List[str]:
return self._short_opts + self._long_opts
def attrs(self) -> Mapping[str, Any]:
# Update any attributes set by processopt.
for attr in ("default", "dest", "help", self.dest):
attrs = "default dest help".split()
attrs.append(self.dest)
for attr in attrs:
try:
self._attrs[attr] = getattr(self, attr)
except AttributeError:
pass
if self._attrs.get("help"):
a = self._attrs["help"]
a = a.replace("%default", "%(default)s")
# a = a.replace('%prog', '%(prog)s')
self._attrs["help"] = a
return self._attrs
def _set_opt_strings(self, opts: Sequence[str]) -> None:
@@ -357,29 +312,29 @@ class Argument:
for opt in opts:
if len(opt) < 2:
raise ArgumentError(
f"invalid option string {opt!r}: "
"must be at least two characters long",
"invalid option string %r: "
"must be at least two characters long" % opt,
self,
)
elif len(opt) == 2:
if not (opt[0] == "-" and opt[1] != "-"):
raise ArgumentError(
f"invalid short option string {opt!r}: "
"must be of the form -x, (x any non-dash char)",
"invalid short option string %r: "
"must be of the form -x, (x any non-dash char)" % opt,
self,
)
self._short_opts.append(opt)
else:
if not (opt[0:2] == "--" and opt[2] != "-"):
raise ArgumentError(
f"invalid long option string {opt!r}: "
"must start with --, followed by non-dash",
"invalid long option string %r: "
"must start with --, followed by non-dash" % opt,
self,
)
self._long_opts.append(opt)
def __repr__(self) -> str:
args: list[str] = []
args: List[str] = []
if self._short_opts:
args += ["_short_opts: " + repr(self._short_opts)]
if self._long_opts:
@@ -397,15 +352,16 @@ class OptionGroup:
def __init__(
self,
arggroup: argparse._ArgumentGroup,
name: str,
parser: Parser | None,
description: str = "",
parser: Optional[Parser] = None,
*,
_ispytest: bool = False,
) -> None:
check_ispytest(_ispytest)
self._arggroup = arggroup
self.name = name
self.options: list[Argument] = []
self.description = description
self.options: List[Argument] = []
self.parser = parser
def addoption(self, *opts: str, **attrs: Any) -> None:
@@ -419,14 +375,14 @@ class OptionGroup:
:param opts:
Option names, can be short or long options.
:param attrs:
Same attributes as the argparse library's :meth:`add_argument()
Same attributes as the argparse library's :py:func:`add_argument()
<argparse.ArgumentParser.add_argument>` function accepts.
"""
conflict = set(opts).intersection(
name for opt in self.options for name in opt.names()
)
if conflict:
raise ValueError(f"option names {conflict} already added")
raise ValueError("option names %s already added" % conflict)
option = Argument(*opts, **attrs)
self._addoption_instance(option, shortupper=False)
@@ -434,47 +390,101 @@ class OptionGroup:
option = Argument(*opts, **attrs)
self._addoption_instance(option, shortupper=True)
def _addoption_instance(self, option: Argument, shortupper: bool = False) -> None:
def _addoption_instance(self, option: "Argument", shortupper: bool = False) -> None:
if not shortupper:
for opt in option._short_opts:
if opt[0] == "-" and opt[1].islower():
raise ValueError("lowercase shortoptions reserved")
if self.parser:
self.parser.processoption(option)
self._arggroup.add_argument(*option.names(), **option.attrs())
self.options.append(option)
class PytestArgumentParser(argparse.ArgumentParser):
class MyOptionParser(argparse.ArgumentParser):
def __init__(
self,
parser: Parser,
usage: str | None,
extra_info: dict[str, str],
extra_info: Optional[Dict[str, Any]] = None,
prog: Optional[str] = None,
) -> None:
self._parser = parser
super().__init__(
usage=usage,
prog=prog,
usage=parser._usage,
add_help=False,
formatter_class=DropShorterLongHelpFormatter,
allow_abbrev=False,
fromfile_prefix_chars="@",
)
# extra_info is a dict of (param -> value) to display if there's
# an usage error to provide more contextual information to the user.
self.extra_info = extra_info
self.extra_info = extra_info if extra_info else {}
def error(self, message: str) -> NoReturn:
"""Transform argparse error message into UsageError."""
msg = f"{self.prog}: error: {message}"
if self.extra_info:
msg += "\n" + "\n".join(
f" {k}: {v}" for k, v in sorted(self.extra_info.items())
)
if hasattr(self._parser, "_config_source_hint"):
# Type ignored because the attribute is set dynamically.
msg = f"{msg} ({self._parser._config_source_hint})" # type: ignore
raise UsageError(self.format_usage() + msg)
# Type ignored because typeshed has a very complex type in the superclass.
def parse_args( # type: ignore
self,
args: Optional[Sequence[str]] = None,
namespace: Optional[argparse.Namespace] = None,
) -> argparse.Namespace:
"""Allow splitting of positional arguments."""
parsed, unrecognized = self.parse_known_args(args, namespace)
if unrecognized:
for arg in unrecognized:
if arg and arg[0] == "-":
lines = ["unrecognized arguments: %s" % (" ".join(unrecognized))]
for k, v in sorted(self.extra_info.items()):
lines.append(f" {k}: {v}")
self.error("\n".join(lines))
getattr(parsed, FILE_OR_DIR).extend(unrecognized)
return parsed
if sys.version_info[:2] < (3, 9): # pragma: no cover
# Backport of https://github.com/python/cpython/pull/14316 so we can
# disable long --argument abbreviations without breaking short flags.
def _parse_optional(
self, arg_string: str
) -> Optional[Tuple[Optional[argparse.Action], str, Optional[str]]]:
if not arg_string:
return None
if not arg_string[0] in self.prefix_chars:
return None
if arg_string in self._option_string_actions:
action = self._option_string_actions[arg_string]
return action, arg_string, None
if len(arg_string) == 1:
return None
if "=" in arg_string:
option_string, explicit_arg = arg_string.split("=", 1)
if option_string in self._option_string_actions:
action = self._option_string_actions[option_string]
return action, option_string, explicit_arg
if self.allow_abbrev or not arg_string.startswith("--"):
option_tuples = self._get_option_tuples(arg_string)
if len(option_tuples) > 1:
msg = gettext(
"ambiguous option: %(option)s could match %(matches)s"
)
options = ", ".join(option for _, option, _ in option_tuples)
self.error(msg % {"option": arg_string, "matches": options})
elif len(option_tuples) == 1:
(option_tuple,) = option_tuples
return option_tuple
if self._negative_number_matcher.match(arg_string):
if not self._has_negative_number_optionals:
return None
if " " in arg_string:
return None
return None, arg_string, None
class DropShorterLongHelpFormatter(argparse.HelpFormatter):
"""Shorten help for long options that differ only in extra hyphens.
@@ -494,7 +504,7 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
orgstr = super()._format_action_invocation(action)
if orgstr and orgstr[0] != "-": # only optional arguments
return orgstr
res: str | None = getattr(action, "_formatted_action_invocation", None)
res: Optional[str] = getattr(action, "_formatted_action_invocation", None)
if res:
return res
options = orgstr.split(", ")
@@ -503,13 +513,13 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
action._formatted_action_invocation = orgstr # type: ignore
return orgstr
return_list = []
short_long: dict[str, str] = {}
short_long: Dict[str, str] = {}
for option in options:
if len(option) == 2 or option[2] == " ":
continue
if not option.startswith("--"):
raise ArgumentError(
f'long optional argument without "--": [{option}]', option
'long optional argument without "--": [%s]' % (option), option
)
xxoption = option[2:]
shortened = xxoption.replace("-", "")
@@ -539,40 +549,3 @@ class DropShorterLongHelpFormatter(argparse.HelpFormatter):
for line in text.splitlines():
lines.extend(textwrap.wrap(line.strip(), width))
return lines
class OverrideIniAction(argparse.Action):
"""Custom argparse action that makes a CLI flag equivalent to overriding an
option, in addition to behaving like `store_true`.
This can simplify things since code only needs to inspect the config option
and not consider the CLI flag.
"""
def __init__(
self,
option_strings: Sequence[str],
dest: str,
nargs: int | str | None = None,
*args,
ini_option: str,
ini_value: str,
**kwargs,
) -> None:
super().__init__(option_strings, dest, 0, *args, **kwargs)
self.ini_option = ini_option
self.ini_value = ini_value
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
*args,
**kwargs,
) -> None:
setattr(namespace, self.dest, True)
current_overrides = getattr(namespace, "override_ini", None)
if current_overrides is None:
current_overrides = []
current_overrides.append(f"{self.ini_option}={self.ini_value}")
setattr(namespace, "override_ini", current_overrides)

View File

@@ -1,20 +1,15 @@
from __future__ import annotations
from collections.abc import Mapping
import functools
from pathlib import Path
from typing import Any
import warnings
import pluggy
from pathlib import Path
from typing import Optional
from ..compat import LEGACY_PATH
from ..compat import legacy_path
from ..deprecated import HOOK_LEGACY_PATH_ARG
from _pytest.nodes import _check_path
# hookname: (Path, LEGACY_PATH)
imply_paths_hooks: Mapping[str, tuple[str, str]] = {
imply_paths_hooks = {
"pytest_ignore_collect": ("collection_path", "path"),
"pytest_collect_file": ("file_path", "path"),
"pytest_pycollect_makemodule": ("module_path", "path"),
@@ -23,14 +18,6 @@ imply_paths_hooks: Mapping[str, tuple[str, str]] = {
}
def _check_path(path: Path, fspath: LEGACY_PATH) -> None:
if Path(fspath) != path:
raise ValueError(
f"Path({fspath!r}) != {path!r}\n"
"if both path and fspath are given they need to be equal"
)
class PathAwareHookProxy:
"""
this helper wraps around hook callers
@@ -40,24 +27,24 @@ class PathAwareHookProxy:
this may have to be changed later depending on bugs
"""
def __init__(self, hook_relay: pluggy.HookRelay) -> None:
self._hook_relay = hook_relay
def __init__(self, hook_caller):
self.__hook_caller = hook_caller
def __dir__(self) -> list[str]:
return dir(self._hook_relay)
def __dir__(self):
return dir(self.__hook_caller)
def __getattr__(self, key: str) -> pluggy.HookCaller:
hook: pluggy.HookCaller = getattr(self._hook_relay, key)
def __getattr__(self, key, _wraps=functools.wraps):
hook = getattr(self.__hook_caller, key)
if key not in imply_paths_hooks:
self.__dict__[key] = hook
return hook
else:
path_var, fspath_var = imply_paths_hooks[key]
@functools.wraps(hook)
def fixed_hook(**kw: Any) -> Any:
path_value: Path | None = kw.pop(path_var, None)
fspath_value: LEGACY_PATH | None = kw.pop(fspath_var, None)
@_wraps(hook)
def fixed_hook(**kw):
path_value: Optional[Path] = kw.pop(path_var, None)
fspath_value: Optional[LEGACY_PATH] = kw.pop(fspath_var, None)
if fspath_value is not None:
warnings.warn(
HOOK_LEGACY_PATH_ARG.format(
@@ -78,8 +65,6 @@ class PathAwareHookProxy:
kw[fspath_var] = fspath_value
return hook(**kw)
fixed_hook.name = hook.name # type: ignore[attr-defined]
fixed_hook.spec = hook.spec # type: ignore[attr-defined]
fixed_hook.__name__ = key
self.__dict__[key] = fixed_hook
return fixed_hook # type: ignore[return-value]
return fixed_hook

View File

@@ -1,14 +1,10 @@
from __future__ import annotations
from typing import final
from _pytest.compat import final
@final
class UsageError(Exception):
"""Error in pytest usage or invocation."""
__module__ = "pytest"
class PrintHelp(Exception):
"""Raised when pytest should print its help to skip the rest of the

View File

@@ -1,14 +1,14 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Sequence
from dataclasses import dataclass
from dataclasses import KW_ONLY
import os
from pathlib import Path
import sys
from typing import Literal
from typing import TypeAlias
from pathlib import Path
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Sequence
from typing import Tuple
from typing import TYPE_CHECKING
from typing import Union
import iniconfig
@@ -18,29 +18,8 @@ from _pytest.pathlib import absolutepath
from _pytest.pathlib import commonpath
from _pytest.pathlib import safe_exists
@dataclass(frozen=True)
class ConfigValue:
"""Represents a configuration value with its origin and parsing mode.
This allows tracking whether a value came from a configuration file
or from a CLI override (--override-ini), which is important for
determining precedence when dealing with ini option aliases.
The mode tracks the parsing mode/data model used for the value:
- "ini": from INI files or [tool.pytest.ini_options], where the only
supported value types are `str` or `list[str]`.
- "toml": from TOML files (not in INI mode), where native TOML types
are preserved.
"""
value: object
_: KW_ONLY
origin: Literal["file", "override"]
mode: Literal["ini", "toml"]
ConfigDict: TypeAlias = dict[str, ConfigValue]
if TYPE_CHECKING:
from . import Config
def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
@@ -57,23 +36,21 @@ def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
def load_config_dict_from_file(
filepath: Path,
) -> ConfigDict | None:
) -> Optional[Dict[str, Union[str, List[str]]]]:
"""Load pytest configuration from the given file path, if supported.
Return None if the file does not contain valid pytest configuration.
"""
# Configuration from ini files are obtained from the [pytest] section, if present.
if filepath.suffix == ".ini":
iniconfig = _parse_ini_config(filepath)
if "pytest" in iniconfig:
return {
k: ConfigValue(v, origin="file", mode="ini")
for k, v in iniconfig["pytest"].items()
}
return dict(iniconfig["pytest"].items())
else:
# "pytest.ini" files are always the source of configuration, even if empty.
if filepath.name in {"pytest.ini", ".pytest.ini"}:
if filepath.name == "pytest.ini":
return {}
# '.cfg' files are considered if they contain a "[tool:pytest]" section.
@@ -81,18 +58,13 @@ def load_config_dict_from_file(
iniconfig = _parse_ini_config(filepath)
if "tool:pytest" in iniconfig.sections:
return {
k: ConfigValue(v, origin="file", mode="ini")
for k, v in iniconfig["tool:pytest"].items()
}
return dict(iniconfig["tool:pytest"].items())
elif "pytest" in iniconfig.sections:
# If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that
# plain "[pytest]" sections in setup.cfg files is no longer supported (#3086).
fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)
# '.toml' files are considered if they contain a [tool.pytest] table (toml mode)
# or [tool.pytest.ini_options] table (ini mode) for pyproject.toml,
# or [pytest] table (toml mode) for pytest.toml/.pytest.toml.
# '.toml' files are considered if they contain a [tool.pytest.ini_options] table.
elif filepath.suffix == ".toml":
if sys.version_info >= (3, 11):
import tomllib
@@ -105,67 +77,25 @@ def load_config_dict_from_file(
except tomllib.TOMLDecodeError as exc:
raise UsageError(f"{filepath}: {exc}") from exc
# pytest.toml and .pytest.toml use [pytest] table directly.
if filepath.name in ("pytest.toml", ".pytest.toml"):
pytest_config = config.get("pytest", {})
if pytest_config:
# TOML mode - preserve native TOML types.
return {
k: ConfigValue(v, origin="file", mode="toml")
for k, v in pytest_config.items()
}
# "pytest.toml" files are always the source of configuration, even if empty.
return {}
result = config.get("tool", {}).get("pytest", {}).get("ini_options", None)
if result is not None:
# TOML supports richer data types than ini files (strings, arrays, floats, ints, etc),
# however we need to convert all scalar values to str for compatibility with the rest
# of the configuration system, which expects strings only.
def make_scalar(v: object) -> Union[str, List[str]]:
return v if isinstance(v, list) else str(v)
# pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options].
else:
tool_pytest = config.get("tool", {}).get("pytest", {})
# Check for toml mode config: [tool.pytest] with content outside of ini_options.
toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"}
# Check for ini mode config: [tool.pytest.ini_options].
ini_config = tool_pytest.get("ini_options", None)
if toml_config and ini_config:
raise UsageError(
f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and "
"[tool.pytest.ini_options] (string-based INI format) simultaneously. "
"Please use [tool.pytest] with native TOML types (recommended) "
"or [tool.pytest.ini_options] for backwards compatibility."
)
if toml_config:
# TOML mode - preserve native TOML types.
return {
k: ConfigValue(v, origin="file", mode="toml")
for k, v in toml_config.items()
}
elif ini_config is not None:
# INI mode - TOML supports richer data types than INI files, but we need to
# convert all scalar values to str for compatibility with the INI system.
def make_scalar(v: object) -> str | list[str]:
return v if isinstance(v, list) else str(v)
return {
k: ConfigValue(make_scalar(v), origin="file", mode="ini")
for k, v in ini_config.items()
}
return {k: make_scalar(v) for k, v in result.items()}
return None
def locate_config(
invocation_dir: Path,
args: Iterable[Path],
) -> tuple[Path | None, Path | None, ConfigDict, Sequence[str]]:
) -> Tuple[Optional[Path], Optional[Path], Dict[str, Union[str, List[str]]]]:
"""Search in the list of arguments for a valid ini-file for pytest,
and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where
ignored-config-files is a list of config basenames found that contain
pytest configuration but were ignored."""
and return a tuple of (rootdir, inifile, cfg-dict)."""
config_names = [
"pytest.toml",
".pytest.toml",
"pytest.ini",
".pytest.ini",
"pyproject.toml",
@@ -174,39 +104,21 @@ def locate_config(
]
args = [x for x in args if not str(x).startswith("-")]
if not args:
args = [invocation_dir]
found_pyproject_toml: Path | None = None
ignored_config_files: list[str] = []
args = [Path.cwd()]
for arg in args:
argpath = absolutepath(arg)
for base in (argpath, *argpath.parents):
for config_name in config_names:
p = base / config_name
if p.is_file():
if p.name == "pyproject.toml" and found_pyproject_toml is None:
found_pyproject_toml = p
ini_config = load_config_dict_from_file(p)
if ini_config is not None:
index = config_names.index(config_name)
for remainder in config_names[index + 1 :]:
p2 = base / remainder
if (
p2.is_file()
and load_config_dict_from_file(p2) is not None
):
ignored_config_files.append(remainder)
return base, p, ini_config, ignored_config_files
if found_pyproject_toml is not None:
return found_pyproject_toml.parent, found_pyproject_toml, {}, []
return None, None, {}, []
return base, p, ini_config
return None, None, {}
def get_common_ancestor(
invocation_dir: Path,
paths: Iterable[Path],
) -> Path:
common_ancestor: Path | None = None
def get_common_ancestor(paths: Iterable[Path]) -> Path:
common_ancestor: Optional[Path] = None
for path in paths:
if not path.exists():
continue
@@ -222,13 +134,13 @@ def get_common_ancestor(
if shared is not None:
common_ancestor = shared
if common_ancestor is None:
common_ancestor = invocation_dir
common_ancestor = Path.cwd()
elif common_ancestor.is_file():
common_ancestor = common_ancestor.parent
return common_ancestor
def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
def get_dirs_from_args(args: Iterable[str]) -> List[Path]:
def is_option(x: str) -> bool:
return x.startswith("-")
@@ -250,70 +162,26 @@ def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]
def parse_override_ini(override_ini: Sequence[str] | None) -> ConfigDict:
"""Parse the -o/--override-ini command line arguments and return the overrides.
:raises UsageError:
If one of the values is malformed.
"""
overrides = {}
# override_ini is a list of "ini=value" options.
# Always use the last item if multiple values are set for same ini-name,
# e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2.
for ini_config in override_ini or ():
try:
key, user_ini_value = ini_config.split("=", 1)
except ValueError as e:
raise UsageError(
f"-o/--override-ini expects option=value style (got: {ini_config!r})."
) from e
else:
overrides[key] = ConfigValue(user_ini_value, origin="override", mode="ini")
return overrides
CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."
def determine_setup(
*,
inifile: str | None,
override_ini: Sequence[str] | None,
inifile: Optional[str],
args: Sequence[str],
rootdir_cmd_arg: str | None,
invocation_dir: Path,
) -> tuple[Path, Path | None, ConfigDict, Sequence[str]]:
"""Determine the rootdir, inifile and ini configuration values from the
command line arguments.
:param inifile:
The `--inifile` command line argument, if given.
:param override_ini:
The -o/--override-ini command line arguments, if given.
:param args:
The free command line arguments.
:param rootdir_cmd_arg:
The `--rootdir` command line argument, if given.
:param invocation_dir:
The working directory when pytest was invoked.
:raises UsageError:
"""
rootdir_cmd_arg: Optional[str] = None,
config: Optional["Config"] = None,
) -> Tuple[Path, Optional[Path], Dict[str, Union[str, List[str]]]]:
rootdir = None
dirs = get_dirs_from_args(args)
ignored_config_files: Sequence[str] = []
if inifile:
inipath_ = absolutepath(inifile)
inipath: Path | None = inipath_
inipath: Optional[Path] = inipath_
inicfg = load_config_dict_from_file(inipath_) or {}
if rootdir_cmd_arg is None:
rootdir = inipath_.parent
else:
ancestor = get_common_ancestor(invocation_dir, dirs)
rootdir, inipath, inicfg, ignored_config_files = locate_config(
invocation_dir, [ancestor]
)
ancestor = get_common_ancestor(dirs)
rootdir, inipath, inicfg = locate_config([ancestor])
if rootdir is None and rootdir_cmd_arg is None:
for possible_rootdir in (ancestor, *ancestor.parents):
if (possible_rootdir / "setup.py").is_file():
@@ -321,25 +189,25 @@ def determine_setup(
break
else:
if dirs != [ancestor]:
rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs)
rootdir, inipath, inicfg = locate_config(dirs)
if rootdir is None:
rootdir = get_common_ancestor(
invocation_dir, [invocation_dir, ancestor]
)
if config is not None:
cwd = config.invocation_params.dir
else:
cwd = Path.cwd()
rootdir = get_common_ancestor([cwd, ancestor])
if is_fs_root(rootdir):
rootdir = ancestor
if rootdir_cmd_arg:
rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
if not rootdir.is_dir():
raise UsageError(
f"Directory '{rootdir}' not found. Check your '--rootdir' option."
"Directory '{}' not found. Check your '--rootdir' option.".format(
rootdir
)
)
ini_overrides = parse_override_ini(override_ini)
inicfg.update(ini_overrides)
assert rootdir is not None
return rootdir, inipath, inicfg, ignored_config_files
return rootdir, inipath, inicfg or {}
def is_fs_root(p: Path) -> bool:

View File

@@ -1,21 +1,21 @@
# mypy: allow-untyped-defs
# ruff: noqa: T100
"""Interactive debugging with PDB, the Python Debugger."""
from __future__ import annotations
import argparse
from collections.abc import Callable
from collections.abc import Generator
import functools
import sys
import types
from typing import Any
import unittest
from typing import Any
from typing import Callable
from typing import Generator
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
from _pytest import outcomes
from _pytest._code import ExceptionInfo
from _pytest.capture import CaptureManager
from _pytest.config import Config
from _pytest.config import ConftestImportFailure
from _pytest.config import hookimpl
@@ -24,10 +24,13 @@ from _pytest.config.argparsing import Parser
from _pytest.config.exceptions import UsageError
from _pytest.nodes import Node
from _pytest.reports import BaseReport
from _pytest.runner import CallInfo
if TYPE_CHECKING:
from _pytest.capture import CaptureManager
from _pytest.runner import CallInfo
def _validate_usepdb_cls(value: str) -> tuple[str, str]:
def _validate_usepdb_cls(value: str) -> Tuple[str, str]:
"""Validate syntax of --pdbcls option."""
try:
modname, classname = value.split(":")
@@ -40,13 +43,13 @@ def _validate_usepdb_cls(value: str) -> tuple[str, str]:
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("general")
group.addoption(
group._addoption(
"--pdb",
dest="usepdb",
action="store_true",
help="Start the interactive Python debugger on errors or KeyboardInterrupt",
)
group.addoption(
group._addoption(
"--pdbcls",
dest="usepdb_cls",
metavar="modulename:classname",
@@ -54,7 +57,7 @@ def pytest_addoption(parser: Parser) -> None:
help="Specify a custom interactive Python debugger for use with --pdb."
"For example: --pdbcls=IPython.terminal.debugger:TerminalPdb",
)
group.addoption(
group._addoption(
"--trace",
dest="trace",
action="store_true",
@@ -92,22 +95,22 @@ def pytest_configure(config: Config) -> None:
class pytestPDB:
"""Pseudo PDB that defers to the real pdb."""
_pluginmanager: PytestPluginManager | None = None
_config: Config | None = None
_saved: list[
tuple[Callable[..., None], PytestPluginManager | None, Config | None]
_pluginmanager: Optional[PytestPluginManager] = None
_config: Optional[Config] = None
_saved: List[
Tuple[Callable[..., None], Optional[PytestPluginManager], Optional[Config]]
] = []
_recursive_debug = 0
_wrapped_pdb_cls: tuple[type[Any], type[Any]] | None = None
_wrapped_pdb_cls: Optional[Tuple[Type[Any], Type[Any]]] = None
@classmethod
def _is_capturing(cls, capman: CaptureManager | None) -> str | bool:
def _is_capturing(cls, capman: Optional["CaptureManager"]) -> Union[str, bool]:
if capman:
return capman.is_capturing()
return False
@classmethod
def _import_pdb_cls(cls, capman: CaptureManager | None):
def _import_pdb_cls(cls, capman: Optional["CaptureManager"]):
if not cls._config:
import pdb
@@ -146,10 +149,12 @@ class pytestPDB:
return wrapped_cls
@classmethod
def _get_pdb_wrapper_class(cls, pdb_cls, capman: CaptureManager | None):
def _get_pdb_wrapper_class(cls, pdb_cls, capman: Optional["CaptureManager"]):
import _pytest.config
class PytestPdbWrapper(pdb_cls):
# Type ignored because mypy doesn't support "dynamic"
# inheritance like this.
class PytestPdbWrapper(pdb_cls): # type: ignore[valid-type,misc]
_pytest_capman = capman
_continued = False
@@ -159,9 +164,6 @@ class pytestPDB:
cls._recursive_debug -= 1
return ret
if hasattr(pdb_cls, "do_debug"):
do_debug.__doc__ = pdb_cls.do_debug.__doc__
def do_continue(self, arg):
ret = super().do_continue(arg)
if cls._recursive_debug == 0:
@@ -177,7 +179,8 @@ class pytestPDB:
else:
tw.sep(
">",
f"PDB continue (IO-capturing resumed for {capturing})",
"PDB continue (IO-capturing resumed for %s)"
% capturing,
)
assert capman is not None
capman.resume()
@@ -188,17 +191,15 @@ class pytestPDB:
self._continued = True
return ret
if hasattr(pdb_cls, "do_continue"):
do_continue.__doc__ = pdb_cls.do_continue.__doc__
do_c = do_cont = do_continue
def do_quit(self, arg):
# Raise Exit outcome when quit command is used in pdb.
#
# This is a bit of a hack - it would be better if BdbQuit
# could be handled, but this would require to wrap the
# whole pytest run, and adjust the report etc.
"""Raise Exit outcome when quit command is used in pdb.
This is a bit of a hack - it would be better if BdbQuit
could be handled, but this would require to wrap the
whole pytest run, and adjust the report etc.
"""
ret = super().do_quit(arg)
if cls._recursive_debug == 0:
@@ -206,9 +207,6 @@ class pytestPDB:
return ret
if hasattr(pdb_cls, "do_quit"):
do_quit.__doc__ = pdb_cls.do_quit.__doc__
do_q = do_quit
do_exit = do_quit
@@ -243,7 +241,7 @@ class pytestPDB:
import _pytest.config
if cls._pluginmanager is None:
capman: CaptureManager | None = None
capman: Optional[CaptureManager] = None
else:
capman = cls._pluginmanager.getplugin("capturemanager")
if capman:
@@ -265,7 +263,8 @@ class pytestPDB:
elif capturing:
tw.sep(
">",
f"PDB {method} (IO-capturing turned off for {capturing})",
"PDB %s (IO-capturing turned off for %s)"
% (method, capturing),
)
else:
tw.sep(">", f"PDB {method}")
@@ -286,7 +285,7 @@ class pytestPDB:
class PdbInvoke:
def pytest_exception_interact(
self, node: Node, call: CallInfo[Any], report: BaseReport
self, node: Node, call: "CallInfo[Any]", report: BaseReport
) -> None:
capman = node.config.pluginmanager.getplugin("capturemanager")
if capman:
@@ -300,18 +299,18 @@ class PdbInvoke:
_enter_pdb(node, call.excinfo, report)
def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None:
exc_or_tb = _postmortem_exc_or_tb(excinfo)
post_mortem(exc_or_tb)
tb = _postmortem_traceback(excinfo)
post_mortem(tb)
class PdbTrace:
@hookimpl(wrapper=True)
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]:
@hookimpl(hookwrapper=True)
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, None, None]:
wrap_pytest_function_for_tracing(pyfuncitem)
return (yield)
yield
def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def wrap_pytest_function_for_tracing(pyfuncitem):
"""Change the Python function object of the given Function item by a
wrapper which actually enters pdb before calling the python function
itself, effectively leaving the user in the pdb prompt in the first
@@ -323,14 +322,14 @@ def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
# python < 3.7.4) runcall's first param is `func`, which means we'd get
# an exception if one of the kwargs to testfunction was called `func`.
@functools.wraps(testfunction)
def wrapper(*args, **kwargs) -> None:
def wrapper(*args, **kwargs):
func = functools.partial(testfunction, *args, **kwargs)
_pdb.runcall(func)
pyfuncitem.obj = wrapper
def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def maybe_wrap_pytest_function_for_tracing(pyfuncitem):
"""Wrap the given pytestfunct item for tracing support if --trace was given in
the command line."""
if pyfuncitem.config.getvalue("trace"):
@@ -340,7 +339,7 @@ def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
def _enter_pdb(
node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport
) -> BaseReport:
# XXX we reuse the TerminalReporter's terminalwriter
# XXX we re-use the TerminalReporter's terminalwriter
# because this seems to avoid some encoding related troubles
# for not completely clear reasons.
tw = node.config.pluginmanager.getplugin("terminalreporter")._tw
@@ -362,46 +361,31 @@ def _enter_pdb(
tw.sep(">", "traceback")
rep.toterminal(tw)
tw.sep(">", "entering PDB")
tb_or_exc = _postmortem_exc_or_tb(excinfo)
tb = _postmortem_traceback(excinfo)
rep._pdbshown = True # type: ignore[attr-defined]
post_mortem(tb_or_exc)
post_mortem(tb)
return rep
def _postmortem_exc_or_tb(
excinfo: ExceptionInfo[BaseException],
) -> types.TracebackType | BaseException:
def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType:
from doctest import UnexpectedException
get_exc = sys.version_info >= (3, 13)
if isinstance(excinfo.value, UnexpectedException):
# A doctest.UnexpectedException is not useful for post_mortem.
# Use the underlying exception instead:
underlying_exc = excinfo.value
if get_exc:
return underlying_exc.exc_info[1]
return underlying_exc.exc_info[2]
return excinfo.value.exc_info[2]
elif isinstance(excinfo.value, ConftestImportFailure):
# A config.ConftestImportFailure is not useful for post_mortem.
# Use the underlying exception instead:
cause = excinfo.value.cause
if get_exc:
return cause
assert cause.__traceback__ is not None
return cause.__traceback__
return excinfo.value.excinfo[2]
else:
assert excinfo._excinfo is not None
if get_exc:
return excinfo._excinfo[1]
return excinfo._excinfo[2]
def post_mortem(tb_or_exc: types.TracebackType | BaseException) -> None:
def post_mortem(t: types.TracebackType) -> None:
p = pytestPDB._init_pdb("post_mortem")
p.reset()
p.interaction(None, tb_or_exc)
p.interaction(None, t)
if p.quitting:
outcomes.exit("Quitting debugger")

View File

@@ -8,52 +8,111 @@ All constants defined in this module should be either instances of
:class:`PytestWarning`, or :class:`UnformattedWarning`
in case of warnings which need to format their messages.
"""
from __future__ import annotations
from warnings import warn
from _pytest.warning_types import PytestDeprecationWarning
from _pytest.warning_types import PytestRemovedIn9Warning
from _pytest.warning_types import PytestRemovedIn10Warning
from _pytest.warning_types import PytestRemovedIn8Warning
from _pytest.warning_types import UnformattedWarning
# set of plugins which have been integrated into the core; we use this list to ignore
# them during registration to avoid conflicts
DEPRECATED_EXTERNAL_PLUGINS = {
"pytest_catchlog",
"pytest_capturelog",
"pytest_faulthandler",
"pytest_subtests",
}
NOSE_SUPPORT = UnformattedWarning(
PytestRemovedIn8Warning,
"Support for nose tests is deprecated and will be removed in a future release.\n"
"{nodeid} is using nose method: `{method}` ({stage})\n"
"See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose",
)
# This could have been removed pytest 8, but it's harmless and common, so no rush to remove.
NOSE_SUPPORT_METHOD = UnformattedWarning(
PytestRemovedIn8Warning,
"Support for nose tests is deprecated and will be removed in a future release.\n"
"{nodeid} is using nose-specific method: `{method}(self)`\n"
"To remove this warning, rename it to `{method}_method(self)`\n"
"See docs: https://docs.pytest.org/en/stable/deprecations.html#support-for-tests-written-for-nose",
)
# This can be* removed pytest 8, but it's harmless and common, so no rush to remove.
# * If you're in the future: "could have been".
YIELD_FIXTURE = PytestDeprecationWarning(
"@pytest.yield_fixture is deprecated.\n"
"Use @pytest.fixture instead; they are the same."
)
WARNING_CMDLINE_PREPARSE_HOOK = PytestRemovedIn8Warning(
"The pytest_cmdline_preparse hook is deprecated and will be removed in a future release. \n"
"Please use pytest_load_initial_conftests hook instead."
)
FSCOLLECTOR_GETHOOKPROXY_ISINITPATH = PytestRemovedIn8Warning(
"The gethookproxy() and isinitpath() methods of FSCollector and Package are deprecated; "
"use self.session.gethookproxy() and self.session.isinitpath() instead. "
)
STRICT_OPTION = PytestRemovedIn8Warning(
"The --strict option is deprecated, use --strict-markers instead."
)
# This deprecation is never really meant to be removed.
PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.")
ARGUMENT_PERCENT_DEFAULT = PytestRemovedIn8Warning(
'pytest now uses argparse. "%default" should be changed to "%(default)s"',
)
ARGUMENT_TYPE_STR_CHOICE = UnformattedWarning(
PytestRemovedIn8Warning,
"`type` argument to addoption() is the string {typ!r}."
" For choices this is optional and can be omitted, "
" but when supplied should be a type (for example `str` or `int`)."
" (options: {names})",
)
ARGUMENT_TYPE_STR = UnformattedWarning(
PytestRemovedIn8Warning,
"`type` argument to addoption() is the string {typ!r}, "
" but when supplied should be a type (for example `str` or `int`)."
" (options: {names})",
)
HOOK_LEGACY_PATH_ARG = UnformattedWarning(
PytestRemovedIn9Warning,
PytestRemovedIn8Warning,
"The ({pylib_path_arg}: py.path.local) argument is deprecated, please use ({pathlib_path_arg}: pathlib.Path)\n"
"see https://docs.pytest.org/en/latest/deprecations.html"
"#py-path-local-arguments-for-hooks-replaced-with-pathlib-path",
)
NODE_CTOR_FSPATH_ARG = UnformattedWarning(
PytestRemovedIn9Warning,
PytestRemovedIn8Warning,
"The (fspath: py.path.local) argument to {node_type_name} is deprecated. "
"Please use the (path: pathlib.Path) argument instead.\n"
"See https://docs.pytest.org/en/latest/deprecations.html"
"#fspath-argument-for-node-constructors-replaced-with-pathlib-path",
)
WARNS_NONE_ARG = PytestRemovedIn8Warning(
"Passing None has been deprecated.\n"
"See https://docs.pytest.org/en/latest/how-to/capture-warnings.html"
"#additional-use-cases-of-warnings-in-tests"
" for alternatives in common use cases."
)
KEYWORD_MSG_ARG = UnformattedWarning(
PytestRemovedIn8Warning,
"pytest.{func}(msg=...) is now deprecated, use pytest.{func}(reason=...) instead",
)
INSTANCE_COLLECTOR = PytestRemovedIn8Warning(
"The pytest.Instance collector type is deprecated and is no longer used. "
"See https://docs.pytest.org/en/latest/deprecations.html#the-pytest-instance-collector",
)
HOOK_LEGACY_MARKING = UnformattedWarning(
PytestDeprecationWarning,
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
@@ -63,18 +122,6 @@ HOOK_LEGACY_MARKING = UnformattedWarning(
"#configuring-hook-specs-impls-using-markers",
)
MARKED_FIXTURE = PytestRemovedIn9Warning(
"Marks applied to fixtures have no effect\n"
"See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function"
)
MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES = PytestRemovedIn10Warning(
"monkeypatch.syspath_prepend() called with pkg_resources legacy namespace packages detected.\n"
"Legacy namespace packages (using pkg_resources.declare_namespace) are deprecated.\n"
"Please use native namespace packages (PEP 420) instead.\n"
"See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages"
)
# You want to make some `__init__` or function "private".
#
# def my_private_function(some, args):

View File

@@ -1,26 +1,28 @@
# mypy: allow-untyped-defs
"""Discover and run doctests in modules and test files."""
from __future__ import annotations
import bdb
from collections.abc import Callable
from collections.abc import Generator
from collections.abc import Iterable
from collections.abc import Sequence
from contextlib import contextmanager
import functools
import inspect
import os
from pathlib import Path
import platform
import re
import sys
import traceback
import types
from typing import Any
from typing import TYPE_CHECKING
import warnings
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Dict
from typing import Generator
from typing import Iterable
from typing import List
from typing import Optional
from typing import Pattern
from typing import Sequence
from typing import Tuple
from typing import Type
from typing import TYPE_CHECKING
from typing import Union
from _pytest import outcomes
from _pytest._code.code import ExceptionInfo
@@ -31,22 +33,20 @@ from _pytest.compat import safe_getattr
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.fixtures import fixture
from _pytest.fixtures import TopRequest
from _pytest.fixtures import FixtureRequest
from _pytest.nodes import Collector
from _pytest.nodes import Item
from _pytest.outcomes import OutcomeException
from _pytest.outcomes import skip
from _pytest.pathlib import fnmatch_ex
from _pytest.pathlib import import_path
from _pytest.python import Module
from _pytest.python_api import approx
from _pytest.warning_types import PytestWarning
if TYPE_CHECKING:
import doctest
from typing_extensions import Self
DOCTEST_REPORT_CHOICE_NONE = "none"
DOCTEST_REPORT_CHOICE_CDIFF = "cdiff"
DOCTEST_REPORT_CHOICE_NDIFF = "ndiff"
@@ -64,7 +64,7 @@ DOCTEST_REPORT_CHOICES = (
# Lazy definition of runner class
RUNNER_CLASS = None
# Lazy definition of output checker class
CHECKER_CLASS: type[doctest.OutputChecker] | None = None
CHECKER_CLASS: Optional[Type["doctest.OutputChecker"]] = None
def pytest_addoption(parser: Parser) -> None:
@@ -105,7 +105,7 @@ def pytest_addoption(parser: Parser) -> None:
"--doctest-ignore-import-errors",
action="store_true",
default=False,
help="Ignore doctest collection errors",
help="Ignore doctest ImportErrors",
dest="doctest_ignore_import_errors",
)
group.addoption(
@@ -126,15 +126,17 @@ def pytest_unconfigure() -> None:
def pytest_collect_file(
file_path: Path,
parent: Collector,
) -> DoctestModule | DoctestTextfile | None:
) -> Optional[Union["DoctestModule", "DoctestTextfile"]]:
config = parent.config
if file_path.suffix == ".py":
if config.option.doctestmodules and not any(
(_is_setup_py(file_path), _is_main_py(file_path))
):
return DoctestModule.from_parent(parent, path=file_path)
mod: DoctestModule = DoctestModule.from_parent(parent, path=file_path)
return mod
elif _is_doctest(config, file_path, parent):
return DoctestTextfile.from_parent(parent, path=file_path)
txt: DoctestTextfile = DoctestTextfile.from_parent(parent, path=file_path)
return txt
return None
@@ -158,7 +160,7 @@ def _is_main_py(path: Path) -> bool:
class ReprFailDoctest(TerminalRepr):
def __init__(
self, reprlocation_lines: Sequence[tuple[ReprFileLocation, Sequence[str]]]
self, reprlocation_lines: Sequence[Tuple[ReprFileLocation, Sequence[str]]]
) -> None:
self.reprlocation_lines = reprlocation_lines
@@ -170,12 +172,12 @@ class ReprFailDoctest(TerminalRepr):
class MultipleDoctestFailures(Exception):
def __init__(self, failures: Sequence[doctest.DocTestFailure]) -> None:
def __init__(self, failures: Sequence["doctest.DocTestFailure"]) -> None:
super().__init__()
self.failures = failures
def _init_runner_class() -> type[doctest.DocTestRunner]:
def _init_runner_class() -> Type["doctest.DocTestRunner"]:
import doctest
class PytestDoctestRunner(doctest.DebugRunner):
@@ -187,8 +189,8 @@ def _init_runner_class() -> type[doctest.DocTestRunner]:
def __init__(
self,
checker: doctest.OutputChecker | None = None,
verbose: bool | None = None,
checker: Optional["doctest.OutputChecker"] = None,
verbose: Optional[bool] = None,
optionflags: int = 0,
continue_on_failure: bool = True,
) -> None:
@@ -198,8 +200,8 @@ def _init_runner_class() -> type[doctest.DocTestRunner]:
def report_failure(
self,
out,
test: doctest.DocTest,
example: doctest.Example,
test: "doctest.DocTest",
example: "doctest.Example",
got: str,
) -> None:
failure = doctest.DocTestFailure(test, example, got)
@@ -211,9 +213,9 @@ def _init_runner_class() -> type[doctest.DocTestRunner]:
def report_unexpected_exception(
self,
out,
test: doctest.DocTest,
example: doctest.Example,
exc_info: tuple[type[BaseException], BaseException, types.TracebackType],
test: "doctest.DocTest",
example: "doctest.Example",
exc_info: Tuple[Type[BaseException], BaseException, types.TracebackType],
) -> None:
if isinstance(exc_info[1], OutcomeException):
raise exc_info[1]
@@ -229,11 +231,11 @@ def _init_runner_class() -> type[doctest.DocTestRunner]:
def _get_runner(
checker: doctest.OutputChecker | None = None,
verbose: bool | None = None,
checker: Optional["doctest.OutputChecker"] = None,
verbose: Optional[bool] = None,
optionflags: int = 0,
continue_on_failure: bool = True,
) -> doctest.DocTestRunner:
) -> "doctest.DocTestRunner":
# We need this in order to do a lazy import on doctest
global RUNNER_CLASS
if RUNNER_CLASS is None:
@@ -252,50 +254,45 @@ class DoctestItem(Item):
def __init__(
self,
name: str,
parent: DoctestTextfile | DoctestModule,
runner: doctest.DocTestRunner,
dtest: doctest.DocTest,
parent: "Union[DoctestTextfile, DoctestModule]",
runner: Optional["doctest.DocTestRunner"] = None,
dtest: Optional["doctest.DocTest"] = None,
) -> None:
super().__init__(name, parent)
self.runner = runner
self.dtest = dtest
# Stuff needed for fixture support.
self.obj = None
fm = self.session._fixturemanager
fixtureinfo = fm.getfixtureinfo(node=self, func=None, cls=None)
self._fixtureinfo = fixtureinfo
self.fixturenames = fixtureinfo.names_closure
self._initrequest()
self.fixture_request: Optional[FixtureRequest] = None
@classmethod
def from_parent( # type: ignore[override]
def from_parent( # type: ignore
cls,
parent: DoctestTextfile | DoctestModule,
parent: "Union[DoctestTextfile, DoctestModule]",
*,
name: str,
runner: doctest.DocTestRunner,
dtest: doctest.DocTest,
) -> Self:
runner: "doctest.DocTestRunner",
dtest: "doctest.DocTest",
):
# incompatible signature due to imposed limits on subclass
"""The public named constructor."""
return super().from_parent(name=name, parent=parent, runner=runner, dtest=dtest)
def _initrequest(self) -> None:
self.funcargs: dict[str, object] = {}
self._request = TopRequest(self, _ispytest=True) # type: ignore[arg-type]
def setup(self) -> None:
self._request._fillfixtures()
globs = dict(getfixture=self._request.getfixturevalue)
for name, value in self._request.getfixturevalue("doctest_namespace").items():
globs[name] = value
self.dtest.globs.update(globs)
if self.dtest is not None:
self.fixture_request = _setup_fixtures(self)
globs = dict(getfixture=self.fixture_request.getfixturevalue)
for name, value in self.fixture_request.getfixturevalue(
"doctest_namespace"
).items():
globs[name] = value
self.dtest.globs.update(globs)
def runtest(self) -> None:
assert self.dtest is not None
assert self.runner is not None
_check_all_skipped(self.dtest)
self._disable_output_capturing_for_darwin()
failures: list[doctest.DocTestFailure] = []
failures: List["doctest.DocTestFailure"] = []
# Type ignored because we change the type of `out` from what
# doctest expects.
self.runner.run(self.dtest, out=failures) # type: ignore[arg-type]
@@ -317,14 +314,14 @@ class DoctestItem(Item):
def repr_failure( # type: ignore[override]
self,
excinfo: ExceptionInfo[BaseException],
) -> str | TerminalRepr:
) -> Union[str, TerminalRepr]:
import doctest
failures: (
Sequence[doctest.DocTestFailure | doctest.UnexpectedException] | None
) = None
failures: Optional[
Sequence[Union[doctest.DocTestFailure, doctest.UnexpectedException]]
] = None
if isinstance(
excinfo.value, doctest.DocTestFailure | doctest.UnexpectedException
excinfo.value, (doctest.DocTestFailure, doctest.UnexpectedException)
):
failures = [excinfo.value]
elif isinstance(excinfo.value, MultipleDoctestFailures):
@@ -353,7 +350,7 @@ class DoctestItem(Item):
# add line numbers to the left of the error message
assert test.lineno is not None
lines = [
f"{i + test.lineno + 1:03d} {x}" for (i, x) in enumerate(lines)
"%03d %s" % (i + test.lineno + 1, x) for (i, x) in enumerate(lines)
]
# trim docstring error lines to 10
lines = lines[max(example.lineno - 9, 0) : example.lineno + 1]
@@ -371,18 +368,19 @@ class DoctestItem(Item):
).split("\n")
else:
inner_excinfo = ExceptionInfo.from_exc_info(failure.exc_info)
lines += [f"UNEXPECTED EXCEPTION: {inner_excinfo.value!r}"]
lines += ["UNEXPECTED EXCEPTION: %s" % repr(inner_excinfo.value)]
lines += [
x.strip("\n") for x in traceback.format_exception(*failure.exc_info)
]
reprlocation_lines.append((reprlocation, lines))
return ReprFailDoctest(reprlocation_lines)
def reportinfo(self) -> tuple[os.PathLike[str] | str, int | None, str]:
return self.path, self.dtest.lineno, f"[doctest] {self.name}"
def reportinfo(self) -> Tuple[Union["os.PathLike[str]", str], Optional[int], str]:
assert self.dtest is not None
return self.path, self.dtest.lineno, "[doctest] %s" % self.name
def _get_flag_lookup() -> dict[str, int]:
def _get_flag_lookup() -> Dict[str, int]:
import doctest
return dict(
@@ -398,8 +396,8 @@ def _get_flag_lookup() -> dict[str, int]:
)
def get_optionflags(config: Config) -> int:
optionflags_str = config.getini("doctest_optionflags")
def get_optionflags(parent):
optionflags_str = parent.config.getini("doctest_optionflags")
flag_lookup_table = _get_flag_lookup()
flag_acc = 0
for flag in optionflags_str:
@@ -407,8 +405,8 @@ def get_optionflags(config: Config) -> int:
return flag_acc
def _get_continue_on_failure(config: Config) -> bool:
continue_on_failure: bool = config.getvalue("doctest_continue_on_failure")
def _get_continue_on_failure(config):
continue_on_failure = config.getvalue("doctest_continue_on_failure")
if continue_on_failure:
# We need to turn off this if we use pdb since we should stop at
# the first failure.
@@ -431,7 +429,7 @@ class DoctestTextfile(Module):
name = self.path.name
globs = {"__name__": "__main__"}
optionflags = get_optionflags(self.config)
optionflags = get_optionflags(self)
runner = _get_runner(
verbose=False,
@@ -448,7 +446,7 @@ class DoctestTextfile(Module):
)
def _check_all_skipped(test: doctest.DocTest) -> None:
def _check_all_skipped(test: "doctest.DocTest") -> None:
"""Raise pytest.skip() if all examples in the given DocTest have the SKIP
option set."""
import doctest
@@ -468,13 +466,13 @@ def _is_mocked(obj: object) -> bool:
@contextmanager
def _patch_unwrap_mock_aware() -> Generator[None]:
def _patch_unwrap_mock_aware() -> Generator[None, None, None]:
"""Context manager which replaces ``inspect.unwrap`` with a version
that's aware of mock objects and doesn't recurse into them."""
real_unwrap = inspect.unwrap
def _mock_aware_unwrap(
func: Callable[..., Any], *, stop: Callable[[Any], Any] | None = None
func: Callable[..., Any], *, stop: Optional[Callable[[Any], Any]] = None
) -> Any:
try:
if stop is None or stop is _is_mocked:
@@ -483,9 +481,9 @@ def _patch_unwrap_mock_aware() -> Generator[None]:
return real_unwrap(func, stop=lambda obj: _is_mocked(obj) or _stop(func))
except Exception as e:
warnings.warn(
f"Got {e!r} when unwrapping {func!r}. This is usually caused "
"Got %r when unwrapping %r. This is usually caused "
"by a violation of Python's object protocol; see e.g. "
"https://github.com/pytest-dev/pytest/issues/5080",
"https://github.com/pytest-dev/pytest/issues/5080" % (e, func),
PytestWarning,
)
raise
@@ -502,32 +500,41 @@ class DoctestModule(Module):
import doctest
class MockAwareDocTestFinder(doctest.DocTestFinder):
py_ver_info_minor = sys.version_info[:2]
is_find_lineno_broken = (
py_ver_info_minor < (3, 11)
or (py_ver_info_minor == (3, 11) and sys.version_info.micro < 9)
or (py_ver_info_minor == (3, 12) and sys.version_info.micro < 3)
)
if is_find_lineno_broken:
"""A hackish doctest finder that overrides stdlib internals to fix a stdlib bug.
def _find_lineno(self, obj, source_lines):
"""On older Pythons, doctest code does not take into account
`@property`. https://github.com/python/cpython/issues/61648
https://github.com/pytest-dev/pytest/issues/3456
https://bugs.python.org/issue25532
"""
Moreover, wrapped Doctests need to be unwrapped so the correct
line number is returned. #8796
"""
if isinstance(obj, property):
obj = getattr(obj, "fget", obj)
def _find_lineno(self, obj, source_lines):
"""Doctest code does not take into account `@property`, this
is a hackish way to fix it. https://bugs.python.org/issue17446
if hasattr(obj, "__wrapped__"):
# Get the main obj in case of it being wrapped
obj = inspect.unwrap(obj)
Wrapped Doctests will need to be unwrapped so the correct
line number is returned. This will be reported upstream. #8796
"""
if isinstance(obj, property):
obj = getattr(obj, "fget", obj)
if hasattr(obj, "__wrapped__"):
# Get the main obj in case of it being wrapped
obj = inspect.unwrap(obj)
# Type ignored because this is a private function.
return super()._find_lineno( # type:ignore[misc]
obj,
source_lines,
)
def _find(
self, tests, obj, name, module, source_lines, globs, seen
) -> None:
if _is_mocked(obj):
return
with _patch_unwrap_mock_aware():
# Type ignored because this is a private function.
return super()._find_lineno( # type:ignore[misc]
obj,
source_lines,
super()._find( # type:ignore[misc]
tests, obj, name, module, source_lines, globs, seen
)
if sys.version_info < (3, 13):
@@ -538,27 +545,38 @@ class DoctestModule(Module):
Here we override `_from_module` to check the underlying
function instead. https://github.com/python/cpython/issues/107995
"""
if isinstance(object, functools.cached_property):
if hasattr(functools, "cached_property") and isinstance(
object, functools.cached_property
):
object = object.func
# Type ignored because this is a private function.
return super()._from_module(module, object) # type: ignore[misc]
try:
module = self.obj
except Collector.CollectError:
if self.config.getvalue("doctest_ignore_import_errors"):
skip(f"unable to import module {self.path!r}")
else:
raise
# While doctests currently don't support fixtures directly, we still
# need to pick up autouse fixtures.
self.session._fixturemanager.parsefactories(self)
else: # pragma: no cover
pass
if self.path.name == "conftest.py":
module = self.config.pluginmanager._importconftest(
self.path,
self.config.getoption("importmode"),
rootpath=self.config.rootpath,
)
else:
try:
module = import_path(
self.path,
root=self.config.rootpath,
mode=self.config.getoption("importmode"),
)
except ImportError:
if self.config.getvalue("doctest_ignore_import_errors"):
skip("unable to import module %r" % self.path)
else:
raise
# Uses internal doctest module parsing mechanism.
finder = MockAwareDocTestFinder()
optionflags = get_optionflags(self.config)
optionflags = get_optionflags(self)
runner = _get_runner(
verbose=False,
optionflags=optionflags,
@@ -573,8 +591,25 @@ class DoctestModule(Module):
)
def _init_checker_class() -> type[doctest.OutputChecker]:
def _setup_fixtures(doctest_item: DoctestItem) -> FixtureRequest:
"""Used by DoctestTextfile and DoctestItem to setup fixture information."""
def func() -> None:
pass
doctest_item.funcargs = {} # type: ignore[attr-defined]
fm = doctest_item.session._fixturemanager
doctest_item._fixtureinfo = fm.getfixtureinfo( # type: ignore[attr-defined]
node=doctest_item, func=func, cls=None, funcargs=False
)
fixture_request = FixtureRequest(doctest_item, _ispytest=True)
fixture_request._fillfixtures()
return fixture_request
def _init_checker_class() -> Type["doctest.OutputChecker"]:
import doctest
import re
class LiteralsOutputChecker(doctest.OutputChecker):
# Based on doctest_nose_plugin.py from the nltk project
@@ -617,7 +652,7 @@ def _init_checker_class() -> type[doctest.OutputChecker]:
if not allow_unicode and not allow_bytes and not allow_number:
return False
def remove_prefixes(regex: re.Pattern[str], txt: str) -> str:
def remove_prefixes(regex: Pattern[str], txt: str) -> str:
return re.sub(regex, r"\1\2", txt)
if allow_unicode:
@@ -639,9 +674,9 @@ def _init_checker_class() -> type[doctest.OutputChecker]:
if len(wants) != len(gots):
return got
offset = 0
for w, g in zip(wants, gots, strict=True):
fraction: str | None = w.group("fraction")
exponent: str | None = w.group("exponent1")
for w, g in zip(wants, gots):
fraction: Optional[str] = w.group("fraction")
exponent: Optional[str] = w.group("exponent1")
if exponent is None:
exponent = w.group("exponent2")
precision = 0 if fraction is None else len(fraction)
@@ -660,7 +695,7 @@ def _init_checker_class() -> type[doctest.OutputChecker]:
return LiteralsOutputChecker
def _get_checker() -> doctest.OutputChecker:
def _get_checker() -> "doctest.OutputChecker":
"""Return a doctest.OutputChecker subclass that supports some
additional options:
@@ -719,7 +754,7 @@ def _get_report_choice(key: str) -> int:
@fixture(scope="session")
def doctest_namespace() -> dict[str, Any]:
def doctest_namespace() -> Dict[str, Any]:
"""Fixture that returns a :py:class:`dict` that will be injected into the
namespace of doctests.

View File

@@ -1,47 +1,31 @@
from __future__ import annotations
from collections.abc import Generator
import os
import sys
from typing import Generator
import pytest
from _pytest.config import Config
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
from _pytest.stash import StashKey
import pytest
fault_handler_original_stderr_fd_key = StashKey[int]()
fault_handler_stderr_fd_key = StashKey[int]()
fault_handler_originally_enabled_key = StashKey[bool]()
def pytest_addoption(parser: Parser) -> None:
help_timeout = (
help = (
"Dump the traceback of all threads if a test takes "
"more than TIMEOUT seconds to finish"
)
help_exit_on_timeout = (
"Exit the test process if a test takes more than "
"faulthandler_timeout seconds to finish"
)
parser.addini("faulthandler_timeout", help_timeout, default=0.0)
parser.addini(
"faulthandler_exit_on_timeout", help_exit_on_timeout, type="bool", default=False
)
parser.addini("faulthandler_timeout", help, default=0.0)
def pytest_configure(config: Config) -> None:
import faulthandler
# at teardown we want to restore the original faulthandler fileno
# but faulthandler has no api to return the original fileno
# so here we stash the stderr fileno to be used at teardown
# sys.stderr and sys.__stderr__ may be closed or patched during the session
# so we can't rely on their values being good at that point (#11572).
stderr_fileno = get_stderr_fileno()
if faulthandler.is_enabled():
config.stash[fault_handler_original_stderr_fd_key] = stderr_fileno
config.stash[fault_handler_stderr_fd_key] = os.dup(stderr_fileno)
config.stash[fault_handler_stderr_fd_key] = os.dup(get_stderr_fileno())
config.stash[fault_handler_originally_enabled_key] = faulthandler.is_enabled()
faulthandler.enable(file=config.stash[fault_handler_stderr_fd_key])
@@ -53,10 +37,9 @@ def pytest_unconfigure(config: Config) -> None:
if fault_handler_stderr_fd_key in config.stash:
os.close(config.stash[fault_handler_stderr_fd_key])
del config.stash[fault_handler_stderr_fd_key]
# Re-enable the faulthandler if it was originally enabled.
if fault_handler_original_stderr_fd_key in config.stash:
faulthandler.enable(config.stash[fault_handler_original_stderr_fd_key])
del config.stash[fault_handler_original_stderr_fd_key]
if config.stash.get(fault_handler_originally_enabled_key, False):
# Re-enable the faulthandler if it was originally enabled.
faulthandler.enable(file=get_stderr_fileno())
def get_stderr_fileno() -> int:
@@ -71,7 +54,6 @@ def get_stderr_fileno() -> int:
# pytest-xdist monkeypatches sys.stderr with an object that is not an actual file.
# https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors
# This is potentially dangerous, but the best we can do.
assert sys.__stderr__ is not None
return sys.__stderr__.fileno()
@@ -79,27 +61,20 @@ def get_timeout_config_value(config: Config) -> float:
return float(config.getini("faulthandler_timeout") or 0.0)
def get_exit_on_timeout_config_value(config: Config) -> bool:
exit_on_timeout = config.getini("faulthandler_exit_on_timeout")
assert isinstance(exit_on_timeout, bool)
return exit_on_timeout
@pytest.hookimpl(wrapper=True, trylast=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
@pytest.hookimpl(hookwrapper=True, trylast=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
timeout = get_timeout_config_value(item.config)
exit_on_timeout = get_exit_on_timeout_config_value(item.config)
if timeout > 0:
import faulthandler
stderr = item.config.stash[fault_handler_stderr_fd_key]
faulthandler.dump_traceback_later(timeout, file=stderr, exit=exit_on_timeout)
faulthandler.dump_traceback_later(timeout, file=stderr)
try:
return (yield)
yield
finally:
faulthandler.cancel_dump_traceback_later()
else:
return (yield)
yield
@pytest.hookimpl(tryfirst=True)

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
"""Provides a function to report all internal modules for using freezing
tools."""
from __future__ import annotations
from collections.abc import Iterator
import types
from typing import Iterator
from typing import List
from typing import Union
def freeze_includes() -> list[str]:
def freeze_includes() -> List[str]:
"""Return a list of module names used by pytest that should be
included by cx_freeze."""
import _pytest
@@ -17,7 +16,7 @@ def freeze_includes() -> list[str]:
def _iter_all_modules(
package: str | types.ModuleType,
package: Union[str, types.ModuleType],
prefix: str = "",
) -> Iterator[str]:
"""Iterate over the names of all modules that can be found in the given
@@ -35,7 +34,7 @@ def _iter_all_modules(
else:
# Type ignored because typeshed doesn't define ModuleType.__path__
# (only defined on packages).
package_path = package.__path__
package_path = package.__path__ # type: ignore[attr-defined]
path, prefix = package_path[0], package.__name__ + "."
for _, name, is_package in pkgutil.iter_modules([path]):
if is_package:

View File

@@ -1,58 +1,44 @@
# mypy: allow-untyped-defs
"""Version info, help messages, tracing configuration."""
from __future__ import annotations
import argparse
from collections.abc import Generator
from collections.abc import Sequence
import os
import sys
from typing import Any
from argparse import Action
from typing import List
from typing import Optional
from typing import Union
import pytest
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import PrintHelp
from _pytest.config.argparsing import Parser
from _pytest.terminal import TerminalReporter
import pytest
class HelpAction(argparse.Action):
"""An argparse Action that will raise a PrintHelp exception in order to skip
the rest of the argument parsing when --help is passed.
class HelpAction(Action):
"""An argparse Action that will raise an exception in order to skip the
rest of the argument parsing when --help is passed.
This prevents argparse from raising UsageError when `--help` is used along
with missing required arguments when any are defined, for example by
``pytest_addoption``. This is similar to the way that the builtin argparse
--help option is implemented by raising SystemExit.
To opt in to this behavior, the parse caller must set
`namespace._raise_print_help = True`. Otherwise it just sets the option.
This prevents argparse from quitting due to missing required arguments
when any are defined, for example by ``pytest_addoption``.
This is similar to the way that the builtin argparse --help option is
implemented by raising SystemExit.
"""
def __init__(
self, option_strings: Sequence[str], dest: str, *, help: str | None = None
) -> None:
def __init__(self, option_strings, dest=None, default=False, help=None):
super().__init__(
option_strings=option_strings,
dest=dest,
nargs=0,
const=True,
default=False,
default=default,
nargs=0,
help=help,
)
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: str | Sequence[Any] | None,
option_string: str | None = None,
) -> None:
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, self.const)
if getattr(namespace, "_raise_print_help", False):
# We should only skip the rest of the parsing after preparse is done.
if getattr(parser._parser, "after_preparse", False):
raise PrintHelp
@@ -67,14 +53,14 @@ def pytest_addoption(parser: Parser) -> None:
help="Display pytest version and information about plugins. "
"When given twice, also display information about plugins.",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-h",
"--help",
action=HelpAction,
dest="help",
help="Show help message and configuration info",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-p",
action="append",
dest="plugins",
@@ -82,14 +68,7 @@ def pytest_addoption(parser: Parser) -> None:
metavar="name",
help="Early-load given plugin module name or entry point (multi-allowed). "
"To avoid loading of plugins, use the `no:` prefix, e.g. "
"`no:doctest`. See also --disable-plugin-autoload.",
)
group.addoption(
"--disable-plugin-autoload",
action="store_true",
default=False,
help="Disable plugin auto-loading through entry point packaging metadata. "
"Only plugins explicitly specified in -p or env var PYTEST_PLUGINS will be loaded.",
"`no:doctest`.",
)
group.addoption(
"--traceconfig",
@@ -109,78 +88,79 @@ def pytest_addoption(parser: Parser) -> None:
"This file is opened with 'w' and truncated as a result, care advised. "
"Default: pytestdebug.log.",
)
group._addoption( # private to use reserved lower-case short option
group._addoption(
"-o",
"--override-ini",
dest="override_ini",
action="append",
help='Override configuration option with "option=value" style, '
"e.g. `-o strict_xfail=True -o cache_dir=cache`.",
help='Override ini option with "option=value" style, '
"e.g. `-o xfail_strict=True -o cache_dir=cache`.",
)
@pytest.hookimpl(wrapper=True)
def pytest_cmdline_parse() -> Generator[None, Config, Config]:
config = yield
@pytest.hookimpl(hookwrapper=True)
def pytest_cmdline_parse():
outcome = yield
config: Config = outcome.get_result()
if config.option.debug:
# --debug | --debug <file.log> was provided.
path = config.option.debug
debugfile = open(path, "w", encoding="utf-8")
debugfile.write(
"versions pytest-{}, "
"python-{}\ninvocation_dir={}\ncwd={}\nargs={}\n\n".format(
"versions pytest-%s, "
"python-%s\ncwd=%s\nargs=%s\n\n"
% (
pytest.__version__,
".".join(map(str, sys.version_info)),
config.invocation_params.dir,
os.getcwd(),
config.invocation_params.args,
)
)
config.trace.root.setwriter(debugfile.write)
undo_tracing = config.pluginmanager.enable_tracing()
sys.stderr.write(f"writing pytest debug information to {path}\n")
sys.stderr.write("writing pytest debug information to %s\n" % path)
def unset_tracing() -> None:
debugfile.close()
sys.stderr.write(f"wrote pytest debug information to {debugfile.name}\n")
sys.stderr.write("wrote pytest debug information to %s\n" % debugfile.name)
config.trace.root.setwriter(None)
undo_tracing()
config.add_cleanup(unset_tracing)
return config
def show_version_verbose(config: Config) -> None:
"""Show verbose pytest version installation, including plugins."""
sys.stdout.write(
f"This is pytest version {pytest.__version__}, imported from {pytest.__file__}\n"
)
plugininfo = getpluginversioninfo(config)
if plugininfo:
for line in plugininfo:
sys.stdout.write(line + "\n")
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
# Note: a single `--version` argument is handled directly by `Config.main()` to avoid starting up the entire
# pytest infrastructure just to display the version (#13574).
def showversion(config: Config) -> None:
if config.option.version > 1:
show_version_verbose(config)
return ExitCode.OK
sys.stdout.write(
"This is pytest version {}, imported from {}\n".format(
pytest.__version__, pytest.__file__
)
)
plugininfo = getpluginversioninfo(config)
if plugininfo:
for line in plugininfo:
sys.stdout.write(line + "\n")
else:
sys.stdout.write(f"pytest {pytest.__version__}\n")
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
if config.option.version > 0:
showversion(config)
return 0
elif config.option.help:
config._do_configure()
showhelp(config)
config._ensure_unconfigure()
return ExitCode.OK
return 0
return None
def showhelp(config: Config) -> None:
import textwrap
reporter: TerminalReporter | None = config.pluginmanager.get_plugin(
reporter: Optional[TerminalReporter] = config.pluginmanager.get_plugin(
"terminalreporter"
)
assert reporter is not None
@@ -188,20 +168,22 @@ def showhelp(config: Config) -> None:
tw.write(config._parser.optparser.format_help())
tw.line()
tw.line(
"[pytest] configuration options in the first "
"pytest.toml|pytest.ini|tox.ini|setup.cfg|pyproject.toml file found:"
"[pytest] ini-options in the first "
"pytest.ini|tox.ini|setup.cfg|pyproject.toml file found:"
)
tw.line()
columns = tw.fullwidth # costly call
indent_len = 24 # based on argparse's max_help_position=24
indent = " " * indent_len
for name in config._parser._inidict:
help, type, _default = config._parser._inidict[name]
for name in config._parser._ininames:
help, type, default = config._parser._inidict[name]
if type is None:
type = "string"
if help is None:
raise TypeError(f"help argument cannot be None for {name}")
spec = f"{name} ({type}):"
tw.write(f" {spec}")
tw.write(" %s" % spec)
spec_len = len(spec)
if spec_len > (indent_len - 3):
# Display help starting at a new line.
@@ -229,19 +211,10 @@ def showhelp(config: Config) -> None:
tw.line()
tw.line("Environment variables:")
vars = [
(
"CI",
"When set to a non-empty value, pytest knows it is running in a "
"CI process and does not truncate summary info",
),
("BUILD_NUMBER", "Equivalent to CI"),
("PYTEST_ADDOPTS", "Extra command line options"),
("PYTEST_PLUGINS", "Comma-separated plugins to load during startup"),
("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "Set to disable plugin auto-loading"),
("PYTEST_DEBUG", "Set to enable debug tracing of pytest's internals"),
("PYTEST_DEBUG_TEMPROOT", "Override the system temporary directory"),
("PYTEST_THEME", "The Pygments style to use for code output"),
("PYTEST_THEME_MODE", "Set the PYTEST_THEME to be either 'dark' or 'light'"),
]
for name, help in vars:
tw.line(f" {name:<24} {help}")
@@ -258,13 +231,17 @@ def showhelp(config: Config) -> None:
for warningreport in reporter.stats.get("warnings", []):
tw.line("warning : " + warningreport.message, red=True)
return
def getpluginversioninfo(config: Config) -> list[str]:
conftest_options = [("pytest_plugins", "list of plugin names to load")]
def getpluginversioninfo(config: Config) -> List[str]:
lines = []
plugininfo = config.pluginmanager.list_plugin_distinfo()
if plugininfo:
lines.append("registered third-party plugins:")
lines.append("setuptools registered plugins:")
for plugin, dist in plugininfo:
loc = getattr(plugin, "__file__", repr(plugin))
content = f"{dist.project_name}-{dist.version} at {loc}"
@@ -272,7 +249,7 @@ def getpluginversioninfo(config: Config) -> list[str]:
return lines
def pytest_report_header(config: Config) -> list[str]:
def pytest_report_header(config: Config) -> List[str]:
lines = []
if config.option.debug or config.option.traceconfig:
lines.append(f"using: pytest-{pytest.__version__}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
# mypy: allow-untyped-defs
"""Report test results in JUnit-XML format, for use with Jenkins and build
integration servers.
@@ -7,16 +6,21 @@ Based on initial code from Ross Lawley.
Output conforms to
https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd
"""
from __future__ import annotations
from collections.abc import Callable
import functools
import os
import platform
import re
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import Callable
from typing import Dict
from typing import List
from typing import Match
from typing import Optional
from typing import Tuple
from typing import Union
import pytest
from _pytest import nodes
from _pytest import timing
from _pytest._code.code import ExceptionRepr
@@ -28,7 +32,6 @@ from _pytest.fixtures import FixtureRequest
from _pytest.reports import TestReport
from _pytest.stash import StashKey
from _pytest.terminal import TerminalReporter
import pytest
xml_key = StashKey["LogXML"]()
@@ -45,18 +48,18 @@ def bin_xml_escape(arg: object) -> str:
The idea is to escape visually for the user rather than for XML itself.
"""
def repl(matchobj: re.Match[str]) -> str:
def repl(matchobj: Match[str]) -> str:
i = ord(matchobj.group())
if i <= 0xFF:
return f"#x{i:02X}"
return "#x%02X" % i
else:
return f"#x{i:04X}"
return "#x%04X" % i
# The spec range of valid chars is:
# Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
# For an unknown(?) reason, we disallow #x7F (DEL) as well.
illegal_xml_re = (
"[^\u0009\u000a\u000d\u0020-\u007e\u0080-\ud7ff\ue000-\ufffd\u10000-\u10ffff]"
"[^\u0009\u000A\u000D\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]"
)
return re.sub(illegal_xml_re, repl, str(arg))
@@ -71,10 +74,10 @@ def merge_family(left, right) -> None:
left.update(result)
families = { # pylint: disable=dict-init-mutate
"_base": {"testcase": ["classname", "name"]},
"_base_legacy": {"testcase": ["file", "line", "url"]},
}
families = {}
families["_base"] = {"testcase": ["classname", "name"]}
families["_base_legacy"] = {"testcase": ["file", "line", "url"]}
# xUnit 1.x inherits legacy attributes.
families["xunit1"] = families["_base"].copy()
merge_family(families["xunit1"], families["_base_legacy"])
@@ -84,15 +87,15 @@ families["xunit2"] = families["_base"]
class _NodeReporter:
def __init__(self, nodeid: str | TestReport, xml: LogXML) -> None:
def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None:
self.id = nodeid
self.xml = xml
self.add_stats = self.xml.add_stats
self.family = self.xml.family
self.duration = 0.0
self.properties: list[tuple[str, str]] = []
self.nodes: list[ET.Element] = []
self.attrs: dict[str, str] = {}
self.properties: List[Tuple[str, str]] = []
self.nodes: List[ET.Element] = []
self.attrs: Dict[str, str] = {}
def append(self, node: ET.Element) -> None:
self.xml.add_stats(node.tag)
@@ -104,7 +107,7 @@ class _NodeReporter:
def add_attribute(self, name: str, value: object) -> None:
self.attrs[str(name)] = bin_xml_escape(value)
def make_properties_node(self) -> ET.Element | None:
def make_properties_node(self) -> Optional[ET.Element]:
"""Return a Junit node containing custom properties, if any."""
if self.properties:
properties = ET.Element("properties")
@@ -119,7 +122,7 @@ class _NodeReporter:
classnames = names[:-1]
if self.xml.prefix:
classnames.insert(0, self.xml.prefix)
attrs: dict[str, str] = {
attrs: Dict[str, str] = {
"classname": ".".join(classnames),
"name": bin_xml_escape(names[-1]),
"file": testreport.location[0],
@@ -138,20 +141,20 @@ class _NodeReporter:
# Filter out attributes not permitted by this test family.
# Including custom attributes because they are not valid here.
temp_attrs = {}
for key in self.attrs:
for key in self.attrs.keys():
if key in families[self.family]["testcase"]:
temp_attrs[key] = self.attrs[key]
self.attrs = temp_attrs
def to_xml(self) -> ET.Element:
testcase = ET.Element("testcase", self.attrs, time=f"{self.duration:.3f}")
testcase = ET.Element("testcase", self.attrs, time="%.3f" % self.duration)
properties = self.make_properties_node()
if properties is not None:
testcase.append(properties)
testcase.extend(self.nodes)
return testcase
def _add_simple(self, tag: str, message: str, data: str | None = None) -> None:
def _add_simple(self, tag: str, message: str, data: Optional[str] = None) -> None:
node = ET.Element(tag, message=message)
node.text = bin_xml_escape(data)
self.append(node)
@@ -196,7 +199,7 @@ class _NodeReporter:
self._add_simple("skipped", "xfail-marked test passes unexpectedly")
else:
assert report.longrepr is not None
reprcrash: ReprFileLocation | None = getattr(
reprcrash: Optional[ReprFileLocation] = getattr(
report.longrepr, "reprcrash", None
)
if reprcrash is not None:
@@ -216,7 +219,9 @@ class _NodeReporter:
def append_error(self, report: TestReport) -> None:
assert report.longrepr is not None
reprcrash: ReprFileLocation | None = getattr(report.longrepr, "reprcrash", None)
reprcrash: Optional[ReprFileLocation] = getattr(
report.longrepr, "reprcrash", None
)
if reprcrash is not None:
reason = reprcrash.message
else:
@@ -243,9 +248,7 @@ class _NodeReporter:
skipreason = skipreason[9:]
details = f"{filename}:{lineno}: {skipreason}"
skipped = ET.Element(
"skipped", type="pytest.skip", message=bin_xml_escape(skipreason)
)
skipped = ET.Element("skipped", type="pytest.skip", message=skipreason)
skipped.text = bin_xml_escape(details)
self.append(skipped)
self.write_captured_output(report)
@@ -255,7 +258,7 @@ class _NodeReporter:
self.__dict__.clear()
# Type ignored because mypy doesn't like overriding a method.
# Also the return value doesn't match...
self.to_xml = lambda: data # type: ignore[method-assign]
self.to_xml = lambda: data # type: ignore[assignment]
def _warn_incompatibility_with_xunit2(
@@ -268,7 +271,9 @@ def _warn_incompatibility_with_xunit2(
if xml is not None and xml.family not in ("xunit1", "legacy"):
request.node.warn(
PytestWarning(
f"{fixture_name} is incompatible with junit_family '{xml.family}' (use 'legacy' or 'xunit1')"
"{fixture_name} is incompatible with junit_family '{family}' (use 'legacy' or 'xunit1')".format(
fixture_name=fixture_name, family=xml.family
)
)
)
@@ -360,16 +365,17 @@ def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object]
`pytest-xdist <https://github.com/pytest-dev/pytest-xdist>`__ plugin. See
:issue:`7767` for details.
"""
__tracebackhide__ = True
def record_func(name: str, value: object) -> None:
"""No-op function in case --junit-xml was not passed in the command-line."""
"""No-op function in case --junitxml was not passed in the command-line."""
__tracebackhide__ = True
_check_record_param_type("name", name)
xml = request.config.stash.get(xml_key, None)
if xml is not None:
record_func = xml.add_global_property
record_func = xml.add_global_property # noqa
return record_func
@@ -444,7 +450,7 @@ def pytest_unconfigure(config: Config) -> None:
config.pluginmanager.unregister(xml)
def mangle_test_address(address: str) -> list[str]:
def mangle_test_address(address: str) -> List[str]:
path, possible_open_bracket, params = address.partition("[")
names = path.split("::")
# Convert file path to dotted path.
@@ -459,7 +465,7 @@ class LogXML:
def __init__(
self,
logfile,
prefix: str | None,
prefix: Optional[str],
suite_name: str = "pytest",
logging: str = "no",
report_duration: str = "total",
@@ -474,15 +480,17 @@ class LogXML:
self.log_passing_tests = log_passing_tests
self.report_duration = report_duration
self.family = family
self.stats: dict[str, int] = dict.fromkeys(
self.stats: Dict[str, int] = dict.fromkeys(
["error", "passed", "failure", "skipped"], 0
)
self.node_reporters: dict[tuple[str | TestReport, object], _NodeReporter] = {}
self.node_reporters_ordered: list[_NodeReporter] = []
self.global_properties: list[tuple[str, str]] = []
self.node_reporters: Dict[
Tuple[Union[str, TestReport], object], _NodeReporter
] = {}
self.node_reporters_ordered: List[_NodeReporter] = []
self.global_properties: List[Tuple[str, str]] = []
# List of reports that failed on call but teardown is pending.
self.open_reports: list[TestReport] = []
self.open_reports: List[TestReport] = []
self.cnt_double_fail_tests = 0
# Replaces convenience family with real family.
@@ -501,8 +509,8 @@ class LogXML:
if reporter is not None:
reporter.finalize()
def node_reporter(self, report: TestReport | str) -> _NodeReporter:
nodeid: str | TestReport = getattr(report, "nodeid", report)
def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter:
nodeid: Union[str, TestReport] = getattr(report, "nodeid", report)
# Local hack to handle xdist report order.
workernode = getattr(report, "node", None)
@@ -616,7 +624,7 @@ class LogXML:
def update_testcase_duration(self, report: TestReport) -> None:
"""Accumulate total duration for nodeid from given report and update
the Junit.testcase with the new total if already created."""
if self.report_duration in {"total", report.when}:
if self.report_duration == "total" or report.when == self.report_duration:
reporter = self.node_reporter(report)
reporter.duration += getattr(report, "duration", 0.0)
@@ -634,7 +642,7 @@ class LogXML:
reporter._add_simple("error", "internal error", str(excrepr))
def pytest_sessionstart(self) -> None:
self.suite_start = timing.Instant()
self.suite_start_time = timing.time()
def pytest_sessionfinish(self) -> None:
dirname = os.path.dirname(os.path.abspath(self.logfile))
@@ -642,7 +650,8 @@ class LogXML:
os.makedirs(dirname, exist_ok=True)
with open(self.logfile, "w", encoding="utf-8") as logfile:
duration = self.suite_start.elapsed()
suite_stop_time = timing.time()
suite_time_delta = suite_stop_time - self.suite_start_time
numtests = (
self.stats["passed"]
@@ -660,8 +669,8 @@ class LogXML:
failures=str(self.stats["failure"]),
skipped=str(self.stats["skipped"]),
tests=str(numtests),
time=f"{duration.seconds:.3f}",
timestamp=self.suite_start.as_utc().astimezone().isoformat(),
time="%.3f" % suite_time_delta,
timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(),
hostname=platform.node(),
)
global_properties = self._get_global_properties_node()
@@ -670,22 +679,18 @@ class LogXML:
for node_reporter in self.node_reporters_ordered:
suite_node.append(node_reporter.to_xml())
testsuites = ET.Element("testsuites")
testsuites.set("name", "pytest tests")
testsuites.append(suite_node)
logfile.write(ET.tostring(testsuites, encoding="unicode"))
def pytest_terminal_summary(
self, terminalreporter: TerminalReporter, config: pytest.Config
) -> None:
if config.get_verbosity() >= 0:
terminalreporter.write_sep("-", f"generated xml file: {self.logfile}")
def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
terminalreporter.write_sep("-", f"generated xml file: {self.logfile}")
def add_global_property(self, name: str, value: object) -> None:
__tracebackhide__ = True
_check_record_param_type("name", name)
self.global_properties.append((name, bin_xml_escape(value)))
def _get_global_properties_node(self) -> ET.Element | None:
def _get_global_properties_node(self) -> Optional[ET.Element]:
"""Return a Junit node containing custom properties, if any."""
if self.global_properties:
properties = ET.Element("properties")

View File

@@ -1,19 +1,17 @@
# mypy: allow-untyped-defs
"""Add backward compatibility support for the legacy py path type."""
from __future__ import annotations
import dataclasses
from pathlib import Path
import shlex
import subprocess
from typing import Final
from typing import final
from pathlib import Path
from typing import List
from typing import Optional
from typing import TYPE_CHECKING
from typing import Union
from iniconfig import SectionWrapper
from _pytest.cacheprovider import Cache
from _pytest.compat import final
from _pytest.compat import LEGACY_PATH
from _pytest.compat import legacy_path
from _pytest.config import Config
@@ -33,8 +31,9 @@ from _pytest.pytester import RunResult
from _pytest.terminal import TerminalReporter
from _pytest.tmpdir import TempPathFactory
if TYPE_CHECKING:
from typing_extensions import Final
import pexpect
@@ -49,8 +48,8 @@ class Testdir:
__test__ = False
CLOSE_STDIN: Final = Pytester.CLOSE_STDIN
TimeoutExpired: Final = Pytester.TimeoutExpired
CLOSE_STDIN: "Final" = Pytester.CLOSE_STDIN
TimeoutExpired: "Final" = Pytester.TimeoutExpired
def __init__(self, pytester: Pytester, *, _ispytest: bool = False) -> None:
check_ispytest(_ispytest)
@@ -90,6 +89,7 @@ class Testdir:
return self._pytester.chdir()
def finalize(self) -> None:
"""See :meth:`Pytester._finalize`."""
return self._pytester._finalize()
def makefile(self, ext, *args, **kwargs) -> LEGACY_PATH:
@@ -144,7 +144,7 @@ class Testdir:
"""See :meth:`Pytester.copy_example`."""
return legacy_path(self._pytester.copy_example(name))
def getnode(self, config: Config, arg) -> Item | Collector | None:
def getnode(self, config: Config, arg) -> Optional[Union[Item, Collector]]:
"""See :meth:`Pytester.getnode`."""
return self._pytester.getnode(config, arg)
@@ -152,7 +152,7 @@ class Testdir:
"""See :meth:`Pytester.getpathnode`."""
return self._pytester.getpathnode(path)
def genitems(self, colitems: list[Item | Collector]) -> list[Item]:
def genitems(self, colitems: List[Union[Item, Collector]]) -> List[Item]:
"""See :meth:`Pytester.genitems`."""
return self._pytester.genitems(colitems)
@@ -204,7 +204,9 @@ class Testdir:
source, configargs=configargs, withinit=withinit
)
def collect_by_name(self, modcol: Collector, name: str) -> Item | Collector | None:
def collect_by_name(
self, modcol: Collector, name: str
) -> Optional[Union[Item, Collector]]:
"""See :meth:`Pytester.collect_by_name`."""
return self._pytester.collect_by_name(modcol, name)
@@ -235,11 +237,13 @@ class Testdir:
"""See :meth:`Pytester.runpytest_subprocess`."""
return self._pytester.runpytest_subprocess(*args, timeout=timeout)
def spawn_pytest(self, string: str, expect_timeout: float = 10.0) -> pexpect.spawn:
def spawn_pytest(
self, string: str, expect_timeout: float = 10.0
) -> "pexpect.spawn":
"""See :meth:`Pytester.spawn_pytest`."""
return self._pytester.spawn_pytest(string, expect_timeout=expect_timeout)
def spawn(self, cmd: str, expect_timeout: float = 10.0) -> pexpect.spawn:
def spawn(self, cmd: str, expect_timeout: float = 10.0) -> "pexpect.spawn":
"""See :meth:`Pytester.spawn`."""
return self._pytester.spawn(cmd, expect_timeout=expect_timeout)
@@ -266,7 +270,7 @@ class LegacyTestdirPlugin:
@final
@dataclasses.dataclass
class TempdirFactory:
"""Backward compatibility wrapper that implements ``py.path.local``
"""Backward compatibility wrapper that implements :class:`py.path.local`
for :class:`TempPathFactory`.
.. note::
@@ -285,11 +289,11 @@ class TempdirFactory:
self._tmppath_factory = tmppath_factory
def mktemp(self, basename: str, numbered: bool = True) -> LEGACY_PATH:
"""Same as :meth:`TempPathFactory.mktemp`, but returns a ``py.path.local`` object."""
"""Same as :meth:`TempPathFactory.mktemp`, but returns a :class:`py.path.local` object."""
return legacy_path(self._tmppath_factory.mktemp(basename, numbered).resolve())
def getbasetemp(self) -> LEGACY_PATH:
"""Same as :meth:`TempPathFactory.getbasetemp`, but returns a ``py.path.local`` object."""
"""Same as :meth:`TempPathFactory.getbasetemp`, but returns a :class:`py.path.local` object."""
return legacy_path(self._tmppath_factory.getbasetemp().resolve())
@@ -304,11 +308,16 @@ class LegacyTmpdirPlugin:
@staticmethod
@fixture
def tmpdir(tmp_path: Path) -> LEGACY_PATH:
"""Return a temporary directory (as `legacy_path`_ object)
which is unique to each test function invocation.
The temporary directory is created as a subdirectory
of the base temporary directory, with configurable retention,
as discussed in :ref:`temporary directory location and retention`.
"""Return a temporary directory path object which is unique to each test
function invocation, created as a sub directory of the base temporary
directory.
By default, a new base temporary directory is created each test session,
and old bases are removed after 3 sessions, to aid in debugging. If
``--basetemp`` is used then it is cleared each session. See :ref:`base
temporary directory`.
The returned object is a `legacy_path`_ object.
.. note::
These days, it is preferred to use ``tmp_path``.
@@ -364,7 +373,7 @@ def Config_rootdir(self: Config) -> LEGACY_PATH:
return legacy_path(str(self.rootpath))
def Config_inifile(self: Config) -> LEGACY_PATH | None:
def Config_inifile(self: Config) -> Optional[LEGACY_PATH]:
"""The path to the :ref:`configfile <configfiles>`.
Prefer to use :attr:`inipath`, which is a :class:`pathlib.Path`.
@@ -374,7 +383,7 @@ def Config_inifile(self: Config) -> LEGACY_PATH | None:
return legacy_path(str(self.inipath)) if self.inipath else None
def Session_startdir(self: Session) -> LEGACY_PATH:
def Session_stardir(self: Session) -> LEGACY_PATH:
"""The path from which pytest was invoked.
Prefer to use ``startpath`` which is a :class:`pathlib.Path`.
@@ -384,7 +393,9 @@ def Session_startdir(self: Session) -> LEGACY_PATH:
return legacy_path(self.startpath)
def Config__getini_unknown_type(self, name: str, type: str, value: str | list[str]):
def Config__getini_unknown_type(
self, name: str, type: str, value: Union[str, List[str]]
):
if type == "pathlist":
# TODO: This assert is probably not valid in all cases.
assert self.inipath is not None
@@ -427,7 +438,7 @@ def pytest_load_initial_conftests(early_config: Config) -> None:
mp.setattr(Config, "inifile", property(Config_inifile), raising=False)
# Add Session.startdir property.
mp.setattr(Session, "startdir", property(Session_startdir), raising=False)
mp.setattr(Session, "startdir", property(Session_stardir), raising=False)
# Add pathlist configuration type.
mp.setattr(Config, "_getini_unknown_type", Config__getini_unknown_type)

Some files were not shown because too many files have changed in this diff Show More