feat: complete Phase 2 core components (Camoufox & CurlClient)
This commit is contained in:
parent
a15ca58ef8
commit
1134da7ed3
1273 changed files with 206095 additions and 18491 deletions
|
|
@ -1,6 +1,8 @@
|
||||||
redis==5.0.1
|
redis==5.0.1
|
||||||
msgpack==1.0.7
|
msgpack==1.0.7
|
||||||
curl_cffi==0.5.10
|
curl_cffi>=0.6.0
|
||||||
playwright==1.40.0
|
playwright==1.40.0
|
||||||
pydantic==2.5.3
|
pydantic==2.5.3
|
||||||
pytest==7.4.3
|
pytest>=8.0.0
|
||||||
|
pytest-asyncio>=0.23.0
|
||||||
|
aiohttp==3.9.1
|
||||||
|
|
|
||||||
BIN
src/browser/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
src/browser/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
src/browser/__pycache__/manager.cpython-310.pyc
Normal file
BIN
src/browser/__pycache__/manager.cpython-310.pyc
Normal file
Binary file not shown.
BIN
src/extractor/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
src/extractor/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
src/extractor/__pycache__/client.cpython-310.pyc
Normal file
BIN
src/extractor/__pycache__/client.cpython-310.pyc
Normal file
Binary file not shown.
|
|
@ -58,7 +58,7 @@ class CurlClient:
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self.session:
|
if self.session:
|
||||||
self.session.close()
|
await self.session.close()
|
||||||
|
|
||||||
async def fetch(self, url: str) -> str:
|
async def fetch(self, url: str) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
BIN
tests/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
tests/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/test_handover.cpython-310-pytest-7.4.3.pyc
Normal file
BIN
tests/e2e/__pycache__/test_handover.cpython-310-pytest-7.4.3.pyc
Normal file
Binary file not shown.
BIN
tests/e2e/__pycache__/test_handover.cpython-310-pytest-9.0.2.pyc
Normal file
BIN
tests/e2e/__pycache__/test_handover.cpython-310-pytest-9.0.2.pyc
Normal file
Binary file not shown.
8
venv/bin/curl-cffi
Executable file
8
venv/bin/curl-cffi
Executable 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
8
venv/bin/pygmentize
Executable 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())
|
||||||
Binary file not shown.
|
|
@ -1,9 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["__version__", "version_tuple"]
|
__all__ = ["__version__", "version_tuple"]
|
||||||
|
|
||||||
try:
|
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
|
except ImportError: # pragma: no cover
|
||||||
# broken installation, we don't even try
|
# broken installation, we don't even try
|
||||||
# unknown only works because we do poor mans version compare
|
# unknown only works because we do poor mans version compare
|
||||||
__version__ = "unknown"
|
__version__ = "unknown"
|
||||||
version_tuple = (0, 0, "unknown") # type:ignore[assignment]
|
version_tuple = (0, 0, "unknown")
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -61,13 +61,14 @@ If things do not work right away:
|
||||||
which should throw a KeyError: 'COMPLINE' (which is properly set by the
|
which should throw a KeyError: 'COMPLINE' (which is properly set by the
|
||||||
global argcomplete script).
|
global argcomplete script).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from glob import glob
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from glob import glob
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class FastFilesCompleter:
|
class FastFilesCompleter:
|
||||||
|
|
@ -76,7 +77,7 @@ class FastFilesCompleter:
|
||||||
def __init__(self, directories: bool = True) -> None:
|
def __init__(self, directories: bool = True) -> None:
|
||||||
self.directories = directories
|
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.
|
# Only called on non option completions.
|
||||||
if os.sep in prefix[1:]:
|
if os.sep in prefix[1:]:
|
||||||
prefix_dir = len(os.path.dirname(prefix) + os.sep)
|
prefix_dir = len(os.path.dirname(prefix) + os.sep)
|
||||||
|
|
@ -103,7 +104,7 @@ if os.environ.get("_ARGCOMPLETE"):
|
||||||
import argcomplete.completers
|
import argcomplete.completers
|
||||||
except ImportError:
|
except ImportError:
|
||||||
sys.exit(-1)
|
sys.exit(-1)
|
||||||
filescompleter: Optional[FastFilesCompleter] = FastFilesCompleter()
|
filescompleter: FastFilesCompleter | None = FastFilesCompleter()
|
||||||
|
|
||||||
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
|
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
|
||||||
argcomplete.autocomplete(parser, always_complete_options=False)
|
argcomplete.autocomplete(parser, always_complete_options=False)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
"""Python inspection/code generation API."""
|
"""Python inspection/code generation API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from .code import Code
|
from .code import Code
|
||||||
from .code import ExceptionInfo
|
from .code import ExceptionInfo
|
||||||
from .code import filter_traceback
|
from .code import filter_traceback
|
||||||
|
|
@ -9,14 +12,15 @@ from .code import TracebackEntry
|
||||||
from .source import getrawcode
|
from .source import getrawcode
|
||||||
from .source import Source
|
from .source import Source
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Code",
|
"Code",
|
||||||
"ExceptionInfo",
|
"ExceptionInfo",
|
||||||
"filter_traceback",
|
|
||||||
"Frame",
|
"Frame",
|
||||||
"getfslineno",
|
"Source",
|
||||||
"getrawcode",
|
|
||||||
"Traceback",
|
"Traceback",
|
||||||
"TracebackEntry",
|
"TracebackEntry",
|
||||||
"Source",
|
"filter_traceback",
|
||||||
|
"getfslineno",
|
||||||
|
"getrawcode",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,16 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
|
from bisect import bisect_right
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Iterator
|
||||||
import inspect
|
import inspect
|
||||||
import textwrap
|
import textwrap
|
||||||
import tokenize
|
import tokenize
|
||||||
import types
|
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 overload
|
||||||
from typing import Tuple
|
import warnings
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
|
|
||||||
class Source:
|
class Source:
|
||||||
|
|
@ -22,13 +21,17 @@ class Source:
|
||||||
|
|
||||||
def __init__(self, obj: object = None) -> None:
|
def __init__(self, obj: object = None) -> None:
|
||||||
if not obj:
|
if not obj:
|
||||||
self.lines: List[str] = []
|
self.lines: list[str] = []
|
||||||
|
self.raw_lines: list[str] = []
|
||||||
elif isinstance(obj, Source):
|
elif isinstance(obj, Source):
|
||||||
self.lines = obj.lines
|
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.lines = deindent(x.rstrip("\n") for x in obj)
|
||||||
|
self.raw_lines = list(x.rstrip("\n") for x in obj)
|
||||||
elif isinstance(obj, str):
|
elif isinstance(obj, str):
|
||||||
self.lines = deindent(obj.split("\n"))
|
self.lines = deindent(obj.split("\n"))
|
||||||
|
self.raw_lines = obj.split("\n")
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
rawcode = getrawcode(obj)
|
rawcode = getrawcode(obj)
|
||||||
|
|
@ -36,6 +39,7 @@ class Source:
|
||||||
except TypeError:
|
except TypeError:
|
||||||
src = inspect.getsource(obj) # type: ignore[arg-type]
|
src = inspect.getsource(obj) # type: ignore[arg-type]
|
||||||
self.lines = deindent(src.split("\n"))
|
self.lines = deindent(src.split("\n"))
|
||||||
|
self.raw_lines = src.split("\n")
|
||||||
|
|
||||||
def __eq__(self, other: object) -> bool:
|
def __eq__(self, other: object) -> bool:
|
||||||
if not isinstance(other, Source):
|
if not isinstance(other, Source):
|
||||||
|
|
@ -46,14 +50,12 @@ class Source:
|
||||||
__hash__ = None # type: ignore
|
__hash__ = None # type: ignore
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def __getitem__(self, key: int) -> str:
|
def __getitem__(self, key: int) -> str: ...
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
@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):
|
if isinstance(key, int):
|
||||||
return self.lines[key]
|
return self.lines[key]
|
||||||
else:
|
else:
|
||||||
|
|
@ -61,6 +63,7 @@ class Source:
|
||||||
raise IndexError("cannot slice a Source with a step")
|
raise IndexError("cannot slice a Source with a step")
|
||||||
newsource = Source()
|
newsource = Source()
|
||||||
newsource.lines = self.lines[key.start : key.stop]
|
newsource.lines = self.lines[key.start : key.stop]
|
||||||
|
newsource.raw_lines = self.raw_lines[key.start : key.stop]
|
||||||
return newsource
|
return newsource
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[str]:
|
def __iter__(self) -> Iterator[str]:
|
||||||
|
|
@ -69,7 +72,7 @@ class Source:
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self.lines)
|
return len(self.lines)
|
||||||
|
|
||||||
def strip(self) -> "Source":
|
def strip(self) -> Source:
|
||||||
"""Return new Source object with trailing and leading blank lines removed."""
|
"""Return new Source object with trailing and leading blank lines removed."""
|
||||||
start, end = 0, len(self)
|
start, end = 0, len(self)
|
||||||
while start < end and not self.lines[start].strip():
|
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():
|
while end > start and not self.lines[end - 1].strip():
|
||||||
end -= 1
|
end -= 1
|
||||||
source = Source()
|
source = Source()
|
||||||
|
source.raw_lines = self.raw_lines
|
||||||
source.lines[:] = self.lines[start:end]
|
source.lines[:] = self.lines[start:end]
|
||||||
return source
|
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
|
"""Return a copy of the source object with all lines indented by the
|
||||||
given indent-string."""
|
given indent-string."""
|
||||||
newsource = Source()
|
newsource = Source()
|
||||||
|
newsource.raw_lines = self.raw_lines
|
||||||
newsource.lines = [(indent + line) for line in self.lines]
|
newsource.lines = [(indent + line) for line in self.lines]
|
||||||
return newsource
|
return newsource
|
||||||
|
|
||||||
def getstatement(self, lineno: int) -> "Source":
|
def getstatement(self, lineno: int) -> Source:
|
||||||
"""Return Source statement which contains the given linenumber
|
"""Return Source statement which contains the given linenumber
|
||||||
(counted from 0)."""
|
(counted from 0)."""
|
||||||
start, end = self.getstatementrange(lineno)
|
start, end = self.getstatementrange(lineno)
|
||||||
return self[start:end]
|
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
|
"""Return (start, end) tuple which spans the minimal statement region
|
||||||
which containing the given lineno."""
|
which containing the given lineno."""
|
||||||
if not (0 <= lineno < len(self)):
|
if not (0 <= lineno < len(self)):
|
||||||
raise IndexError("lineno out of range")
|
raise IndexError("lineno out of range")
|
||||||
ast, start, end = getstatementrange_ast(lineno, self)
|
_ast, start, end = getstatementrange_ast(lineno, self)
|
||||||
return start, end
|
return start, end
|
||||||
|
|
||||||
def deindent(self) -> "Source":
|
def deindent(self) -> Source:
|
||||||
"""Return a new Source object deindented."""
|
"""Return a new Source object deindented."""
|
||||||
newsource = Source()
|
newsource = Source()
|
||||||
newsource.lines[:] = deindent(self.lines)
|
newsource.lines[:] = deindent(self.lines)
|
||||||
|
newsource.raw_lines = self.raw_lines
|
||||||
return newsource
|
return newsource
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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:
|
try:
|
||||||
sourcelines, lineno = inspect.findsource(obj)
|
sourcelines, lineno = inspect.findsource(obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None, -1
|
return None, -1
|
||||||
source = Source()
|
source = Source()
|
||||||
source.lines = [line.rstrip() for line in sourcelines]
|
source.lines = [line.rstrip() for line in sourcelines]
|
||||||
|
source.raw_lines = sourcelines
|
||||||
return source, lineno
|
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}")
|
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()
|
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.
|
# Flatten all statements and except handlers into one lineno-list.
|
||||||
# AST's line numbers start indexing at 1.
|
# AST's line numbers start indexing at 1.
|
||||||
values: List[int] = []
|
values: list[int] = []
|
||||||
for x in ast.walk(node):
|
for x in ast.walk(node):
|
||||||
if isinstance(x, (ast.stmt, ast.ExceptHandler)):
|
if isinstance(x, ast.stmt | ast.ExceptHandler):
|
||||||
# Before Python 3.8, the lineno of a decorated class or function pointed at the decorator.
|
# The lineno points to the class/def, so need to include the decorators.
|
||||||
# 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.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
||||||
for d in x.decorator_list:
|
for d in x.decorator_list:
|
||||||
values.append(d.lineno - 1)
|
values.append(d.lineno - 1)
|
||||||
values.append(x.lineno - 1)
|
values.append(x.lineno - 1)
|
||||||
for name in ("finalbody", "orelse"):
|
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:
|
if val:
|
||||||
# Treat the finally/orelse part as its own statement.
|
# Treat the finally/orelse part as its own statement.
|
||||||
values.append(val[0].lineno - 1 - 1)
|
values.append(val[0].lineno - 1 - 1)
|
||||||
|
|
@ -174,8 +180,8 @@ def getstatementrange_ast(
|
||||||
lineno: int,
|
lineno: int,
|
||||||
source: Source,
|
source: Source,
|
||||||
assertion: bool = False,
|
assertion: bool = False,
|
||||||
astnode: Optional[ast.AST] = None,
|
astnode: ast.AST | None = None,
|
||||||
) -> Tuple[ast.AST, int, int]:
|
) -> tuple[ast.AST, int, int]:
|
||||||
if astnode is None:
|
if astnode is None:
|
||||||
content = str(source)
|
content = str(source)
|
||||||
# See #4260:
|
# See #4260:
|
||||||
|
|
@ -197,7 +203,9 @@ def getstatementrange_ast(
|
||||||
# by using the BlockFinder helper used which inspect.getsource() uses itself.
|
# by using the BlockFinder helper used which inspect.getsource() uses itself.
|
||||||
block_finder = inspect.BlockFinder()
|
block_finder = inspect.BlockFinder()
|
||||||
# If we start with an indented line, put blockfinder to "started" mode.
|
# 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])
|
it = ((x + "\n") for x in source.lines[start:end])
|
||||||
try:
|
try:
|
||||||
for tok in tokenize.generate_tokens(lambda: next(it)):
|
for tok in tokenize.generate_tokens(lambda: next(it)):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from .terminalwriter import get_terminal_width
|
from .terminalwriter import get_terminal_width
|
||||||
from .terminalwriter import TerminalWriter
|
from .terminalwriter import TerminalWriter
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
673
venv/lib/python3.10/site-packages/_pytest/_io/pprint.py
Normal file
673
venv/lib/python3.10/site-packages/_pytest/_io/pprint.py
Normal 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)
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import pprint
|
import pprint
|
||||||
import reprlib
|
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:
|
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)
|
exc_info = _try_repr_or_str(exc)
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
except BaseException as inner_exc:
|
||||||
exc_info = f"unpresentable exception ({_try_repr_or_str(exc)})"
|
exc_info = f"unpresentable exception ({_try_repr_or_str(inner_exc)})"
|
||||||
return "<[{} raised in repr()] {} object at 0x{:x}>".format(
|
return (
|
||||||
exc_info, type(obj).__name__, id(obj)
|
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.
|
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:
|
:param maxsize:
|
||||||
If not None, will truncate the resulting repr to that specific size, using ellipsis
|
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)
|
s = ascii(x)
|
||||||
else:
|
else:
|
||||||
s = super().repr(x)
|
s = super().repr(x)
|
||||||
|
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
raise
|
raise
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
|
|
@ -100,7 +97,7 @@ DEFAULT_REPR_MAX_SIZE = 240
|
||||||
|
|
||||||
|
|
||||||
def saferepr(
|
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:
|
) -> str:
|
||||||
"""Return a size-limited safe repr-string for the given object.
|
"""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
|
This function is a wrapper around the Repr/reprlib functionality of the
|
||||||
stdlib.
|
stdlib.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return SafeRepr(maxsize, use_ascii).repr(obj)
|
return SafeRepr(maxsize, use_ascii).repr(obj)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -132,49 +128,3 @@ def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str:
|
||||||
return repr(obj)
|
return repr(obj)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _format_repr_exception(exc, obj)
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
"""Helper functions for writing to terminals and files."""
|
"""Helper functions for writing to terminals and files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from typing import Optional
|
from typing import final
|
||||||
from typing import Sequence
|
from typing import Literal
|
||||||
from typing import TextIO
|
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 .wcwidth import wcswidth
|
||||||
from _pytest.compat import final
|
|
||||||
|
|
||||||
|
|
||||||
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
|
# 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
|
return True
|
||||||
if os.environ.get("PY_COLORS") == "0":
|
if os.environ.get("PY_COLORS") == "0":
|
||||||
return False
|
return False
|
||||||
if "NO_COLOR" in os.environ:
|
if os.environ.get("NO_COLOR"):
|
||||||
return False
|
return False
|
||||||
if "FORCE_COLOR" in os.environ:
|
if os.environ.get("FORCE_COLOR"):
|
||||||
return True
|
return True
|
||||||
return (
|
return (
|
||||||
hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
|
hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
|
||||||
|
|
@ -62,7 +72,7 @@ class TerminalWriter:
|
||||||
invert=7,
|
invert=7,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, file: Optional[TextIO] = None) -> None:
|
def __init__(self, file: TextIO | None = None) -> None:
|
||||||
if file is None:
|
if file is None:
|
||||||
file = sys.stdout
|
file = sys.stdout
|
||||||
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
|
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
|
||||||
|
|
@ -76,7 +86,7 @@ class TerminalWriter:
|
||||||
self._file = file
|
self._file = file
|
||||||
self.hasmarkup = should_do_markup(file)
|
self.hasmarkup = should_do_markup(file)
|
||||||
self._current_line = ""
|
self._current_line = ""
|
||||||
self._terminal_width: Optional[int] = None
|
self._terminal_width: int | None = None
|
||||||
self.code_highlight = True
|
self.code_highlight = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -101,14 +111,14 @@ class TerminalWriter:
|
||||||
if self.hasmarkup:
|
if self.hasmarkup:
|
||||||
esc = [self._esctable[name] for name, on in markup.items() if on]
|
esc = [self._esctable[name] for name, on in markup.items() if on]
|
||||||
if esc:
|
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
|
return text
|
||||||
|
|
||||||
def sep(
|
def sep(
|
||||||
self,
|
self,
|
||||||
sepchar: str,
|
sepchar: str,
|
||||||
title: Optional[str] = None,
|
title: str | None = None,
|
||||||
fullwidth: Optional[int] = None,
|
fullwidth: int | None = None,
|
||||||
**markup: bool,
|
**markup: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
if fullwidth is None:
|
if fullwidth is None:
|
||||||
|
|
@ -151,6 +161,9 @@ class TerminalWriter:
|
||||||
|
|
||||||
msg = self.markup(msg, **markup)
|
msg = self.markup(msg, **markup)
|
||||||
|
|
||||||
|
self.write_raw(msg, flush=flush)
|
||||||
|
|
||||||
|
def write_raw(self, msg: str, *, flush: bool = False) -> None:
|
||||||
try:
|
try:
|
||||||
self._file.write(msg)
|
self._file.write(msg)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
|
|
@ -182,52 +195,64 @@ class TerminalWriter:
|
||||||
"""
|
"""
|
||||||
if indents and len(indents) != len(lines):
|
if indents and len(indents) != len(lines):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"indents size ({}) should have same size as lines ({})".format(
|
f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
|
||||||
len(indents), len(lines)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
if not indents:
|
if not indents:
|
||||||
indents = [""] * len(lines)
|
indents = [""] * len(lines)
|
||||||
source = "\n".join(lines)
|
source = "\n".join(lines)
|
||||||
new_lines = self._highlight(source).splitlines()
|
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)
|
self.line(indent + new_line)
|
||||||
|
|
||||||
def _highlight(self, source: str) -> str:
|
def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer:
|
||||||
"""Highlight the given source code if we have markup support."""
|
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
|
from _pytest.config.exceptions import UsageError
|
||||||
|
|
||||||
if not self.hasmarkup or not self.code_highlight:
|
theme = os.getenv("PYTEST_THEME")
|
||||||
return source
|
theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from pygments.formatters.terminal import TerminalFormatter
|
return TerminalFormatter(bg=theme_mode, style=theme)
|
||||||
from pygments.lexers.python import PythonLexer
|
except pygments.util.ClassNotFound as e:
|
||||||
from pygments import highlight
|
raise UsageError(
|
||||||
import pygments.util
|
f"PYTEST_THEME environment variable has an invalid value: '{theme}'. "
|
||||||
except ImportError:
|
"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
|
return source
|
||||||
else:
|
|
||||||
try:
|
pygments_lexer = self._get_pygments_lexer(lexer)
|
||||||
highlighted: str = highlight(
|
pygments_formatter = self._get_pygments_formatter()
|
||||||
source,
|
|
||||||
PythonLexer(),
|
highlighted: str = pygments.highlight(
|
||||||
TerminalFormatter(
|
source, pygments_lexer, pygments_formatter
|
||||||
bg=os.getenv("PYTEST_THEME_MODE", "dark"),
|
|
||||||
style=os.getenv("PYTEST_THEME"),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
# 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
|
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")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import unicodedata
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(100)
|
@lru_cache(100)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,13 +1,15 @@
|
||||||
"""create errno-specific classes for IO or os calls."""
|
"""create errno-specific classes for IO or os calls."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Callable
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import ParamSpec
|
from typing_extensions import ParamSpec
|
||||||
|
|
||||||
|
|
@ -39,7 +41,7 @@ _winerrnomap = {
|
||||||
3: errno.ENOENT,
|
3: errno.ENOENT,
|
||||||
17: errno.EEXIST,
|
17: errno.EEXIST,
|
||||||
18: errno.EXDEV,
|
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,
|
22: errno.ENOTDIR,
|
||||||
20: errno.ENOTDIR,
|
20: errno.ENOTDIR,
|
||||||
267: errno.ENOTDIR,
|
267: errno.ENOTDIR,
|
||||||
|
|
@ -67,7 +69,7 @@ class ErrorMaker:
|
||||||
try:
|
try:
|
||||||
return self._errno2class[eno]
|
return self._errno2class[eno]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
clsname = errno.errorcode.get(eno, "UnknownErrno%d" % (eno,))
|
clsname = errno.errorcode.get(eno, f"UnknownErrno{eno}")
|
||||||
errorcls = type(
|
errorcls = type(
|
||||||
clsname,
|
clsname,
|
||||||
(Error,),
|
(Error,),
|
||||||
|
|
@ -88,15 +90,23 @@ class ErrorMaker:
|
||||||
except OSError as value:
|
except OSError as value:
|
||||||
if not hasattr(value, "errno"):
|
if not hasattr(value, "errno"):
|
||||||
raise
|
raise
|
||||||
errno = value.errno
|
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
try:
|
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:
|
except KeyError:
|
||||||
raise value
|
raise value
|
||||||
else:
|
else:
|
||||||
# we are not on Windows, or we got a proper OSError
|
# 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}")
|
raise cls(f"{func.__name__}{args!r}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
"""local path implementation."""
|
"""local path implementation."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import contextmanager
|
||||||
import fnmatch
|
import fnmatch
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import posixpath
|
|
||||||
import sys
|
|
||||||
import uuid
|
|
||||||
import warnings
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from os.path import abspath
|
from os.path import abspath
|
||||||
from os.path import dirname
|
from os.path import dirname
|
||||||
from os.path import exists
|
from os.path import exists
|
||||||
|
|
@ -19,19 +18,21 @@ from os.path import isdir
|
||||||
from os.path import isfile
|
from os.path import isfile
|
||||||
from os.path import islink
|
from os.path import islink
|
||||||
from os.path import normpath
|
from os.path import normpath
|
||||||
|
import posixpath
|
||||||
from stat import S_ISDIR
|
from stat import S_ISDIR
|
||||||
from stat import S_ISLNK
|
from stat import S_ISLNK
|
||||||
from stat import S_ISREG
|
from stat import S_ISREG
|
||||||
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
from typing import Literal
|
||||||
from typing import overload
|
from typing import overload
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
import uuid
|
||||||
|
import warnings
|
||||||
|
|
||||||
from . import error
|
from . import error
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
# Moved from local.py.
|
# Moved from local.py.
|
||||||
iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt")
|
iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt")
|
||||||
|
|
@ -160,15 +161,13 @@ class Visitor:
|
||||||
)
|
)
|
||||||
if not self.breadthfirst:
|
if not self.breadthfirst:
|
||||||
for subdir in dirs:
|
for subdir in dirs:
|
||||||
for p in self.gen(subdir):
|
yield from self.gen(subdir)
|
||||||
yield p
|
|
||||||
for p in self.optsort(entries):
|
for p in self.optsort(entries):
|
||||||
if self.fil is None or self.fil(p):
|
if self.fil is None or self.fil(p):
|
||||||
yield p
|
yield p
|
||||||
if self.breadthfirst:
|
if self.breadthfirst:
|
||||||
for subdir in dirs:
|
for subdir in dirs:
|
||||||
for p in self.gen(subdir):
|
yield from self.gen(subdir)
|
||||||
yield p
|
|
||||||
|
|
||||||
|
|
||||||
class FNMatcher:
|
class FNMatcher:
|
||||||
|
|
@ -205,12 +204,10 @@ class Stat:
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def size(self) -> int:
|
def size(self) -> int: ...
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mtime(self) -> float:
|
def mtime(self) -> float: ...
|
||||||
...
|
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
def __getattr__(self, name: str) -> Any:
|
||||||
return getattr(self._osstatresult, "st_" + name)
|
return getattr(self._osstatresult, "st_" + name)
|
||||||
|
|
@ -225,7 +222,7 @@ class Stat:
|
||||||
raise NotImplementedError("XXX win32")
|
raise NotImplementedError("XXX win32")
|
||||||
import pwd
|
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]
|
return entry[0]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
@ -235,7 +232,7 @@ class Stat:
|
||||||
raise NotImplementedError("XXX win32")
|
raise NotImplementedError("XXX win32")
|
||||||
import grp
|
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]
|
return entry[0]
|
||||||
|
|
||||||
def isdir(self):
|
def isdir(self):
|
||||||
|
|
@ -253,7 +250,7 @@ def getuserid(user):
|
||||||
import pwd
|
import pwd
|
||||||
|
|
||||||
if not isinstance(user, int):
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -261,7 +258,7 @@ def getgroupid(group):
|
||||||
import grp
|
import grp
|
||||||
|
|
||||||
if not isinstance(group, int):
|
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
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -318,7 +315,7 @@ class LocalPath:
|
||||||
def readlink(self) -> str:
|
def readlink(self) -> str:
|
||||||
"""Return value of a symbolic link."""
|
"""Return value of a symbolic link."""
|
||||||
# https://github.com/python/mypy/issues/12278
|
# 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):
|
def mklinkto(self, oldname):
|
||||||
"""Posix style hard link to another name."""
|
"""Posix style hard link to another name."""
|
||||||
|
|
@ -435,7 +432,7 @@ class LocalPath:
|
||||||
"""Return a string which is the relative part of the path
|
"""Return a string which is the relative part of the path
|
||||||
to the given 'relpath'.
|
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")
|
raise TypeError(f"{relpath!r}: not a string or path object")
|
||||||
strrelpath = str(relpath)
|
strrelpath = str(relpath)
|
||||||
if strrelpath and strrelpath[-1] != self.sep:
|
if strrelpath and strrelpath[-1] != self.sep:
|
||||||
|
|
@ -452,7 +449,7 @@ class LocalPath:
|
||||||
|
|
||||||
def ensure_dir(self, *args):
|
def ensure_dir(self, *args):
|
||||||
"""Ensure the path joined with args is a directory."""
|
"""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):
|
def bestrelpath(self, dest):
|
||||||
"""Return a string which is a relative path from self
|
"""Return a string which is a relative path from self
|
||||||
|
|
@ -655,12 +652,12 @@ class LocalPath:
|
||||||
if not kw:
|
if not kw:
|
||||||
obj.strpath = self.strpath
|
obj.strpath = self.strpath
|
||||||
return obj
|
return obj
|
||||||
drive, dirname, basename, purebasename, ext = self._getbyspec(
|
drive, dirname, _basename, purebasename, ext = self._getbyspec(
|
||||||
"drive,dirname,basename,purebasename,ext"
|
"drive,dirname,basename,purebasename,ext"
|
||||||
)
|
)
|
||||||
if "basename" in kw:
|
if "basename" in kw:
|
||||||
if "purebasename" in kw or "ext" in kw:
|
if "purebasename" in kw or "ext" in kw:
|
||||||
raise ValueError("invalid specification %r" % kw)
|
raise ValueError(f"invalid specification {kw!r}")
|
||||||
else:
|
else:
|
||||||
pb = kw.setdefault("purebasename", purebasename)
|
pb = kw.setdefault("purebasename", purebasename)
|
||||||
try:
|
try:
|
||||||
|
|
@ -677,7 +674,7 @@ class LocalPath:
|
||||||
else:
|
else:
|
||||||
kw.setdefault("dirname", dirname)
|
kw.setdefault("dirname", dirname)
|
||||||
kw.setdefault("sep", self.sep)
|
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
|
return obj
|
||||||
|
|
||||||
def _getbyspec(self, spec: str) -> list[str]:
|
def _getbyspec(self, spec: str) -> list[str]:
|
||||||
|
|
@ -706,7 +703,7 @@ class LocalPath:
|
||||||
elif name == "ext":
|
elif name == "ext":
|
||||||
res.append(ext)
|
res.append(ext)
|
||||||
else:
|
else:
|
||||||
raise ValueError("invalid part specification %r" % name)
|
raise ValueError(f"invalid part specification {name!r}")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def dirpath(self, *args, **kwargs):
|
def dirpath(self, *args, **kwargs):
|
||||||
|
|
@ -757,7 +754,12 @@ class LocalPath:
|
||||||
if ensure:
|
if ensure:
|
||||||
self.dirpath().ensure(dir=1)
|
self.dirpath().ensure(dir=1)
|
||||||
if encoding:
|
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)
|
return error.checked_call(open, self.strpath, mode)
|
||||||
|
|
||||||
def _fastjoin(self, name):
|
def _fastjoin(self, name):
|
||||||
|
|
@ -775,11 +777,11 @@ class LocalPath:
|
||||||
|
|
||||||
valid checkers::
|
valid checkers::
|
||||||
|
|
||||||
file=1 # is a file
|
file = 1 # is a file
|
||||||
file=0 # is not a file (may not even exist)
|
file = 0 # is not a file (may not even exist)
|
||||||
dir=1 # is a dir
|
dir = 1 # is a dir
|
||||||
link=1 # is a link
|
link = 1 # is a link
|
||||||
exists=1 # exists
|
exists = 1 # exists
|
||||||
|
|
||||||
You can specify multiple checker definitions, for example::
|
You can specify multiple checker definitions, for example::
|
||||||
|
|
||||||
|
|
@ -832,7 +834,7 @@ class LocalPath:
|
||||||
def copy(self, target, mode=False, stat=False):
|
def copy(self, target, mode=False, stat=False):
|
||||||
"""Copy path to target.
|
"""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
|
If stat is True, copy permission, last modification
|
||||||
time, last access time, and flags from path to target.
|
time, last access time, and flags from path to target.
|
||||||
"""
|
"""
|
||||||
|
|
@ -957,12 +959,10 @@ class LocalPath:
|
||||||
return p
|
return p
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def stat(self, raising: Literal[True] = ...) -> Stat:
|
def stat(self, raising: Literal[True] = ...) -> Stat: ...
|
||||||
...
|
|
||||||
|
|
||||||
@overload
|
@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:
|
def stat(self, raising: bool = True) -> Stat | None:
|
||||||
"""Return an os.stat() tuple."""
|
"""Return an os.stat() tuple."""
|
||||||
|
|
@ -1024,7 +1024,7 @@ class LocalPath:
|
||||||
return self.stat().atime
|
return self.stat().atime
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "local(%r)" % self.strpath
|
return f"local({self.strpath!r})"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
"""Return string representation of the Path."""
|
"""Return string representation of the Path."""
|
||||||
|
|
@ -1045,7 +1045,7 @@ class LocalPath:
|
||||||
def pypkgpath(self):
|
def pypkgpath(self):
|
||||||
"""Return the Python package path by looking for the last
|
"""Return the Python package path by looking for the last
|
||||||
directory upwards which still contains an __init__.py.
|
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
|
pkgpath = None
|
||||||
for parent in self.parts(reverse=True):
|
for parent in self.parts(reverse=True):
|
||||||
|
|
@ -1096,9 +1096,7 @@ class LocalPath:
|
||||||
modname = self.purebasename
|
modname = self.purebasename
|
||||||
spec = importlib.util.spec_from_file_location(modname, str(self))
|
spec = importlib.util.spec_from_file_location(modname, str(self))
|
||||||
if spec is None or spec.loader is None:
|
if spec is None or spec.loader is None:
|
||||||
raise ImportError(
|
raise ImportError(f"Can't find module {modname} at location {self!s}")
|
||||||
f"Can't find module {modname} at location {str(self)}"
|
|
||||||
)
|
|
||||||
mod = importlib.util.module_from_spec(spec)
|
mod = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(mod)
|
spec.loader.exec_module(mod)
|
||||||
return mod
|
return mod
|
||||||
|
|
@ -1163,7 +1161,8 @@ class LocalPath:
|
||||||
where the 'self' path points to executable.
|
where the 'self' path points to executable.
|
||||||
The process is directly invoked and not through a system shell.
|
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("stdout", None)
|
||||||
popen_opts.pop("stderr", None)
|
popen_opts.pop("stderr", None)
|
||||||
|
|
@ -1263,13 +1262,14 @@ class LocalPath:
|
||||||
@classmethod
|
@classmethod
|
||||||
def mkdtemp(cls, rootdir=None):
|
def mkdtemp(cls, rootdir=None):
|
||||||
"""Return a Path object pointing to a fresh new temporary directory
|
"""Return a Path object pointing to a fresh new temporary directory
|
||||||
(which we created ourself).
|
(which we created ourselves).
|
||||||
"""
|
"""
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
if rootdir is None:
|
if rootdir is None:
|
||||||
rootdir = cls.get_temproot()
|
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
|
@classmethod
|
||||||
def make_numbered_dir(
|
def make_numbered_dir(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,34 @@
|
||||||
# file generated by setuptools_scm
|
# file generated by setuptools-scm
|
||||||
# don't change, don't track in version control
|
# don't change, don't track in version control
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"__version__",
|
||||||
|
"__version_tuple__",
|
||||||
|
"version",
|
||||||
|
"version_tuple",
|
||||||
|
"__commit_id__",
|
||||||
|
"commit_id",
|
||||||
|
]
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
TYPE_CHECKING = False
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing import Tuple, Union
|
from typing import Tuple
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
||||||
|
COMMIT_ID = Union[str, None]
|
||||||
else:
|
else:
|
||||||
VERSION_TUPLE = object
|
VERSION_TUPLE = object
|
||||||
|
COMMIT_ID = object
|
||||||
|
|
||||||
version: str
|
version: str
|
||||||
__version__: str
|
__version__: str
|
||||||
__version_tuple__: VERSION_TUPLE
|
__version_tuple__: VERSION_TUPLE
|
||||||
version_tuple: VERSION_TUPLE
|
version_tuple: VERSION_TUPLE
|
||||||
|
commit_id: COMMIT_ID
|
||||||
|
__commit_id__: COMMIT_ID
|
||||||
|
|
||||||
__version__ = version = '7.4.3'
|
__version__ = version = '9.0.2'
|
||||||
__version_tuple__ = version_tuple = (7, 4, 3)
|
__version_tuple__ = version_tuple = (9, 0, 2)
|
||||||
|
|
||||||
|
__commit_id__ = commit_id = None
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
"""Support for presenting detailed information in failing assertions."""
|
"""Support for presenting detailed information in failing assertions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Generator
|
from typing import Protocol
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from _pytest.assertion import rewrite
|
from _pytest.assertion import rewrite
|
||||||
|
|
@ -15,6 +18,7 @@ from _pytest.config import hookimpl
|
||||||
from _pytest.config.argparsing import Parser
|
from _pytest.config.argparsing import Parser
|
||||||
from _pytest.nodes import Item
|
from _pytest.nodes import Item
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from _pytest.main import Session
|
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.",
|
"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:
|
def register_assert_rewrite(*names: str) -> None:
|
||||||
"""Register one or more module names to be rewritten on import.
|
"""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):
|
if not isinstance(name, str):
|
||||||
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
|
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
|
||||||
raise TypeError(msg.format(repr(names)))
|
raise TypeError(msg.format(repr(names)))
|
||||||
|
rewrite_hook: RewriteHook
|
||||||
for hook in sys.meta_path:
|
for hook in sys.meta_path:
|
||||||
if isinstance(hook, rewrite.AssertionRewritingHook):
|
if isinstance(hook, rewrite.AssertionRewritingHook):
|
||||||
importhook = hook
|
rewrite_hook = hook
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
# TODO(typing): Add a protocol for mark_rewrite() and use it
|
rewrite_hook = DummyRewriteHook()
|
||||||
# for importhook and for PytestPluginManager.rewrite_hook.
|
rewrite_hook.mark_rewrite(*names)
|
||||||
importhook = DummyRewriteHook() # type: ignore
|
|
||||||
importhook.mark_rewrite(*names)
|
|
||||||
|
class RewriteHook(Protocol):
|
||||||
|
def mark_rewrite(self, *names: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class DummyRewriteHook:
|
class DummyRewriteHook:
|
||||||
|
|
@ -83,7 +110,7 @@ class AssertionState:
|
||||||
def __init__(self, config: Config, mode) -> None:
|
def __init__(self, config: Config, mode) -> None:
|
||||||
self.mode = mode
|
self.mode = mode
|
||||||
self.trace = config.trace.root.get("assertion")
|
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:
|
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
|
||||||
|
|
@ -102,7 +129,7 @@ def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
|
||||||
return hook
|
return hook
|
||||||
|
|
||||||
|
|
||||||
def pytest_collection(session: "Session") -> None:
|
def pytest_collection(session: Session) -> None:
|
||||||
# This hook is only called when test modules are collected
|
# This hook is only called when test modules are collected
|
||||||
# so for example not in the managing process of pytest-xdist
|
# so for example not in the managing process of pytest-xdist
|
||||||
# (which does not collect test modules).
|
# (which does not collect test modules).
|
||||||
|
|
@ -112,18 +139,17 @@ def pytest_collection(session: "Session") -> None:
|
||||||
assertstate.hook.set_session(session)
|
assertstate.hook.set_session(session)
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(tryfirst=True, hookwrapper=True)
|
@hookimpl(wrapper=True, tryfirst=True)
|
||||||
def pytest_runtest_protocol(item: Item) -> Generator[None, None, None]:
|
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
|
||||||
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
|
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
|
||||||
|
|
||||||
The rewrite module will use util._reprcompare if it exists to use custom
|
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
|
reporting via the pytest_assertrepr_compare hook. This sets up this custom
|
||||||
comparison for the test.
|
comparison for the test.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ihook = item.ihook
|
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.
|
"""Call the pytest_assertrepr_compare hook and prepare the result.
|
||||||
|
|
||||||
This uses the first result from the hook and then ensures the
|
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
|
util._assertion_pass = call_assertion_pass_hook
|
||||||
|
|
||||||
yield
|
try:
|
||||||
|
return (yield)
|
||||||
|
finally:
|
||||||
util._reprcompare, util._assertion_pass = saved_assert_hooks
|
util._reprcompare, util._assertion_pass = saved_assert_hooks
|
||||||
util._config = None
|
util._config = None
|
||||||
|
|
||||||
|
|
||||||
def pytest_sessionfinish(session: "Session") -> None:
|
def pytest_sessionfinish(session: Session) -> None:
|
||||||
assertstate = session.config.stash.get(assertstate_key, None)
|
assertstate = session.config.stash.get(assertstate_key, None)
|
||||||
if assertstate:
|
if assertstate:
|
||||||
if assertstate.hook is not None:
|
if assertstate.hook is not None:
|
||||||
|
|
@ -177,5 +204,5 @@ def pytest_sessionfinish(session: "Session") -> None:
|
||||||
|
|
||||||
def pytest_assertrepr_compare(
|
def pytest_assertrepr_compare(
|
||||||
config: Config, op: str, left: Any, right: Any
|
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)
|
return util.assertrepr_compare(config=config, op=op, left=left, right=right)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,13 @@
|
||||||
"""Rewrite assertion AST to produce nice error messages."""
|
"""Rewrite assertion AST to produce nice error messages."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import ast
|
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 errno
|
||||||
import functools
|
import functools
|
||||||
import importlib.abc
|
import importlib.abc
|
||||||
|
|
@ -9,53 +17,46 @@ import io
|
||||||
import itertools
|
import itertools
|
||||||
import marshal
|
import marshal
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import PurePath
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import tokenize
|
import tokenize
|
||||||
import types
|
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 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 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 DEFAULT_REPR_MAX_SIZE
|
||||||
from _pytest._io.saferepr import saferepr
|
from _pytest._io.saferepr import saferepr
|
||||||
|
from _pytest._io.saferepr import saferepr_unlimited
|
||||||
from _pytest._version import version
|
from _pytest._version import version
|
||||||
from _pytest.assertion import util
|
from _pytest.assertion import util
|
||||||
from _pytest.assertion.util import ( # noqa: F401
|
|
||||||
format_explanation as _format_explanation,
|
|
||||||
)
|
|
||||||
from _pytest.config import Config
|
from _pytest.config import Config
|
||||||
|
from _pytest.fixtures import FixtureFunctionDefinition
|
||||||
from _pytest.main import Session
|
from _pytest.main import Session
|
||||||
from _pytest.pathlib import absolutepath
|
from _pytest.pathlib import absolutepath
|
||||||
from _pytest.pathlib import fnmatch_ex
|
from _pytest.pathlib import fnmatch_ex
|
||||||
from _pytest.stash import StashKey
|
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:
|
if TYPE_CHECKING:
|
||||||
from _pytest.assertion import AssertionState
|
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:
|
class Sentinel:
|
||||||
pass
|
pass
|
||||||
|
|
@ -65,7 +66,7 @@ assertstate_key = StashKey["AssertionState"]()
|
||||||
|
|
||||||
# pytest caches rewritten pycs in pycache dirs
|
# pytest caches rewritten pycs in pycache dirs
|
||||||
PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}"
|
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
|
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
|
||||||
|
|
||||||
# Special marker that denotes we have just left a scope definition
|
# 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")
|
self.fnpats = config.getini("python_files")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
self.fnpats = ["test_*.py", "*_test.py"]
|
self.fnpats = ["test_*.py", "*_test.py"]
|
||||||
self.session: Optional[Session] = None
|
self.session: Session | None = None
|
||||||
self._rewritten_names: Dict[str, Path] = {}
|
self._rewritten_names: dict[str, Path] = {}
|
||||||
self._must_rewrite: Set[str] = set()
|
self._must_rewrite: set[str] = set()
|
||||||
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
|
# 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)
|
# which might result in infinite recursion (#3506)
|
||||||
self._writing_pyc = False
|
self._writing_pyc = False
|
||||||
self._basenames_to_check_rewrite = {"conftest"}
|
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
|
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 = session
|
||||||
self._session_paths_checked = False
|
self._session_paths_checked = False
|
||||||
|
|
||||||
|
|
@ -101,18 +102,28 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
|
||||||
def find_spec(
|
def find_spec(
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
path: Optional[Sequence[Union[str, bytes]]] = None,
|
path: Sequence[str | bytes] | None = None,
|
||||||
target: Optional[types.ModuleType] = None,
|
target: types.ModuleType | None = None,
|
||||||
) -> Optional[importlib.machinery.ModuleSpec]:
|
) -> importlib.machinery.ModuleSpec | None:
|
||||||
if self._writing_pyc:
|
if self._writing_pyc:
|
||||||
return None
|
return None
|
||||||
state = self.config.stash[assertstate_key]
|
state = self.config.stash[assertstate_key]
|
||||||
if self._early_rewrite_bailout(name, state):
|
if self._early_rewrite_bailout(name, state):
|
||||||
return None
|
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.
|
# Type ignored because mypy is confused about the `self` binding here.
|
||||||
spec = self._find_spec(name, path) # type: ignore
|
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 (
|
if (
|
||||||
# the import machinery could not find a file to import
|
# the import machinery could not find a file to import
|
||||||
spec is None
|
spec is None
|
||||||
|
|
@ -140,7 +151,7 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
|
||||||
|
|
||||||
def create_module(
|
def create_module(
|
||||||
self, spec: importlib.machinery.ModuleSpec
|
self, spec: importlib.machinery.ModuleSpec
|
||||||
) -> Optional[types.ModuleType]:
|
) -> types.ModuleType | None:
|
||||||
return None # default behaviour is fine
|
return None # default behaviour is fine
|
||||||
|
|
||||||
def exec_module(self, module: types.ModuleType) -> None:
|
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}")
|
state.trace(f"found cached rewritten pyc for {fn}")
|
||||||
exec(co, module.__dict__)
|
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.
|
"""A fast way to get out of rewriting modules.
|
||||||
|
|
||||||
Profiling has shown that the call to PathFinder.find_spec (inside of
|
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}")
|
state.trace(f"early skip of rewriting module: {name}")
|
||||||
return True
|
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
|
# always rewrite conftest files
|
||||||
if os.path.basename(fn) == "conftest.py":
|
if os.path.basename(fn) == "conftest.py":
|
||||||
state.trace(f"rewriting conftest file: {fn!r}")
|
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)
|
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:
|
try:
|
||||||
return self._marked_for_rewrite_cache[name]
|
return self._marked_for_rewrite_cache[name]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|
@ -281,31 +292,18 @@ class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader)
|
||||||
|
|
||||||
self.config.issue_config_time_warning(
|
self.config.issue_config_time_warning(
|
||||||
PytestAssertRewriteWarning(
|
PytestAssertRewriteWarning(
|
||||||
"Module already imported so cannot be rewritten: %s" % name
|
f"Module already imported so cannot be rewritten; {name}"
|
||||||
),
|
),
|
||||||
stacklevel=5,
|
stacklevel=5,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_data(self, pathname: Union[str, bytes]) -> bytes:
|
def get_data(self, pathname: str | bytes) -> bytes:
|
||||||
"""Optional PEP302 get_data API."""
|
"""Optional PEP302 get_data API."""
|
||||||
with open(pathname, "rb") as f:
|
with open(pathname, "rb") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
if sys.version_info >= (3, 10):
|
def get_resource_reader(self, name: str) -> TraversableResources:
|
||||||
if sys.version_info >= (3, 12):
|
return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) # type: ignore[arg-type]
|
||||||
from importlib.resources.abc import TraversableResources
|
|
||||||
else:
|
|
||||||
from importlib.abc import TraversableResources
|
|
||||||
|
|
||||||
def get_resource_reader(self, name: str) -> TraversableResources: # type: ignore
|
|
||||||
if sys.version_info < (3, 11):
|
|
||||||
from importlib.readers import FileReader
|
|
||||||
else:
|
|
||||||
from importlib.resources.readers import FileReader
|
|
||||||
|
|
||||||
return FileReader( # type:ignore[no-any-return]
|
|
||||||
types.SimpleNamespace(path=self._rewritten_names[name])
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _write_pyc_fp(
|
def _write_pyc_fp(
|
||||||
|
|
@ -327,7 +325,7 @@ def _write_pyc_fp(
|
||||||
|
|
||||||
|
|
||||||
def _write_pyc(
|
def _write_pyc(
|
||||||
state: "AssertionState",
|
state: AssertionState,
|
||||||
co: types.CodeType,
|
co: types.CodeType,
|
||||||
source_stat: os.stat_result,
|
source_stat: os.stat_result,
|
||||||
pyc: Path,
|
pyc: Path,
|
||||||
|
|
@ -351,7 +349,7 @@ def _write_pyc(
|
||||||
return True
|
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."""
|
"""Read and rewrite *fn* and return the code object."""
|
||||||
stat = os.stat(fn)
|
stat = os.stat(fn)
|
||||||
source = fn.read_bytes()
|
source = fn.read_bytes()
|
||||||
|
|
@ -364,7 +362,7 @@ def _rewrite_test(fn: Path, config: Config) -> Tuple[os.stat_result, types.CodeT
|
||||||
|
|
||||||
def _read_pyc(
|
def _read_pyc(
|
||||||
source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None
|
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.
|
"""Possibly read a pytest pyc containing rewritten code.
|
||||||
|
|
||||||
Return rewritten code if successful or None if not.
|
Return rewritten code if successful or None if not.
|
||||||
|
|
@ -384,21 +382,21 @@ def _read_pyc(
|
||||||
return None
|
return None
|
||||||
# Check for invalid or out of date pyc file.
|
# Check for invalid or out of date pyc file.
|
||||||
if len(data) != (16):
|
if len(data) != (16):
|
||||||
trace("_read_pyc(%s): invalid pyc (too short)" % source)
|
trace(f"_read_pyc({source}): invalid pyc (too short)")
|
||||||
return None
|
return None
|
||||||
if data[:4] != importlib.util.MAGIC_NUMBER:
|
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
|
return None
|
||||||
if data[4:8] != b"\x00\x00\x00\x00":
|
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
|
return None
|
||||||
mtime_data = data[8:12]
|
mtime_data = data[8:12]
|
||||||
if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF:
|
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
|
return None
|
||||||
size_data = data[12:16]
|
size_data = data[12:16]
|
||||||
if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF:
|
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
|
return None
|
||||||
try:
|
try:
|
||||||
co = marshal.load(fp)
|
co = marshal.load(fp)
|
||||||
|
|
@ -406,7 +404,7 @@ def _read_pyc(
|
||||||
trace(f"_read_pyc({source}): marshal.load error {e}")
|
trace(f"_read_pyc({source}): marshal.load error {e}")
|
||||||
return None
|
return None
|
||||||
if not isinstance(co, types.CodeType):
|
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 None
|
||||||
return co
|
return co
|
||||||
|
|
||||||
|
|
@ -414,8 +412,8 @@ def _read_pyc(
|
||||||
def rewrite_asserts(
|
def rewrite_asserts(
|
||||||
mod: ast.Module,
|
mod: ast.Module,
|
||||||
source: bytes,
|
source: bytes,
|
||||||
module_path: Optional[str] = None,
|
module_path: str | None = None,
|
||||||
config: Optional[Config] = None,
|
config: Config | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Rewrite the assert statements in mod."""
|
"""Rewrite the assert statements in mod."""
|
||||||
AssertionRewriter(module_path, config, source).run(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
|
sequences, especially '\n{' and '\n}' are likely to be present in
|
||||||
JSON reprs.
|
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)
|
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")
|
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."""
|
"""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:
|
if verbosity >= 2:
|
||||||
return None
|
return None
|
||||||
if verbosity >= 1:
|
if verbosity >= 1:
|
||||||
|
|
@ -458,7 +465,7 @@ def _format_assertmsg(obj: object) -> str:
|
||||||
# However in either case we want to preserve the newline.
|
# However in either case we want to preserve the newline.
|
||||||
replaces = [("\n", "\n~"), ("%", "%%")]
|
replaces = [("\n", "\n~"), ("%", "%%")]
|
||||||
if not isinstance(obj, str):
|
if not isinstance(obj, str):
|
||||||
obj = saferepr(obj)
|
obj = saferepr(obj, _get_maxsize_for_saferepr(util._config))
|
||||||
replaces.append(("\\n", "\n~"))
|
replaces.append(("\\n", "\n~"))
|
||||||
|
|
||||||
for r1, r2 in replaces:
|
for r1, r2 in replaces:
|
||||||
|
|
@ -469,7 +476,8 @@ def _format_assertmsg(obj: object) -> str:
|
||||||
|
|
||||||
def _should_repr_global_name(obj: object) -> bool:
|
def _should_repr_global_name(obj: object) -> bool:
|
||||||
if callable(obj):
|
if callable(obj):
|
||||||
return False
|
# For pytest fixtures the __repr__ method provides more information than the function name.
|
||||||
|
return isinstance(obj, FixtureFunctionDefinition)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return not hasattr(obj, "__name__")
|
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:
|
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("%", "%%")
|
return explanation.replace("%", "%%")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -488,7 +496,7 @@ def _call_reprcompare(
|
||||||
expls: Sequence[str],
|
expls: Sequence[str],
|
||||||
each_obj: Sequence[object],
|
each_obj: Sequence[object],
|
||||||
) -> str:
|
) -> 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:
|
try:
|
||||||
done = not res
|
done = not res
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -550,14 +558,14 @@ def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
|
||||||
|
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=1)
|
@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"}."""
|
"""Return a mapping from {lineno: "assertion test expression"}."""
|
||||||
ret: Dict[int, str] = {}
|
ret: dict[int, str] = {}
|
||||||
|
|
||||||
depth = 0
|
depth = 0
|
||||||
lines: List[str] = []
|
lines: list[str] = []
|
||||||
assert_lineno: Optional[int] = None
|
assert_lineno: int | None = None
|
||||||
seen_lines: Set[int] = set()
|
seen_lines: set[int] = set()
|
||||||
|
|
||||||
def _write_and_reset() -> None:
|
def _write_and_reset() -> None:
|
||||||
nonlocal depth, lines, assert_lineno, seen_lines
|
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
|
# multi-line assert with message
|
||||||
elif lineno in seen_lines:
|
elif lineno in seen_lines:
|
||||||
lines[-1] = lines[-1][:offset]
|
lines[-1] = lines[-1][:offset]
|
||||||
# multi line assert with escapd newline before message
|
# multi line assert with escaped newline before message
|
||||||
else:
|
else:
|
||||||
lines.append(line[:offset])
|
lines.append(line[:offset])
|
||||||
_write_and_reset()
|
_write_and_reset()
|
||||||
|
|
@ -664,7 +672,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, module_path: Optional[str], config: Optional[Config], source: bytes
|
self, module_path: str | None, config: Config | None, source: bytes
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.module_path = module_path
|
self.module_path = module_path
|
||||||
|
|
@ -677,9 +685,9 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
self.enable_assertion_pass_hook = False
|
self.enable_assertion_pass_hook = False
|
||||||
self.source = source
|
self.source = source
|
||||||
self.scope: tuple[ast.AST, ...] = ()
|
self.scope: tuple[ast.AST, ...] = ()
|
||||||
self.variables_overwrite: defaultdict[
|
self.variables_overwrite: defaultdict[tuple[ast.AST, ...], dict[str, str]] = (
|
||||||
tuple[ast.AST, ...], Dict[str, str]
|
defaultdict(dict)
|
||||||
] = defaultdict(dict)
|
)
|
||||||
|
|
||||||
def run(self, mod: ast.Module) -> None:
|
def run(self, mod: ast.Module) -> None:
|
||||||
"""Find all assert statements in *mod* and rewrite them."""
|
"""Find all assert statements in *mod* and rewrite them."""
|
||||||
|
|
@ -694,27 +702,17 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
if doc is not None and self.is_rewrite_disabled(doc):
|
if doc is not None and self.is_rewrite_disabled(doc):
|
||||||
return
|
return
|
||||||
pos = 0
|
pos = 0
|
||||||
item = None
|
|
||||||
for item in mod.body:
|
for item in mod.body:
|
||||||
if (
|
match item:
|
||||||
|
case ast.Expr(value=ast.Constant(value=str() as doc)) if (
|
||||||
expect_docstring
|
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):
|
if self.is_rewrite_disabled(doc):
|
||||||
return
|
return
|
||||||
expect_docstring = False
|
expect_docstring = False
|
||||||
elif (
|
case ast.ImportFrom(level=0, module="__future__"):
|
||||||
isinstance(item, ast.ImportFrom)
|
|
||||||
and item.level == 0
|
|
||||||
and item.module == "__future__"
|
|
||||||
):
|
|
||||||
pass
|
pass
|
||||||
else:
|
case _:
|
||||||
break
|
break
|
||||||
pos += 1
|
pos += 1
|
||||||
# Special case: for a decorated function, set the lineno to that of the
|
# Special case: for a decorated function, set the lineno to that of the
|
||||||
|
|
@ -724,7 +722,6 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
else:
|
else:
|
||||||
lineno = item.lineno
|
lineno = item.lineno
|
||||||
# Now actually insert the special imports.
|
# Now actually insert the special imports.
|
||||||
if sys.version_info >= (3, 10):
|
|
||||||
aliases = [
|
aliases = [
|
||||||
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
|
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
|
||||||
ast.alias(
|
ast.alias(
|
||||||
|
|
@ -734,11 +731,6 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
col_offset=0,
|
col_offset=0,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
else:
|
|
||||||
aliases = [
|
|
||||||
ast.alias("builtins", "@py_builtins"),
|
|
||||||
ast.alias("_pytest.assertion.rewrite", "@pytest_ar"),
|
|
||||||
]
|
|
||||||
imports = [
|
imports = [
|
||||||
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
|
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
|
||||||
]
|
]
|
||||||
|
|
@ -746,10 +738,10 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
|
|
||||||
# Collect asserts.
|
# Collect asserts.
|
||||||
self.scope = (mod,)
|
self.scope = (mod,)
|
||||||
nodes: List[Union[ast.AST, Sentinel]] = [mod]
|
nodes: list[ast.AST | Sentinel] = [mod]
|
||||||
while nodes:
|
while nodes:
|
||||||
node = nodes.pop()
|
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))
|
self.scope = tuple((*self.scope, node))
|
||||||
nodes.append(_SCOPE_END_MARKER)
|
nodes.append(_SCOPE_END_MARKER)
|
||||||
if node == _SCOPE_END_MARKER:
|
if node == _SCOPE_END_MARKER:
|
||||||
|
|
@ -758,7 +750,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
assert isinstance(node, ast.AST)
|
assert isinstance(node, ast.AST)
|
||||||
for name, field in ast.iter_fields(node):
|
for name, field in ast.iter_fields(node):
|
||||||
if isinstance(field, list):
|
if isinstance(field, list):
|
||||||
new: List[ast.AST] = []
|
new: list[ast.AST] = []
|
||||||
for i, child in enumerate(field):
|
for i, child in enumerate(field):
|
||||||
if isinstance(child, ast.Assert):
|
if isinstance(child, ast.Assert):
|
||||||
# Transform assert.
|
# Transform assert.
|
||||||
|
|
@ -791,7 +783,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
"""Give *expr* a name."""
|
"""Give *expr* a name."""
|
||||||
name = self.variable()
|
name = self.variable()
|
||||||
self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr))
|
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:
|
def display(self, expr: ast.expr) -> ast.expr:
|
||||||
"""Call saferepr on the expression."""
|
"""Call saferepr on the expression."""
|
||||||
|
|
@ -830,7 +822,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
to format a string of %-formatted values as added by
|
to format a string of %-formatted values as added by
|
||||||
.explanation_param().
|
.explanation_param().
|
||||||
"""
|
"""
|
||||||
self.explanation_specifiers: Dict[str, ast.expr] = {}
|
self.explanation_specifiers: dict[str, ast.expr] = {}
|
||||||
self.stack.append(self.explanation_specifiers)
|
self.stack.append(self.explanation_specifiers)
|
||||||
|
|
||||||
def pop_format_context(self, expl_expr: ast.expr) -> ast.Name:
|
def pop_format_context(self, expl_expr: ast.expr) -> ast.Name:
|
||||||
|
|
@ -844,7 +836,7 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
current = self.stack.pop()
|
current = self.stack.pop()
|
||||||
if self.stack:
|
if self.stack:
|
||||||
self.explanation_specifiers = self.stack[-1]
|
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()))
|
format_dict = ast.Dict(keys, list(current.values()))
|
||||||
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
|
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
|
||||||
name = "@py_format" + str(next(self.variable_counter))
|
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))
|
self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form))
|
||||||
return ast.Name(name, ast.Load())
|
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."""
|
"""Handle expressions we don't have custom code for."""
|
||||||
assert isinstance(node, ast.expr)
|
assert isinstance(node, ast.expr)
|
||||||
res = self.assign(node)
|
res = self.assign(node)
|
||||||
return res, self.explanation_param(self.display(res))
|
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.
|
"""Return the AST statements to replace the ast.Assert instance.
|
||||||
|
|
||||||
This rewrites the test of an assertion to provide
|
This rewrites the test of an assertion to provide
|
||||||
|
|
@ -868,9 +860,10 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
the expression is false.
|
the expression is false.
|
||||||
"""
|
"""
|
||||||
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
|
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
|
||||||
from _pytest.warning_types import PytestAssertRewriteWarning
|
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
|
from _pytest.warning_types import PytestAssertRewriteWarning
|
||||||
|
|
||||||
# TODO: This assert should not be needed.
|
# TODO: This assert should not be needed.
|
||||||
assert self.module_path is not None
|
assert self.module_path is not None
|
||||||
warnings.warn_explicit(
|
warnings.warn_explicit(
|
||||||
|
|
@ -882,15 +875,15 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
lineno=assert_.lineno,
|
lineno=assert_.lineno,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.statements: List[ast.stmt] = []
|
self.statements: list[ast.stmt] = []
|
||||||
self.variables: List[str] = []
|
self.variables: list[str] = []
|
||||||
self.variable_counter = itertools.count()
|
self.variable_counter = itertools.count()
|
||||||
|
|
||||||
if self.enable_assertion_pass_hook:
|
if self.enable_assertion_pass_hook:
|
||||||
self.format_variables: List[str] = []
|
self.format_variables: list[str] = []
|
||||||
|
|
||||||
self.stack: List[Dict[str, ast.expr]] = []
|
self.stack: list[dict[str, ast.expr]] = []
|
||||||
self.expl_stmts: List[ast.stmt] = []
|
self.expl_stmts: list[ast.stmt] = []
|
||||||
self.push_format_context()
|
self.push_format_context()
|
||||||
# Rewrite assert into a bunch of statements.
|
# Rewrite assert into a bunch of statements.
|
||||||
top_condition, explanation = self.visit(assert_.test)
|
top_condition, explanation = self.visit(assert_.test)
|
||||||
|
|
@ -898,16 +891,16 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
negation = ast.UnaryOp(ast.Not(), top_condition)
|
negation = ast.UnaryOp(ast.Not(), top_condition)
|
||||||
|
|
||||||
if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook
|
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
|
# Failed
|
||||||
if assert_.msg:
|
if assert_.msg:
|
||||||
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
||||||
gluestr = "\n>assert "
|
gluestr = "\n>assert "
|
||||||
else:
|
else:
|
||||||
assertmsg = astStr("")
|
assertmsg = ast.Constant("")
|
||||||
gluestr = "assert "
|
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_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
|
||||||
err_name = ast.Name("AssertionError", ast.Load())
|
err_name = ast.Name("AssertionError", ast.Load())
|
||||||
fmt = self.helper("_format_explanation", err_msg)
|
fmt = self.helper("_format_explanation", err_msg)
|
||||||
|
|
@ -923,27 +916,27 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
hook_call_pass = ast.Expr(
|
hook_call_pass = ast.Expr(
|
||||||
self.helper(
|
self.helper(
|
||||||
"_call_assertion_pass",
|
"_call_assertion_pass",
|
||||||
astNum(assert_.lineno),
|
ast.Constant(assert_.lineno),
|
||||||
astStr(orig),
|
ast.Constant(orig),
|
||||||
fmt_pass,
|
fmt_pass,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# If any hooks implement assert_pass hook
|
# If any hooks implement assert_pass hook
|
||||||
hook_impl_test = ast.If(
|
hook_impl_test = ast.If(
|
||||||
self.helper("_check_if_assertion_pass_impl"),
|
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
|
# Test for assertion condition
|
||||||
main_test = ast.If(negation, statements_fail, statements_pass)
|
main_test = ast.If(negation, statements_fail, statements_pass)
|
||||||
self.statements.append(main_test)
|
self.statements.append(main_test)
|
||||||
if self.format_variables:
|
if self.format_variables:
|
||||||
variables = [
|
variables: list[ast.expr] = [
|
||||||
ast.Name(name, ast.Store()) for name in self.format_variables
|
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)
|
self.statements.append(clear_format)
|
||||||
|
|
||||||
else: # Original assertion rewriting
|
else: # Original assertion rewriting
|
||||||
|
|
@ -954,9 +947,9 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
||||||
explanation = "\n>assert " + explanation
|
explanation = "\n>assert " + explanation
|
||||||
else:
|
else:
|
||||||
assertmsg = astStr("")
|
assertmsg = ast.Constant("")
|
||||||
explanation = "assert " + explanation
|
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)
|
msg = self.pop_format_context(template)
|
||||||
fmt = self.helper("_format_explanation", msg)
|
fmt = self.helper("_format_explanation", msg)
|
||||||
err_name = ast.Name("AssertionError", ast.Load())
|
err_name = ast.Name("AssertionError", ast.Load())
|
||||||
|
|
@ -968,37 +961,40 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
# Clear temporary variables by setting them to None.
|
# Clear temporary variables by setting them to None.
|
||||||
if self.variables:
|
if self.variables:
|
||||||
variables = [ast.Name(name, ast.Store()) for name in 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)
|
self.statements.append(clear)
|
||||||
# Fix locations (line numbers/column offsets).
|
# Fix locations (line numbers/column offsets).
|
||||||
for stmt in self.statements:
|
for stmt in self.statements:
|
||||||
for node in traverse_node(stmt):
|
for node in traverse_node(stmt):
|
||||||
|
if getattr(node, "lineno", None) is None:
|
||||||
|
# apply the assertion location to all generated ast nodes without source location
|
||||||
|
# and preserve the location of existing nodes or generated nodes with an correct location.
|
||||||
ast.copy_location(node, assert_)
|
ast.copy_location(node, assert_)
|
||||||
return self.statements
|
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
|
# This method handles the 'walrus operator' repr of the target
|
||||||
# name if it's a local variable or _should_repr_global_name()
|
# name if it's a local variable or _should_repr_global_name()
|
||||||
# thinks it's acceptable.
|
# thinks it's acceptable.
|
||||||
locs = ast.Call(self.builtin("locals"), [], [])
|
locs = ast.Call(self.builtin("locals"), [], [])
|
||||||
target_id = name.target.id # type: ignore[attr-defined]
|
target_id = name.target.id
|
||||||
inlocs = ast.Compare(astStr(target_id), [ast.In()], [locs])
|
inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs])
|
||||||
dorepr = self.helper("_should_repr_global_name", name)
|
dorepr = self.helper("_should_repr_global_name", name)
|
||||||
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
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)
|
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
|
# Display the repr of the name if it's a local variable or
|
||||||
# _should_repr_global_name() thinks it's acceptable.
|
# _should_repr_global_name() thinks it's acceptable.
|
||||||
locs = ast.Call(self.builtin("locals"), [], [])
|
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)
|
dorepr = self.helper("_should_repr_global_name", name)
|
||||||
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
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)
|
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()
|
res_var = self.variable()
|
||||||
expl_list = self.assign(ast.List([], ast.Load()))
|
expl_list = self.assign(ast.List([], ast.Load()))
|
||||||
app = ast.Attribute(expl_list, "append", 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.
|
# Process each operand, short-circuiting if needed.
|
||||||
for i, v in enumerate(boolop.values):
|
for i, v in enumerate(boolop.values):
|
||||||
if i:
|
if i:
|
||||||
fail_inner: List[ast.stmt] = []
|
fail_inner: list[ast.stmt] = []
|
||||||
# cond is set in a prior loop iteration below
|
# 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
|
self.expl_stmts = fail_inner
|
||||||
# Check if the left operand is a namedExpr and the value has already been visited
|
match v:
|
||||||
if (
|
# Check if the left operand is an ast.NamedExpr and the value has already been visited
|
||||||
isinstance(v, ast.Compare)
|
case ast.Compare(
|
||||||
and isinstance(v.left, namedExpr)
|
left=ast.NamedExpr(target=ast.Name(id=target_id))
|
||||||
and v.left.target.id
|
) if target_id in [
|
||||||
in [
|
e.id for e in boolop.values[:i] if hasattr(e, "id")
|
||||||
ast_expr.id
|
]:
|
||||||
for ast_expr in boolop.values[:i]
|
|
||||||
if hasattr(ast_expr, "id")
|
|
||||||
]
|
|
||||||
):
|
|
||||||
pytest_temp = self.variable()
|
pytest_temp = self.variable()
|
||||||
self.variables_overwrite[self.scope][
|
self.variables_overwrite[self.scope][target_id] = v.left # type:ignore[assignment]
|
||||||
v.left.target.id
|
# mypy's false positive, we're checking that the 'target' attribute exists.
|
||||||
] = v.left # type:ignore[assignment]
|
v.left.target.id = pytest_temp # type:ignore[attr-defined]
|
||||||
v.left.target.id = pytest_temp
|
|
||||||
self.push_format_context()
|
self.push_format_context()
|
||||||
res, expl = self.visit(v)
|
res, expl = self.visit(v)
|
||||||
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
|
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], [])
|
call = ast.Call(app, [expl_format], [])
|
||||||
self.expl_stmts.append(ast.Expr(call))
|
self.expl_stmts.append(ast.Expr(call))
|
||||||
if i < levels:
|
if i < levels:
|
||||||
cond: ast.expr = res
|
cond: ast.expr = res
|
||||||
if is_or:
|
if is_or:
|
||||||
cond = ast.UnaryOp(ast.Not(), cond)
|
cond = ast.UnaryOp(ast.Not(), cond)
|
||||||
inner: List[ast.stmt] = []
|
inner: list[ast.stmt] = []
|
||||||
self.statements.append(ast.If(cond, inner, []))
|
self.statements.append(ast.If(cond, inner, []))
|
||||||
self.statements = body = inner
|
self.statements = body = inner
|
||||||
self.statements = save
|
self.statements = save
|
||||||
self.expl_stmts = fail_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)
|
expl = self.pop_format_context(expl_template)
|
||||||
return ast.Name(res_var, ast.Load()), self.explanation_param(expl)
|
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__]
|
pattern = UNARY_MAP[unary.op.__class__]
|
||||||
operand_res, operand_expl = self.visit(unary.operand)
|
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,)
|
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__]
|
symbol = BINOP_MAP[binop.op.__class__]
|
||||||
left_expr, left_expl = self.visit(binop.left)
|
left_expr, left_expl = self.visit(binop.left)
|
||||||
right_expr, right_expl = self.visit(binop.right)
|
right_expr, right_expl = self.visit(binop.right)
|
||||||
explanation = f"({left_expl} {symbol} {right_expl})"
|
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
|
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)
|
new_func, func_expl = self.visit(call.func)
|
||||||
arg_expls = []
|
arg_expls = []
|
||||||
new_args = []
|
new_args = []
|
||||||
|
|
@ -1072,19 +1065,16 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get(
|
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get(
|
||||||
self.scope, {}
|
self.scope, {}
|
||||||
):
|
):
|
||||||
arg = self.variables_overwrite[self.scope][
|
arg = self.variables_overwrite[self.scope][arg.id] # type:ignore[assignment]
|
||||||
arg.id
|
|
||||||
] # type:ignore[assignment]
|
|
||||||
res, expl = self.visit(arg)
|
res, expl = self.visit(arg)
|
||||||
arg_expls.append(expl)
|
arg_expls.append(expl)
|
||||||
new_args.append(res)
|
new_args.append(res)
|
||||||
for keyword in call.keywords:
|
for keyword in call.keywords:
|
||||||
if isinstance(
|
match keyword.value:
|
||||||
keyword.value, ast.Name
|
case ast.Name(id=id) if id in self.variables_overwrite.get(
|
||||||
) and keyword.value.id in self.variables_overwrite.get(self.scope, {}):
|
self.scope, {}
|
||||||
keyword.value = self.variables_overwrite[self.scope][
|
):
|
||||||
keyword.value.id
|
keyword.value = self.variables_overwrite[self.scope][id] # type:ignore[assignment]
|
||||||
] # type:ignore[assignment]
|
|
||||||
res, expl = self.visit(keyword.value)
|
res, expl = self.visit(keyword.value)
|
||||||
new_kwargs.append(ast.keyword(keyword.arg, res))
|
new_kwargs.append(ast.keyword(keyword.arg, res))
|
||||||
if keyword.arg:
|
if keyword.arg:
|
||||||
|
|
@ -1093,70 +1083,68 @@ class AssertionRewriter(ast.NodeVisitor):
|
||||||
arg_expls.append("**" + expl)
|
arg_expls.append("**" + expl)
|
||||||
|
|
||||||
expl = "{}({})".format(func_expl, ", ".join(arg_expls))
|
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 = self.assign(new_call)
|
||||||
res_expl = self.explanation_param(self.display(res))
|
res_expl = self.explanation_param(self.display(res))
|
||||||
outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}"
|
outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}"
|
||||||
return res, outer_expl
|
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.
|
# A Starred node can appear in a function call.
|
||||||
res, expl = self.visit(starred.value)
|
res, expl = self.visit(starred.value)
|
||||||
new_starred = ast.Starred(res, starred.ctx)
|
new_starred = ast.Starred(res, starred.ctx)
|
||||||
return new_starred, "*" + expl
|
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):
|
if not isinstance(attr.ctx, ast.Load):
|
||||||
return self.generic_visit(attr)
|
return self.generic_visit(attr)
|
||||||
value, value_expl = self.visit(attr.value)
|
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))
|
res_expl = self.explanation_param(self.display(res))
|
||||||
pat = "%s\n{%s = %s.%s\n}"
|
pat = "%s\n{%s = %s.%s\n}"
|
||||||
expl = pat % (res_expl, res_expl, value_expl, attr.attr)
|
expl = pat % (res_expl, res_expl, value_expl, attr.attr)
|
||||||
return res, expl
|
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()
|
self.push_format_context()
|
||||||
# We first check if we have overwritten a variable in the previous assert
|
# We first check if we have overwritten a variable in the previous assert
|
||||||
if isinstance(
|
match comp.left:
|
||||||
comp.left, ast.Name
|
case ast.Name(id=name_id) if name_id in self.variables_overwrite.get(
|
||||||
) and comp.left.id in self.variables_overwrite.get(self.scope, {}):
|
self.scope, {}
|
||||||
comp.left = self.variables_overwrite[self.scope][
|
):
|
||||||
comp.left.id
|
comp.left = self.variables_overwrite[self.scope][name_id] # type: ignore[assignment]
|
||||||
] # type:ignore[assignment]
|
case ast.NamedExpr(target=ast.Name(id=target_id)):
|
||||||
if isinstance(comp.left, namedExpr):
|
self.variables_overwrite[self.scope][target_id] = comp.left # type: ignore[assignment]
|
||||||
self.variables_overwrite[self.scope][
|
|
||||||
comp.left.target.id
|
|
||||||
] = comp.left # type:ignore[assignment]
|
|
||||||
left_res, left_expl = self.visit(comp.left)
|
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})"
|
left_expl = f"({left_expl})"
|
||||||
res_variables = [self.variable() for i in range(len(comp.ops))]
|
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]
|
store_names = [ast.Name(v, ast.Store()) for v in res_variables]
|
||||||
it = zip(range(len(comp.ops)), comp.ops, comp.comparators)
|
it = zip(range(len(comp.ops)), comp.ops, comp.comparators, strict=True)
|
||||||
expls = []
|
expls: list[ast.expr] = []
|
||||||
syms = []
|
syms: list[ast.expr] = []
|
||||||
results = [left_res]
|
results = [left_res]
|
||||||
for i, op, next_operand in it:
|
for i, op, next_operand in it:
|
||||||
if (
|
match (next_operand, left_res):
|
||||||
isinstance(next_operand, namedExpr)
|
case (
|
||||||
and isinstance(left_res, ast.Name)
|
ast.NamedExpr(target=ast.Name(id=target_id)),
|
||||||
and next_operand.target.id == left_res.id
|
ast.Name(id=name_id),
|
||||||
):
|
) if target_id == name_id:
|
||||||
next_operand.target.id = self.variable()
|
next_operand.target.id = self.variable()
|
||||||
self.variables_overwrite[self.scope][
|
self.variables_overwrite[self.scope][name_id] = next_operand # type: ignore[assignment]
|
||||||
left_res.id
|
|
||||||
] = next_operand # type:ignore[assignment]
|
|
||||||
next_res, next_expl = self.visit(next_operand)
|
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})"
|
next_expl = f"({next_expl})"
|
||||||
results.append(next_res)
|
results.append(next_res)
|
||||||
sym = BINOP_MAP[op.__class__]
|
sym = BINOP_MAP[op.__class__]
|
||||||
syms.append(astStr(sym))
|
syms.append(ast.Constant(sym))
|
||||||
expl = f"{left_expl} {sym} {next_expl}"
|
expl = f"{left_expl} {sym} {next_expl}"
|
||||||
expls.append(astStr(expl))
|
expls.append(ast.Constant(expl))
|
||||||
res_expr = ast.Compare(left_res, [op], [next_res])
|
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
|
||||||
self.statements.append(ast.Assign([store_names[i]], res_expr))
|
self.statements.append(ast.Assign([store_names[i]], res_expr))
|
||||||
left_res, left_expl = next_res, next_expl
|
left_res, left_expl = next_res, next_expl
|
||||||
# Use pytest.assertion.util._reprcompare if that's available.
|
# Use pytest.assertion.util._reprcompare if that's available.
|
||||||
|
|
@ -1191,7 +1179,10 @@ def try_makedirs(cache_dir: Path) -> bool:
|
||||||
return False
|
return False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
# as of now, EROFS doesn't have an equivalent OSError-subclass
|
# 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
|
return False
|
||||||
raise
|
raise
|
||||||
return True
|
return True
|
||||||
|
|
@ -1199,7 +1190,7 @@ def try_makedirs(cache_dir: Path) -> bool:
|
||||||
|
|
||||||
def get_cache_dir(file_path: Path) -> Path:
|
def get_cache_dir(file_path: Path) -> Path:
|
||||||
"""Return the cache directory to write .pyc files for the given .py file 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:
|
# given:
|
||||||
# prefix = '/tmp/pycs'
|
# prefix = '/tmp/pycs'
|
||||||
# path = '/home/user/proj/test_app.py'
|
# path = '/home/user/proj/test_app.py'
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,65 @@
|
||||||
"""Utilities for truncating assertion output.
|
"""Utilities for truncating assertion output.
|
||||||
|
|
||||||
Current default behaviour is to truncate assertion explanations at
|
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
|
from _pytest.nodes import Item
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MAX_LINES = 8
|
DEFAULT_MAX_LINES = 8
|
||||||
DEFAULT_MAX_CHARS = 8 * 80
|
DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80
|
||||||
USAGE_MSG = "use '-vv' to show"
|
USAGE_MSG = "use '-vv' to show"
|
||||||
|
|
||||||
|
|
||||||
def truncate_if_required(
|
def truncate_if_required(explanation: list[str], item: Item) -> list[str]:
|
||||||
explanation: List[str], item: Item, max_length: Optional[int] = None
|
|
||||||
) -> List[str]:
|
|
||||||
"""Truncate this assertion explanation if the given test item is eligible."""
|
"""Truncate this assertion explanation if the given test item is eligible."""
|
||||||
if _should_truncate_item(item):
|
should_truncate, max_lines, max_chars = _get_truncation_parameters(item)
|
||||||
return _truncate_explanation(explanation)
|
if should_truncate:
|
||||||
|
return _truncate_explanation(
|
||||||
|
explanation,
|
||||||
|
max_lines=max_lines,
|
||||||
|
max_chars=max_chars,
|
||||||
|
)
|
||||||
return explanation
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
def _should_truncate_item(item: Item) -> bool:
|
def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]:
|
||||||
"""Whether or not this test item is eligible for truncation."""
|
"""Return the truncation parameters related to the given item, as (should truncate, max lines, max chars)."""
|
||||||
verbose = item.config.option.verbose
|
# We do not need to truncate if one of conditions is met:
|
||||||
return verbose < 2 and not util.running_on_ci()
|
# 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(
|
def _truncate_explanation(
|
||||||
input_lines: List[str],
|
input_lines: list[str],
|
||||||
max_lines: Optional[int] = None,
|
max_lines: int,
|
||||||
max_chars: Optional[int] = None,
|
max_chars: int,
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
"""Truncate given list of strings that makes up the assertion explanation.
|
"""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
|
first, taking the truncation explanation into account. The remaining lines
|
||||||
will be replaced by a usage message.
|
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
|
# Check if truncation required
|
||||||
input_char_count = len("".join(input_lines))
|
input_char_count = len("".join(input_lines))
|
||||||
# The length of the truncation explanation depends on the number of lines
|
# The length of the truncation explanation depends on the number of lines
|
||||||
|
|
@ -70,16 +84,23 @@ def _truncate_explanation(
|
||||||
):
|
):
|
||||||
return input_lines
|
return input_lines
|
||||||
# Truncate first to max_lines, and then truncate to max_chars if necessary
|
# Truncate first to max_lines, and then truncate to max_chars if necessary
|
||||||
|
if max_lines > 0:
|
||||||
truncated_explanation = input_lines[:max_lines]
|
truncated_explanation = input_lines[:max_lines]
|
||||||
|
else:
|
||||||
|
truncated_explanation = input_lines
|
||||||
truncated_char = True
|
truncated_char = True
|
||||||
# We reevaluate the need to truncate chars following removal of some lines
|
# 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 = _truncate_by_char_count(
|
||||||
truncated_explanation, max_chars
|
truncated_explanation, max_chars
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
truncated_char = False
|
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)
|
truncated_line_count = len(input_lines) - len(truncated_explanation)
|
||||||
if truncated_explanation[-1]:
|
if truncated_explanation[-1]:
|
||||||
# Add ellipsis and take into account part-truncated final line
|
# Add ellipsis and take into account part-truncated final line
|
||||||
|
|
@ -90,14 +111,15 @@ def _truncate_explanation(
|
||||||
else:
|
else:
|
||||||
# Add proper ellipsis when we were able to fit a full line exactly
|
# Add proper ellipsis when we were able to fit a full line exactly
|
||||||
truncated_explanation[-1] = "..."
|
truncated_explanation[-1] = "..."
|
||||||
return truncated_explanation + [
|
return [
|
||||||
|
*truncated_explanation,
|
||||||
"",
|
"",
|
||||||
f"...Full output truncated ({truncated_line_count} line"
|
f"...Full output truncated ({truncated_line_count} line"
|
||||||
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
|
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
|
# Find point at which input length exceeds total allowed length
|
||||||
iterated_char_count = 0
|
iterated_char_count = 0
|
||||||
for iterated_index, input_line in enumerate(input_lines):
|
for iterated_index, input_line in enumerate(input_lines):
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,54 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
"""Utilities for assertion debugging."""
|
"""Utilities for assertion debugging."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import collections.abc
|
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
|
import pprint
|
||||||
from typing import AbstractSet
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Literal
|
||||||
from typing import Iterable
|
from typing import Protocol
|
||||||
from typing import List
|
|
||||||
from typing import Mapping
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Sequence
|
|
||||||
from unicodedata import normalize
|
from unicodedata import normalize
|
||||||
|
|
||||||
import _pytest._code
|
|
||||||
from _pytest import outcomes
|
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
|
||||||
from _pytest._io.saferepr import saferepr_unlimited
|
from _pytest._io.saferepr import saferepr_unlimited
|
||||||
|
from _pytest.compat import running_on_ci
|
||||||
from _pytest.config import Config
|
from _pytest.config import Config
|
||||||
|
|
||||||
|
|
||||||
# The _reprcompare attribute on the util module is used by the new assertion
|
# The _reprcompare attribute on the util module is used by the new assertion
|
||||||
# interpretation code and assertion rewriter to detect this plugin was
|
# interpretation code and assertion rewriter to detect this plugin was
|
||||||
# loaded and in turn call the hooks defined here as part of the
|
# loaded and in turn call the hooks defined here as part of the
|
||||||
# DebugInterpreter.
|
# 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
|
# Works similarly as _reprcompare attribute. Is populated with the hook call
|
||||||
# when pytest_runtest_setup is called.
|
# 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 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:
|
def format_explanation(explanation: str) -> str:
|
||||||
|
|
@ -48,7 +66,7 @@ def format_explanation(explanation: str) -> str:
|
||||||
return "\n".join(result)
|
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.
|
r"""Return a list of individual lines in the explanation.
|
||||||
|
|
||||||
This will return a list of lines split on '\n{', '\n}' and '\n~'.
|
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
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def _format_lines(lines: Sequence[str]) -> List[str]:
|
def _format_lines(lines: Sequence[str]) -> list[str]:
|
||||||
"""Format the individual lines.
|
"""Format the individual lines.
|
||||||
|
|
||||||
This will replace the '{', '}' and '~' characters of our mini formatting
|
This will replace the '{', '}' and '~' characters of our mini formatting
|
||||||
|
|
@ -113,7 +131,7 @@ def isdict(x: Any) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def isset(x: Any) -> bool:
|
def isset(x: Any) -> bool:
|
||||||
return isinstance(x, (set, frozenset))
|
return isinstance(x, set | frozenset)
|
||||||
|
|
||||||
|
|
||||||
def isnamedtuple(obj: Any) -> bool:
|
def isnamedtuple(obj: Any) -> bool:
|
||||||
|
|
@ -132,7 +150,7 @@ def isiterable(obj: Any) -> bool:
|
||||||
try:
|
try:
|
||||||
iter(obj)
|
iter(obj)
|
||||||
return not istext(obj)
|
return not istext(obj)
|
||||||
except TypeError:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,7 +169,7 @@ def has_default_eq(
|
||||||
code_filename = obj.__eq__.__code__.co_filename
|
code_filename = obj.__eq__.__code__.co_filename
|
||||||
|
|
||||||
if isattrs(obj):
|
if isattrs(obj):
|
||||||
return "attrs generated eq" in code_filename
|
return "attrs generated " in code_filename
|
||||||
|
|
||||||
return code_filename == "<string>" # data class
|
return code_filename == "<string>" # data class
|
||||||
return True
|
return True
|
||||||
|
|
@ -159,9 +177,9 @@ def has_default_eq(
|
||||||
|
|
||||||
def assertrepr_compare(
|
def assertrepr_compare(
|
||||||
config, op: str, left: Any, right: Any, use_ascii: bool = False
|
config, op: str, left: Any, right: Any, use_ascii: bool = False
|
||||||
) -> Optional[List[str]]:
|
) -> list[str] | None:
|
||||||
"""Return specialised explanations for some operators/operands."""
|
"""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.
|
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
|
||||||
# See issue #3246.
|
# See issue #3246.
|
||||||
|
|
@ -185,34 +203,54 @@ def assertrepr_compare(
|
||||||
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
|
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
|
||||||
|
|
||||||
summary = f"{left_repr} {op} {right_repr}"
|
summary = f"{left_repr} {op} {right_repr}"
|
||||||
|
highlighter = config.get_terminal_writer()._highlight
|
||||||
|
|
||||||
explanation = None
|
explanation = None
|
||||||
try:
|
try:
|
||||||
if op == "==":
|
if op == "==":
|
||||||
explanation = _compare_eq_any(left, right, verbose)
|
explanation = _compare_eq_any(left, right, highlighter, verbose)
|
||||||
elif op == "not in":
|
elif op == "not in":
|
||||||
if istext(left) and istext(right):
|
if istext(left) and istext(right):
|
||||||
explanation = _notin_text(left, right, verbose)
|
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:
|
except outcomes.Exit:
|
||||||
raise
|
raise
|
||||||
except Exception:
|
except Exception:
|
||||||
|
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
|
||||||
explanation = [
|
explanation = [
|
||||||
"(pytest_assertion plugin: representation of details failed: {}.".format(
|
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
|
||||||
_pytest._code.ExceptionInfo.from_current()._getreprcrash()
|
|
||||||
),
|
|
||||||
" Probably an object has a faulty __repr__.)",
|
" Probably an object has a faulty __repr__.)",
|
||||||
]
|
]
|
||||||
|
|
||||||
if not explanation:
|
if not explanation:
|
||||||
return None
|
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 = []
|
explanation = []
|
||||||
if istext(left) and istext(right):
|
if istext(left) and istext(right):
|
||||||
explanation = _diff_text(left, right, verbose)
|
explanation = _diff_text(left, right, highlighter, verbose)
|
||||||
else:
|
else:
|
||||||
from _pytest.python_api import ApproxBase
|
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
|
other_side = right if isinstance(left, ApproxBase) else left
|
||||||
|
|
||||||
explanation = approx_side._repr_compare(other_side)
|
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)
|
isdatacls(left) or isattrs(left) or isnamedtuple(left)
|
||||||
):
|
):
|
||||||
# Note: unlike dataclasses/attrs, namedtuples compare only the
|
# Note: unlike dataclasses/attrs, namedtuples compare only the
|
||||||
# field values, not the type or field names. But this branch
|
# field values, not the type or field names. But this branch
|
||||||
# intentionally only handles the same-type case, which was often
|
# intentionally only handles the same-type case, which was often
|
||||||
# used in older code bases before dataclasses/attrs were available.
|
# 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):
|
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):
|
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):
|
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):
|
if isiterable(left) and isiterable(right):
|
||||||
expl = _compare_eq_iterable(left, right, verbose)
|
expl = _compare_eq_iterable(left, right, highlighter, verbose)
|
||||||
explanation.extend(expl)
|
explanation.extend(expl)
|
||||||
|
|
||||||
return explanation
|
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.
|
"""Return the explanation for the diff between text.
|
||||||
|
|
||||||
Unless --verbose is used this will skip leading and trailing
|
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
|
from difflib import ndiff
|
||||||
|
|
||||||
explanation: List[str] = []
|
explanation: list[str] = []
|
||||||
|
|
||||||
if verbose < 1:
|
if verbose < 1:
|
||||||
i = 0 # just in case left or right has zero length
|
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:
|
if i > 42:
|
||||||
i -= 10 # Provide some context
|
i -= 10 # Provide some context
|
||||||
explanation = [
|
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:]
|
left = left[i:]
|
||||||
right = right[i:]
|
right = right[i:]
|
||||||
|
|
@ -273,8 +313,8 @@ def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]:
|
||||||
if i > 42:
|
if i > 42:
|
||||||
i -= 10 # Provide some context
|
i -= 10 # Provide some context
|
||||||
explanation += [
|
explanation += [
|
||||||
"Skipping {} identical trailing "
|
f"Skipping {i} identical trailing "
|
||||||
"characters in diff, use -v to show".format(i)
|
"characters in diff, use -v to show"
|
||||||
]
|
]
|
||||||
left = left[:-i]
|
left = left[:-i]
|
||||||
right = right[:-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()"]
|
explanation += ["Strings contain only whitespace, escaping them using repr()"]
|
||||||
# "right" is the expected base against which we compare "left",
|
# "right" is the expected base against which we compare "left",
|
||||||
# see https://github.com/pytest-dev/pytest/issues/3333
|
# see https://github.com/pytest-dev/pytest/issues/3333
|
||||||
explanation += [
|
explanation.extend(
|
||||||
|
highlighter(
|
||||||
|
"\n".join(
|
||||||
line.strip("\n")
|
line.strip("\n")
|
||||||
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
|
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
|
||||||
]
|
),
|
||||||
|
lexer="diff",
|
||||||
|
).splitlines()
|
||||||
|
)
|
||||||
return explanation
|
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(
|
def _compare_eq_iterable(
|
||||||
left: Iterable[Any], right: Iterable[Any], verbose: int = 0
|
left: Iterable[Any],
|
||||||
) -> List[str]:
|
right: Iterable[Any],
|
||||||
|
highlighter: _HighlightFunc,
|
||||||
|
verbose: int = 0,
|
||||||
|
) -> list[str]:
|
||||||
if verbose <= 0 and not running_on_ci():
|
if verbose <= 0 and not running_on_ci():
|
||||||
return ["Use -v to get more diff"]
|
return ["Use -v to get more diff"]
|
||||||
# dynamic import to speedup pytest
|
# dynamic import to speedup pytest
|
||||||
import difflib
|
import difflib
|
||||||
|
|
||||||
left_formatting = pprint.pformat(left).splitlines()
|
left_formatting = PrettyPrinter().pformat(left).splitlines()
|
||||||
right_formatting = pprint.pformat(right).splitlines()
|
right_formatting = PrettyPrinter().pformat(right).splitlines()
|
||||||
|
|
||||||
# Re-format for different output lengths.
|
explanation = ["", "Full diff:"]
|
||||||
lines_left = len(left_formatting)
|
|
||||||
lines_right = len(right_formatting)
|
|
||||||
if lines_left != lines_right:
|
|
||||||
left_formatting = _pformat_dispatch(left).splitlines()
|
|
||||||
right_formatting = _pformat_dispatch(right).splitlines()
|
|
||||||
|
|
||||||
if lines_left > 1 or lines_right > 1:
|
|
||||||
_surrounding_parens_on_own_lines(left_formatting)
|
|
||||||
_surrounding_parens_on_own_lines(right_formatting)
|
|
||||||
|
|
||||||
explanation = ["Full diff:"]
|
|
||||||
# "right" is the expected base against which we compare "left",
|
# "right" is the expected base against which we compare "left",
|
||||||
# see https://github.com/pytest-dev/pytest/issues/3333
|
# see https://github.com/pytest-dev/pytest/issues/3333
|
||||||
explanation.extend(
|
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
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
def _compare_eq_sequence(
|
def _compare_eq_sequence(
|
||||||
left: Sequence[Any], right: Sequence[Any], verbose: int = 0
|
left: Sequence[Any],
|
||||||
) -> List[str]:
|
right: Sequence[Any],
|
||||||
|
highlighter: _HighlightFunc,
|
||||||
|
verbose: int = 0,
|
||||||
|
) -> list[str]:
|
||||||
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
|
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
|
||||||
explanation: List[str] = []
|
explanation: list[str] = []
|
||||||
len_left = len(left)
|
len_left = len(left)
|
||||||
len_right = len(right)
|
len_right = len(right)
|
||||||
for i in range(min(len_left, len_right)):
|
for i in range(min(len_left, len_right)):
|
||||||
|
|
@ -359,7 +393,10 @@ def _compare_eq_sequence(
|
||||||
left_value = left[i]
|
left_value = left[i]
|
||||||
right_value = right[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
|
break
|
||||||
|
|
||||||
if comparing_bytes:
|
if comparing_bytes:
|
||||||
|
|
@ -379,74 +416,134 @@ def _compare_eq_sequence(
|
||||||
extra = saferepr(right[len_left])
|
extra = saferepr(right[len_left])
|
||||||
|
|
||||||
if len_diff == 1:
|
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:
|
else:
|
||||||
explanation += [
|
explanation += [
|
||||||
"%s contains %d more items, first extra item: %s"
|
f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
|
||||||
% (dir_with_more, len_diff, extra)
|
|
||||||
]
|
]
|
||||||
return explanation
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
def _compare_eq_set(
|
def _compare_eq_set(
|
||||||
left: AbstractSet[Any], right: AbstractSet[Any], verbose: int = 0
|
left: AbstractSet[Any],
|
||||||
) -> List[str]:
|
right: AbstractSet[Any],
|
||||||
|
highlighter: _HighlightFunc,
|
||||||
|
verbose: int = 0,
|
||||||
|
) -> list[str]:
|
||||||
explanation = []
|
explanation = []
|
||||||
diff_left = left - right
|
explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
|
||||||
diff_right = right - left
|
explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
|
||||||
if diff_left:
|
return explanation
|
||||||
explanation.append("Extra items in the left set:")
|
|
||||||
for item in diff_left:
|
|
||||||
explanation.append(saferepr(item))
|
def _compare_gt_set(
|
||||||
if diff_right:
|
left: AbstractSet[Any],
|
||||||
explanation.append("Extra items in the right set:")
|
right: AbstractSet[Any],
|
||||||
for item in diff_right:
|
highlighter: _HighlightFunc,
|
||||||
explanation.append(saferepr(item))
|
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
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
def _compare_eq_dict(
|
def _compare_eq_dict(
|
||||||
left: Mapping[Any, Any], right: Mapping[Any, Any], verbose: int = 0
|
left: Mapping[Any, Any],
|
||||||
) -> List[str]:
|
right: Mapping[Any, Any],
|
||||||
explanation: List[str] = []
|
highlighter: _HighlightFunc,
|
||||||
|
verbose: int = 0,
|
||||||
|
) -> list[str]:
|
||||||
|
explanation: list[str] = []
|
||||||
set_left = set(left)
|
set_left = set(left)
|
||||||
set_right = set(right)
|
set_right = set(right)
|
||||||
common = set_left.intersection(set_right)
|
common = set_left.intersection(set_right)
|
||||||
same = {k: left[k] for k in common if left[k] == right[k]}
|
same = {k: left[k] for k in common if left[k] == right[k]}
|
||||||
if same and verbose < 2:
|
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:
|
elif same:
|
||||||
explanation += ["Common items:"]
|
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]}
|
diff = {k for k in common if left[k] != right[k]}
|
||||||
if diff:
|
if diff:
|
||||||
explanation += ["Differing items:"]
|
explanation += ["Differing items:"]
|
||||||
for k in diff:
|
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
|
extra_left = set_left - set_right
|
||||||
len_extra_left = len(extra_left)
|
len_extra_left = len(extra_left)
|
||||||
if len_extra_left:
|
if len_extra_left:
|
||||||
explanation.append(
|
explanation.append(
|
||||||
"Left contains %d more item%s:"
|
f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
|
||||||
% (len_extra_left, "" if len_extra_left == 1 else "s")
|
|
||||||
)
|
)
|
||||||
explanation.extend(
|
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
|
extra_right = set_right - set_left
|
||||||
len_extra_right = len(extra_right)
|
len_extra_right = len(extra_right)
|
||||||
if len_extra_right:
|
if len_extra_right:
|
||||||
explanation.append(
|
explanation.append(
|
||||||
"Right contains %d more item%s:"
|
f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
|
||||||
% (len_extra_right, "" if len_extra_right == 1 else "s")
|
|
||||||
)
|
)
|
||||||
explanation.extend(
|
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
|
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):
|
if not has_default_eq(left):
|
||||||
return []
|
return []
|
||||||
if isdatacls(left):
|
if isdatacls(left):
|
||||||
|
|
@ -475,35 +572,37 @@ def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
|
||||||
if same or diff:
|
if same or diff:
|
||||||
explanation += [""]
|
explanation += [""]
|
||||||
if same and verbose < 2:
|
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:
|
elif same:
|
||||||
explanation += ["Matching attributes:"]
|
explanation += ["Matching attributes:"]
|
||||||
explanation += pprint.pformat(same).splitlines()
|
explanation += highlighter(pprint.pformat(same)).splitlines()
|
||||||
if diff:
|
if diff:
|
||||||
explanation += ["Differing attributes:"]
|
explanation += ["Differing attributes:"]
|
||||||
explanation += pprint.pformat(diff).splitlines()
|
explanation += highlighter(pprint.pformat(diff)).splitlines()
|
||||||
for field in diff:
|
for field in diff:
|
||||||
field_left = getattr(left, field)
|
field_left = getattr(left, field)
|
||||||
field_right = getattr(right, field)
|
field_right = getattr(right, field)
|
||||||
explanation += [
|
explanation += [
|
||||||
"",
|
"",
|
||||||
"Drill down into differing attribute %s:" % field,
|
f"Drill down into differing attribute {field}:",
|
||||||
("%s%s: %r != %r") % (indent, field, field_left, field_right),
|
f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}",
|
||||||
]
|
]
|
||||||
explanation += [
|
explanation += [
|
||||||
indent + line
|
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
|
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)
|
index = text.find(term)
|
||||||
head = text[:index]
|
head = text[:index]
|
||||||
tail = text[index + len(term) :]
|
tail = text[index + len(term) :]
|
||||||
correct_text = head + tail
|
correct_text = head + tail
|
||||||
diff = _diff_text(text, correct_text, verbose)
|
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
|
||||||
newdiff = ["%s is contained here:" % saferepr(term, maxsize=42)]
|
newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"]
|
||||||
for line in diff:
|
for line in diff:
|
||||||
if line.startswith("Skipping"):
|
if line.startswith("Skipping"):
|
||||||
continue
|
continue
|
||||||
|
|
@ -514,9 +613,3 @@ def _notin_text(term: str, text: str, verbose: int = 0) -> List[str]:
|
||||||
else:
|
else:
|
||||||
newdiff.append(line)
|
newdiff.append(line)
|
||||||
return newdiff
|
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)
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,25 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
"""Implementation of the cache provider."""
|
"""Implementation of the cache provider."""
|
||||||
|
|
||||||
# This plugin was not named "cache" to avoid conflicts with the external
|
# This plugin was not named "cache" to avoid conflicts with the external
|
||||||
# pytest-cache version.
|
# pytest-cache version.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
from collections.abc import Iterable
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import errno
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict
|
import tempfile
|
||||||
from typing import Generator
|
from typing import final
|
||||||
from typing import Iterable
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
from typing import Set
|
|
||||||
from typing import Union
|
|
||||||
|
|
||||||
from .pathlib import resolve_from_str
|
from .pathlib import resolve_from_str
|
||||||
from .pathlib import rm_rf
|
from .pathlib import rm_rf
|
||||||
from .reports import CollectReport
|
from .reports import CollectReport
|
||||||
from _pytest import nodes
|
from _pytest import nodes
|
||||||
from _pytest._io import TerminalWriter
|
from _pytest._io import TerminalWriter
|
||||||
from _pytest.compat import final
|
|
||||||
from _pytest.config import Config
|
from _pytest.config import Config
|
||||||
from _pytest.config import ExitCode
|
from _pytest.config import ExitCode
|
||||||
from _pytest.config import hookimpl
|
from _pytest.config import hookimpl
|
||||||
|
|
@ -27,10 +28,11 @@ from _pytest.deprecated import check_ispytest
|
||||||
from _pytest.fixtures import fixture
|
from _pytest.fixtures import fixture
|
||||||
from _pytest.fixtures import FixtureRequest
|
from _pytest.fixtures import FixtureRequest
|
||||||
from _pytest.main import Session
|
from _pytest.main import Session
|
||||||
|
from _pytest.nodes import Directory
|
||||||
from _pytest.nodes import File
|
from _pytest.nodes import File
|
||||||
from _pytest.python import Package
|
|
||||||
from _pytest.reports import TestReport
|
from _pytest.reports import TestReport
|
||||||
|
|
||||||
|
|
||||||
README_CONTENT = """\
|
README_CONTENT = """\
|
||||||
# pytest cache directory #
|
# pytest cache directory #
|
||||||
|
|
||||||
|
|
@ -72,7 +74,7 @@ class Cache:
|
||||||
self._config = config
|
self._config = config
|
||||||
|
|
||||||
@classmethod
|
@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.
|
"""Create the Cache instance for a Config.
|
||||||
|
|
||||||
:meta private:
|
:meta private:
|
||||||
|
|
@ -111,6 +113,7 @@ class Cache:
|
||||||
"""
|
"""
|
||||||
check_ispytest(_ispytest)
|
check_ispytest(_ispytest)
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from _pytest.warning_types import PytestCacheWarning
|
from _pytest.warning_types import PytestCacheWarning
|
||||||
|
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|
@ -119,6 +122,10 @@ class Cache:
|
||||||
stacklevel=3,
|
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:
|
def mkdir(self, name: str) -> Path:
|
||||||
"""Return a directory path object with the given name.
|
"""Return a directory path object with the given name.
|
||||||
|
|
||||||
|
|
@ -137,7 +144,7 @@ class Cache:
|
||||||
if len(path.parts) > 1:
|
if len(path.parts) > 1:
|
||||||
raise ValueError("name is not allowed to contain path separators")
|
raise ValueError("name is not allowed to contain path separators")
|
||||||
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
|
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
|
||||||
res.mkdir(exist_ok=True, parents=True)
|
self._mkdir(res)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
def _getvaluepath(self, key: str) -> Path:
|
def _getvaluepath(self, key: str) -> Path:
|
||||||
|
|
@ -174,19 +181,13 @@ class Cache:
|
||||||
"""
|
"""
|
||||||
path = self._getvaluepath(key)
|
path = self._getvaluepath(key)
|
||||||
try:
|
try:
|
||||||
if path.parent.is_dir():
|
self._mkdir(path.parent)
|
||||||
cache_dir_exists_already = True
|
|
||||||
else:
|
|
||||||
cache_dir_exists_already = self._cachedir.exists()
|
|
||||||
path.parent.mkdir(exist_ok=True, parents=True)
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.warn(
|
self.warn(
|
||||||
f"could not create cache path {path}: {exc}",
|
f"could not create cache path {path}: {exc}",
|
||||||
_ispytest=True,
|
_ispytest=True,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not cache_dir_exists_already:
|
|
||||||
self._ensure_supporting_files()
|
|
||||||
data = json.dumps(value, ensure_ascii=False, indent=2)
|
data = json.dumps(value, ensure_ascii=False, indent=2)
|
||||||
try:
|
try:
|
||||||
f = path.open("w", encoding="UTF-8")
|
f = path.open("w", encoding="UTF-8")
|
||||||
|
|
@ -199,60 +200,85 @@ class Cache:
|
||||||
with f:
|
with f:
|
||||||
f.write(data)
|
f.write(data)
|
||||||
|
|
||||||
def _ensure_supporting_files(self) -> None:
|
def _ensure_cache_dir_and_supporting_files(self) -> None:
|
||||||
"""Create supporting files in the cache dir that are not really part of the cache."""
|
"""Create the cache dir and its supporting files."""
|
||||||
readme_path = self._cachedir / "README.md"
|
if self._cachedir.is_dir():
|
||||||
readme_path.write_text(README_CONTENT, encoding="UTF-8")
|
return
|
||||||
|
|
||||||
gitignore_path = self._cachedir.joinpath(".gitignore")
|
self._cachedir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
msg = "# Created by pytest automatically.\n*\n"
|
with tempfile.TemporaryDirectory(
|
||||||
gitignore_path.write_text(msg, encoding="UTF-8")
|
prefix="pytest-cache-files-",
|
||||||
|
dir=self._cachedir.parent,
|
||||||
|
) as newpath:
|
||||||
|
path = Path(newpath)
|
||||||
|
|
||||||
cachedir_tag_path = self._cachedir.joinpath("CACHEDIR.TAG")
|
# Reset permissions to the default, see #12308.
|
||||||
cachedir_tag_path.write_bytes(CACHEDIR_TAG_CONTENT)
|
# 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:
|
class LFPluginCollWrapper:
|
||||||
def __init__(self, lfplugin: "LFPlugin") -> None:
|
def __init__(self, lfplugin: LFPlugin) -> None:
|
||||||
self.lfplugin = lfplugin
|
self.lfplugin = lfplugin
|
||||||
self._collected_at_least_one_failure = False
|
self._collected_at_least_one_failure = False
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_make_collect_report(self, collector: nodes.Collector):
|
def pytest_make_collect_report(
|
||||||
if isinstance(collector, (Session, Package)):
|
self, collector: nodes.Collector
|
||||||
out = yield
|
) -> Generator[None, CollectReport, CollectReport]:
|
||||||
res: CollectReport = out.get_result()
|
res = yield
|
||||||
|
if isinstance(collector, Session | Directory):
|
||||||
# Sort any lf-paths to the beginning.
|
# Sort any lf-paths to the beginning.
|
||||||
lf_paths = self.lfplugin._last_failed_paths
|
lf_paths = self.lfplugin._last_failed_paths
|
||||||
|
|
||||||
# Use stable sort to priorize last failed.
|
# Use stable sort to prioritize last failed.
|
||||||
def sort_key(node: Union[nodes.Item, nodes.Collector]) -> bool:
|
def sort_key(node: nodes.Item | nodes.Collector) -> bool:
|
||||||
# Package.path is the __init__.py file, we need the directory.
|
return node.path in lf_paths
|
||||||
if isinstance(node, Package):
|
|
||||||
path = node.path.parent
|
|
||||||
else:
|
|
||||||
path = node.path
|
|
||||||
return path in lf_paths
|
|
||||||
|
|
||||||
res.result = sorted(
|
res.result = sorted(
|
||||||
res.result,
|
res.result,
|
||||||
key=sort_key,
|
key=sort_key,
|
||||||
reverse=True,
|
reverse=True,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
elif isinstance(collector, File):
|
elif isinstance(collector, File):
|
||||||
if collector.path in self.lfplugin._last_failed_paths:
|
if collector.path in self.lfplugin._last_failed_paths:
|
||||||
out = yield
|
|
||||||
res = out.get_result()
|
|
||||||
result = res.result
|
result = res.result
|
||||||
lastfailed = self.lfplugin.lastfailed
|
lastfailed = self.lfplugin.lastfailed
|
||||||
|
|
||||||
# Only filter with known failures.
|
# Only filter with known failures.
|
||||||
if not self._collected_at_least_one_failure:
|
if not self._collected_at_least_one_failure:
|
||||||
if not any(x.nodeid in lastfailed for x in result):
|
if not any(x.nodeid in lastfailed for x in result):
|
||||||
return
|
return res
|
||||||
self.lfplugin.config.pluginmanager.register(
|
self.lfplugin.config.pluginmanager.register(
|
||||||
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
|
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
|
||||||
)
|
)
|
||||||
|
|
@ -268,21 +294,19 @@ class LFPluginCollWrapper:
|
||||||
# Keep all sub-collectors.
|
# Keep all sub-collectors.
|
||||||
or isinstance(x, nodes.Collector)
|
or isinstance(x, nodes.Collector)
|
||||||
]
|
]
|
||||||
return
|
|
||||||
yield
|
return res
|
||||||
|
|
||||||
|
|
||||||
class LFPluginCollSkipfiles:
|
class LFPluginCollSkipfiles:
|
||||||
def __init__(self, lfplugin: "LFPlugin") -> None:
|
def __init__(self, lfplugin: LFPlugin) -> None:
|
||||||
self.lfplugin = lfplugin
|
self.lfplugin = lfplugin
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def pytest_make_collect_report(
|
def pytest_make_collect_report(
|
||||||
self, collector: nodes.Collector
|
self, collector: nodes.Collector
|
||||||
) -> Optional[CollectReport]:
|
) -> CollectReport | None:
|
||||||
# Packages are Files, but we only want to skip test-bearing Files,
|
if isinstance(collector, File):
|
||||||
# so don't filter Packages.
|
|
||||||
if isinstance(collector, File) and not isinstance(collector, Package):
|
|
||||||
if collector.path not in self.lfplugin._last_failed_paths:
|
if collector.path not in self.lfplugin._last_failed_paths:
|
||||||
self.lfplugin._skipped_files += 1
|
self.lfplugin._skipped_files += 1
|
||||||
|
|
||||||
|
|
@ -300,9 +324,9 @@ class LFPlugin:
|
||||||
active_keys = "lf", "failedfirst"
|
active_keys = "lf", "failedfirst"
|
||||||
self.active = any(config.getoption(key) for key in active_keys)
|
self.active = any(config.getoption(key) for key in active_keys)
|
||||||
assert config.cache
|
assert config.cache
|
||||||
self.lastfailed: Dict[str, bool] = config.cache.get("cache/lastfailed", {})
|
self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {})
|
||||||
self._previously_failed_count: Optional[int] = None
|
self._previously_failed_count: int | None = None
|
||||||
self._report_status: Optional[str] = None
|
self._report_status: str | None = None
|
||||||
self._skipped_files = 0 # count skipped files during collection due to --lf
|
self._skipped_files = 0 # count skipped files during collection due to --lf
|
||||||
|
|
||||||
if config.getoption("lf"):
|
if config.getoption("lf"):
|
||||||
|
|
@ -311,7 +335,7 @@ class LFPlugin:
|
||||||
LFPluginCollWrapper(self), "lfplugin-collwrapper"
|
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
|
"""Return a set with all Paths of the previously failed nodeids and
|
||||||
their parents."""
|
their parents."""
|
||||||
rootpath = self.config.rootpath
|
rootpath = self.config.rootpath
|
||||||
|
|
@ -322,9 +346,9 @@ class LFPlugin:
|
||||||
result.update(path.parents)
|
result.update(path.parents)
|
||||||
return {x for x in result if x.exists()}
|
return {x for x in result if x.exists()}
|
||||||
|
|
||||||
def pytest_report_collectionfinish(self) -> Optional[str]:
|
def pytest_report_collectionfinish(self) -> str | None:
|
||||||
if self.active and self.config.getoption("verbose") >= 0:
|
if self.active and self.config.get_verbosity() >= 0:
|
||||||
return "run-last-failure: %s" % self._report_status
|
return f"run-last-failure: {self._report_status}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def pytest_runtest_logreport(self, report: TestReport) -> None:
|
def pytest_runtest_logreport(self, report: TestReport) -> None:
|
||||||
|
|
@ -342,14 +366,14 @@ class LFPlugin:
|
||||||
else:
|
else:
|
||||||
self.lastfailed[report.nodeid] = True
|
self.lastfailed[report.nodeid] = True
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True, tryfirst=True)
|
@hookimpl(wrapper=True, tryfirst=True)
|
||||||
def pytest_collection_modifyitems(
|
def pytest_collection_modifyitems(
|
||||||
self, config: Config, items: List[nodes.Item]
|
self, config: Config, items: list[nodes.Item]
|
||||||
) -> Generator[None, None, None]:
|
) -> Generator[None]:
|
||||||
yield
|
res = yield
|
||||||
|
|
||||||
if not self.active:
|
if not self.active:
|
||||||
return
|
return res
|
||||||
|
|
||||||
if self.lastfailed:
|
if self.lastfailed:
|
||||||
previously_failed = []
|
previously_failed = []
|
||||||
|
|
@ -364,8 +388,8 @@ class LFPlugin:
|
||||||
if not previously_failed:
|
if not previously_failed:
|
||||||
# Running a subset of all tests with recorded failures
|
# Running a subset of all tests with recorded failures
|
||||||
# only outside of it.
|
# only outside of it.
|
||||||
self._report_status = "%d known failures not in selected tests" % (
|
self._report_status = (
|
||||||
len(self.lastfailed),
|
f"{len(self.lastfailed)} known failures not in selected tests"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if self.config.getoption("lf"):
|
if self.config.getoption("lf"):
|
||||||
|
|
@ -376,15 +400,13 @@ class LFPlugin:
|
||||||
|
|
||||||
noun = "failure" if self._previously_failed_count == 1 else "failures"
|
noun = "failure" if self._previously_failed_count == 1 else "failures"
|
||||||
suffix = " first" if self.config.getoption("failedfirst") else ""
|
suffix = " first" if self.config.getoption("failedfirst") else ""
|
||||||
self._report_status = "rerun previous {count} {noun}{suffix}".format(
|
self._report_status = (
|
||||||
count=self._previously_failed_count, suffix=suffix, noun=noun
|
f"rerun previous {self._previously_failed_count} {noun}{suffix}"
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._skipped_files > 0:
|
if self._skipped_files > 0:
|
||||||
files_noun = "file" if self._skipped_files == 1 else "files"
|
files_noun = "file" if self._skipped_files == 1 else "files"
|
||||||
self._report_status += " (skipped {files} {files_noun})".format(
|
self._report_status += f" (skipped {self._skipped_files} {files_noun})"
|
||||||
files=self._skipped_files, files_noun=files_noun
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self._report_status = "no previously failed tests, "
|
self._report_status = "no previously failed tests, "
|
||||||
if self.config.getoption("last_failed_no_failures") == "none":
|
if self.config.getoption("last_failed_no_failures") == "none":
|
||||||
|
|
@ -394,6 +416,8 @@ class LFPlugin:
|
||||||
else:
|
else:
|
||||||
self._report_status += "not deselecting items."
|
self._report_status += "not deselecting items."
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
def pytest_sessionfinish(self, session: Session) -> None:
|
def pytest_sessionfinish(self, session: Session) -> None:
|
||||||
config = self.config
|
config = self.config
|
||||||
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
|
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
|
||||||
|
|
@ -414,15 +438,13 @@ class NFPlugin:
|
||||||
assert config.cache is not None
|
assert config.cache is not None
|
||||||
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
|
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True, tryfirst=True)
|
@hookimpl(wrapper=True, tryfirst=True)
|
||||||
def pytest_collection_modifyitems(
|
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]:
|
||||||
self, items: List[nodes.Item]
|
res = yield
|
||||||
) -> Generator[None, None, None]:
|
|
||||||
yield
|
|
||||||
|
|
||||||
if self.active:
|
if self.active:
|
||||||
new_items: Dict[str, nodes.Item] = {}
|
new_items: dict[str, nodes.Item] = {}
|
||||||
other_items: Dict[str, nodes.Item] = {}
|
other_items: dict[str, nodes.Item] = {}
|
||||||
for item in items:
|
for item in items:
|
||||||
if item.nodeid not in self.cached_nodeids:
|
if item.nodeid not in self.cached_nodeids:
|
||||||
new_items[item.nodeid] = item
|
new_items[item.nodeid] = item
|
||||||
|
|
@ -436,8 +458,10 @@ class NFPlugin:
|
||||||
else:
|
else:
|
||||||
self.cached_nodeids.update(item.nodeid for item in items)
|
self.cached_nodeids.update(item.nodeid for item in items)
|
||||||
|
|
||||||
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> List[nodes.Item]:
|
return res
|
||||||
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True) # type: ignore[no-any-return]
|
|
||||||
|
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:
|
def pytest_sessionfinish(self) -> None:
|
||||||
config = self.config
|
config = self.config
|
||||||
|
|
@ -452,14 +476,17 @@ class NFPlugin:
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser: Parser) -> None:
|
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 = parser.getgroup("general")
|
||||||
group.addoption(
|
group.addoption(
|
||||||
"--lf",
|
"--lf",
|
||||||
"--last-failed",
|
"--last-failed",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
dest="lf",
|
dest="lf",
|
||||||
help="Rerun only the tests that failed "
|
help="Rerun only the tests that failed at the last run (or all if none failed)",
|
||||||
"at the last run (or all if none failed)",
|
|
||||||
)
|
)
|
||||||
group.addoption(
|
group.addoption(
|
||||||
"--ff",
|
"--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:
|
if config.option.cacheshow and not config.option.help:
|
||||||
from _pytest.main import wrap_session
|
from _pytest.main import wrap_session
|
||||||
|
|
||||||
|
|
@ -523,6 +550,13 @@ def pytest_cmdline_main(config: Config) -> Optional[Union[int, ExitCode]]:
|
||||||
|
|
||||||
@hookimpl(tryfirst=True)
|
@hookimpl(tryfirst=True)
|
||||||
def pytest_configure(config: Config) -> None:
|
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.cache = Cache.for_config(config, _ispytest=True)
|
||||||
config.pluginmanager.register(LFPlugin(config), "lfplugin")
|
config.pluginmanager.register(LFPlugin(config), "lfplugin")
|
||||||
config.pluginmanager.register(NFPlugin(config), "nfplugin")
|
config.pluginmanager.register(NFPlugin(config), "nfplugin")
|
||||||
|
|
@ -544,7 +578,7 @@ def cache(request: FixtureRequest) -> Cache:
|
||||||
return request.config.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."""
|
"""Display cachedir with --cache-show and if non-default."""
|
||||||
if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache":
|
if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache":
|
||||||
assert config.cache is not None
|
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:
|
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
|
from pprint import pformat
|
||||||
|
|
||||||
assert config.cache is not None
|
assert config.cache is not None
|
||||||
|
|
@ -578,25 +622,25 @@ def cacheshow(config: Config, session: Session) -> int:
|
||||||
dummy = object()
|
dummy = object()
|
||||||
basedir = config.cache._cachedir
|
basedir = config.cache._cachedir
|
||||||
vdir = basedir / Cache._CACHE_PREFIX_VALUES
|
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()):
|
for valpath in sorted(x for x in vdir.rglob(glob) if x.is_file()):
|
||||||
key = str(valpath.relative_to(vdir))
|
key = str(valpath.relative_to(vdir))
|
||||||
val = config.cache.get(key, dummy)
|
val = config.cache.get(key, dummy)
|
||||||
if val is 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:
|
else:
|
||||||
tw.line("%s contains:" % key)
|
tw.line(f"{key} contains:")
|
||||||
for line in pformat(val).splitlines():
|
for line in pformat(val).splitlines():
|
||||||
tw.line(" " + line)
|
tw.line(" " + line)
|
||||||
|
|
||||||
ddir = basedir / Cache._CACHE_PREFIX_DIRS
|
ddir = basedir / Cache._CACHE_PREFIX_DIRS
|
||||||
if ddir.is_dir():
|
if ddir.is_dir():
|
||||||
contents = sorted(ddir.rglob(glob))
|
contents = sorted(ddir.rglob(glob))
|
||||||
tw.sep("-", "cache directories for %r" % glob)
|
tw.sep("-", f"cache directories for {glob!r}")
|
||||||
for p in contents:
|
for p in contents:
|
||||||
# if p.is_dir():
|
# if p.is_dir():
|
||||||
# print("%s/" % p.relative_to(basedir))
|
# print("%s/" % p.relative_to(basedir))
|
||||||
if p.is_file():
|
if p.is_file():
|
||||||
key = str(p.relative_to(basedir))
|
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
|
return 0
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,36 @@
|
||||||
|
# mypy: allow-untyped-defs
|
||||||
"""Per-test stdout/stderr capturing mechanism."""
|
"""Per-test stdout/stderr capturing mechanism."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import abc
|
import abc
|
||||||
import collections
|
import collections
|
||||||
|
from collections.abc import Generator
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Iterator
|
||||||
import contextlib
|
import contextlib
|
||||||
import io
|
import io
|
||||||
|
from io import UnsupportedOperation
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from io import UnsupportedOperation
|
|
||||||
from tempfile import TemporaryFile
|
from tempfile import TemporaryFile
|
||||||
from types import TracebackType
|
from types import TracebackType
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import AnyStr
|
from typing import AnyStr
|
||||||
from typing import BinaryIO
|
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 Generic
|
||||||
from typing import Iterable
|
from typing import Literal
|
||||||
from typing import Iterator
|
|
||||||
from typing import List
|
|
||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from typing import Optional
|
|
||||||
from typing import TextIO
|
from typing import TextIO
|
||||||
from typing import Tuple
|
|
||||||
from typing import Type
|
|
||||||
from typing import TYPE_CHECKING
|
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 Config
|
||||||
from _pytest.config import hookimpl
|
from _pytest.config import hookimpl
|
||||||
from _pytest.config.argparsing import Parser
|
from _pytest.config.argparsing import Parser
|
||||||
|
|
@ -34,17 +40,15 @@ from _pytest.fixtures import SubRequest
|
||||||
from _pytest.nodes import Collector
|
from _pytest.nodes import Collector
|
||||||
from _pytest.nodes import File
|
from _pytest.nodes import File
|
||||||
from _pytest.nodes import Item
|
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:
|
def pytest_addoption(parser: Parser) -> None:
|
||||||
group = parser.getgroup("general")
|
group = parser.getgroup("general")
|
||||||
group._addoption(
|
group.addoption(
|
||||||
"--capture",
|
"--capture",
|
||||||
action="store",
|
action="store",
|
||||||
default="fd",
|
default="fd",
|
||||||
|
|
@ -52,7 +56,7 @@ def pytest_addoption(parser: Parser) -> None:
|
||||||
choices=["fd", "sys", "no", "tee-sys"],
|
choices=["fd", "sys", "no", "tee-sys"],
|
||||||
help="Per-test capturing method: one of 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",
|
"-s",
|
||||||
action="store_const",
|
action="store_const",
|
||||||
const="no",
|
const="no",
|
||||||
|
|
@ -76,6 +80,23 @@ def _colorama_workaround() -> None:
|
||||||
pass
|
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:
|
def _windowsconsoleio_workaround(stream: TextIO) -> None:
|
||||||
"""Workaround for Windows Unicode console handling.
|
"""Workaround for Windows Unicode console handling.
|
||||||
|
|
||||||
|
|
@ -104,17 +125,16 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666).
|
# 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
|
return
|
||||||
|
|
||||||
buffered = hasattr(stream.buffer, "raw")
|
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
|
||||||
raw_stdout = stream.buffer.raw if buffered else stream.buffer # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined]
|
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
|
||||||
return
|
return
|
||||||
|
|
||||||
def _reopen_stdio(f, mode):
|
def _reopen_stdio(f, mode):
|
||||||
if not buffered and mode[0] == "w":
|
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
|
||||||
buffering = 0
|
buffering = 0
|
||||||
else:
|
else:
|
||||||
buffering = -1
|
buffering = -1
|
||||||
|
|
@ -132,12 +152,13 @@ def _windowsconsoleio_workaround(stream: TextIO) -> None:
|
||||||
sys.stderr = _reopen_stdio(sys.stderr, "wb")
|
sys.stderr = _reopen_stdio(sys.stderr, "wb")
|
||||||
|
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_load_initial_conftests(early_config: Config):
|
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
|
||||||
ns = early_config.known_args_namespace
|
ns = early_config.known_args_namespace
|
||||||
if ns.capture == "fd":
|
if ns.capture == "fd":
|
||||||
_windowsconsoleio_workaround(sys.stdout)
|
_windowsconsoleio_workaround(sys.stdout)
|
||||||
_colorama_workaround()
|
_colorama_workaround()
|
||||||
|
_readline_workaround()
|
||||||
pluginmanager = early_config.pluginmanager
|
pluginmanager = early_config.pluginmanager
|
||||||
capman = CaptureManager(ns.capture)
|
capman = CaptureManager(ns.capture)
|
||||||
pluginmanager.register(capman, "capturemanager")
|
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).
|
# Finally trigger conftest loading but while capturing (issue #93).
|
||||||
capman.start_global_capturing()
|
capman.start_global_capturing()
|
||||||
outcome = yield
|
try:
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
capman.suspend_global_capture()
|
capman.suspend_global_capture()
|
||||||
if outcome.excinfo is not None:
|
except BaseException:
|
||||||
out, err = capman.read_global_capture()
|
out, err = capman.read_global_capture()
|
||||||
sys.stdout.write(out)
|
sys.stdout.write(out)
|
||||||
sys.stderr.write(err)
|
sys.stderr.write(err)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
# IO Helpers.
|
# IO Helpers.
|
||||||
|
|
@ -171,7 +196,8 @@ class EncodedFile(io.TextIOWrapper):
|
||||||
def mode(self) -> str:
|
def mode(self) -> str:
|
||||||
# TextIOWrapper doesn't expose a mode, but at least some of our
|
# TextIOWrapper doesn't expose a mode, but at least some of our
|
||||||
# tests check it.
|
# 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):
|
class CaptureIO(io.TextIOWrapper):
|
||||||
|
|
@ -196,6 +222,7 @@ class TeeCaptureIO(CaptureIO):
|
||||||
class DontReadFromInput(TextIO):
|
class DontReadFromInput(TextIO):
|
||||||
@property
|
@property
|
||||||
def encoding(self) -> str:
|
def encoding(self) -> str:
|
||||||
|
assert sys.__stdin__ is not None
|
||||||
return sys.__stdin__.encoding
|
return sys.__stdin__.encoding
|
||||||
|
|
||||||
def read(self, size: int = -1) -> str:
|
def read(self, size: int = -1) -> str:
|
||||||
|
|
@ -208,7 +235,7 @@ class DontReadFromInput(TextIO):
|
||||||
def __next__(self) -> str:
|
def __next__(self) -> str:
|
||||||
return self.readline()
|
return self.readline()
|
||||||
|
|
||||||
def readlines(self, hint: Optional[int] = -1) -> List[str]:
|
def readlines(self, hint: int | None = -1) -> list[str]:
|
||||||
raise OSError(
|
raise OSError(
|
||||||
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
||||||
)
|
)
|
||||||
|
|
@ -240,7 +267,7 @@ class DontReadFromInput(TextIO):
|
||||||
def tell(self) -> int:
|
def tell(self) -> int:
|
||||||
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
|
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")
|
raise UnsupportedOperation("cannot truncate stdin")
|
||||||
|
|
||||||
def write(self, data: str) -> int:
|
def write(self, data: str) -> int:
|
||||||
|
|
@ -252,14 +279,14 @@ class DontReadFromInput(TextIO):
|
||||||
def writable(self) -> bool:
|
def writable(self) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def __enter__(self) -> "DontReadFromInput":
|
def __enter__(self) -> Self:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(
|
def __exit__(
|
||||||
self,
|
self,
|
||||||
type: Optional[Type[BaseException]],
|
type: type[BaseException] | None,
|
||||||
value: Optional[BaseException],
|
value: BaseException | None,
|
||||||
traceback: Optional[TracebackType],
|
traceback: TracebackType | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -334,7 +361,7 @@ class NoCapture(CaptureBase[str]):
|
||||||
|
|
||||||
class SysCaptureBase(CaptureBase[AnyStr]):
|
class SysCaptureBase(CaptureBase[AnyStr]):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, fd: int, tmpfile: Optional[TextIO] = None, *, tee: bool = False
|
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
|
||||||
) -> None:
|
) -> None:
|
||||||
name = patchsysdict[fd]
|
name = patchsysdict[fd]
|
||||||
self._old: TextIO = getattr(sys, name)
|
self._old: TextIO = getattr(sys, name)
|
||||||
|
|
@ -351,7 +378,7 @@ class SysCaptureBase(CaptureBase[AnyStr]):
|
||||||
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
||||||
class_name,
|
class_name,
|
||||||
self.name,
|
self.name,
|
||||||
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
|
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
|
||||||
self._state,
|
self._state,
|
||||||
self.tmpfile,
|
self.tmpfile,
|
||||||
)
|
)
|
||||||
|
|
@ -360,17 +387,17 @@ class SysCaptureBase(CaptureBase[AnyStr]):
|
||||||
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
||||||
self.__class__.__name__,
|
self.__class__.__name__,
|
||||||
self.name,
|
self.name,
|
||||||
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
|
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
|
||||||
self._state,
|
self._state,
|
||||||
self.tmpfile,
|
self.tmpfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
|
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
||||||
assert (
|
assert self._state in states, (
|
||||||
self._state in states
|
"cannot {} in state {!r}: expected one of {}".format(
|
||||||
), "cannot {} in state {!r}: expected one of {}".format(
|
|
||||||
op, self._state, ", ".join(states)
|
op, self._state, ", ".join(states)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
self._assert_state("start", ("initialized",))
|
self._assert_state("start", ("initialized",))
|
||||||
|
|
@ -452,7 +479,7 @@ class FDCaptureBase(CaptureBase[AnyStr]):
|
||||||
# Further complications are the need to support suspend() and the
|
# Further complications are the need to support suspend() and the
|
||||||
# possibility of FD reuse (e.g. the tmpfile getting the very same
|
# possibility of FD reuse (e.g. the tmpfile getting the very same
|
||||||
# target FD). The following approach is robust, I believe.
|
# 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)
|
os.dup2(self.targetfd_invalid, targetfd)
|
||||||
else:
|
else:
|
||||||
self.targetfd_invalid = None
|
self.targetfd_invalid = None
|
||||||
|
|
@ -477,20 +504,17 @@ class FDCaptureBase(CaptureBase[AnyStr]):
|
||||||
self._state = "initialized"
|
self._state = "initialized"
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<{} {} oldfd={} _state={!r} tmpfile={!r}>".format(
|
return (
|
||||||
self.__class__.__name__,
|
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
|
||||||
self.targetfd,
|
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
|
||||||
self.targetfd_save,
|
|
||||||
self._state,
|
|
||||||
self.tmpfile,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _assert_state(self, op: str, states: Tuple[str, ...]) -> None:
|
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
||||||
assert (
|
assert self._state in states, (
|
||||||
self._state in states
|
"cannot {} in state {!r}: expected one of {}".format(
|
||||||
), "cannot {} in state {!r}: expected one of {}".format(
|
|
||||||
op, self._state, ", ".join(states)
|
op, self._state, ", ".join(states)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start capturing on targetfd using memorized tmpfile."""
|
"""Start capturing on targetfd using memorized tmpfile."""
|
||||||
|
|
@ -546,7 +570,7 @@ class FDCaptureBinary(FDCaptureBase[bytes]):
|
||||||
res = self.tmpfile.buffer.read()
|
res = self.tmpfile.buffer.read()
|
||||||
self.tmpfile.seek(0)
|
self.tmpfile.seek(0)
|
||||||
self.tmpfile.truncate()
|
self.tmpfile.truncate()
|
||||||
return res
|
return res # type: ignore[return-value]
|
||||||
|
|
||||||
def writeorg(self, data: bytes) -> None:
|
def writeorg(self, data: bytes) -> None:
|
||||||
"""Write to original file descriptor."""
|
"""Write to original file descriptor."""
|
||||||
|
|
@ -585,7 +609,7 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
|
||||||
|
|
||||||
@final
|
@final
|
||||||
class CaptureResult(NamedTuple, Generic[AnyStr]):
|
class CaptureResult(NamedTuple, Generic[AnyStr]):
|
||||||
"""The result of :method:`CaptureFixture.readouterr`."""
|
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
|
||||||
|
|
||||||
out: AnyStr
|
out: AnyStr
|
||||||
err: AnyStr
|
err: AnyStr
|
||||||
|
|
@ -593,9 +617,10 @@ if sys.version_info >= (3, 11) or TYPE_CHECKING:
|
||||||
else:
|
else:
|
||||||
|
|
||||||
class CaptureResult(
|
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__ = ()
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
@ -606,21 +631,18 @@ class MultiCapture(Generic[AnyStr]):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
in_: Optional[CaptureBase[AnyStr]],
|
in_: CaptureBase[AnyStr] | None,
|
||||||
out: Optional[CaptureBase[AnyStr]],
|
out: CaptureBase[AnyStr] | None,
|
||||||
err: Optional[CaptureBase[AnyStr]],
|
err: CaptureBase[AnyStr] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.in_: Optional[CaptureBase[AnyStr]] = in_
|
self.in_: CaptureBase[AnyStr] | None = in_
|
||||||
self.out: Optional[CaptureBase[AnyStr]] = out
|
self.out: CaptureBase[AnyStr] | None = out
|
||||||
self.err: Optional[CaptureBase[AnyStr]] = err
|
self.err: CaptureBase[AnyStr] | None = err
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<MultiCapture out={!r} err={!r} in_={!r} _state={!r} _in_suspended={!r}>".format(
|
return (
|
||||||
self.out,
|
f"<MultiCapture out={self.out!r} err={self.err!r} in_={self.in_!r} "
|
||||||
self.err,
|
f"_state={self._state!r} _in_suspended={self._in_suspended!r}>"
|
||||||
self.in_,
|
|
||||||
self._state,
|
|
||||||
self._in_suspended,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def start_capturing(self) -> None:
|
def start_capturing(self) -> None:
|
||||||
|
|
@ -632,7 +654,7 @@ class MultiCapture(Generic[AnyStr]):
|
||||||
if self.err:
|
if self.err:
|
||||||
self.err.start()
|
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."""
|
"""Pop current snapshot out/err capture and flush to orig streams."""
|
||||||
out, err = self.readouterr()
|
out, err = self.readouterr()
|
||||||
if out:
|
if out:
|
||||||
|
|
@ -687,7 +709,7 @@ class MultiCapture(Generic[AnyStr]):
|
||||||
return CaptureResult(out, err) # type: ignore[arg-type]
|
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":
|
if method == "fd":
|
||||||
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
|
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
|
||||||
elif method == "sys":
|
elif method == "sys":
|
||||||
|
|
@ -723,21 +745,22 @@ class CaptureManager:
|
||||||
needed to ensure the fixtures take precedence over the global capture.
|
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._method: Final = method
|
||||||
self._global_capturing: Optional[MultiCapture[str]] = None
|
self._global_capturing: MultiCapture[str] | None = None
|
||||||
self._capture_fixture: Optional[CaptureFixture[Any]] = None
|
self._capture_fixture: CaptureFixture[Any] | None = None
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "<CaptureManager _method={!r} _global_capturing={!r} _capture_fixture={!r}>".format(
|
return (
|
||||||
self._method, self._global_capturing, self._capture_fixture
|
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():
|
if self.is_globally_capturing():
|
||||||
return "global"
|
return "global"
|
||||||
if self._capture_fixture:
|
if self._capture_fixture:
|
||||||
return "fixture %s" % self._capture_fixture.request.fixturename
|
return f"fixture {self._capture_fixture.request.fixturename}"
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Global capturing control
|
# Global capturing control
|
||||||
|
|
@ -781,14 +804,12 @@ class CaptureManager:
|
||||||
|
|
||||||
# Fixture Control
|
# Fixture Control
|
||||||
|
|
||||||
def set_fixture(self, capture_fixture: "CaptureFixture[Any]") -> None:
|
def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None:
|
||||||
if self._capture_fixture:
|
if self._capture_fixture:
|
||||||
current_fixture = self._capture_fixture.request.fixturename
|
current_fixture = self._capture_fixture.request.fixturename
|
||||||
requested_fixture = capture_fixture.request.fixturename
|
requested_fixture = capture_fixture.request.fixturename
|
||||||
capture_fixture.request.raiseerror(
|
capture_fixture.request.raiseerror(
|
||||||
"cannot use {} and {} at the same time".format(
|
f"cannot use {requested_fixture} and {current_fixture} at the same time"
|
||||||
requested_fixture, current_fixture
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self._capture_fixture = capture_fixture
|
self._capture_fixture = capture_fixture
|
||||||
|
|
||||||
|
|
@ -817,7 +838,7 @@ class CaptureManager:
|
||||||
# Helper context managers
|
# Helper context managers
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@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."""
|
"""Context manager to temporarily disable global and current fixture capturing."""
|
||||||
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
|
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
|
||||||
if do_fixture:
|
if do_fixture:
|
||||||
|
|
@ -834,7 +855,7 @@ class CaptureManager:
|
||||||
self.resume_fixture()
|
self.resume_fixture()
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@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.resume_global_capture()
|
||||||
self.activate_fixture()
|
self.activate_fixture()
|
||||||
try:
|
try:
|
||||||
|
|
@ -849,35 +870,39 @@ class CaptureManager:
|
||||||
|
|
||||||
# Hooks
|
# Hooks
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_make_collect_report(self, collector: Collector):
|
def pytest_make_collect_report(
|
||||||
|
self, collector: Collector
|
||||||
|
) -> Generator[None, CollectReport, CollectReport]:
|
||||||
if isinstance(collector, File):
|
if isinstance(collector, File):
|
||||||
self.resume_global_capture()
|
self.resume_global_capture()
|
||||||
outcome = yield
|
try:
|
||||||
|
rep = yield
|
||||||
|
finally:
|
||||||
self.suspend_global_capture()
|
self.suspend_global_capture()
|
||||||
out, err = self.read_global_capture()
|
out, err = self.read_global_capture()
|
||||||
rep = outcome.get_result()
|
|
||||||
if out:
|
if out:
|
||||||
rep.sections.append(("Captured stdout", out))
|
rep.sections.append(("Captured stdout", out))
|
||||||
if err:
|
if err:
|
||||||
rep.sections.append(("Captured stderr", err))
|
rep.sections.append(("Captured stderr", err))
|
||||||
else:
|
else:
|
||||||
yield
|
rep = yield
|
||||||
|
return rep
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_runtest_setup(self, item: Item) -> Generator[None, None, None]:
|
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
|
||||||
with self.item_capture("setup", item):
|
with self.item_capture("setup", item):
|
||||||
yield
|
return (yield)
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_runtest_call(self, item: Item) -> Generator[None, None, None]:
|
def pytest_runtest_call(self, item: Item) -> Generator[None]:
|
||||||
with self.item_capture("call", item):
|
with self.item_capture("call", item):
|
||||||
yield
|
return (yield)
|
||||||
|
|
||||||
@hookimpl(hookwrapper=True)
|
@hookimpl(wrapper=True)
|
||||||
def pytest_runtest_teardown(self, item: Item) -> Generator[None, None, None]:
|
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
|
||||||
with self.item_capture("teardown", item):
|
with self.item_capture("teardown", item):
|
||||||
yield
|
return (yield)
|
||||||
|
|
||||||
@hookimpl(tryfirst=True)
|
@hookimpl(tryfirst=True)
|
||||||
def pytest_keyboard_interrupt(self) -> None:
|
def pytest_keyboard_interrupt(self) -> None:
|
||||||
|
|
@ -894,15 +919,17 @@ class CaptureFixture(Generic[AnyStr]):
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
captureclass: Type[CaptureBase[AnyStr]],
|
captureclass: type[CaptureBase[AnyStr]],
|
||||||
request: SubRequest,
|
request: SubRequest,
|
||||||
*,
|
*,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
_ispytest: bool = False,
|
_ispytest: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
check_ispytest(_ispytest)
|
check_ispytest(_ispytest)
|
||||||
self.captureclass: Type[CaptureBase[AnyStr]] = captureclass
|
self.captureclass: type[CaptureBase[AnyStr]] = captureclass
|
||||||
self.request = request
|
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_out: AnyStr = self.captureclass.EMPTY_BUFFER
|
||||||
self._captured_err: 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:
|
if self._capture is None:
|
||||||
self._capture = MultiCapture(
|
self._capture = MultiCapture(
|
||||||
in_=None,
|
in_=None,
|
||||||
out=self.captureclass(1),
|
out=self.captureclass(1, **self._config),
|
||||||
err=self.captureclass(2),
|
err=self.captureclass(2, **self._config),
|
||||||
)
|
)
|
||||||
self._capture.start_capturing()
|
self._capture.start_capturing()
|
||||||
|
|
||||||
|
|
@ -957,7 +984,7 @@ class CaptureFixture(Generic[AnyStr]):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def disabled(self) -> Generator[None, None, None]:
|
def disabled(self) -> Generator[None]:
|
||||||
"""Temporarily disable capturing while inside the ``with`` block."""
|
"""Temporarily disable capturing while inside the ``with`` block."""
|
||||||
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
|
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
|
||||||
"capturemanager"
|
"capturemanager"
|
||||||
|
|
@ -970,7 +997,7 @@ class CaptureFixture(Generic[AnyStr]):
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@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``.
|
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
||||||
|
|
||||||
The captured output is made available via ``capsys.readouterr()`` method
|
The captured output is made available via ``capsys.readouterr()`` method
|
||||||
|
|
@ -998,7 +1025,42 @@ def capsys(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@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``.
|
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
||||||
|
|
||||||
The captured output is made available via ``capsysbinary.readouterr()``
|
The captured output is made available via ``capsysbinary.readouterr()``
|
||||||
|
|
@ -1026,7 +1088,7 @@ def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes], None,
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@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``.
|
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
|
||||||
|
|
||||||
The captured output is made available via ``capfd.readouterr()`` method
|
The captured output is made available via ``capfd.readouterr()`` method
|
||||||
|
|
@ -1054,7 +1116,7 @@ def capfd(request: SubRequest) -> Generator[CaptureFixture[str], None, None]:
|
||||||
|
|
||||||
|
|
||||||
@fixture
|
@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``.
|
r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
|
||||||
|
|
||||||
The captured output is made available via ``capfd.readouterr()`` method
|
The captured output is made available via ``capfd.readouterr()`` method
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,28 @@
|
||||||
"""Python version compatibility code."""
|
# mypy: allow-untyped-defs
|
||||||
|
"""Python version compatibility code and random general utilities."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import dataclasses
|
from collections.abc import Callable
|
||||||
import enum
|
import enum
|
||||||
import functools
|
import functools
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from inspect import Parameter
|
from inspect import Parameter
|
||||||
from inspect import signature
|
from inspect import Signature
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from typing import Callable
|
from typing import Final
|
||||||
from typing import Generic
|
|
||||||
from typing import NoReturn
|
from typing import NoReturn
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from typing import TypeVar
|
|
||||||
|
|
||||||
import py
|
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:
|
if sys.version_info >= (3, 14):
|
||||||
from typing_extensions import Final
|
from annotationlib import Format
|
||||||
|
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
_S = TypeVar("_S")
|
|
||||||
|
|
||||||
#: constant to prepare valuing pylib path replacements/lazy proxies later on
|
#: constant to prepare valuing pylib path replacements/lazy proxies later on
|
||||||
# intended for removal in pytest 8.0 or 9.0
|
# 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
|
# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
|
||||||
class NotSetType(enum.Enum):
|
class NotSetType(enum.Enum):
|
||||||
token = 0
|
token = 0
|
||||||
NOTSET: Final = NotSetType.token # noqa: E305
|
NOTSET: Final = NotSetType.token
|
||||||
# fmt: on
|
# 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:
|
def iscoroutinefunction(func: object) -> bool:
|
||||||
"""Return True if func is a coroutine function (a function defined with async
|
"""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
|
def syntax, and doesn't contain yield), or a function decorated with
|
||||||
@asyncio.coroutine.
|
@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"
|
importing asyncio directly, which in turns also initializes the "logging"
|
||||||
module as a side-effect (see issue #8).
|
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)
|
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)
|
function = get_real_func(function)
|
||||||
fn = Path(inspect.getfile(function))
|
fn = Path(inspect.getfile(function))
|
||||||
lineno = function.__code__.co_firstlineno
|
lineno = function.__code__.co_firstlineno
|
||||||
|
|
@ -103,8 +81,8 @@ def getlocation(function, curdir: str | None = None) -> str:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
return "%s:%d" % (relfn, lineno + 1)
|
return f"{relfn}:{lineno + 1}"
|
||||||
return "%s:%d" % (fn, lineno + 1)
|
return f"{fn}:{lineno + 1}"
|
||||||
|
|
||||||
|
|
||||||
def num_mock_patch_args(function) -> int:
|
def num_mock_patch_args(function) -> int:
|
||||||
|
|
@ -127,10 +105,9 @@ def num_mock_patch_args(function) -> int:
|
||||||
|
|
||||||
|
|
||||||
def getfuncargnames(
|
def getfuncargnames(
|
||||||
function: Callable[..., Any],
|
function: Callable[..., object],
|
||||||
*,
|
*,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
is_method: bool = False,
|
|
||||||
cls: type | None = None,
|
cls: type | None = None,
|
||||||
) -> tuple[str, ...]:
|
) -> tuple[str, ...]:
|
||||||
"""Return the names of a function's mandatory arguments.
|
"""Return the names of a function's mandatory arguments.
|
||||||
|
|
@ -141,9 +118,8 @@ def getfuncargnames(
|
||||||
* Aren't bound with functools.partial.
|
* Aren't bound with functools.partial.
|
||||||
* Aren't replaced with mocks.
|
* Aren't replaced with mocks.
|
||||||
|
|
||||||
The is_method and cls arguments indicate that the function should
|
The cls arguments indicate that the function should be treated as a bound
|
||||||
be treated as a bound method even though it's not unless, only in
|
method even though it's not unless the function is a static method.
|
||||||
the case of cls, the function is a static method.
|
|
||||||
|
|
||||||
The name parameter should be the original name in which the function was collected.
|
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
|
# creates a tuple of the names of the parameters that don't have
|
||||||
# defaults.
|
# defaults.
|
||||||
try:
|
try:
|
||||||
parameters = signature(function).parameters
|
parameters = signature(function).parameters.values()
|
||||||
except (ValueError, TypeError) as e:
|
except (ValueError, TypeError) as e:
|
||||||
from _pytest.outcomes import fail
|
from _pytest.outcomes import fail
|
||||||
|
|
||||||
|
|
@ -168,7 +144,7 @@ def getfuncargnames(
|
||||||
|
|
||||||
arg_names = tuple(
|
arg_names = tuple(
|
||||||
p.name
|
p.name
|
||||||
for p in parameters.values()
|
for p in parameters
|
||||||
if (
|
if (
|
||||||
p.kind is Parameter.POSITIONAL_OR_KEYWORD
|
p.kind is Parameter.POSITIONAL_OR_KEYWORD
|
||||||
or p.kind is Parameter.KEYWORD_ONLY
|
or p.kind is Parameter.KEYWORD_ONLY
|
||||||
|
|
@ -179,9 +155,9 @@ def getfuncargnames(
|
||||||
name = function.__name__
|
name = function.__name__
|
||||||
|
|
||||||
# If this function should be treated as a bound method even though
|
# If this function should be treated as a bound method even though
|
||||||
# it's passed as an unbound method or function, remove the first
|
# it's passed as an unbound method or function, and its first parameter
|
||||||
# parameter name.
|
# wasn't defined as positional only, remove the first parameter name.
|
||||||
if is_method or (
|
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 `getattr` because we don't want to resolve the staticmethod.
|
||||||
# Not using `cls.__dict__` because we want to check the entire MRO.
|
# Not using `cls.__dict__` because we want to check the entire MRO.
|
||||||
cls
|
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:
|
def ascii_escaped(val: bytes | str) -> str:
|
||||||
r"""If val is pure ASCII, return it as an str, otherwise, escape
|
r"""If val is pure ASCII, return it as an str, otherwise, escape
|
||||||
bytes objects into a sequence of escaped bytes:
|
bytes objects into a sequence of escaped bytes:
|
||||||
|
|
||||||
b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6'
|
b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6'
|
||||||
|
|
||||||
and escapes unicode objects into a sequence of escaped unicode
|
and escapes strings into a sequence of escaped unicode ids, e.g.:
|
||||||
ids, e.g.:
|
|
||||||
|
|
||||||
r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944'
|
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.
|
a UTF-8 string.
|
||||||
"""
|
"""
|
||||||
if isinstance(val, bytes):
|
if isinstance(val, bytes):
|
||||||
ret = _bytes_to_ascii(val)
|
ret = val.decode("ascii", "backslashreplace")
|
||||||
else:
|
else:
|
||||||
ret = val.encode("unicode_escape").decode("ascii")
|
ret = val.encode("unicode_escape").decode("ascii")
|
||||||
return _translate_non_printable(ret)
|
return ret.translate(_non_printable_ascii_translate_table)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass
|
|
||||||
class _PytestWrapper:
|
|
||||||
"""Dummy wrapper around a function object for internal use only.
|
|
||||||
|
|
||||||
Used to correctly unwrap the underlying function object when we are
|
|
||||||
creating fixtures, because we wrap the function object ourselves with a
|
|
||||||
decorator to issue warnings when the fixture function is called directly.
|
|
||||||
"""
|
|
||||||
|
|
||||||
obj: Any
|
|
||||||
|
|
||||||
|
|
||||||
def get_real_func(obj):
|
def get_real_func(obj):
|
||||||
"""Get the real function object of the (possibly) wrapped object by
|
"""Get the real function object of the (possibly) wrapped object by
|
||||||
functools.wraps or functools.partial."""
|
:func:`functools.wraps`, or :func:`functools.partial`."""
|
||||||
start_obj = obj
|
obj = inspect.unwrap(obj)
|
||||||
for i in range(100):
|
|
||||||
# __pytest_wrapped__ is set by @pytest.fixture when wrapping the fixture function
|
|
||||||
# to trigger a warning if it gets called directly instead of by pytest: we don't
|
|
||||||
# want to unwrap further than this otherwise we lose useful wrappings like @mock.patch (#3774)
|
|
||||||
new_obj = getattr(obj, "__pytest_wrapped__", None)
|
|
||||||
if isinstance(new_obj, _PytestWrapper):
|
|
||||||
obj = new_obj.obj
|
|
||||||
break
|
|
||||||
new_obj = getattr(obj, "__wrapped__", None)
|
|
||||||
if new_obj is None:
|
|
||||||
break
|
|
||||||
obj = new_obj
|
|
||||||
else:
|
|
||||||
from _pytest._io.saferepr import saferepr
|
|
||||||
|
|
||||||
raise ValueError(
|
|
||||||
("could not find real function of {start}\nstopped at {current}").format(
|
|
||||||
start=saferepr(start_obj), current=saferepr(obj)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if isinstance(obj, functools.partial):
|
if isinstance(obj, functools.partial):
|
||||||
obj = obj.func
|
obj = obj.func
|
||||||
return obj
|
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):
|
def getimfunc(func):
|
||||||
try:
|
try:
|
||||||
return func.__func__
|
return func.__func__
|
||||||
|
|
@ -338,47 +257,6 @@ def safe_isclass(obj: object) -> bool:
|
||||||
return False
|
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:
|
def get_user_id() -> int | None:
|
||||||
"""Return the current process's real user id or None if it could not be
|
"""Return the current process's real user id or None if it could not be
|
||||||
determined.
|
determined.
|
||||||
|
|
@ -400,36 +278,37 @@ def get_user_id() -> int | None:
|
||||||
return uid if uid != ERROR else None
|
return uid if uid != ERROR else None
|
||||||
|
|
||||||
|
|
||||||
# Perform exhaustiveness checking.
|
if sys.version_info >= (3, 11):
|
||||||
#
|
from typing import assert_never
|
||||||
# Consider this example:
|
else:
|
||||||
#
|
|
||||||
# MyUnion = Union[int, str]
|
def assert_never(value: NoReturn) -> NoReturn:
|
||||||
#
|
|
||||||
# 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__})"
|
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
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue