feat: complete Phase 2 core components (Camoufox & CurlClient)

This commit is contained in:
Luciabrightcode 2025-12-22 18:01:15 +08:00
parent a15ca58ef8
commit 1134da7ed3
1273 changed files with 206095 additions and 18491 deletions

View file

@ -1,6 +1,8 @@
redis==5.0.1
msgpack==1.0.7
curl_cffi==0.5.10
curl_cffi>=0.6.0
playwright==1.40.0
pydantic==2.5.3
pytest==7.4.3
pytest>=8.0.0
pytest-asyncio>=0.23.0
aiohttp==3.9.1

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -58,7 +58,7 @@ class CurlClient:
async def close(self) -> None:
if self.session:
self.session.close()
await self.session.close()
async def fetch(self, url: str) -> str:
"""

Binary file not shown.

8
venv/bin/curl-cffi Executable file
View file

@ -0,0 +1,8 @@
#!/home/kasm-user/workspace/FAEA/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from curl_cffi.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
venv/bin/pygmentize Executable file
View file

@ -0,0 +1,8 @@
#!/home/kasm-user/workspace/FAEA/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pygments.cmdline import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View file

@ -1,9 +1,13 @@
from __future__ import annotations
__all__ = ["__version__", "version_tuple"]
try:
from ._version import version as __version__, version_tuple
from ._version import version as __version__
from ._version import 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") # type:ignore[assignment]
version_tuple = (0, 0, "unknown")

View file

@ -61,13 +61,14 @@ 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:
@ -76,7 +77,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)
@ -103,7 +104,7 @@ if os.environ.get("_ARGCOMPLETE"):
import argcomplete.completers
except ImportError:
sys.exit(-1)
filescompleter: Optional[FastFilesCompleter] = FastFilesCompleter()
filescompleter: FastFilesCompleter | None = FastFilesCompleter()
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
argcomplete.autocomplete(parser, always_complete_options=False)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,16 @@
# 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
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
import warnings
class Source:
@ -22,13 +21,17 @@ class Source:
def __init__(self, obj: object = None) -> None:
if not obj:
self.lines: List[str] = []
self.lines: list[str] = []
self.raw_lines: list[str] = []
elif isinstance(obj, Source):
self.lines = obj.lines
elif isinstance(obj, (tuple, list)):
self.raw_lines = obj.raw_lines
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)
@ -36,6 +39,7 @@ 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):
@ -46,14 +50,12 @@ 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: Union[int, slice]) -> Union[str, "Source"]:
def __getitem__(self, key: int | slice) -> str | Source:
if isinstance(key, int):
return self.lines[key]
else:
@ -61,6 +63,7 @@ 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]:
@ -69,7 +72,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():
@ -77,34 +80,37 @@ 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:
@ -116,13 +122,14 @@ class Source:
#
def findsource(obj) -> Tuple[Optional[Source], int]:
def findsource(obj) -> tuple[Source | None, 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
@ -139,24 +146,23 @@ 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, Optional[int]]:
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
# 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)):
# 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)):
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):
for d in x.decorator_list:
values.append(d.lineno - 1)
values.append(x.lineno - 1)
for name in ("finalbody", "orelse"):
val: Optional[List[ast.stmt]] = getattr(x, name, None)
val: list[ast.stmt] | None = getattr(x, name, None)
if val:
# Treat the finally/orelse part as its own statement.
values.append(val[0].lineno - 1 - 1)
@ -174,8 +180,8 @@ def getstatementrange_ast(
lineno: int,
source: Source,
assertion: bool = False,
astnode: Optional[ast.AST] = None,
) -> Tuple[ast.AST, int, int]:
astnode: ast.AST | None = None,
) -> tuple[ast.AST, int, int]:
if astnode is None:
content = str(source)
# See #4260:
@ -197,7 +203,9 @@ 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 = source.lines[start][0].isspace()
block_finder.started = (
bool(source.lines[start]) and 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,3 +1,5 @@
from __future__ import annotations
from .terminalwriter import get_terminal_width
from .terminalwriter import TerminalWriter

View file

@ -0,0 +1,673 @@
# 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,9 +1,7 @@
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:
@ -20,10 +18,10 @@ def _format_repr_exception(exc: BaseException, obj: object) -> str:
exc_info = _try_repr_or_str(exc)
except (KeyboardInterrupt, SystemExit):
raise
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)
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}>"
)
@ -41,7 +39,7 @@ class SafeRepr(reprlib.Repr):
information on exceptions raised during the call.
"""
def __init__(self, maxsize: Optional[int], use_ascii: bool = False) -> None:
def __init__(self, maxsize: int | None, use_ascii: bool = False) -> None:
"""
:param maxsize:
If not None, will truncate the resulting repr to that specific size, using ellipsis
@ -62,7 +60,6 @@ class SafeRepr(reprlib.Repr):
s = ascii(x)
else:
s = super().repr(x)
except (KeyboardInterrupt, SystemExit):
raise
except BaseException as exc:
@ -100,7 +97,7 @@ DEFAULT_REPR_MAX_SIZE = 240
def saferepr(
obj: object, maxsize: Optional[int] = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
obj: object, maxsize: int | None = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
) -> str:
"""Return a size-limited safe repr-string for the given object.
@ -111,7 +108,6 @@ def saferepr(
This function is a wrapper around the Repr/reprlib functionality of the
stdlib.
"""
return SafeRepr(maxsize, use_ascii).repr(obj)
@ -132,49 +128,3 @@ 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,13 +1,23 @@
"""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 Optional
from typing import Sequence
from typing import final
from typing import Literal
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.
@ -28,9 +38,9 @@ def should_do_markup(file: TextIO) -> bool:
return True
if os.environ.get("PY_COLORS") == "0":
return False
if "NO_COLOR" in os.environ:
if os.environ.get("NO_COLOR"):
return False
if "FORCE_COLOR" in os.environ:
if os.environ.get("FORCE_COLOR"):
return True
return (
hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
@ -62,7 +72,7 @@ class TerminalWriter:
invert=7,
)
def __init__(self, file: Optional[TextIO] = None) -> None:
def __init__(self, file: TextIO | None = None) -> None:
if file is None:
file = sys.stdout
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
@ -76,7 +86,7 @@ class TerminalWriter:
self._file = file
self.hasmarkup = should_do_markup(file)
self._current_line = ""
self._terminal_width: Optional[int] = None
self._terminal_width: int | None = None
self.code_highlight = True
@property
@ -101,14 +111,14 @@ class TerminalWriter:
if self.hasmarkup:
esc = [self._esctable[name] for name, on in markup.items() if on]
if esc:
text = "".join("\x1b[%sm" % cod for cod in esc) + text + "\x1b[0m"
text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
return text
def sep(
self,
sepchar: str,
title: Optional[str] = None,
fullwidth: Optional[int] = None,
title: str | None = None,
fullwidth: int | None = None,
**markup: bool,
) -> None:
if fullwidth is None:
@ -151,20 +161,23 @@ class TerminalWriter:
msg = self.markup(msg, **markup)
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)
self.write_raw(msg, flush=flush)
if flush:
self.flush()
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()
def line(self, s: str = "", **markup: bool) -> None:
self.write(s, **markup)
@ -182,52 +195,64 @@ class TerminalWriter:
"""
if indents and len(indents) != len(lines):
raise ValueError(
"indents size ({}) should have same size as lines ({})".format(
len(indents), len(lines)
)
f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
)
if not indents:
indents = [""] * len(lines)
source = "\n".join(lines)
new_lines = self._highlight(source).splitlines()
for indent, new_line in zip(indents, new_lines):
# Would be better to strict=True but that fails some CI jobs.
for indent, new_line in zip(indents, new_lines, strict=False):
self.line(indent + new_line)
def _highlight(self, source: str) -> str:
"""Highlight the given source code if we have markup support."""
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:
from _pytest.config.exceptions import UsageError
if not self.hasmarkup or not self.code_highlight:
return source
theme = os.getenv("PYTEST_THEME")
theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
try:
from pygments.formatters.terminal import TerminalFormatter
from pygments.lexers.python import PythonLexer
from pygments import highlight
import pygments.util
except ImportError:
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:
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")
)
)
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

View file

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

View file

@ -1,13 +1,15 @@
"""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
@ -39,7 +41,7 @@ _winerrnomap = {
3: errno.ENOENT,
17: errno.EEXIST,
18: errno.EXDEV,
13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailiable
13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailable
22: errno.ENOTDIR,
20: errno.ENOTDIR,
267: errno.ENOTDIR,
@ -67,7 +69,7 @@ class ErrorMaker:
try:
return self._errno2class[eno]
except KeyError:
clsname = errno.errorcode.get(eno, "UnknownErrno%d" % (eno,))
clsname = errno.errorcode.get(eno, f"UnknownErrno{eno}")
errorcls = type(
clsname,
(Error,),
@ -88,15 +90,23 @@ class ErrorMaker:
except OSError as value:
if not hasattr(value, "errno"):
raise
errno = value.errno
if sys.platform == "win32":
try:
cls = self._geterrnoclass(_winerrnomap[errno])
# 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]
except KeyError:
raise value
else:
# we are not on Windows, or we got a proper OSError
cls = self._geterrnoclass(errno)
if value.errno is None:
cls = type(
"UnknownErrnoNone",
(Error,),
{"__module__": "py.error", "__doc__": None},
)
else:
cls = self._geterrnoclass(value.errno)
raise cls(f"{func.__name__}{args!r}")

View file

@ -1,16 +1,15 @@
# 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
@ -19,19 +18,21 @@ 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")
@ -160,15 +161,13 @@ class Visitor:
)
if not self.breadthfirst:
for subdir in dirs:
for p in self.gen(subdir):
yield p
yield from self.gen(subdir)
for p in self.optsort(entries):
if self.fil is None or self.fil(p):
yield p
if self.breadthfirst:
for subdir in dirs:
for p in self.gen(subdir):
yield p
yield from self.gen(subdir)
class FNMatcher:
@ -205,12 +204,10 @@ 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)
@ -225,7 +222,7 @@ class Stat:
raise NotImplementedError("XXX win32")
import pwd
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined]
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined,unused-ignore]
return entry[0]
@property
@ -235,7 +232,7 @@ class Stat:
raise NotImplementedError("XXX win32")
import grp
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined]
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined,unused-ignore]
return entry[0]
def isdir(self):
@ -253,7 +250,7 @@ def getuserid(user):
import pwd
if not isinstance(user, int):
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined]
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined,unused-ignore]
return user
@ -261,7 +258,7 @@ def getgroupid(group):
import grp
if not isinstance(group, int):
group = grp.getgrnam(group)[2] # type:ignore[attr-defined]
group = grp.getgrnam(group)[2] # type:ignore[attr-defined,unused-ignore]
return group
@ -318,7 +315,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]
return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value,unused-ignore]
def mklinkto(self, oldname):
"""Posix style hard link to another name."""
@ -435,7 +432,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:
@ -452,7 +449,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
@ -655,12 +652,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("invalid specification %r" % kw)
raise ValueError(f"invalid specification {kw!r}")
else:
pb = kw.setdefault("purebasename", purebasename)
try:
@ -677,7 +674,7 @@ class LocalPath:
else:
kw.setdefault("dirname", dirname)
kw.setdefault("sep", self.sep)
obj.strpath = normpath("%(dirname)s%(sep)s%(basename)s" % kw)
obj.strpath = normpath("{dirname}{sep}{basename}".format(**kw))
return obj
def _getbyspec(self, spec: str) -> list[str]:
@ -706,7 +703,7 @@ class LocalPath:
elif name == "ext":
res.append(ext)
else:
raise ValueError("invalid part specification %r" % name)
raise ValueError(f"invalid part specification {name!r}")
return res
def dirpath(self, *args, **kwargs):
@ -757,7 +754,12 @@ 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):
@ -775,11 +777,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::
@ -832,7 +834,7 @@ class LocalPath:
def copy(self, target, mode=False, stat=False):
"""Copy path to target.
If mode is True, will copy copy permission from path to target.
If mode is True, will copy permission from path to target.
If stat is True, copy permission, last modification
time, last access time, and flags from path to target.
"""
@ -957,12 +959,10 @@ 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 "local(%r)" % self.strpath
return f"local({self.strpath!r})"
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 can not be determined.
Return None if a pkgpath cannot be determined.
"""
pkgpath = None
for parent in self.parts(reverse=True):
@ -1096,9 +1096,7 @@ 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 {str(self)}"
)
raise ImportError(f"Can't find module {modname} at location {self!s}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@ -1163,7 +1161,8 @@ class LocalPath:
where the 'self' path points to executable.
The process is directly invoked and not through a system shell.
"""
from subprocess import Popen, PIPE
from subprocess import PIPE
from subprocess import Popen
popen_opts.pop("stdout", None)
popen_opts.pop("stderr", None)
@ -1263,13 +1262,14 @@ class LocalPath:
@classmethod
def mkdtemp(cls, rootdir=None):
"""Return a Path object pointing to a fresh new temporary directory
(which we created ourself).
(which we created ourselves).
"""
import tempfile
if rootdir is None:
rootdir = cls.get_temproot()
return cls(error.checked_call(tempfile.mkdtemp, dir=str(rootdir)))
path = error.checked_call(tempfile.mkdtemp, dir=str(rootdir))
return cls(path)
@classmethod
def make_numbered_dir(

View file

@ -1,16 +1,34 @@
# 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, Union
from typing import Tuple
from typing import 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 = '7.4.3'
__version_tuple__ = version_tuple = (7, 4, 3)
__version__ = version = '9.0.2'
__version_tuple__ = version_tuple = (9, 0, 2)
__commit_id__ = commit_id = None

View file

@ -1,9 +1,12 @@
# 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 Generator
from typing import List
from typing import Optional
from typing import Protocol
from typing import TYPE_CHECKING
from _pytest.assertion import rewrite
@ -15,6 +18,7 @@ from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
if TYPE_CHECKING:
from _pytest.main import Session
@ -43,6 +47,26 @@ 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.
@ -59,15 +83,18 @@ 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):
importhook = hook
rewrite_hook = hook
break
else:
# 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)
rewrite_hook = DummyRewriteHook()
rewrite_hook.mark_rewrite(*names)
class RewriteHook(Protocol):
def mark_rewrite(self, *names: str) -> None: ...
class DummyRewriteHook:
@ -83,7 +110,7 @@ class AssertionState:
def __init__(self, config: Config, mode) -> None:
self.mode = mode
self.trace = config.trace.root.get("assertion")
self.hook: Optional[rewrite.AssertionRewritingHook] = None
self.hook: rewrite.AssertionRewritingHook | None = None
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
@ -102,7 +129,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).
@ -112,18 +139,17 @@ def pytest_collection(session: "Session") -> None:
assertstate.hook.set_session(session)
@hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
@hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
"""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) -> Optional[str]:
def callbinrepr(op, left: object, right: object) -> str | None:
"""Call the pytest_assertrepr_compare hook and prepare the result.
This uses the first result from the hook and then ensures the
@ -162,13 +188,14 @@ def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
util._assertion_pass = call_assertion_pass_hook
yield
util._reprcompare, util._assertion_pass = saved_assert_hooks
util._config = None
try:
return (yield)
finally:
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:
@ -177,5 +204,5 @@ def pytest_sessionfinish(session: "Session") -> None:
def pytest_assertrepr_compare(
config: Config, op: str, left: Any, right: Any
) -> Optional[List[str]]:
) -> list[str] | None:
return util.assertrepr_compare(config=config, op=op, left=left, right=right)

View file

@ -1,5 +1,13 @@
"""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
@ -9,53 +17,46 @@ 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
from typing import Union
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 _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
@ -65,7 +66,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
@ -81,17 +82,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: Optional[Session] = None
self._rewritten_names: Dict[str, Path] = {}
self._must_rewrite: Set[str] = set()
self.session: Session | None = 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: Optional[Session]) -> None:
def set_session(self, session: Session | None) -> None:
self.session = session
self._session_paths_checked = False
@ -101,18 +102,28 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
def find_spec(
self,
name: str,
path: Optional[Sequence[Union[str, bytes]]] = None,
target: Optional[types.ModuleType] = None,
) -> Optional[importlib.machinery.ModuleSpec]:
path: Sequence[str | bytes] | None = None,
target: types.ModuleType | None = None,
) -> importlib.machinery.ModuleSpec | None:
if self._writing_pyc:
return None
state = self.config.stash[assertstate_key]
if self._early_rewrite_bailout(name, state):
return None
state.trace("find_module called for: %s" % name)
state.trace(f"find_module called for: {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
@ -140,7 +151,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
def create_module(
self, spec: importlib.machinery.ModuleSpec
) -> Optional[types.ModuleType]:
) -> types.ModuleType | None:
return None # default behaviour is fine
def exec_module(self, module: types.ModuleType) -> None:
@ -185,7 +196,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
@ -224,7 +235,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}")
@ -245,7 +256,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:
@ -281,31 +292,18 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
self.config.issue_config_time_warning(
PytestAssertRewriteWarning(
"Module already imported so cannot be rewritten: %s" % name
f"Module already imported so cannot be rewritten; {name}"
),
stacklevel=5,
)
def get_data(self, pathname: Union[str, bytes]) -> bytes:
def get_data(self, pathname: str | bytes) -> bytes:
"""Optional PEP302 get_data API."""
with open(pathname, "rb") as f:
return f.read()
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 get_resource_reader(self, name: str) -> TraversableResources:
return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) # type: ignore[arg-type]
def _write_pyc_fp(
@ -327,7 +325,7 @@ def _write_pyc_fp(
def _write_pyc(
state: "AssertionState",
state: AssertionState,
co: types.CodeType,
source_stat: os.stat_result,
pyc: Path,
@ -351,7 +349,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()
@ -364,7 +362,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
) -> Optional[types.CodeType]:
) -> types.CodeType | None:
"""Possibly read a pytest pyc containing rewritten code.
Return rewritten code if successful or None if not.
@ -384,21 +382,21 @@ def _read_pyc(
return None
# Check for invalid or out of date pyc file.
if len(data) != (16):
trace("_read_pyc(%s): invalid pyc (too short)" % source)
trace(f"_read_pyc({source}): invalid pyc (too short)")
return None
if data[:4] != importlib.util.MAGIC_NUMBER:
trace("_read_pyc(%s): invalid pyc (bad magic number)" % source)
trace(f"_read_pyc({source}): invalid pyc (bad magic number)")
return None
if data[4:8] != b"\x00\x00\x00\x00":
trace("_read_pyc(%s): invalid pyc (unsupported flags)" % source)
trace(f"_read_pyc({source}): invalid pyc (unsupported flags)")
return None
mtime_data = data[8:12]
if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF:
trace("_read_pyc(%s): out of date" % source)
trace(f"_read_pyc({source}): out of date")
return None
size_data = data[12:16]
if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF:
trace("_read_pyc(%s): invalid pyc (incorrect size)" % source)
trace(f"_read_pyc({source}): invalid pyc (incorrect size)")
return None
try:
co = marshal.load(fp)
@ -406,7 +404,7 @@ def _read_pyc(
trace(f"_read_pyc({source}): marshal.load error {e}")
return None
if not isinstance(co, types.CodeType):
trace("_read_pyc(%s): not a code object" % source)
trace(f"_read_pyc({source}): not a code object")
return None
return co
@ -414,8 +412,8 @@ def _read_pyc(
def rewrite_asserts(
mod: ast.Module,
source: bytes,
module_path: Optional[str] = None,
config: Optional[Config] = None,
module_path: str | None = None,
config: Config | None = None,
) -> None:
"""Rewrite the assert statements in mod."""
AssertionRewriter(module_path, config, source).run(mod)
@ -431,13 +429,22 @@ 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: Optional[Config]) -> Optional[int]:
def _get_maxsize_for_saferepr(config: Config | None) -> int | None:
"""Get `maxsize` configuration for saferepr based on the given config object."""
verbosity = config.getoption("verbose") if config is not None else 0
if config is None:
verbosity = 0
else:
verbosity = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
if verbosity >= 2:
return None
if verbosity >= 1:
@ -458,7 +465,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)
obj = saferepr(obj, _get_maxsize_for_saferepr(util._config))
replaces.append(("\\n", "\n~"))
for r1, r2 in replaces:
@ -469,7 +476,8 @@ def _format_assertmsg(obj: object) -> str:
def _should_repr_global_name(obj: object) -> bool:
if callable(obj):
return False
# For pytest fixtures the __repr__ method provides more information than the function name.
return isinstance(obj, FixtureFunctionDefinition)
try:
return not hasattr(obj, "__name__")
@ -478,7 +486,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("%", "%%")
@ -488,7 +496,7 @@ def _call_reprcompare(
expls: Sequence[str],
each_obj: Sequence[object],
) -> str:
for i, res, expl in zip(range(len(ops)), results, expls):
for i, res, expl in zip(range(len(ops)), results, expls, strict=True):
try:
done = not res
except Exception:
@ -550,14 +558,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: Optional[int] = None
seen_lines: Set[int] = set()
lines: list[str] = []
assert_lineno: int | None = None
seen_lines: set[int] = set()
def _write_and_reset() -> None:
nonlocal depth, lines, assert_lineno, seen_lines
@ -591,7 +599,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 escapd newline before message
# multi line assert with escaped newline before message
else:
lines.append(line[:offset])
_write_and_reset()
@ -664,7 +672,7 @@ class AssertionRewriter(ast.NodeVisitor):
"""
def __init__(
self, module_path: Optional[str], config: Optional[Config], source: bytes
self, module_path: str | None, config: Config | None, source: bytes
) -> None:
super().__init__()
self.module_path = module_path
@ -677,9 +685,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."""
@ -694,28 +702,18 @@ 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:
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
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
pos += 1
# Special case: for a decorated function, set the lineno to that of the
# first decorator, not the `def`. Issue #4984.
@ -724,21 +722,15 @@ class AssertionRewriter(ast.NodeVisitor):
else:
lineno = item.lineno
# Now actually insert the special imports.
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"),
]
aliases = [
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
ast.alias(
"_pytest.assertion.rewrite",
"@pytest_ar",
lineno=lineno,
col_offset=0,
),
]
imports = [
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
]
@ -746,10 +738,10 @@ class AssertionRewriter(ast.NodeVisitor):
# Collect asserts.
self.scope = (mod,)
nodes: List[Union[ast.AST, Sentinel]] = [mod]
nodes: list[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:
@ -758,7 +750,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.
@ -791,7 +783,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.Name(name, ast.Load())
return ast.copy_location(ast.Name(name, ast.Load()), expr)
def display(self, expr: ast.expr) -> ast.expr:
"""Call saferepr on the expression."""
@ -830,7 +822,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:
@ -844,7 +836,7 @@ class AssertionRewriter(ast.NodeVisitor):
current = self.stack.pop()
if self.stack:
self.explanation_specifiers = self.stack[-1]
keys = [astStr(key) for key in current.keys()]
keys: list[ast.expr | None] = [ast.Constant(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))
@ -853,13 +845,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
@ -868,9 +860,10 @@ class AssertionRewriter(ast.NodeVisitor):
the expression is false.
"""
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
from _pytest.warning_types import PytestAssertRewriteWarning
import warnings
from _pytest.warning_types import PytestAssertRewriteWarning
# TODO: This assert should not be needed.
assert self.module_path is not None
warnings.warn_explicit(
@ -882,15 +875,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)
@ -898,16 +891,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(astStr(explanation))
msg = self.pop_format_context(ast.Constant(explanation))
# Failed
if assert_.msg:
assertmsg = self.helper("_format_assertmsg", assert_.msg)
gluestr = "\n>assert "
else:
assertmsg = astStr("")
assertmsg = ast.Constant("")
gluestr = "assert "
err_explanation = ast.BinOp(astStr(gluestr), ast.Add(), msg)
err_explanation = ast.BinOp(ast.Constant(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)
@ -923,27 +916,27 @@ class AssertionRewriter(ast.NodeVisitor):
hook_call_pass = ast.Expr(
self.helper(
"_call_assertion_pass",
astNum(assert_.lineno),
astStr(orig),
ast.Constant(assert_.lineno),
ast.Constant(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 = [hook_impl_test]
statements_pass: list[ast.stmt] = [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 = [
variables: list[ast.expr] = [
ast.Name(name, ast.Store()) for name in self.format_variables
]
clear_format = ast.Assign(variables, astNameConstant(None))
clear_format = ast.Assign(variables, ast.Constant(None))
self.statements.append(clear_format)
else: # Original assertion rewriting
@ -954,9 +947,9 @@ class AssertionRewriter(ast.NodeVisitor):
assertmsg = self.helper("_format_assertmsg", assert_.msg)
explanation = "\n>assert " + explanation
else:
assertmsg = astStr("")
assertmsg = ast.Constant("")
explanation = "assert " + explanation
template = ast.BinOp(assertmsg, ast.Add(), astStr(explanation))
template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation))
msg = self.pop_format_context(template)
fmt = self.helper("_format_explanation", msg)
err_name = ast.Name("AssertionError", ast.Load())
@ -968,37 +961,40 @@ 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, astNameConstant(None))
clear = ast.Assign(variables, ast.Constant(None))
self.statements.append(clear)
# Fix locations (line numbers/column offsets).
for stmt in self.statements:
for node in traverse_node(stmt):
ast.copy_location(node, assert_)
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_)
return self.statements
def visit_NamedExpr(self, name: namedExpr) -> Tuple[namedExpr, str]:
def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.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 # type: ignore[attr-defined]
inlocs = ast.Compare(astStr(target_id), [ast.In()], [locs])
target_id = name.target.id
inlocs = ast.Compare(ast.Constant(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), astStr(target_id))
expr = ast.IfExp(test, self.display(name), ast.Constant(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(astStr(name.id), [ast.In()], [locs])
inlocs = ast.Compare(ast.Constant(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), astStr(name.id))
expr = ast.IfExp(test, self.display(name), ast.Constant(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())
@ -1010,60 +1006,57 @@ 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
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821
self.expl_stmts = fail_inner
# 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
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]
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(astStr(expl))
expl_format = self.pop_format_context(ast.Constant(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, astNum(is_or))
expl_template = self.helper("_format_boolop", expl_list, ast.Constant(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.UnaryOp(unary.op, operand_res))
res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary))
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.BinOp(left_expr, binop.op, right_expr))
res = self.assign(
ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop)
)
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 = []
@ -1072,19 +1065,16 @@ 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:
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]
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]
res, expl = self.visit(keyword.value)
new_kwargs.append(ast.keyword(keyword.arg, res))
if keyword.arg:
@ -1093,70 +1083,68 @@ class AssertionRewriter(ast.NodeVisitor):
arg_expls.append("**" + expl)
expl = "{}({})".format(func_expl, ", ".join(arg_expls))
new_call = ast.Call(new_func, new_args, new_kwargs)
new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call)
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.Attribute(value, attr.attr, ast.Load()))
res = self.assign(
ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr)
)
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
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]
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]
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 = [ast.Name(v, ast.Load()) for v in res_variables]
load_names: list[ast.expr] = [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)
expls = []
syms = []
it = zip(range(len(comp.ops)), comp.ops, comp.comparators, strict=True)
expls: list[ast.expr] = []
syms: list[ast.expr] = []
results = [left_res]
for i, op, next_operand in it:
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]
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]
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(astStr(sym))
syms.append(ast.Constant(sym))
expl = f"{left_expl} {sym} {next_expl}"
expls.append(astStr(expl))
res_expr = ast.Compare(left_res, [op], [next_res])
expls.append(ast.Constant(expl))
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
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.
@ -1191,7 +1179,10 @@ def try_makedirs(cache_dir: Path) -> bool:
return False
except OSError as e:
# as of now, EROFS doesn't have an equivalent OSError-subclass
if e.errno == errno.EROFS:
#
# squashfuse_ll returns ENOSYS "OSError: [Errno 38] Function not
# implemented" for a read-only error
if e.errno in {errno.EROFS, errno.ENOSYS}:
return False
raise
return True
@ -1199,7 +1190,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.version_info >= (3, 8) and sys.pycache_prefix:
if sys.pycache_prefix:
# given:
# prefix = '/tmp/pycs'
# path = '/home/user/proj/test_app.py'

View file

@ -1,51 +1,65 @@
"""Utilities for truncating assertion output.
Current default behaviour is to truncate assertion explanations at
~8 terminal lines, unless running in "-vv" mode or running on CI.
terminal lines, unless running with an assertions verbosity level of at least 2 or running on CI.
"""
from typing import List
from typing import Optional
from _pytest.assertion import util
from __future__ import annotations
from _pytest.compat import running_on_ci
from _pytest.config import Config
from _pytest.nodes import Item
DEFAULT_MAX_LINES = 8
DEFAULT_MAX_CHARS = 8 * 80
DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80
USAGE_MSG = "use '-vv' to show"
def truncate_if_required(
explanation: List[str], item: Item, max_length: Optional[int] = None
) -> List[str]:
def truncate_if_required(explanation: list[str], item: Item) -> list[str]:
"""Truncate this assertion explanation if the given test item is eligible."""
if _should_truncate_item(item):
return _truncate_explanation(explanation)
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,
)
return explanation
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 _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 _truncate_explanation(
input_lines: List[str],
max_lines: Optional[int] = None,
max_chars: Optional[int] = None,
) -> List[str]:
input_lines: list[str],
max_lines: int,
max_chars: int,
) -> list[str]:
"""Truncate given list of strings that makes up the assertion explanation.
Truncates to either 8 lines, or 640 characters - whichever the input reaches
Truncates to either max_lines, or max_chars - 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
@ -70,16 +84,23 @@ def _truncate_explanation(
):
return input_lines
# Truncate first to max_lines, and then truncate to max_chars if necessary
truncated_explanation = input_lines[:max_lines]
if max_lines > 0:
truncated_explanation = input_lines[:max_lines]
else:
truncated_explanation = input_lines
truncated_char = True
# We reevaluate the need to truncate chars following removal of some lines
if len("".join(truncated_explanation)) > tolerable_max_chars:
if len("".join(truncated_explanation)) > tolerable_max_chars and max_chars > 0:
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
@ -90,14 +111,15 @@ 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,36 +1,54 @@
# mypy: allow-untyped-defs
"""Utilities for assertion debugging."""
from __future__ import annotations
import collections.abc
import os
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 pprint
from typing import AbstractSet
from typing import Any
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 typing import Literal
from typing import Protocol
from unicodedata import normalize
import _pytest._code
from _pytest import outcomes
from _pytest._io.saferepr import _pformat_dispatch
import _pytest._code
from _pytest._io.pprint import PrettyPrinter
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: Optional[Callable[[str, object, object], Optional[str]]] = None
_reprcompare: Callable[[str, object, object], str | None] | None = None
# Works similarly as _reprcompare attribute. Is populated with the hook call
# when pytest_runtest_setup is called.
_assertion_pass: Optional[Callable[[int, str, str], None]] = None
_assertion_pass: Callable[[int, str, str], None] | None = None
# Config object which is assigned during pytest_runtest_protocol.
_config: Optional[Config] = None
_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
def format_explanation(explanation: str) -> str:
@ -48,7 +66,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~'.
@ -65,7 +83,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
@ -113,7 +131,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:
@ -132,7 +150,7 @@ def isiterable(obj: Any) -> bool:
try:
iter(obj)
return not istext(obj)
except TypeError:
except Exception:
return False
@ -151,7 +169,7 @@ def has_default_eq(
code_filename = obj.__eq__.__code__.co_filename
if isattrs(obj):
return "attrs generated eq" in code_filename
return "attrs generated " in code_filename
return code_filename == "<string>" # data class
return True
@ -159,9 +177,9 @@ def has_default_eq(
def assertrepr_compare(
config, op: str, left: Any, right: Any, use_ascii: bool = False
) -> Optional[List[str]]:
) -> list[str] | None:
"""Return specialised explanations for some operators/operands."""
verbose = config.getoption("verbose")
verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
# See issue #3246.
@ -185,34 +203,54 @@ 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, verbose)
explanation = _compare_eq_any(left, right, highlighter, 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 = [
"(pytest_assertion plugin: representation of details failed: {}.".format(
_pytest._code.ExceptionInfo.from_current()._getreprcrash()
),
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
" Probably an object has a faulty __repr__.)",
]
if not explanation:
return None
return [summary] + explanation
if explanation[0] != "":
explanation = ["", *explanation]
return [summary, *explanation]
def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
def _compare_eq_any(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
) -> list[str]:
explanation = []
if istext(left) and istext(right):
explanation = _diff_text(left, right, verbose)
explanation = _diff_text(left, right, highlighter, verbose)
else:
from _pytest.python_api import ApproxBase
@ -222,29 +260,31 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
other_side = right if isinstance(left, ApproxBase) else left
explanation = approx_side._repr_compare(other_side)
elif type(left) == type(right) and (
elif type(left) is 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, verbose)
explanation = _compare_eq_cls(left, right, highlighter, verbose)
elif issequence(left) and issequence(right):
explanation = _compare_eq_sequence(left, right, verbose)
explanation = _compare_eq_sequence(left, right, highlighter, verbose)
elif isset(left) and isset(right):
explanation = _compare_eq_set(left, right, verbose)
explanation = _compare_eq_set(left, right, highlighter, verbose)
elif isdict(left) and isdict(right):
explanation = _compare_eq_dict(left, right, verbose)
explanation = _compare_eq_dict(left, right, highlighter, verbose)
if isiterable(left) and isiterable(right):
expl = _compare_eq_iterable(left, right, verbose)
expl = _compare_eq_iterable(left, right, highlighter, verbose)
explanation.extend(expl)
return explanation
def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
def _diff_text(
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
) -> list[str]:
"""Return the explanation for the diff between text.
Unless --verbose is used this will skip leading and trailing
@ -252,7 +292,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
"""
from difflib import ndiff
explanation: List[str] = []
explanation: list[str] = []
if verbose < 1:
i = 0 # just in case left or right has zero length
@ -262,7 +302,7 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
if i > 42:
i -= 10 # Provide some context
explanation = [
"Skipping %s identical leading characters in diff, use -v to show" % i
f"Skipping {i} identical leading characters in diff, use -v to show"
]
left = left[i:]
right = right[i:]
@ -273,8 +313,8 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
if i > 42:
i -= 10 # Provide some context
explanation += [
"Skipping {} identical trailing "
"characters in diff, use -v to show".format(i)
f"Skipping {i} identical trailing "
"characters in diff, use -v to show"
]
left = left[:-i]
right = right[:-i]
@ -285,61 +325,55 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
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 += [
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
]
explanation.extend(
highlighter(
"\n".join(
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
),
lexer="diff",
).splitlines()
)
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], verbose: int = 0
) -> List[str]:
left: Iterable[Any],
right: Iterable[Any],
highlighter: _HighlightFunc,
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 = pprint.pformat(left).splitlines()
right_formatting = pprint.pformat(right).splitlines()
left_formatting = PrettyPrinter().pformat(left).splitlines()
right_formatting = PrettyPrinter().pformat(right).splitlines()
# 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:"]
explanation = ["", "Full diff:"]
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
explanation.extend(
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
highlighter(
"\n".join(
line.rstrip()
for line in difflib.ndiff(right_formatting, left_formatting)
),
lexer="diff",
).splitlines()
)
return explanation
def _compare_eq_sequence(
left: Sequence[Any], right: Sequence[Any], verbose: int = 0
) -> List[str]:
left: Sequence[Any],
right: Sequence[Any],
highlighter: _HighlightFunc,
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)):
@ -359,7 +393,10 @@ def _compare_eq_sequence(
left_value = left[i]
right_value = right[i]
explanation += [f"At index {i} diff: {left_value!r} != {right_value!r}"]
explanation.append(
f"At index {i} diff:"
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
)
break
if comparing_bytes:
@ -379,74 +416,134 @@ def _compare_eq_sequence(
extra = saferepr(right[len_left])
if len_diff == 1:
explanation += [f"{dir_with_more} contains one more item: {extra}"]
explanation += [
f"{dir_with_more} contains one more item: {highlighter(extra)}"
]
else:
explanation += [
"%s contains %d more items, first extra item: %s"
% (dir_with_more, len_diff, extra)
f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
]
return explanation
def _compare_eq_set(
left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0
) -> List[str]:
left: AbstractSet[Any],
right: AbstractSet[Any],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = []
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))
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)))
return explanation
def _compare_eq_dict(
left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0
) -> List[str]:
explanation: List[str] = []
left: Mapping[Any, Any],
right: Mapping[Any, Any],
highlighter: _HighlightFunc,
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 += ["Omitting %s identical items, use -vv to show" % len(same)]
explanation += [f"Omitting {len(same)} identical items, use -vv to show"]
elif same:
explanation += ["Common items:"]
explanation += pprint.pformat(same).splitlines()
explanation += highlighter(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 += [saferepr({k: left[k]}) + " != " + saferepr({k: right[k]})]
explanation += [
highlighter(saferepr({k: left[k]}))
+ " != "
+ highlighter(saferepr({k: right[k]}))
]
extra_left = set_left - set_right
len_extra_left = len(extra_left)
if len_extra_left:
explanation.append(
"Left contains %d more item%s:"
% (len_extra_left, "" if len_extra_left == 1 else "s")
f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
)
explanation.extend(
pprint.pformat({k: left[k] for k in extra_left}).splitlines()
highlighter(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(
"Right contains %d more item%s:"
% (len_extra_right, "" if len_extra_right == 1 else "s")
f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
)
explanation.extend(
pprint.pformat({k: right[k] for k in extra_right}).splitlines()
highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines()
)
return explanation
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
def _compare_eq_cls(
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
) -> list[str]:
if not has_default_eq(left):
return []
if isdatacls(left):
@ -475,35 +572,37 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
if same or diff:
explanation += [""]
if same and verbose < 2:
explanation.append("Omitting %s identical items, use -vv to show" % len(same))
explanation.append(f"Omitting {len(same)} identical items, use -vv to show")
elif same:
explanation += ["Matching attributes:"]
explanation += pprint.pformat(same).splitlines()
explanation += highlighter(pprint.pformat(same)).splitlines()
if diff:
explanation += ["Differing attributes:"]
explanation += pprint.pformat(diff).splitlines()
explanation += highlighter(pprint.pformat(diff)).splitlines()
for field in diff:
field_left = getattr(left, field)
field_right = getattr(right, field)
explanation += [
"",
"Drill down into differing attribute %s:" % field,
("%s%s: %r != %r") % (indent, field, field_left, field_right),
f"Drill down into differing attribute {field}:",
f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}",
]
explanation += [
indent + line
for line in _compare_eq_any(field_left, field_right, verbose)
for line in _compare_eq_any(
field_left, field_right, highlighter, 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, verbose)
newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)]
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"]
for line in diff:
if line.startswith("Skipping"):
continue
@ -514,9 +613,3 @@ 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,24 +1,25 @@
# 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
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
import tempfile
from typing import final
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
@ -27,10 +28,11 @@ 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 #
@ -72,7 +74,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:
@ -111,6 +113,7 @@ class Cache:
"""
check_ispytest(_ispytest)
import warnings
from _pytest.warning_types import PytestCacheWarning
warnings.warn(
@ -119,6 +122,10 @@ 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.
@ -137,7 +144,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)
res.mkdir(exist_ok=True, parents=True)
self._mkdir(res)
return res
def _getvaluepath(self, key: str) -> Path:
@ -174,19 +181,13 @@ class Cache:
"""
path = self._getvaluepath(key)
try:
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)
self._mkdir(path.parent)
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")
@ -199,60 +200,85 @@ class Cache:
with f:
f.write(data)
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")
def _ensure_cache_dir_and_supporting_files(self) -> None:
"""Create the cache dir and its supporting files."""
if self._cachedir.is_dir():
return
gitignore_path = self._cachedir.joinpath(".gitignore")
msg = "# Created by pytest automatically.\n*\n"
gitignore_path.write_text(msg, 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)
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG")
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
# 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()
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(hookwrapper=True)
def pytest_make_collect_report(self, collector: nodes.Collector):
if isinstance(collector, (Session, Package)):
out = yield
res: CollectReport = out.get_result()
@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: nodes.Collector
) -> Generator[None, CollectReport, CollectReport]:
res = yield
if isinstance(collector, Session | Directory):
# Sort any lf-paths to the beginning.
lf_paths = self.lfplugin._last_failed_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
# Use stable sort to prioritize last failed.
def sort_key(node: nodes.Item | nodes.Collector) -> bool:
return node.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
return res
self.lfplugin.config.pluginmanager.register(
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
)
@ -268,21 +294,19 @@ class LFPluginCollWrapper:
# Keep all sub-collectors.
or isinstance(x, nodes.Collector)
]
return
yield
return res
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
) -> 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):
) -> CollectReport | None:
if isinstance(collector, File):
if collector.path not in self.lfplugin._last_failed_paths:
self.lfplugin._skipped_files += 1
@ -300,9 +324,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: Optional[int] = None
self._report_status: Optional[str] = None
self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {})
self._previously_failed_count: int | None = None
self._report_status: str | None = None
self._skipped_files = 0 # count skipped files during collection due to --lf
if config.getoption("lf"):
@ -311,7 +335,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
@ -322,9 +346,9 @@ class LFPlugin:
result.update(path.parents)
return {x for x in result if x.exists()}
def pytest_report_collectionfinish(self) -> Optional[str]:
if self.active and self.config.getoption("verbose") >= 0:
return "run-last-failure: %s" % self._report_status
def pytest_report_collectionfinish(self) -> str | None:
if self.active and self.config.get_verbosity() >= 0:
return f"run-last-failure: {self._report_status}"
return None
def pytest_runtest_logreport(self, report: TestReport) -> None:
@ -342,14 +366,14 @@ class LFPlugin:
else:
self.lastfailed[report.nodeid] = True
@hookimpl(hookwrapper=True, tryfirst=True)
@hookimpl(wrapper=True, tryfirst=True)
def pytest_collection_modifyitems(
self, config: Config, items: List[nodes.Item]
) -> Generator[None, None, None]:
yield
self, config: Config, items: list[nodes.Item]
) -> Generator[None]:
res = yield
if not self.active:
return
return res
if self.lastfailed:
previously_failed = []
@ -364,8 +388,8 @@ class LFPlugin:
if not previously_failed:
# Running a subset of all tests with recorded failures
# only outside of it.
self._report_status = "%d known failures not in selected tests" % (
len(self.lastfailed),
self._report_status = (
f"{len(self.lastfailed)} known failures not in selected tests"
)
else:
if self.config.getoption("lf"):
@ -376,15 +400,13 @@ class LFPlugin:
noun = "failure" if self._previously_failed_count == 1 else "failures"
suffix = " first" if self.config.getoption("failedfirst") else ""
self._report_status = "rerun previous {count} {noun}{suffix}".format(
count=self._previously_failed_count, suffix=suffix, noun=noun
self._report_status = (
f"rerun previous {self._previously_failed_count} {noun}{suffix}"
)
if self._skipped_files > 0:
files_noun = "file" if self._skipped_files == 1 else "files"
self._report_status += " (skipped {files} {files_noun})".format(
files=self._skipped_files, files_noun=files_noun
)
self._report_status += f" (skipped {self._skipped_files} {files_noun})"
else:
self._report_status = "no previously failed tests, "
if self.config.getoption("last_failed_no_failures") == "none":
@ -394,6 +416,8 @@ 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"):
@ -414,15 +438,13 @@ class NFPlugin:
assert config.cache is not None
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
@hookimpl(hookwrapper=True, tryfirst=True)
def pytest_collection_modifyitems(
self, items: List[nodes.Item]
) -> Generator[None, None, None]:
yield
@hookimpl(wrapper=True, tryfirst=True)
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]:
res = 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
@ -436,8 +458,10 @@ class NFPlugin:
else:
self.cached_nodeids.update(item.nodeid for item in items)
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]
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 pytest_sessionfinish(self) -> None:
config = self.config
@ -452,14 +476,17 @@ 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",
@ -513,7 +540,7 @@ def pytest_addoption(parser: Parser) -> None:
)
def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
if config.option.cacheshow and not config.option.help:
from _pytest.main import wrap_session
@ -523,6 +550,13 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
@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")
@ -544,7 +578,7 @@ def cache(request: FixtureRequest) -> Cache:
return request.config.cache
def pytest_report_header(config: Config) -> Optional[str]:
def pytest_report_header(config: Config) -> str | None:
"""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
@ -561,6 +595,16 @@ def pytest_report_header(config: Config) -> Optional[str]:
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
@ -578,25 +622,25 @@ def cacheshow(config: Config, session: Session) -> int:
dummy = object()
basedir = config.cache._cachedir
vdir = basedir / Cache._CACHE_PREFIX_VALUES
tw.sep("-", "cache values for %r" % glob)
tw.sep("-", f"cache values for {glob!r}")
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("%s contains unreadable content, will be ignored" % key)
tw.line(f"{key} contains unreadable content, will be ignored")
else:
tw.line("%s contains:" % key)
tw.line(f"{key} contains:")
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("-", "cache directories for %r" % glob)
tw.sep("-", f"cache directories for {glob!r}")
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:d}")
tw.line(f"{key} is a file of length {p.stat().st_size}")
return 0

View file

@ -1,30 +1,36 @@
# 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 Generator
from typing import cast
from typing import Final
from typing import final
from typing import Generic
from typing import Iterable
from typing import Iterator
from typing import List
from typing import Literal
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
from _pytest.compat import final
if TYPE_CHECKING:
from typing_extensions import Self
from _pytest.config import Config
from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
@ -34,17 +40,15 @@ 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",
@ -52,7 +56,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(
group._addoption( # private to use reserved lower-case short option
"-s",
action="store_const",
const="no",
@ -76,6 +80,23 @@ 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.
@ -104,17 +125,16 @@ 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]
if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore]
return
buffered = hasattr(stream.buffer, "raw")
raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined]
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined]
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
return
def _reopen_stdio(f, mode):
if not buffered and mode[0] == "w":
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
buffering = 0
else:
buffering = -1
@ -132,12 +152,13 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
sys.stderr = _reopen_stdio(sys.stderr, "wb")
@hookimpl(hookwrapper=True)
def pytest_load_initial_conftests(early_config: Config):
@hookimpl(wrapper=True)
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
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")
@ -147,12 +168,16 @@ def pytest_load_initial_conftests(early_config: Config):
# Finally trigger conftest loading but while capturing (issue #93).
capman.start_global_capturing()
outcome = yield
capman.suspend_global_capture()
if outcome.excinfo is not None:
try:
try:
yield
finally:
capman.suspend_global_capture()
except BaseException:
out, err = capman.read_global_capture()
sys.stdout.write(out)
sys.stderr.write(err)
raise
# IO Helpers.
@ -171,7 +196,8 @@ class EncodedFile(io.TextIOWrapper):
def mode(self) -> str:
# TextIOWrapper doesn't expose a mode, but at least some of our
# tests check it.
return self.buffer.mode.replace("b", "")
assert hasattr(self.buffer, "mode")
return cast(str, self.buffer.mode.replace("b", ""))
class CaptureIO(io.TextIOWrapper):
@ -196,6 +222,7 @@ 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:
@ -208,7 +235,7 @@ class DontReadFromInput(TextIO):
def __next__(self) -> str:
return self.readline()
def readlines(self, hint: Optional[int] = -1) -> List[str]:
def readlines(self, hint: int | None = -1) -> list[str]:
raise OSError(
"pytest: reading from stdin while output is captured! Consider using `-s`."
)
@ -240,7 +267,7 @@ class DontReadFromInput(TextIO):
def tell(self) -> int:
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
def truncate(self, size: Optional[int] = None) -> int:
def truncate(self, size: int | None = None) -> int:
raise UnsupportedOperation("cannot truncate stdin")
def write(self, data: str) -> int:
@ -252,14 +279,14 @@ class DontReadFromInput(TextIO):
def writable(self) -> bool:
return False
def __enter__(self) -> "DontReadFromInput":
def __enter__(self) -> Self:
return self
def __exit__(
self,
type: Optional[Type[BaseException]],
value: Optional[BaseException],
traceback: Optional[TracebackType],
type: type[BaseException] | None,
value: BaseException | None,
traceback: TracebackType | None,
) -> None:
pass
@ -334,7 +361,7 @@ class NoCapture(CaptureBase[str]):
class SysCaptureBase(CaptureBase[AnyStr]):
def __init__(
self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
) -> None:
name = patchsysdict[fd]
self._old: TextIO = getattr(sys, name)
@ -351,7 +378,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,
)
@ -360,16 +387,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:
@ -452,7 +479,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: Optional[int] = os.open(os.devnull, os.O_RDWR)
self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR)
os.dup2(self.targetfd_invalid, targetfd)
else:
self.targetfd_invalid = None
@ -477,19 +504,16 @@ class FDCaptureBase(CaptureBase[AnyStr]):
self._state = "initialized"
def __repr__(self) -> str:
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
self.__class__.__name__,
self.targetfd,
self.targetfd_save,
self._state,
self.tmpfile,
return (
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
)
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:
@ -546,7 +570,7 @@ class FDCaptureBinary(FDCaptureBase[bytes]):
res = self.tmpfile.buffer.read()
self.tmpfile.seek(0)
self.tmpfile.truncate()
return res
return res # type: ignore[return-value]
def writeorg(self, data: bytes) -> None:
"""Write to original file descriptor."""
@ -585,7 +609,7 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
@final
class CaptureResult(NamedTuple, Generic[AnyStr]):
"""The result of :method:`CaptureFixture.readouterr`."""
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
out: AnyStr
err: AnyStr
@ -593,9 +617,10 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
else:
class CaptureResult(
collections.namedtuple("CaptureResult", ["out", "err"]), Generic[AnyStr]
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
Generic[AnyStr],
):
"""The result of :method:`CaptureFixture.readouterr`."""
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
__slots__ = ()
@ -606,21 +631,18 @@ class MultiCapture(Generic[AnyStr]):
def __init__(
self,
in_: Optional[CaptureBase[AnyStr]],
out: Optional[CaptureBase[AnyStr]],
err: Optional[CaptureBase[AnyStr]],
in_: CaptureBase[AnyStr] | None,
out: CaptureBase[AnyStr] | None,
err: CaptureBase[AnyStr] | None,
) -> None:
self.in_: Optional[CaptureBase[AnyStr]] = in_
self.out: Optional[CaptureBase[AnyStr]] = out
self.err: Optional[CaptureBase[AnyStr]] = err
self.in_: CaptureBase[AnyStr] | None = in_
self.out: CaptureBase[AnyStr] | None = out
self.err: CaptureBase[AnyStr] | None = err
def __repr__(self) -> str:
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,
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}>"
)
def start_capturing(self) -> None:
@ -632,7 +654,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:
@ -687,7 +709,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":
@ -723,21 +745,22 @@ 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: Optional[MultiCapture[str]] = None
self._capture_fixture: Optional[CaptureFixture[Any]] = None
self._global_capturing: MultiCapture[str] | None = None
self._capture_fixture: CaptureFixture[Any] | None = None
def __repr__(self) -> str:
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
self._method, self._global_capturing, self._capture_fixture
return (
f"<CaptureManager _method={self._method!r} _global_capturing={self._global_capturing!r} "
f"_capture_fixture={self._capture_fixture!r}>"
)
def is_capturing(self) -> Union[str, bool]:
def is_capturing(self) -> str | bool:
if self.is_globally_capturing():
return "global"
if self._capture_fixture:
return "fixture %s" % self._capture_fixture.request.fixturename
return f"fixture {self._capture_fixture.request.fixturename}"
return False
# Global capturing control
@ -781,14 +804,12 @@ 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(
"cannot use {} and {} at the same time".format(
requested_fixture, current_fixture
)
f"cannot use {requested_fixture} and {current_fixture} at the same time"
)
self._capture_fixture = capture_fixture
@ -817,7 +838,7 @@ class CaptureManager:
# Helper context managers
@contextlib.contextmanager
def global_and_fixture_disabled(self) -> Generator[None, None, None]:
def global_and_fixture_disabled(self) -> Generator[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:
@ -834,7 +855,7 @@ class CaptureManager:
self.resume_fixture()
@contextlib.contextmanager
def item_capture(self, when: str, item: Item) -> Generator[None, None, None]:
def item_capture(self, when: str, item: Item) -> Generator[None]:
self.resume_global_capture()
self.activate_fixture()
try:
@ -843,41 +864,45 @@ 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(hookwrapper=True)
def pytest_make_collect_report(self, collector: Collector):
@hookimpl(wrapper=True)
def pytest_make_collect_report(
self, collector: Collector
) -> Generator[None, CollectReport, CollectReport]:
if isinstance(collector, File):
self.resume_global_capture()
outcome = yield
self.suspend_global_capture()
try:
rep = yield
finally:
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:
yield
rep = yield
return rep
@hookimpl(hookwrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
@hookimpl(wrapper=True)
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
with self.item_capture("setup", item):
yield
return (yield)
@hookimpl(hookwrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
@hookimpl(wrapper=True)
def pytest_runtest_call(self, item: Item) -> Generator[None]:
with self.item_capture("call", item):
yield
return (yield)
@hookimpl(hookwrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
@hookimpl(wrapper=True)
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
with self.item_capture("teardown", item):
yield
return (yield)
@hookimpl(tryfirst=True)
def pytest_keyboard_interrupt(self) -> None:
@ -894,15 +919,17 @@ 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._capture: Optional[MultiCapture[AnyStr]] = None
self._config = config if config else {}
self._capture: MultiCapture[AnyStr] | None = None
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
@ -910,8 +937,8 @@ class CaptureFixture(Generic[AnyStr]):
if self._capture is None:
self._capture = MultiCapture(
in_=None,
out=self.captureclass(1),
err=self.captureclass(2),
out=self.captureclass(1, **self._config),
err=self.captureclass(2, **self._config),
)
self._capture.start_capturing()
@ -957,7 +984,7 @@ class CaptureFixture(Generic[AnyStr]):
return False
@contextlib.contextmanager
def disabled(self) -> Generator[None, None, None]:
def disabled(self) -> Generator[None]:
"""Temporarily disable capturing while inside the ``with`` block."""
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
"capturemanager"
@ -970,7 +997,7 @@ class CaptureFixture(Generic[AnyStr]):
@fixture
def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsys.readouterr()`` method
@ -998,7 +1025,42 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
@fixture
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
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]]:
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
The captured output is made available via ``capsysbinary.readouterr()``
@ -1026,7 +1088,7 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None,
@fixture
def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
The captured output is made available via ``capfd.readouterr()`` method
@ -1054,7 +1116,7 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
@fixture
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None, None]:
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
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,41 +1,28 @@
"""Python version compatibility code."""
# mypy: allow-untyped-defs
"""Python version compatibility code and random general utilities."""
from __future__ import annotations
import dataclasses
from collections.abc import Callable
import enum
import functools
import inspect
import os
import sys
from inspect import Parameter
from inspect import signature
from inspect import Signature
import os
from pathlib import Path
import sys
from typing import Any
from typing import Callable
from typing import Generic
from typing import Final
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 TYPE_CHECKING:
from typing_extensions import Final
if sys.version_info >= (3, 14):
from annotationlib import Format
_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
@ -55,32 +42,16 @@ 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 # noqa: E305
NOTSET: Final = NotSetType.token
# 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 couroutines.py to avoid
Note: copied and modified from Python 3.5's builtin coroutines.py to avoid
importing asyncio directly, which in turns also initializes the "logging"
module as a side-effect (see issue #8).
"""
@ -93,7 +64,14 @@ def is_async_function(func: object) -> bool:
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
def getlocation(function, curdir: str | None = None) -> str:
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:
function = get_real_func(function)
fn = Path(inspect.getfile(function))
lineno = function.__code__.co_firstlineno
@ -103,8 +81,8 @@ def getlocation(function, curdir: str | None = None) -> str:
except ValueError:
pass
else:
return "%s:%d" % (relfn, lineno + 1)
return "%s:%d" % (fn, lineno + 1)
return f"{relfn}:{lineno + 1}"
return f"{fn}:{lineno + 1}"
def num_mock_patch_args(function) -> int:
@ -127,10 +105,9 @@ def num_mock_patch_args(function) -> int:
def getfuncargnames(
function: Callable[..., Any],
function: Callable[..., object],
*,
name: str = "",
is_method: bool = False,
cls: type | None = None,
) -> tuple[str, ...]:
"""Return the names of a function's mandatory arguments.
@ -141,9 +118,8 @@ def getfuncargnames(
* Aren't bound with functools.partial.
* Aren't replaced with mocks.
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 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 name parameter should be the original name in which the function was collected.
"""
@ -157,7 +133,7 @@ def getfuncargnames(
# creates a tuple of the names of the parameters that don't have
# defaults.
try:
parameters = signature(function).parameters
parameters = signature(function).parameters.values()
except (ValueError, TypeError) as e:
from _pytest.outcomes import fail
@ -168,7 +144,7 @@ def getfuncargnames(
arg_names = tuple(
p.name
for p in parameters.values()
for p in parameters
if (
p.kind is Parameter.POSITIONAL_OR_KEYWORD
or p.kind is Parameter.KEYWORD_ONLY
@ -179,9 +155,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, remove the first
# parameter name.
if is_method or (
# 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 (
# 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
@ -216,25 +192,13 @@ _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 unicode objects into a sequence of escaped unicode
ids, e.g.:
and escapes strings into a sequence of escaped unicode ids, e.g.:
r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944'
@ -245,67 +209,22 @@ def ascii_escaped(val: bytes | str) -> str:
a UTF-8 string.
"""
if isinstance(val, bytes):
ret = _bytes_to_ascii(val)
ret = val.decode("ascii", "backslashreplace")
else:
ret = val.encode("unicode_escape").decode("ascii")
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
return ret.translate(_non_printable_ascii_translate_table)
def get_real_func(obj):
"""Get the real function object of the (possibly) wrapped object by
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
:func:`functools.wraps`, or :func:`functools.partial`."""
obj = inspect.unwrap(obj)
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__
@ -338,47 +257,6 @@ 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.
@ -400,36 +278,37 @@ def get_user_id() -> int | None:
return uid if uid != ERROR else None
# 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__})"
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)

File diff suppressed because it is too large Load diff

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