Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- .venv/lib/python3.11/site-packages/_pytest/__init__.py +13 -0
- .venv/lib/python3.11/site-packages/_pytest/__pycache__/cacheprovider.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/__pycache__/doctest.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/__pycache__/unraisableexception.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/__init__.py +26 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/__init__.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/code.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/source.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/code.py +1409 -0
- .venv/lib/python3.11/site-packages/_pytest/_code/source.py +215 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__init__.py +10 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/__init__.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/pprint.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/saferepr.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/terminalwriter.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/wcwidth.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/pprint.py +673 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/saferepr.py +130 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/terminalwriter.py +274 -0
- .venv/lib/python3.11/site-packages/_pytest/_io/wcwidth.py +57 -0
- .venv/lib/python3.11/site-packages/_pytest/_version.py +16 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/__init__.py +192 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/__init__.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/rewrite.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/truncate.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/util.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/rewrite.py +1211 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/truncate.py +117 -0
- .venv/lib/python3.11/site-packages/_pytest/assertion/util.py +609 -0
- .venv/lib/python3.11/site-packages/_pytest/capture.py +1087 -0
- .venv/lib/python3.11/site-packages/_pytest/debugging.py +385 -0
- .venv/lib/python3.11/site-packages/_pytest/deprecated.py +91 -0
- .venv/lib/python3.11/site-packages/_pytest/faulthandler.py +105 -0
- .venv/lib/python3.11/site-packages/_pytest/fixtures.py +1932 -0
- .venv/lib/python3.11/site-packages/_pytest/freeze_support.py +45 -0
- .venv/lib/python3.11/site-packages/_pytest/junitxml.py +697 -0
- .venv/lib/python3.11/site-packages/_pytest/main.py +1072 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/__init__.py +292 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/__init__.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/expression.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/structures.cpython-311.pyc +0 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/expression.py +333 -0
- .venv/lib/python3.11/site-packages/_pytest/mark/structures.py +615 -0
- .venv/lib/python3.11/site-packages/_pytest/monkeypatch.py +415 -0
- .venv/lib/python3.11/site-packages/_pytest/outcomes.py +318 -0
- .venv/lib/python3.11/site-packages/_pytest/pastebin.py +113 -0
- .venv/lib/python3.11/site-packages/_pytest/pathlib.py +1038 -0
- .venv/lib/python3.11/site-packages/_pytest/py.typed +0 -0
- .venv/lib/python3.11/site-packages/_pytest/pytester.py +1766 -0
.gitattributes
CHANGED
|
@@ -181,3 +181,5 @@ tuning-competition-baseline/.venv/lib/python3.11/site-packages/torch/_inductor/_
|
|
| 181 |
.venv/lib/python3.11/site-packages/ray/rllib/env/__pycache__/multi_agent_episode.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 182 |
.venv/lib/python3.11/site-packages/ray/tune/execution/__pycache__/tune_controller.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 183 |
.venv/lib/python3.11/site-packages/ray/thirdparty_files/psutil/_psutil_linux.abi3.so filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 181 |
.venv/lib/python3.11/site-packages/ray/rllib/env/__pycache__/multi_agent_episode.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 182 |
.venv/lib/python3.11/site-packages/ray/tune/execution/__pycache__/tune_controller.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
| 183 |
.venv/lib/python3.11/site-packages/ray/thirdparty_files/psutil/_psutil_linux.abi3.so filter=lfs diff=lfs merge=lfs -text
|
| 184 |
+
.venv/lib/python3.11/site-packages/ray/core/src/ray/gcs/gcs_server filter=lfs diff=lfs merge=lfs -text
|
| 185 |
+
.venv/lib/python3.11/site-packages/ray/thirdparty_files/psutil/__pycache__/_pslinux.cpython-311.pyc filter=lfs diff=lfs merge=lfs -text
|
.venv/lib/python3.11/site-packages/_pytest/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
__all__ = ["__version__", "version_tuple"]
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from ._version import version as __version__
|
| 8 |
+
from ._version import version_tuple
|
| 9 |
+
except ImportError: # pragma: no cover
|
| 10 |
+
# broken installation, we don't even try
|
| 11 |
+
# unknown only works because we do poor mans version compare
|
| 12 |
+
__version__ = "unknown"
|
| 13 |
+
version_tuple = (0, 0, "unknown")
|
.venv/lib/python3.11/site-packages/_pytest/__pycache__/cacheprovider.cpython-311.pyc
ADDED
|
Binary file (34.2 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/__pycache__/doctest.cpython-311.pyc
ADDED
|
Binary file (37.1 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/__pycache__/unraisableexception.cpython-311.pyc
ADDED
|
Binary file (5.41 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_code/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Python inspection/code generation API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from .code import Code
|
| 6 |
+
from .code import ExceptionInfo
|
| 7 |
+
from .code import filter_traceback
|
| 8 |
+
from .code import Frame
|
| 9 |
+
from .code import getfslineno
|
| 10 |
+
from .code import Traceback
|
| 11 |
+
from .code import TracebackEntry
|
| 12 |
+
from .source import getrawcode
|
| 13 |
+
from .source import Source
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"Code",
|
| 18 |
+
"ExceptionInfo",
|
| 19 |
+
"filter_traceback",
|
| 20 |
+
"Frame",
|
| 21 |
+
"getfslineno",
|
| 22 |
+
"getrawcode",
|
| 23 |
+
"Traceback",
|
| 24 |
+
"TracebackEntry",
|
| 25 |
+
"Source",
|
| 26 |
+
]
|
.venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (842 Bytes). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/code.cpython-311.pyc
ADDED
|
Binary file (68.1 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_code/__pycache__/source.cpython-311.pyc
ADDED
|
Binary file (12.7 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_code/code.py
ADDED
|
@@ -0,0 +1,1409 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import ast
|
| 5 |
+
import dataclasses
|
| 6 |
+
import inspect
|
| 7 |
+
from inspect import CO_VARARGS
|
| 8 |
+
from inspect import CO_VARKEYWORDS
|
| 9 |
+
from io import StringIO
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import re
|
| 13 |
+
import sys
|
| 14 |
+
import traceback
|
| 15 |
+
from traceback import format_exception_only
|
| 16 |
+
from types import CodeType
|
| 17 |
+
from types import FrameType
|
| 18 |
+
from types import TracebackType
|
| 19 |
+
from typing import Any
|
| 20 |
+
from typing import Callable
|
| 21 |
+
from typing import ClassVar
|
| 22 |
+
from typing import Final
|
| 23 |
+
from typing import final
|
| 24 |
+
from typing import Generic
|
| 25 |
+
from typing import Iterable
|
| 26 |
+
from typing import List
|
| 27 |
+
from typing import Literal
|
| 28 |
+
from typing import Mapping
|
| 29 |
+
from typing import overload
|
| 30 |
+
from typing import Pattern
|
| 31 |
+
from typing import Sequence
|
| 32 |
+
from typing import SupportsIndex
|
| 33 |
+
from typing import Tuple
|
| 34 |
+
from typing import Type
|
| 35 |
+
from typing import TypeVar
|
| 36 |
+
from typing import Union
|
| 37 |
+
|
| 38 |
+
import pluggy
|
| 39 |
+
|
| 40 |
+
import _pytest
|
| 41 |
+
from _pytest._code.source import findsource
|
| 42 |
+
from _pytest._code.source import getrawcode
|
| 43 |
+
from _pytest._code.source import getstatementrange_ast
|
| 44 |
+
from _pytest._code.source import Source
|
| 45 |
+
from _pytest._io import TerminalWriter
|
| 46 |
+
from _pytest._io.saferepr import safeformat
|
| 47 |
+
from _pytest._io.saferepr import saferepr
|
| 48 |
+
from _pytest.compat import get_real_func
|
| 49 |
+
from _pytest.deprecated import check_ispytest
|
| 50 |
+
from _pytest.pathlib import absolutepath
|
| 51 |
+
from _pytest.pathlib import bestrelpath
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if sys.version_info < (3, 11):
|
| 55 |
+
from exceptiongroup import BaseExceptionGroup
|
| 56 |
+
|
| 57 |
+
TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"]
|
| 58 |
+
|
| 59 |
+
EXCEPTION_OR_MORE = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class Code:
|
| 63 |
+
"""Wrapper around Python code objects."""
|
| 64 |
+
|
| 65 |
+
__slots__ = ("raw",)
|
| 66 |
+
|
| 67 |
+
def __init__(self, obj: CodeType) -> None:
|
| 68 |
+
self.raw = obj
|
| 69 |
+
|
| 70 |
+
@classmethod
|
| 71 |
+
def from_function(cls, obj: object) -> Code:
|
| 72 |
+
return cls(getrawcode(obj))
|
| 73 |
+
|
| 74 |
+
def __eq__(self, other):
|
| 75 |
+
return self.raw == other.raw
|
| 76 |
+
|
| 77 |
+
# Ignore type because of https://github.com/python/mypy/issues/4266.
|
| 78 |
+
__hash__ = None # type: ignore
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def firstlineno(self) -> int:
|
| 82 |
+
return self.raw.co_firstlineno - 1
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def name(self) -> str:
|
| 86 |
+
return self.raw.co_name
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def path(self) -> Path | str:
|
| 90 |
+
"""Return a path object pointing to source code, or an ``str`` in
|
| 91 |
+
case of ``OSError`` / non-existing file."""
|
| 92 |
+
if not self.raw.co_filename:
|
| 93 |
+
return ""
|
| 94 |
+
try:
|
| 95 |
+
p = absolutepath(self.raw.co_filename)
|
| 96 |
+
# maybe don't try this checking
|
| 97 |
+
if not p.exists():
|
| 98 |
+
raise OSError("path check failed.")
|
| 99 |
+
return p
|
| 100 |
+
except OSError:
|
| 101 |
+
# XXX maybe try harder like the weird logic
|
| 102 |
+
# in the standard lib [linecache.updatecache] does?
|
| 103 |
+
return self.raw.co_filename
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def fullsource(self) -> Source | None:
|
| 107 |
+
"""Return a _pytest._code.Source object for the full source file of the code."""
|
| 108 |
+
full, _ = findsource(self.raw)
|
| 109 |
+
return full
|
| 110 |
+
|
| 111 |
+
def source(self) -> Source:
|
| 112 |
+
"""Return a _pytest._code.Source object for the code object's source only."""
|
| 113 |
+
# return source only for that part of code
|
| 114 |
+
return Source(self.raw)
|
| 115 |
+
|
| 116 |
+
def getargs(self, var: bool = False) -> tuple[str, ...]:
|
| 117 |
+
"""Return a tuple with the argument names for the code object.
|
| 118 |
+
|
| 119 |
+
If 'var' is set True also return the names of the variable and
|
| 120 |
+
keyword arguments when present.
|
| 121 |
+
"""
|
| 122 |
+
# Handy shortcut for getting args.
|
| 123 |
+
raw = self.raw
|
| 124 |
+
argcount = raw.co_argcount
|
| 125 |
+
if var:
|
| 126 |
+
argcount += raw.co_flags & CO_VARARGS
|
| 127 |
+
argcount += raw.co_flags & CO_VARKEYWORDS
|
| 128 |
+
return raw.co_varnames[:argcount]
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class Frame:
|
| 132 |
+
"""Wrapper around a Python frame holding f_locals and f_globals
|
| 133 |
+
in which expressions can be evaluated."""
|
| 134 |
+
|
| 135 |
+
__slots__ = ("raw",)
|
| 136 |
+
|
| 137 |
+
def __init__(self, frame: FrameType) -> None:
|
| 138 |
+
self.raw = frame
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def lineno(self) -> int:
|
| 142 |
+
return self.raw.f_lineno - 1
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def f_globals(self) -> dict[str, Any]:
|
| 146 |
+
return self.raw.f_globals
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def f_locals(self) -> dict[str, Any]:
|
| 150 |
+
return self.raw.f_locals
|
| 151 |
+
|
| 152 |
+
@property
|
| 153 |
+
def code(self) -> Code:
|
| 154 |
+
return Code(self.raw.f_code)
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def statement(self) -> Source:
|
| 158 |
+
"""Statement this frame is at."""
|
| 159 |
+
if self.code.fullsource is None:
|
| 160 |
+
return Source("")
|
| 161 |
+
return self.code.fullsource.getstatement(self.lineno)
|
| 162 |
+
|
| 163 |
+
def eval(self, code, **vars):
|
| 164 |
+
"""Evaluate 'code' in the frame.
|
| 165 |
+
|
| 166 |
+
'vars' are optional additional local variables.
|
| 167 |
+
|
| 168 |
+
Returns the result of the evaluation.
|
| 169 |
+
"""
|
| 170 |
+
f_locals = self.f_locals.copy()
|
| 171 |
+
f_locals.update(vars)
|
| 172 |
+
return eval(code, self.f_globals, f_locals)
|
| 173 |
+
|
| 174 |
+
def repr(self, object: object) -> str:
|
| 175 |
+
"""Return a 'safe' (non-recursive, one-line) string repr for 'object'."""
|
| 176 |
+
return saferepr(object)
|
| 177 |
+
|
| 178 |
+
def getargs(self, var: bool = False):
|
| 179 |
+
"""Return a list of tuples (name, value) for all arguments.
|
| 180 |
+
|
| 181 |
+
If 'var' is set True, also include the variable and keyword arguments
|
| 182 |
+
when present.
|
| 183 |
+
"""
|
| 184 |
+
retval = []
|
| 185 |
+
for arg in self.code.getargs(var):
|
| 186 |
+
try:
|
| 187 |
+
retval.append((arg, self.f_locals[arg]))
|
| 188 |
+
except KeyError:
|
| 189 |
+
pass # this can occur when using Psyco
|
| 190 |
+
return retval
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class TracebackEntry:
|
| 194 |
+
"""A single entry in a Traceback."""
|
| 195 |
+
|
| 196 |
+
__slots__ = ("_rawentry", "_repr_style")
|
| 197 |
+
|
| 198 |
+
def __init__(
|
| 199 |
+
self,
|
| 200 |
+
rawentry: TracebackType,
|
| 201 |
+
repr_style: Literal["short", "long"] | None = None,
|
| 202 |
+
) -> None:
|
| 203 |
+
self._rawentry: Final = rawentry
|
| 204 |
+
self._repr_style: Final = repr_style
|
| 205 |
+
|
| 206 |
+
def with_repr_style(
|
| 207 |
+
self, repr_style: Literal["short", "long"] | None
|
| 208 |
+
) -> TracebackEntry:
|
| 209 |
+
return TracebackEntry(self._rawentry, repr_style)
|
| 210 |
+
|
| 211 |
+
@property
|
| 212 |
+
def lineno(self) -> int:
|
| 213 |
+
return self._rawentry.tb_lineno - 1
|
| 214 |
+
|
| 215 |
+
@property
|
| 216 |
+
def frame(self) -> Frame:
|
| 217 |
+
return Frame(self._rawentry.tb_frame)
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def relline(self) -> int:
|
| 221 |
+
return self.lineno - self.frame.code.firstlineno
|
| 222 |
+
|
| 223 |
+
def __repr__(self) -> str:
|
| 224 |
+
return "<TracebackEntry %s:%d>" % (self.frame.code.path, self.lineno + 1)
|
| 225 |
+
|
| 226 |
+
@property
|
| 227 |
+
def statement(self) -> Source:
|
| 228 |
+
"""_pytest._code.Source object for the current statement."""
|
| 229 |
+
source = self.frame.code.fullsource
|
| 230 |
+
assert source is not None
|
| 231 |
+
return source.getstatement(self.lineno)
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def path(self) -> Path | str:
|
| 235 |
+
"""Path to the source code."""
|
| 236 |
+
return self.frame.code.path
|
| 237 |
+
|
| 238 |
+
@property
|
| 239 |
+
def locals(self) -> dict[str, Any]:
|
| 240 |
+
"""Locals of underlying frame."""
|
| 241 |
+
return self.frame.f_locals
|
| 242 |
+
|
| 243 |
+
def getfirstlinesource(self) -> int:
|
| 244 |
+
return self.frame.code.firstlineno
|
| 245 |
+
|
| 246 |
+
def getsource(
|
| 247 |
+
self, astcache: dict[str | Path, ast.AST] | None = None
|
| 248 |
+
) -> Source | None:
|
| 249 |
+
"""Return failing source code."""
|
| 250 |
+
# we use the passed in astcache to not reparse asttrees
|
| 251 |
+
# within exception info printing
|
| 252 |
+
source = self.frame.code.fullsource
|
| 253 |
+
if source is None:
|
| 254 |
+
return None
|
| 255 |
+
key = astnode = None
|
| 256 |
+
if astcache is not None:
|
| 257 |
+
key = self.frame.code.path
|
| 258 |
+
if key is not None:
|
| 259 |
+
astnode = astcache.get(key, None)
|
| 260 |
+
start = self.getfirstlinesource()
|
| 261 |
+
try:
|
| 262 |
+
astnode, _, end = getstatementrange_ast(
|
| 263 |
+
self.lineno, source, astnode=astnode
|
| 264 |
+
)
|
| 265 |
+
except SyntaxError:
|
| 266 |
+
end = self.lineno + 1
|
| 267 |
+
else:
|
| 268 |
+
if key is not None and astcache is not None:
|
| 269 |
+
astcache[key] = astnode
|
| 270 |
+
return source[start:end]
|
| 271 |
+
|
| 272 |
+
source = property(getsource)
|
| 273 |
+
|
| 274 |
+
def ishidden(self, excinfo: ExceptionInfo[BaseException] | None) -> bool:
|
| 275 |
+
"""Return True if the current frame has a var __tracebackhide__
|
| 276 |
+
resolving to True.
|
| 277 |
+
|
| 278 |
+
If __tracebackhide__ is a callable, it gets called with the
|
| 279 |
+
ExceptionInfo instance and can decide whether to hide the traceback.
|
| 280 |
+
|
| 281 |
+
Mostly for internal use.
|
| 282 |
+
"""
|
| 283 |
+
tbh: bool | Callable[[ExceptionInfo[BaseException] | None], bool] = False
|
| 284 |
+
for maybe_ns_dct in (self.frame.f_locals, self.frame.f_globals):
|
| 285 |
+
# in normal cases, f_locals and f_globals are dictionaries
|
| 286 |
+
# however via `exec(...)` / `eval(...)` they can be other types
|
| 287 |
+
# (even incorrect types!).
|
| 288 |
+
# as such, we suppress all exceptions while accessing __tracebackhide__
|
| 289 |
+
try:
|
| 290 |
+
tbh = maybe_ns_dct["__tracebackhide__"]
|
| 291 |
+
except Exception:
|
| 292 |
+
pass
|
| 293 |
+
else:
|
| 294 |
+
break
|
| 295 |
+
if tbh and callable(tbh):
|
| 296 |
+
return tbh(excinfo)
|
| 297 |
+
return tbh
|
| 298 |
+
|
| 299 |
+
def __str__(self) -> str:
|
| 300 |
+
name = self.frame.code.name
|
| 301 |
+
try:
|
| 302 |
+
line = str(self.statement).lstrip()
|
| 303 |
+
except KeyboardInterrupt:
|
| 304 |
+
raise
|
| 305 |
+
except BaseException:
|
| 306 |
+
line = "???"
|
| 307 |
+
# This output does not quite match Python's repr for traceback entries,
|
| 308 |
+
# but changing it to do so would break certain plugins. See
|
| 309 |
+
# https://github.com/pytest-dev/pytest/pull/7535/ for details.
|
| 310 |
+
return " File %r:%d in %s\n %s\n" % (
|
| 311 |
+
str(self.path),
|
| 312 |
+
self.lineno + 1,
|
| 313 |
+
name,
|
| 314 |
+
line,
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
@property
|
| 318 |
+
def name(self) -> str:
|
| 319 |
+
"""co_name of underlying code."""
|
| 320 |
+
return self.frame.code.raw.co_name
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
class Traceback(List[TracebackEntry]):
|
| 324 |
+
"""Traceback objects encapsulate and offer higher level access to Traceback entries."""
|
| 325 |
+
|
| 326 |
+
def __init__(
|
| 327 |
+
self,
|
| 328 |
+
tb: TracebackType | Iterable[TracebackEntry],
|
| 329 |
+
) -> None:
|
| 330 |
+
"""Initialize from given python traceback object and ExceptionInfo."""
|
| 331 |
+
if isinstance(tb, TracebackType):
|
| 332 |
+
|
| 333 |
+
def f(cur: TracebackType) -> Iterable[TracebackEntry]:
|
| 334 |
+
cur_: TracebackType | None = cur
|
| 335 |
+
while cur_ is not None:
|
| 336 |
+
yield TracebackEntry(cur_)
|
| 337 |
+
cur_ = cur_.tb_next
|
| 338 |
+
|
| 339 |
+
super().__init__(f(tb))
|
| 340 |
+
else:
|
| 341 |
+
super().__init__(tb)
|
| 342 |
+
|
| 343 |
+
def cut(
|
| 344 |
+
self,
|
| 345 |
+
path: os.PathLike[str] | str | None = None,
|
| 346 |
+
lineno: int | None = None,
|
| 347 |
+
firstlineno: int | None = None,
|
| 348 |
+
excludepath: os.PathLike[str] | None = None,
|
| 349 |
+
) -> Traceback:
|
| 350 |
+
"""Return a Traceback instance wrapping part of this Traceback.
|
| 351 |
+
|
| 352 |
+
By providing any combination of path, lineno and firstlineno, the
|
| 353 |
+
first frame to start the to-be-returned traceback is determined.
|
| 354 |
+
|
| 355 |
+
This allows cutting the first part of a Traceback instance e.g.
|
| 356 |
+
for formatting reasons (removing some uninteresting bits that deal
|
| 357 |
+
with handling of the exception/traceback).
|
| 358 |
+
"""
|
| 359 |
+
path_ = None if path is None else os.fspath(path)
|
| 360 |
+
excludepath_ = None if excludepath is None else os.fspath(excludepath)
|
| 361 |
+
for x in self:
|
| 362 |
+
code = x.frame.code
|
| 363 |
+
codepath = code.path
|
| 364 |
+
if path is not None and str(codepath) != path_:
|
| 365 |
+
continue
|
| 366 |
+
if (
|
| 367 |
+
excludepath is not None
|
| 368 |
+
and isinstance(codepath, Path)
|
| 369 |
+
and excludepath_ in (str(p) for p in codepath.parents) # type: ignore[operator]
|
| 370 |
+
):
|
| 371 |
+
continue
|
| 372 |
+
if lineno is not None and x.lineno != lineno:
|
| 373 |
+
continue
|
| 374 |
+
if firstlineno is not None and x.frame.code.firstlineno != firstlineno:
|
| 375 |
+
continue
|
| 376 |
+
return Traceback(x._rawentry)
|
| 377 |
+
return self
|
| 378 |
+
|
| 379 |
+
@overload
|
| 380 |
+
def __getitem__(self, key: SupportsIndex) -> TracebackEntry: ...
|
| 381 |
+
|
| 382 |
+
@overload
|
| 383 |
+
def __getitem__(self, key: slice) -> Traceback: ...
|
| 384 |
+
|
| 385 |
+
def __getitem__(self, key: SupportsIndex | slice) -> TracebackEntry | Traceback:
|
| 386 |
+
if isinstance(key, slice):
|
| 387 |
+
return self.__class__(super().__getitem__(key))
|
| 388 |
+
else:
|
| 389 |
+
return super().__getitem__(key)
|
| 390 |
+
|
| 391 |
+
def filter(
|
| 392 |
+
self,
|
| 393 |
+
excinfo_or_fn: ExceptionInfo[BaseException] | Callable[[TracebackEntry], bool],
|
| 394 |
+
/,
|
| 395 |
+
) -> Traceback:
|
| 396 |
+
"""Return a Traceback instance with certain items removed.
|
| 397 |
+
|
| 398 |
+
If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry``s
|
| 399 |
+
which are hidden (see ishidden() above).
|
| 400 |
+
|
| 401 |
+
Otherwise, the filter is a function that gets a single argument, a
|
| 402 |
+
``TracebackEntry`` instance, and should return True when the item should
|
| 403 |
+
be added to the ``Traceback``, False when not.
|
| 404 |
+
"""
|
| 405 |
+
if isinstance(excinfo_or_fn, ExceptionInfo):
|
| 406 |
+
fn = lambda x: not x.ishidden(excinfo_or_fn) # noqa: E731
|
| 407 |
+
else:
|
| 408 |
+
fn = excinfo_or_fn
|
| 409 |
+
return Traceback(filter(fn, self))
|
| 410 |
+
|
| 411 |
+
def recursionindex(self) -> int | None:
|
| 412 |
+
"""Return the index of the frame/TracebackEntry where recursion originates if
|
| 413 |
+
appropriate, None if no recursion occurred."""
|
| 414 |
+
cache: dict[tuple[Any, int, int], list[dict[str, Any]]] = {}
|
| 415 |
+
for i, entry in enumerate(self):
|
| 416 |
+
# id for the code.raw is needed to work around
|
| 417 |
+
# the strange metaprogramming in the decorator lib from pypi
|
| 418 |
+
# which generates code objects that have hash/value equality
|
| 419 |
+
# XXX needs a test
|
| 420 |
+
key = entry.frame.code.path, id(entry.frame.code.raw), entry.lineno
|
| 421 |
+
values = cache.setdefault(key, [])
|
| 422 |
+
# Since Python 3.13 f_locals is a proxy, freeze it.
|
| 423 |
+
loc = dict(entry.frame.f_locals)
|
| 424 |
+
if values:
|
| 425 |
+
for otherloc in values:
|
| 426 |
+
if otherloc == loc:
|
| 427 |
+
return i
|
| 428 |
+
values.append(loc)
|
| 429 |
+
return None
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
E = TypeVar("E", bound=BaseException, covariant=True)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
@final
|
| 436 |
+
@dataclasses.dataclass
|
| 437 |
+
class ExceptionInfo(Generic[E]):
|
| 438 |
+
"""Wraps sys.exc_info() objects and offers help for navigating the traceback."""
|
| 439 |
+
|
| 440 |
+
_assert_start_repr: ClassVar = "AssertionError('assert "
|
| 441 |
+
|
| 442 |
+
_excinfo: tuple[type[E], E, TracebackType] | None
|
| 443 |
+
_striptext: str
|
| 444 |
+
_traceback: Traceback | None
|
| 445 |
+
|
| 446 |
+
def __init__(
|
| 447 |
+
self,
|
| 448 |
+
excinfo: tuple[type[E], E, TracebackType] | None,
|
| 449 |
+
striptext: str = "",
|
| 450 |
+
traceback: Traceback | None = None,
|
| 451 |
+
*,
|
| 452 |
+
_ispytest: bool = False,
|
| 453 |
+
) -> None:
|
| 454 |
+
check_ispytest(_ispytest)
|
| 455 |
+
self._excinfo = excinfo
|
| 456 |
+
self._striptext = striptext
|
| 457 |
+
self._traceback = traceback
|
| 458 |
+
|
| 459 |
+
@classmethod
|
| 460 |
+
def from_exception(
|
| 461 |
+
cls,
|
| 462 |
+
# Ignoring error: "Cannot use a covariant type variable as a parameter".
|
| 463 |
+
# This is OK to ignore because this class is (conceptually) readonly.
|
| 464 |
+
# See https://github.com/python/mypy/issues/7049.
|
| 465 |
+
exception: E, # type: ignore[misc]
|
| 466 |
+
exprinfo: str | None = None,
|
| 467 |
+
) -> ExceptionInfo[E]:
|
| 468 |
+
"""Return an ExceptionInfo for an existing exception.
|
| 469 |
+
|
| 470 |
+
The exception must have a non-``None`` ``__traceback__`` attribute,
|
| 471 |
+
otherwise this function fails with an assertion error. This means that
|
| 472 |
+
the exception must have been raised, or added a traceback with the
|
| 473 |
+
:py:meth:`~BaseException.with_traceback()` method.
|
| 474 |
+
|
| 475 |
+
:param exprinfo:
|
| 476 |
+
A text string helping to determine if we should strip
|
| 477 |
+
``AssertionError`` from the output. Defaults to the exception
|
| 478 |
+
message/``__str__()``.
|
| 479 |
+
|
| 480 |
+
.. versionadded:: 7.4
|
| 481 |
+
"""
|
| 482 |
+
assert exception.__traceback__, (
|
| 483 |
+
"Exceptions passed to ExcInfo.from_exception(...)"
|
| 484 |
+
" must have a non-None __traceback__."
|
| 485 |
+
)
|
| 486 |
+
exc_info = (type(exception), exception, exception.__traceback__)
|
| 487 |
+
return cls.from_exc_info(exc_info, exprinfo)
|
| 488 |
+
|
| 489 |
+
@classmethod
|
| 490 |
+
def from_exc_info(
|
| 491 |
+
cls,
|
| 492 |
+
exc_info: tuple[type[E], E, TracebackType],
|
| 493 |
+
exprinfo: str | None = None,
|
| 494 |
+
) -> ExceptionInfo[E]:
|
| 495 |
+
"""Like :func:`from_exception`, but using old-style exc_info tuple."""
|
| 496 |
+
_striptext = ""
|
| 497 |
+
if exprinfo is None and isinstance(exc_info[1], AssertionError):
|
| 498 |
+
exprinfo = getattr(exc_info[1], "msg", None)
|
| 499 |
+
if exprinfo is None:
|
| 500 |
+
exprinfo = saferepr(exc_info[1])
|
| 501 |
+
if exprinfo and exprinfo.startswith(cls._assert_start_repr):
|
| 502 |
+
_striptext = "AssertionError: "
|
| 503 |
+
|
| 504 |
+
return cls(exc_info, _striptext, _ispytest=True)
|
| 505 |
+
|
| 506 |
+
@classmethod
|
| 507 |
+
def from_current(cls, exprinfo: str | None = None) -> ExceptionInfo[BaseException]:
|
| 508 |
+
"""Return an ExceptionInfo matching the current traceback.
|
| 509 |
+
|
| 510 |
+
.. warning::
|
| 511 |
+
|
| 512 |
+
Experimental API
|
| 513 |
+
|
| 514 |
+
:param exprinfo:
|
| 515 |
+
A text string helping to determine if we should strip
|
| 516 |
+
``AssertionError`` from the output. Defaults to the exception
|
| 517 |
+
message/``__str__()``.
|
| 518 |
+
"""
|
| 519 |
+
tup = sys.exc_info()
|
| 520 |
+
assert tup[0] is not None, "no current exception"
|
| 521 |
+
assert tup[1] is not None, "no current exception"
|
| 522 |
+
assert tup[2] is not None, "no current exception"
|
| 523 |
+
exc_info = (tup[0], tup[1], tup[2])
|
| 524 |
+
return ExceptionInfo.from_exc_info(exc_info, exprinfo)
|
| 525 |
+
|
| 526 |
+
@classmethod
|
| 527 |
+
def for_later(cls) -> ExceptionInfo[E]:
|
| 528 |
+
"""Return an unfilled ExceptionInfo."""
|
| 529 |
+
return cls(None, _ispytest=True)
|
| 530 |
+
|
| 531 |
+
def fill_unfilled(self, exc_info: tuple[type[E], E, TracebackType]) -> None:
|
| 532 |
+
"""Fill an unfilled ExceptionInfo created with ``for_later()``."""
|
| 533 |
+
assert self._excinfo is None, "ExceptionInfo was already filled"
|
| 534 |
+
self._excinfo = exc_info
|
| 535 |
+
|
| 536 |
+
@property
|
| 537 |
+
def type(self) -> type[E]:
|
| 538 |
+
"""The exception class."""
|
| 539 |
+
assert (
|
| 540 |
+
self._excinfo is not None
|
| 541 |
+
), ".type can only be used after the context manager exits"
|
| 542 |
+
return self._excinfo[0]
|
| 543 |
+
|
| 544 |
+
@property
|
| 545 |
+
def value(self) -> E:
|
| 546 |
+
"""The exception value."""
|
| 547 |
+
assert (
|
| 548 |
+
self._excinfo is not None
|
| 549 |
+
), ".value can only be used after the context manager exits"
|
| 550 |
+
return self._excinfo[1]
|
| 551 |
+
|
| 552 |
+
@property
|
| 553 |
+
def tb(self) -> TracebackType:
|
| 554 |
+
"""The exception raw traceback."""
|
| 555 |
+
assert (
|
| 556 |
+
self._excinfo is not None
|
| 557 |
+
), ".tb can only be used after the context manager exits"
|
| 558 |
+
return self._excinfo[2]
|
| 559 |
+
|
| 560 |
+
@property
|
| 561 |
+
def typename(self) -> str:
|
| 562 |
+
"""The type name of the exception."""
|
| 563 |
+
assert (
|
| 564 |
+
self._excinfo is not None
|
| 565 |
+
), ".typename can only be used after the context manager exits"
|
| 566 |
+
return self.type.__name__
|
| 567 |
+
|
| 568 |
+
@property
|
| 569 |
+
def traceback(self) -> Traceback:
|
| 570 |
+
"""The traceback."""
|
| 571 |
+
if self._traceback is None:
|
| 572 |
+
self._traceback = Traceback(self.tb)
|
| 573 |
+
return self._traceback
|
| 574 |
+
|
| 575 |
+
@traceback.setter
|
| 576 |
+
def traceback(self, value: Traceback) -> None:
|
| 577 |
+
self._traceback = value
|
| 578 |
+
|
| 579 |
+
def __repr__(self) -> str:
|
| 580 |
+
if self._excinfo is None:
|
| 581 |
+
return "<ExceptionInfo for raises contextmanager>"
|
| 582 |
+
return f"<{self.__class__.__name__} {saferepr(self._excinfo[1])} tblen={len(self.traceback)}>"
|
| 583 |
+
|
| 584 |
+
def exconly(self, tryshort: bool = False) -> str:
|
| 585 |
+
"""Return the exception as a string.
|
| 586 |
+
|
| 587 |
+
When 'tryshort' resolves to True, and the exception is an
|
| 588 |
+
AssertionError, only the actual exception part of the exception
|
| 589 |
+
representation is returned (so 'AssertionError: ' is removed from
|
| 590 |
+
the beginning).
|
| 591 |
+
"""
|
| 592 |
+
lines = format_exception_only(self.type, self.value)
|
| 593 |
+
text = "".join(lines)
|
| 594 |
+
text = text.rstrip()
|
| 595 |
+
if tryshort:
|
| 596 |
+
if text.startswith(self._striptext):
|
| 597 |
+
text = text[len(self._striptext) :]
|
| 598 |
+
return text
|
| 599 |
+
|
| 600 |
+
def errisinstance(self, exc: EXCEPTION_OR_MORE) -> bool:
|
| 601 |
+
"""Return True if the exception is an instance of exc.
|
| 602 |
+
|
| 603 |
+
Consider using ``isinstance(excinfo.value, exc)`` instead.
|
| 604 |
+
"""
|
| 605 |
+
return isinstance(self.value, exc)
|
| 606 |
+
|
| 607 |
+
def _getreprcrash(self) -> ReprFileLocation | None:
|
| 608 |
+
# Find last non-hidden traceback entry that led to the exception of the
|
| 609 |
+
# traceback, or None if all hidden.
|
| 610 |
+
for i in range(-1, -len(self.traceback) - 1, -1):
|
| 611 |
+
entry = self.traceback[i]
|
| 612 |
+
if not entry.ishidden(self):
|
| 613 |
+
path, lineno = entry.frame.code.raw.co_filename, entry.lineno
|
| 614 |
+
exconly = self.exconly(tryshort=True)
|
| 615 |
+
return ReprFileLocation(path, lineno + 1, exconly)
|
| 616 |
+
return None
|
| 617 |
+
|
| 618 |
+
def getrepr(
|
| 619 |
+
self,
|
| 620 |
+
showlocals: bool = False,
|
| 621 |
+
style: TracebackStyle = "long",
|
| 622 |
+
abspath: bool = False,
|
| 623 |
+
tbfilter: bool
|
| 624 |
+
| Callable[[ExceptionInfo[BaseException]], _pytest._code.code.Traceback] = True,
|
| 625 |
+
funcargs: bool = False,
|
| 626 |
+
truncate_locals: bool = True,
|
| 627 |
+
truncate_args: bool = True,
|
| 628 |
+
chain: bool = True,
|
| 629 |
+
) -> ReprExceptionInfo | ExceptionChainRepr:
|
| 630 |
+
"""Return str()able representation of this exception info.
|
| 631 |
+
|
| 632 |
+
:param bool showlocals:
|
| 633 |
+
Show locals per traceback entry.
|
| 634 |
+
Ignored if ``style=="native"``.
|
| 635 |
+
|
| 636 |
+
:param str style:
|
| 637 |
+
long|short|line|no|native|value traceback style.
|
| 638 |
+
|
| 639 |
+
:param bool abspath:
|
| 640 |
+
If paths should be changed to absolute or left unchanged.
|
| 641 |
+
|
| 642 |
+
:param tbfilter:
|
| 643 |
+
A filter for traceback entries.
|
| 644 |
+
|
| 645 |
+
* If false, don't hide any entries.
|
| 646 |
+
* If true, hide internal entries and entries that contain a local
|
| 647 |
+
variable ``__tracebackhide__ = True``.
|
| 648 |
+
* If a callable, delegates the filtering to the callable.
|
| 649 |
+
|
| 650 |
+
Ignored if ``style`` is ``"native"``.
|
| 651 |
+
|
| 652 |
+
:param bool funcargs:
|
| 653 |
+
Show fixtures ("funcargs" for legacy purposes) per traceback entry.
|
| 654 |
+
|
| 655 |
+
:param bool truncate_locals:
|
| 656 |
+
With ``showlocals==True``, make sure locals can be safely represented as strings.
|
| 657 |
+
|
| 658 |
+
:param bool truncate_args:
|
| 659 |
+
With ``showargs==True``, make sure args can be safely represented as strings.
|
| 660 |
+
|
| 661 |
+
:param bool chain:
|
| 662 |
+
If chained exceptions in Python 3 should be shown.
|
| 663 |
+
|
| 664 |
+
.. versionchanged:: 3.9
|
| 665 |
+
|
| 666 |
+
Added the ``chain`` parameter.
|
| 667 |
+
"""
|
| 668 |
+
if style == "native":
|
| 669 |
+
return ReprExceptionInfo(
|
| 670 |
+
reprtraceback=ReprTracebackNative(
|
| 671 |
+
traceback.format_exception(
|
| 672 |
+
self.type,
|
| 673 |
+
self.value,
|
| 674 |
+
self.traceback[0]._rawentry if self.traceback else None,
|
| 675 |
+
)
|
| 676 |
+
),
|
| 677 |
+
reprcrash=self._getreprcrash(),
|
| 678 |
+
)
|
| 679 |
+
|
| 680 |
+
fmt = FormattedExcinfo(
|
| 681 |
+
showlocals=showlocals,
|
| 682 |
+
style=style,
|
| 683 |
+
abspath=abspath,
|
| 684 |
+
tbfilter=tbfilter,
|
| 685 |
+
funcargs=funcargs,
|
| 686 |
+
truncate_locals=truncate_locals,
|
| 687 |
+
truncate_args=truncate_args,
|
| 688 |
+
chain=chain,
|
| 689 |
+
)
|
| 690 |
+
return fmt.repr_excinfo(self)
|
| 691 |
+
|
| 692 |
+
def _stringify_exception(self, exc: BaseException) -> str:
|
| 693 |
+
try:
|
| 694 |
+
notes = getattr(exc, "__notes__", [])
|
| 695 |
+
except KeyError:
|
| 696 |
+
# Workaround for https://github.com/python/cpython/issues/98778 on
|
| 697 |
+
# Python <= 3.9, and some 3.10 and 3.11 patch versions.
|
| 698 |
+
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
|
| 699 |
+
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
|
| 700 |
+
notes = []
|
| 701 |
+
else:
|
| 702 |
+
raise
|
| 703 |
+
|
| 704 |
+
return "\n".join(
|
| 705 |
+
[
|
| 706 |
+
str(exc),
|
| 707 |
+
*notes,
|
| 708 |
+
]
|
| 709 |
+
)
|
| 710 |
+
|
| 711 |
+
def match(self, regexp: str | Pattern[str]) -> Literal[True]:
|
| 712 |
+
"""Check whether the regular expression `regexp` matches the string
|
| 713 |
+
representation of the exception using :func:`python:re.search`.
|
| 714 |
+
|
| 715 |
+
If it matches `True` is returned, otherwise an `AssertionError` is raised.
|
| 716 |
+
"""
|
| 717 |
+
__tracebackhide__ = True
|
| 718 |
+
value = self._stringify_exception(self.value)
|
| 719 |
+
msg = f"Regex pattern did not match.\n Regex: {regexp!r}\n Input: {value!r}"
|
| 720 |
+
if regexp == value:
|
| 721 |
+
msg += "\n Did you mean to `re.escape()` the regex?"
|
| 722 |
+
assert re.search(regexp, value), msg
|
| 723 |
+
# Return True to allow for "assert excinfo.match()".
|
| 724 |
+
return True
|
| 725 |
+
|
| 726 |
+
def _group_contains(
|
| 727 |
+
self,
|
| 728 |
+
exc_group: BaseExceptionGroup[BaseException],
|
| 729 |
+
expected_exception: EXCEPTION_OR_MORE,
|
| 730 |
+
match: str | Pattern[str] | None,
|
| 731 |
+
target_depth: int | None = None,
|
| 732 |
+
current_depth: int = 1,
|
| 733 |
+
) -> bool:
|
| 734 |
+
"""Return `True` if a `BaseExceptionGroup` contains a matching exception."""
|
| 735 |
+
if (target_depth is not None) and (current_depth > target_depth):
|
| 736 |
+
# already descended past the target depth
|
| 737 |
+
return False
|
| 738 |
+
for exc in exc_group.exceptions:
|
| 739 |
+
if isinstance(exc, BaseExceptionGroup):
|
| 740 |
+
if self._group_contains(
|
| 741 |
+
exc, expected_exception, match, target_depth, current_depth + 1
|
| 742 |
+
):
|
| 743 |
+
return True
|
| 744 |
+
if (target_depth is not None) and (current_depth != target_depth):
|
| 745 |
+
# not at the target depth, no match
|
| 746 |
+
continue
|
| 747 |
+
if not isinstance(exc, expected_exception):
|
| 748 |
+
continue
|
| 749 |
+
if match is not None:
|
| 750 |
+
value = self._stringify_exception(exc)
|
| 751 |
+
if not re.search(match, value):
|
| 752 |
+
continue
|
| 753 |
+
return True
|
| 754 |
+
return False
|
| 755 |
+
|
| 756 |
+
def group_contains(
|
| 757 |
+
self,
|
| 758 |
+
expected_exception: EXCEPTION_OR_MORE,
|
| 759 |
+
*,
|
| 760 |
+
match: str | Pattern[str] | None = None,
|
| 761 |
+
depth: int | None = None,
|
| 762 |
+
) -> bool:
|
| 763 |
+
"""Check whether a captured exception group contains a matching exception.
|
| 764 |
+
|
| 765 |
+
:param Type[BaseException] | Tuple[Type[BaseException]] expected_exception:
|
| 766 |
+
The expected exception type, or a tuple if one of multiple possible
|
| 767 |
+
exception types are expected.
|
| 768 |
+
|
| 769 |
+
:param str | Pattern[str] | None match:
|
| 770 |
+
If specified, a string containing a regular expression,
|
| 771 |
+
or a regular expression object, that is tested against the string
|
| 772 |
+
representation of the exception and its `PEP-678 <https://peps.python.org/pep-0678/>` `__notes__`
|
| 773 |
+
using :func:`re.search`.
|
| 774 |
+
|
| 775 |
+
To match a literal string that may contain :ref:`special characters
|
| 776 |
+
<re-syntax>`, the pattern can first be escaped with :func:`re.escape`.
|
| 777 |
+
|
| 778 |
+
:param Optional[int] depth:
|
| 779 |
+
If `None`, will search for a matching exception at any nesting depth.
|
| 780 |
+
If >= 1, will only match an exception if it's at the specified depth (depth = 1 being
|
| 781 |
+
the exceptions contained within the topmost exception group).
|
| 782 |
+
|
| 783 |
+
.. versionadded:: 8.0
|
| 784 |
+
"""
|
| 785 |
+
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
|
| 786 |
+
assert isinstance(self.value, BaseExceptionGroup), msg
|
| 787 |
+
msg = "`depth` must be >= 1 if specified"
|
| 788 |
+
assert (depth is None) or (depth >= 1), msg
|
| 789 |
+
return self._group_contains(self.value, expected_exception, match, depth)
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
@dataclasses.dataclass
|
| 793 |
+
class FormattedExcinfo:
|
| 794 |
+
"""Presenting information about failing Functions and Generators."""
|
| 795 |
+
|
| 796 |
+
# for traceback entries
|
| 797 |
+
flow_marker: ClassVar = ">"
|
| 798 |
+
fail_marker: ClassVar = "E"
|
| 799 |
+
|
| 800 |
+
showlocals: bool = False
|
| 801 |
+
style: TracebackStyle = "long"
|
| 802 |
+
abspath: bool = True
|
| 803 |
+
tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = True
|
| 804 |
+
funcargs: bool = False
|
| 805 |
+
truncate_locals: bool = True
|
| 806 |
+
truncate_args: bool = True
|
| 807 |
+
chain: bool = True
|
| 808 |
+
astcache: dict[str | Path, ast.AST] = dataclasses.field(
|
| 809 |
+
default_factory=dict, init=False, repr=False
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
def _getindent(self, source: Source) -> int:
|
| 813 |
+
# Figure out indent for the given source.
|
| 814 |
+
try:
|
| 815 |
+
s = str(source.getstatement(len(source) - 1))
|
| 816 |
+
except KeyboardInterrupt:
|
| 817 |
+
raise
|
| 818 |
+
except BaseException:
|
| 819 |
+
try:
|
| 820 |
+
s = str(source[-1])
|
| 821 |
+
except KeyboardInterrupt:
|
| 822 |
+
raise
|
| 823 |
+
except BaseException:
|
| 824 |
+
return 0
|
| 825 |
+
return 4 + (len(s) - len(s.lstrip()))
|
| 826 |
+
|
| 827 |
+
def _getentrysource(self, entry: TracebackEntry) -> Source | None:
|
| 828 |
+
source = entry.getsource(self.astcache)
|
| 829 |
+
if source is not None:
|
| 830 |
+
source = source.deindent()
|
| 831 |
+
return source
|
| 832 |
+
|
| 833 |
+
def repr_args(self, entry: TracebackEntry) -> ReprFuncArgs | None:
|
| 834 |
+
if self.funcargs:
|
| 835 |
+
args = []
|
| 836 |
+
for argname, argvalue in entry.frame.getargs(var=True):
|
| 837 |
+
if self.truncate_args:
|
| 838 |
+
str_repr = saferepr(argvalue)
|
| 839 |
+
else:
|
| 840 |
+
str_repr = saferepr(argvalue, maxsize=None)
|
| 841 |
+
args.append((argname, str_repr))
|
| 842 |
+
return ReprFuncArgs(args)
|
| 843 |
+
return None
|
| 844 |
+
|
| 845 |
+
def get_source(
|
| 846 |
+
self,
|
| 847 |
+
source: Source | None,
|
| 848 |
+
line_index: int = -1,
|
| 849 |
+
excinfo: ExceptionInfo[BaseException] | None = None,
|
| 850 |
+
short: bool = False,
|
| 851 |
+
) -> list[str]:
|
| 852 |
+
"""Return formatted and marked up source lines."""
|
| 853 |
+
lines = []
|
| 854 |
+
if source is not None and line_index < 0:
|
| 855 |
+
line_index += len(source)
|
| 856 |
+
if source is None or line_index >= len(source.lines) or line_index < 0:
|
| 857 |
+
# `line_index` could still be outside `range(len(source.lines))` if
|
| 858 |
+
# we're processing AST with pathological position attributes.
|
| 859 |
+
source = Source("???")
|
| 860 |
+
line_index = 0
|
| 861 |
+
space_prefix = " "
|
| 862 |
+
if short:
|
| 863 |
+
lines.append(space_prefix + source.lines[line_index].strip())
|
| 864 |
+
else:
|
| 865 |
+
for line in source.lines[:line_index]:
|
| 866 |
+
lines.append(space_prefix + line)
|
| 867 |
+
lines.append(self.flow_marker + " " + source.lines[line_index])
|
| 868 |
+
for line in source.lines[line_index + 1 :]:
|
| 869 |
+
lines.append(space_prefix + line)
|
| 870 |
+
if excinfo is not None:
|
| 871 |
+
indent = 4 if short else self._getindent(source)
|
| 872 |
+
lines.extend(self.get_exconly(excinfo, indent=indent, markall=True))
|
| 873 |
+
return lines
|
| 874 |
+
|
| 875 |
+
def get_exconly(
|
| 876 |
+
self,
|
| 877 |
+
excinfo: ExceptionInfo[BaseException],
|
| 878 |
+
indent: int = 4,
|
| 879 |
+
markall: bool = False,
|
| 880 |
+
) -> list[str]:
|
| 881 |
+
lines = []
|
| 882 |
+
indentstr = " " * indent
|
| 883 |
+
# Get the real exception information out.
|
| 884 |
+
exlines = excinfo.exconly(tryshort=True).split("\n")
|
| 885 |
+
failindent = self.fail_marker + indentstr[1:]
|
| 886 |
+
for line in exlines:
|
| 887 |
+
lines.append(failindent + line)
|
| 888 |
+
if not markall:
|
| 889 |
+
failindent = indentstr
|
| 890 |
+
return lines
|
| 891 |
+
|
| 892 |
+
def repr_locals(self, locals: Mapping[str, object]) -> ReprLocals | None:
|
| 893 |
+
if self.showlocals:
|
| 894 |
+
lines = []
|
| 895 |
+
keys = [loc for loc in locals if loc[0] != "@"]
|
| 896 |
+
keys.sort()
|
| 897 |
+
for name in keys:
|
| 898 |
+
value = locals[name]
|
| 899 |
+
if name == "__builtins__":
|
| 900 |
+
lines.append("__builtins__ = <builtins>")
|
| 901 |
+
else:
|
| 902 |
+
# This formatting could all be handled by the
|
| 903 |
+
# _repr() function, which is only reprlib.Repr in
|
| 904 |
+
# disguise, so is very configurable.
|
| 905 |
+
if self.truncate_locals:
|
| 906 |
+
str_repr = saferepr(value)
|
| 907 |
+
else:
|
| 908 |
+
str_repr = safeformat(value)
|
| 909 |
+
# if len(str_repr) < 70 or not isinstance(value, (list, tuple, dict)):
|
| 910 |
+
lines.append(f"{name:<10} = {str_repr}")
|
| 911 |
+
# else:
|
| 912 |
+
# self._line("%-10s =\\" % (name,))
|
| 913 |
+
# # XXX
|
| 914 |
+
# pprint.pprint(value, stream=self.excinfowriter)
|
| 915 |
+
return ReprLocals(lines)
|
| 916 |
+
return None
|
| 917 |
+
|
| 918 |
+
def repr_traceback_entry(
|
| 919 |
+
self,
|
| 920 |
+
entry: TracebackEntry | None,
|
| 921 |
+
excinfo: ExceptionInfo[BaseException] | None = None,
|
| 922 |
+
) -> ReprEntry:
|
| 923 |
+
lines: list[str] = []
|
| 924 |
+
style = (
|
| 925 |
+
entry._repr_style
|
| 926 |
+
if entry is not None and entry._repr_style is not None
|
| 927 |
+
else self.style
|
| 928 |
+
)
|
| 929 |
+
if style in ("short", "long") and entry is not None:
|
| 930 |
+
source = self._getentrysource(entry)
|
| 931 |
+
if source is None:
|
| 932 |
+
source = Source("???")
|
| 933 |
+
line_index = 0
|
| 934 |
+
else:
|
| 935 |
+
line_index = entry.lineno - entry.getfirstlinesource()
|
| 936 |
+
short = style == "short"
|
| 937 |
+
reprargs = self.repr_args(entry) if not short else None
|
| 938 |
+
s = self.get_source(source, line_index, excinfo, short=short)
|
| 939 |
+
lines.extend(s)
|
| 940 |
+
if short:
|
| 941 |
+
message = f"in {entry.name}"
|
| 942 |
+
else:
|
| 943 |
+
message = excinfo and excinfo.typename or ""
|
| 944 |
+
entry_path = entry.path
|
| 945 |
+
path = self._makepath(entry_path)
|
| 946 |
+
reprfileloc = ReprFileLocation(path, entry.lineno + 1, message)
|
| 947 |
+
localsrepr = self.repr_locals(entry.locals)
|
| 948 |
+
return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style)
|
| 949 |
+
elif style == "value":
|
| 950 |
+
if excinfo:
|
| 951 |
+
lines.extend(str(excinfo.value).split("\n"))
|
| 952 |
+
return ReprEntry(lines, None, None, None, style)
|
| 953 |
+
else:
|
| 954 |
+
if excinfo:
|
| 955 |
+
lines.extend(self.get_exconly(excinfo, indent=4))
|
| 956 |
+
return ReprEntry(lines, None, None, None, style)
|
| 957 |
+
|
| 958 |
+
def _makepath(self, path: Path | str) -> str:
|
| 959 |
+
if not self.abspath and isinstance(path, Path):
|
| 960 |
+
try:
|
| 961 |
+
np = bestrelpath(Path.cwd(), path)
|
| 962 |
+
except OSError:
|
| 963 |
+
return str(path)
|
| 964 |
+
if len(np) < len(str(path)):
|
| 965 |
+
return np
|
| 966 |
+
return str(path)
|
| 967 |
+
|
| 968 |
+
def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback:
|
| 969 |
+
traceback = excinfo.traceback
|
| 970 |
+
if callable(self.tbfilter):
|
| 971 |
+
traceback = self.tbfilter(excinfo)
|
| 972 |
+
elif self.tbfilter:
|
| 973 |
+
traceback = traceback.filter(excinfo)
|
| 974 |
+
|
| 975 |
+
if isinstance(excinfo.value, RecursionError):
|
| 976 |
+
traceback, extraline = self._truncate_recursive_traceback(traceback)
|
| 977 |
+
else:
|
| 978 |
+
extraline = None
|
| 979 |
+
|
| 980 |
+
if not traceback:
|
| 981 |
+
if extraline is None:
|
| 982 |
+
extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
|
| 983 |
+
entries = [self.repr_traceback_entry(None, excinfo)]
|
| 984 |
+
return ReprTraceback(entries, extraline, style=self.style)
|
| 985 |
+
|
| 986 |
+
last = traceback[-1]
|
| 987 |
+
if self.style == "value":
|
| 988 |
+
entries = [self.repr_traceback_entry(last, excinfo)]
|
| 989 |
+
return ReprTraceback(entries, None, style=self.style)
|
| 990 |
+
|
| 991 |
+
entries = [
|
| 992 |
+
self.repr_traceback_entry(entry, excinfo if last == entry else None)
|
| 993 |
+
for entry in traceback
|
| 994 |
+
]
|
| 995 |
+
return ReprTraceback(entries, extraline, style=self.style)
|
| 996 |
+
|
| 997 |
+
def _truncate_recursive_traceback(
|
| 998 |
+
self, traceback: Traceback
|
| 999 |
+
) -> tuple[Traceback, str | None]:
|
| 1000 |
+
"""Truncate the given recursive traceback trying to find the starting
|
| 1001 |
+
point of the recursion.
|
| 1002 |
+
|
| 1003 |
+
The detection is done by going through each traceback entry and
|
| 1004 |
+
finding the point in which the locals of the frame are equal to the
|
| 1005 |
+
locals of a previous frame (see ``recursionindex()``).
|
| 1006 |
+
|
| 1007 |
+
Handle the situation where the recursion process might raise an
|
| 1008 |
+
exception (for example comparing numpy arrays using equality raises a
|
| 1009 |
+
TypeError), in which case we do our best to warn the user of the
|
| 1010 |
+
error and show a limited traceback.
|
| 1011 |
+
"""
|
| 1012 |
+
try:
|
| 1013 |
+
recursionindex = traceback.recursionindex()
|
| 1014 |
+
except Exception as e:
|
| 1015 |
+
max_frames = 10
|
| 1016 |
+
extraline: str | None = (
|
| 1017 |
+
"!!! Recursion error detected, but an error occurred locating the origin of recursion.\n"
|
| 1018 |
+
" The following exception happened when comparing locals in the stack frame:\n"
|
| 1019 |
+
f" {type(e).__name__}: {e!s}\n"
|
| 1020 |
+
f" Displaying first and last {max_frames} stack frames out of {len(traceback)}."
|
| 1021 |
+
)
|
| 1022 |
+
# Type ignored because adding two instances of a List subtype
|
| 1023 |
+
# currently incorrectly has type List instead of the subtype.
|
| 1024 |
+
traceback = traceback[:max_frames] + traceback[-max_frames:] # type: ignore
|
| 1025 |
+
else:
|
| 1026 |
+
if recursionindex is not None:
|
| 1027 |
+
extraline = "!!! Recursion detected (same locals & position)"
|
| 1028 |
+
traceback = traceback[: recursionindex + 1]
|
| 1029 |
+
else:
|
| 1030 |
+
extraline = None
|
| 1031 |
+
|
| 1032 |
+
return traceback, extraline
|
| 1033 |
+
|
| 1034 |
+
def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr:
|
| 1035 |
+
repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = []
|
| 1036 |
+
e: BaseException | None = excinfo.value
|
| 1037 |
+
excinfo_: ExceptionInfo[BaseException] | None = excinfo
|
| 1038 |
+
descr = None
|
| 1039 |
+
seen: set[int] = set()
|
| 1040 |
+
while e is not None and id(e) not in seen:
|
| 1041 |
+
seen.add(id(e))
|
| 1042 |
+
|
| 1043 |
+
if excinfo_:
|
| 1044 |
+
# Fall back to native traceback as a temporary workaround until
|
| 1045 |
+
# full support for exception groups added to ExceptionInfo.
|
| 1046 |
+
# See https://github.com/pytest-dev/pytest/issues/9159
|
| 1047 |
+
if isinstance(e, BaseExceptionGroup):
|
| 1048 |
+
reprtraceback: ReprTracebackNative | ReprTraceback = (
|
| 1049 |
+
ReprTracebackNative(
|
| 1050 |
+
traceback.format_exception(
|
| 1051 |
+
type(excinfo_.value),
|
| 1052 |
+
excinfo_.value,
|
| 1053 |
+
excinfo_.traceback[0]._rawentry,
|
| 1054 |
+
)
|
| 1055 |
+
)
|
| 1056 |
+
)
|
| 1057 |
+
else:
|
| 1058 |
+
reprtraceback = self.repr_traceback(excinfo_)
|
| 1059 |
+
reprcrash = excinfo_._getreprcrash()
|
| 1060 |
+
else:
|
| 1061 |
+
# Fallback to native repr if the exception doesn't have a traceback:
|
| 1062 |
+
# ExceptionInfo objects require a full traceback to work.
|
| 1063 |
+
reprtraceback = ReprTracebackNative(
|
| 1064 |
+
traceback.format_exception(type(e), e, None)
|
| 1065 |
+
)
|
| 1066 |
+
reprcrash = None
|
| 1067 |
+
repr_chain += [(reprtraceback, reprcrash, descr)]
|
| 1068 |
+
|
| 1069 |
+
if e.__cause__ is not None and self.chain:
|
| 1070 |
+
e = e.__cause__
|
| 1071 |
+
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
| 1072 |
+
descr = "The above exception was the direct cause of the following exception:"
|
| 1073 |
+
elif (
|
| 1074 |
+
e.__context__ is not None and not e.__suppress_context__ and self.chain
|
| 1075 |
+
):
|
| 1076 |
+
e = e.__context__
|
| 1077 |
+
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
| 1078 |
+
descr = "During handling of the above exception, another exception occurred:"
|
| 1079 |
+
else:
|
| 1080 |
+
e = None
|
| 1081 |
+
repr_chain.reverse()
|
| 1082 |
+
return ExceptionChainRepr(repr_chain)
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
@dataclasses.dataclass(eq=False)
|
| 1086 |
+
class TerminalRepr:
|
| 1087 |
+
def __str__(self) -> str:
|
| 1088 |
+
# FYI this is called from pytest-xdist's serialization of exception
|
| 1089 |
+
# information.
|
| 1090 |
+
io = StringIO()
|
| 1091 |
+
tw = TerminalWriter(file=io)
|
| 1092 |
+
self.toterminal(tw)
|
| 1093 |
+
return io.getvalue().strip()
|
| 1094 |
+
|
| 1095 |
+
def __repr__(self) -> str:
|
| 1096 |
+
return f"<{self.__class__} instance at {id(self):0x}>"
|
| 1097 |
+
|
| 1098 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1099 |
+
raise NotImplementedError()
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
+
# This class is abstract -- only subclasses are instantiated.
|
| 1103 |
+
@dataclasses.dataclass(eq=False)
|
| 1104 |
+
class ExceptionRepr(TerminalRepr):
|
| 1105 |
+
# Provided by subclasses.
|
| 1106 |
+
reprtraceback: ReprTraceback
|
| 1107 |
+
reprcrash: ReprFileLocation | None
|
| 1108 |
+
sections: list[tuple[str, str, str]] = dataclasses.field(
|
| 1109 |
+
init=False, default_factory=list
|
| 1110 |
+
)
|
| 1111 |
+
|
| 1112 |
+
def addsection(self, name: str, content: str, sep: str = "-") -> None:
|
| 1113 |
+
self.sections.append((name, content, sep))
|
| 1114 |
+
|
| 1115 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1116 |
+
for name, content, sep in self.sections:
|
| 1117 |
+
tw.sep(sep, name)
|
| 1118 |
+
tw.line(content)
|
| 1119 |
+
|
| 1120 |
+
|
| 1121 |
+
@dataclasses.dataclass(eq=False)
|
| 1122 |
+
class ExceptionChainRepr(ExceptionRepr):
|
| 1123 |
+
chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]
|
| 1124 |
+
|
| 1125 |
+
def __init__(
|
| 1126 |
+
self,
|
| 1127 |
+
chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]],
|
| 1128 |
+
) -> None:
|
| 1129 |
+
# reprcrash and reprtraceback of the outermost (the newest) exception
|
| 1130 |
+
# in the chain.
|
| 1131 |
+
super().__init__(
|
| 1132 |
+
reprtraceback=chain[-1][0],
|
| 1133 |
+
reprcrash=chain[-1][1],
|
| 1134 |
+
)
|
| 1135 |
+
self.chain = chain
|
| 1136 |
+
|
| 1137 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1138 |
+
for element in self.chain:
|
| 1139 |
+
element[0].toterminal(tw)
|
| 1140 |
+
if element[2] is not None:
|
| 1141 |
+
tw.line("")
|
| 1142 |
+
tw.line(element[2], yellow=True)
|
| 1143 |
+
super().toterminal(tw)
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
@dataclasses.dataclass(eq=False)
|
| 1147 |
+
class ReprExceptionInfo(ExceptionRepr):
|
| 1148 |
+
reprtraceback: ReprTraceback
|
| 1149 |
+
reprcrash: ReprFileLocation | None
|
| 1150 |
+
|
| 1151 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1152 |
+
self.reprtraceback.toterminal(tw)
|
| 1153 |
+
super().toterminal(tw)
|
| 1154 |
+
|
| 1155 |
+
|
| 1156 |
+
@dataclasses.dataclass(eq=False)
|
| 1157 |
+
class ReprTraceback(TerminalRepr):
|
| 1158 |
+
reprentries: Sequence[ReprEntry | ReprEntryNative]
|
| 1159 |
+
extraline: str | None
|
| 1160 |
+
style: TracebackStyle
|
| 1161 |
+
|
| 1162 |
+
entrysep: ClassVar = "_ "
|
| 1163 |
+
|
| 1164 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1165 |
+
# The entries might have different styles.
|
| 1166 |
+
for i, entry in enumerate(self.reprentries):
|
| 1167 |
+
if entry.style == "long":
|
| 1168 |
+
tw.line("")
|
| 1169 |
+
entry.toterminal(tw)
|
| 1170 |
+
if i < len(self.reprentries) - 1:
|
| 1171 |
+
next_entry = self.reprentries[i + 1]
|
| 1172 |
+
if (
|
| 1173 |
+
entry.style == "long"
|
| 1174 |
+
or entry.style == "short"
|
| 1175 |
+
and next_entry.style == "long"
|
| 1176 |
+
):
|
| 1177 |
+
tw.sep(self.entrysep)
|
| 1178 |
+
|
| 1179 |
+
if self.extraline:
|
| 1180 |
+
tw.line(self.extraline)
|
| 1181 |
+
|
| 1182 |
+
|
| 1183 |
+
class ReprTracebackNative(ReprTraceback):
|
| 1184 |
+
def __init__(self, tblines: Sequence[str]) -> None:
|
| 1185 |
+
self.reprentries = [ReprEntryNative(tblines)]
|
| 1186 |
+
self.extraline = None
|
| 1187 |
+
self.style = "native"
|
| 1188 |
+
|
| 1189 |
+
|
| 1190 |
+
@dataclasses.dataclass(eq=False)
|
| 1191 |
+
class ReprEntryNative(TerminalRepr):
|
| 1192 |
+
lines: Sequence[str]
|
| 1193 |
+
|
| 1194 |
+
style: ClassVar[TracebackStyle] = "native"
|
| 1195 |
+
|
| 1196 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1197 |
+
tw.write("".join(self.lines))
|
| 1198 |
+
|
| 1199 |
+
|
| 1200 |
+
@dataclasses.dataclass(eq=False)
|
| 1201 |
+
class ReprEntry(TerminalRepr):
|
| 1202 |
+
lines: Sequence[str]
|
| 1203 |
+
reprfuncargs: ReprFuncArgs | None
|
| 1204 |
+
reprlocals: ReprLocals | None
|
| 1205 |
+
reprfileloc: ReprFileLocation | None
|
| 1206 |
+
style: TracebackStyle
|
| 1207 |
+
|
| 1208 |
+
def _write_entry_lines(self, tw: TerminalWriter) -> None:
|
| 1209 |
+
"""Write the source code portions of a list of traceback entries with syntax highlighting.
|
| 1210 |
+
|
| 1211 |
+
Usually entries are lines like these:
|
| 1212 |
+
|
| 1213 |
+
" x = 1"
|
| 1214 |
+
"> assert x == 2"
|
| 1215 |
+
"E assert 1 == 2"
|
| 1216 |
+
|
| 1217 |
+
This function takes care of rendering the "source" portions of it (the lines without
|
| 1218 |
+
the "E" prefix) using syntax highlighting, taking care to not highlighting the ">"
|
| 1219 |
+
character, as doing so might break line continuations.
|
| 1220 |
+
"""
|
| 1221 |
+
if not self.lines:
|
| 1222 |
+
return
|
| 1223 |
+
|
| 1224 |
+
if self.style == "value":
|
| 1225 |
+
# Using tw.write instead of tw.line for testing purposes due to TWMock implementation;
|
| 1226 |
+
# lines written with TWMock.line and TWMock._write_source cannot be distinguished
|
| 1227 |
+
# from each other, whereas lines written with TWMock.write are marked with TWMock.WRITE
|
| 1228 |
+
for line in self.lines:
|
| 1229 |
+
tw.write(line)
|
| 1230 |
+
tw.write("\n")
|
| 1231 |
+
return
|
| 1232 |
+
|
| 1233 |
+
# separate indents and source lines that are not failures: we want to
|
| 1234 |
+
# highlight the code but not the indentation, which may contain markers
|
| 1235 |
+
# such as "> assert 0"
|
| 1236 |
+
fail_marker = f"{FormattedExcinfo.fail_marker} "
|
| 1237 |
+
indent_size = len(fail_marker)
|
| 1238 |
+
indents: list[str] = []
|
| 1239 |
+
source_lines: list[str] = []
|
| 1240 |
+
failure_lines: list[str] = []
|
| 1241 |
+
for index, line in enumerate(self.lines):
|
| 1242 |
+
is_failure_line = line.startswith(fail_marker)
|
| 1243 |
+
if is_failure_line:
|
| 1244 |
+
# from this point on all lines are considered part of the failure
|
| 1245 |
+
failure_lines.extend(self.lines[index:])
|
| 1246 |
+
break
|
| 1247 |
+
else:
|
| 1248 |
+
indents.append(line[:indent_size])
|
| 1249 |
+
source_lines.append(line[indent_size:])
|
| 1250 |
+
|
| 1251 |
+
tw._write_source(source_lines, indents)
|
| 1252 |
+
|
| 1253 |
+
# failure lines are always completely red and bold
|
| 1254 |
+
for line in failure_lines:
|
| 1255 |
+
tw.line(line, bold=True, red=True)
|
| 1256 |
+
|
| 1257 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1258 |
+
if self.style == "short":
|
| 1259 |
+
if self.reprfileloc:
|
| 1260 |
+
self.reprfileloc.toterminal(tw)
|
| 1261 |
+
self._write_entry_lines(tw)
|
| 1262 |
+
if self.reprlocals:
|
| 1263 |
+
self.reprlocals.toterminal(tw, indent=" " * 8)
|
| 1264 |
+
return
|
| 1265 |
+
|
| 1266 |
+
if self.reprfuncargs:
|
| 1267 |
+
self.reprfuncargs.toterminal(tw)
|
| 1268 |
+
|
| 1269 |
+
self._write_entry_lines(tw)
|
| 1270 |
+
|
| 1271 |
+
if self.reprlocals:
|
| 1272 |
+
tw.line("")
|
| 1273 |
+
self.reprlocals.toterminal(tw)
|
| 1274 |
+
if self.reprfileloc:
|
| 1275 |
+
if self.lines:
|
| 1276 |
+
tw.line("")
|
| 1277 |
+
self.reprfileloc.toterminal(tw)
|
| 1278 |
+
|
| 1279 |
+
def __str__(self) -> str:
|
| 1280 |
+
return "{}\n{}\n{}".format(
|
| 1281 |
+
"\n".join(self.lines), self.reprlocals, self.reprfileloc
|
| 1282 |
+
)
|
| 1283 |
+
|
| 1284 |
+
|
| 1285 |
+
@dataclasses.dataclass(eq=False)
|
| 1286 |
+
class ReprFileLocation(TerminalRepr):
|
| 1287 |
+
path: str
|
| 1288 |
+
lineno: int
|
| 1289 |
+
message: str
|
| 1290 |
+
|
| 1291 |
+
def __post_init__(self) -> None:
|
| 1292 |
+
self.path = str(self.path)
|
| 1293 |
+
|
| 1294 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1295 |
+
# Filename and lineno output for each entry, using an output format
|
| 1296 |
+
# that most editors understand.
|
| 1297 |
+
msg = self.message
|
| 1298 |
+
i = msg.find("\n")
|
| 1299 |
+
if i != -1:
|
| 1300 |
+
msg = msg[:i]
|
| 1301 |
+
tw.write(self.path, bold=True, red=True)
|
| 1302 |
+
tw.line(f":{self.lineno}: {msg}")
|
| 1303 |
+
|
| 1304 |
+
|
| 1305 |
+
@dataclasses.dataclass(eq=False)
|
| 1306 |
+
class ReprLocals(TerminalRepr):
|
| 1307 |
+
lines: Sequence[str]
|
| 1308 |
+
|
| 1309 |
+
def toterminal(self, tw: TerminalWriter, indent="") -> None:
|
| 1310 |
+
for line in self.lines:
|
| 1311 |
+
tw.line(indent + line)
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
@dataclasses.dataclass(eq=False)
|
| 1315 |
+
class ReprFuncArgs(TerminalRepr):
|
| 1316 |
+
args: Sequence[tuple[str, object]]
|
| 1317 |
+
|
| 1318 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1319 |
+
if self.args:
|
| 1320 |
+
linesofar = ""
|
| 1321 |
+
for name, value in self.args:
|
| 1322 |
+
ns = f"{name} = {value}"
|
| 1323 |
+
if len(ns) + len(linesofar) + 2 > tw.fullwidth:
|
| 1324 |
+
if linesofar:
|
| 1325 |
+
tw.line(linesofar)
|
| 1326 |
+
linesofar = ns
|
| 1327 |
+
else:
|
| 1328 |
+
if linesofar:
|
| 1329 |
+
linesofar += ", " + ns
|
| 1330 |
+
else:
|
| 1331 |
+
linesofar = ns
|
| 1332 |
+
if linesofar:
|
| 1333 |
+
tw.line(linesofar)
|
| 1334 |
+
tw.line("")
|
| 1335 |
+
|
| 1336 |
+
|
| 1337 |
+
def getfslineno(obj: object) -> tuple[str | Path, int]:
|
| 1338 |
+
"""Return source location (path, lineno) for the given object.
|
| 1339 |
+
|
| 1340 |
+
If the source cannot be determined return ("", -1).
|
| 1341 |
+
|
| 1342 |
+
The line number is 0-based.
|
| 1343 |
+
"""
|
| 1344 |
+
# xxx let decorators etc specify a sane ordering
|
| 1345 |
+
# NOTE: this used to be done in _pytest.compat.getfslineno, initially added
|
| 1346 |
+
# in 6ec13a2b9. It ("place_as") appears to be something very custom.
|
| 1347 |
+
obj = get_real_func(obj)
|
| 1348 |
+
if hasattr(obj, "place_as"):
|
| 1349 |
+
obj = obj.place_as
|
| 1350 |
+
|
| 1351 |
+
try:
|
| 1352 |
+
code = Code.from_function(obj)
|
| 1353 |
+
except TypeError:
|
| 1354 |
+
try:
|
| 1355 |
+
fn = inspect.getsourcefile(obj) or inspect.getfile(obj) # type: ignore[arg-type]
|
| 1356 |
+
except TypeError:
|
| 1357 |
+
return "", -1
|
| 1358 |
+
|
| 1359 |
+
fspath = fn and absolutepath(fn) or ""
|
| 1360 |
+
lineno = -1
|
| 1361 |
+
if fspath:
|
| 1362 |
+
try:
|
| 1363 |
+
_, lineno = findsource(obj)
|
| 1364 |
+
except OSError:
|
| 1365 |
+
pass
|
| 1366 |
+
return fspath, lineno
|
| 1367 |
+
|
| 1368 |
+
return code.path, code.firstlineno
|
| 1369 |
+
|
| 1370 |
+
|
| 1371 |
+
# Relative paths that we use to filter traceback entries from appearing to the user;
|
| 1372 |
+
# see filter_traceback.
|
| 1373 |
+
# note: if we need to add more paths than what we have now we should probably use a list
|
| 1374 |
+
# for better maintenance.
|
| 1375 |
+
|
| 1376 |
+
_PLUGGY_DIR = Path(pluggy.__file__.rstrip("oc"))
|
| 1377 |
+
# pluggy is either a package or a single module depending on the version
|
| 1378 |
+
if _PLUGGY_DIR.name == "__init__.py":
|
| 1379 |
+
_PLUGGY_DIR = _PLUGGY_DIR.parent
|
| 1380 |
+
_PYTEST_DIR = Path(_pytest.__file__).parent
|
| 1381 |
+
|
| 1382 |
+
|
| 1383 |
+
def filter_traceback(entry: TracebackEntry) -> bool:
|
| 1384 |
+
"""Return True if a TracebackEntry instance should be included in tracebacks.
|
| 1385 |
+
|
| 1386 |
+
We hide traceback entries of:
|
| 1387 |
+
|
| 1388 |
+
* dynamically generated code (no code to show up for it);
|
| 1389 |
+
* internal traceback from pytest or its internal libraries, py and pluggy.
|
| 1390 |
+
"""
|
| 1391 |
+
# entry.path might sometimes return a str object when the entry
|
| 1392 |
+
# points to dynamically generated code.
|
| 1393 |
+
# See https://bitbucket.org/pytest-dev/py/issues/71.
|
| 1394 |
+
raw_filename = entry.frame.code.raw.co_filename
|
| 1395 |
+
is_generated = "<" in raw_filename and ">" in raw_filename
|
| 1396 |
+
if is_generated:
|
| 1397 |
+
return False
|
| 1398 |
+
|
| 1399 |
+
# entry.path might point to a non-existing file, in which case it will
|
| 1400 |
+
# also return a str object. See #1133.
|
| 1401 |
+
p = Path(entry.path)
|
| 1402 |
+
|
| 1403 |
+
parents = p.parents
|
| 1404 |
+
if _PLUGGY_DIR in parents:
|
| 1405 |
+
return False
|
| 1406 |
+
if _PYTEST_DIR in parents:
|
| 1407 |
+
return False
|
| 1408 |
+
|
| 1409 |
+
return True
|
.venv/lib/python3.11/site-packages/_pytest/_code/source.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import ast
|
| 5 |
+
from bisect import bisect_right
|
| 6 |
+
import inspect
|
| 7 |
+
import textwrap
|
| 8 |
+
import tokenize
|
| 9 |
+
import types
|
| 10 |
+
from typing import Iterable
|
| 11 |
+
from typing import Iterator
|
| 12 |
+
from typing import overload
|
| 13 |
+
import warnings
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Source:
|
| 17 |
+
"""An immutable object holding a source code fragment.
|
| 18 |
+
|
| 19 |
+
When using Source(...), the source lines are deindented.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, obj: object = None) -> None:
|
| 23 |
+
if not obj:
|
| 24 |
+
self.lines: list[str] = []
|
| 25 |
+
elif isinstance(obj, Source):
|
| 26 |
+
self.lines = obj.lines
|
| 27 |
+
elif isinstance(obj, (tuple, list)):
|
| 28 |
+
self.lines = deindent(x.rstrip("\n") for x in obj)
|
| 29 |
+
elif isinstance(obj, str):
|
| 30 |
+
self.lines = deindent(obj.split("\n"))
|
| 31 |
+
else:
|
| 32 |
+
try:
|
| 33 |
+
rawcode = getrawcode(obj)
|
| 34 |
+
src = inspect.getsource(rawcode)
|
| 35 |
+
except TypeError:
|
| 36 |
+
src = inspect.getsource(obj) # type: ignore[arg-type]
|
| 37 |
+
self.lines = deindent(src.split("\n"))
|
| 38 |
+
|
| 39 |
+
def __eq__(self, other: object) -> bool:
|
| 40 |
+
if not isinstance(other, Source):
|
| 41 |
+
return NotImplemented
|
| 42 |
+
return self.lines == other.lines
|
| 43 |
+
|
| 44 |
+
# Ignore type because of https://github.com/python/mypy/issues/4266.
|
| 45 |
+
__hash__ = None # type: ignore
|
| 46 |
+
|
| 47 |
+
@overload
|
| 48 |
+
def __getitem__(self, key: int) -> str: ...
|
| 49 |
+
|
| 50 |
+
@overload
|
| 51 |
+
def __getitem__(self, key: slice) -> Source: ...
|
| 52 |
+
|
| 53 |
+
def __getitem__(self, key: int | slice) -> str | Source:
|
| 54 |
+
if isinstance(key, int):
|
| 55 |
+
return self.lines[key]
|
| 56 |
+
else:
|
| 57 |
+
if key.step not in (None, 1):
|
| 58 |
+
raise IndexError("cannot slice a Source with a step")
|
| 59 |
+
newsource = Source()
|
| 60 |
+
newsource.lines = self.lines[key.start : key.stop]
|
| 61 |
+
return newsource
|
| 62 |
+
|
| 63 |
+
def __iter__(self) -> Iterator[str]:
|
| 64 |
+
return iter(self.lines)
|
| 65 |
+
|
| 66 |
+
def __len__(self) -> int:
|
| 67 |
+
return len(self.lines)
|
| 68 |
+
|
| 69 |
+
def strip(self) -> Source:
|
| 70 |
+
"""Return new Source object with trailing and leading blank lines removed."""
|
| 71 |
+
start, end = 0, len(self)
|
| 72 |
+
while start < end and not self.lines[start].strip():
|
| 73 |
+
start += 1
|
| 74 |
+
while end > start and not self.lines[end - 1].strip():
|
| 75 |
+
end -= 1
|
| 76 |
+
source = Source()
|
| 77 |
+
source.lines[:] = self.lines[start:end]
|
| 78 |
+
return source
|
| 79 |
+
|
| 80 |
+
def indent(self, indent: str = " " * 4) -> Source:
|
| 81 |
+
"""Return a copy of the source object with all lines indented by the
|
| 82 |
+
given indent-string."""
|
| 83 |
+
newsource = Source()
|
| 84 |
+
newsource.lines = [(indent + line) for line in self.lines]
|
| 85 |
+
return newsource
|
| 86 |
+
|
| 87 |
+
def getstatement(self, lineno: int) -> Source:
|
| 88 |
+
"""Return Source statement which contains the given linenumber
|
| 89 |
+
(counted from 0)."""
|
| 90 |
+
start, end = self.getstatementrange(lineno)
|
| 91 |
+
return self[start:end]
|
| 92 |
+
|
| 93 |
+
def getstatementrange(self, lineno: int) -> tuple[int, int]:
|
| 94 |
+
"""Return (start, end) tuple which spans the minimal statement region
|
| 95 |
+
which containing the given lineno."""
|
| 96 |
+
if not (0 <= lineno < len(self)):
|
| 97 |
+
raise IndexError("lineno out of range")
|
| 98 |
+
ast, start, end = getstatementrange_ast(lineno, self)
|
| 99 |
+
return start, end
|
| 100 |
+
|
| 101 |
+
def deindent(self) -> Source:
|
| 102 |
+
"""Return a new Source object deindented."""
|
| 103 |
+
newsource = Source()
|
| 104 |
+
newsource.lines[:] = deindent(self.lines)
|
| 105 |
+
return newsource
|
| 106 |
+
|
| 107 |
+
def __str__(self) -> str:
|
| 108 |
+
return "\n".join(self.lines)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
#
|
| 112 |
+
# helper functions
|
| 113 |
+
#
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def findsource(obj) -> tuple[Source | None, int]:
|
| 117 |
+
try:
|
| 118 |
+
sourcelines, lineno = inspect.findsource(obj)
|
| 119 |
+
except Exception:
|
| 120 |
+
return None, -1
|
| 121 |
+
source = Source()
|
| 122 |
+
source.lines = [line.rstrip() for line in sourcelines]
|
| 123 |
+
return source, lineno
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def getrawcode(obj: object, trycall: bool = True) -> types.CodeType:
|
| 127 |
+
"""Return code object for given function."""
|
| 128 |
+
try:
|
| 129 |
+
return obj.__code__ # type: ignore[attr-defined,no-any-return]
|
| 130 |
+
except AttributeError:
|
| 131 |
+
pass
|
| 132 |
+
if trycall:
|
| 133 |
+
call = getattr(obj, "__call__", None)
|
| 134 |
+
if call and not isinstance(obj, type):
|
| 135 |
+
return getrawcode(call, trycall=False)
|
| 136 |
+
raise TypeError(f"could not get code object for {obj!r}")
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def deindent(lines: Iterable[str]) -> list[str]:
|
| 140 |
+
return textwrap.dedent("\n".join(lines)).splitlines()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
|
| 144 |
+
# Flatten all statements and except handlers into one lineno-list.
|
| 145 |
+
# AST's line numbers start indexing at 1.
|
| 146 |
+
values: list[int] = []
|
| 147 |
+
for x in ast.walk(node):
|
| 148 |
+
if isinstance(x, (ast.stmt, ast.ExceptHandler)):
|
| 149 |
+
# The lineno points to the class/def, so need to include the decorators.
|
| 150 |
+
if isinstance(x, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
| 151 |
+
for d in x.decorator_list:
|
| 152 |
+
values.append(d.lineno - 1)
|
| 153 |
+
values.append(x.lineno - 1)
|
| 154 |
+
for name in ("finalbody", "orelse"):
|
| 155 |
+
val: list[ast.stmt] | None = getattr(x, name, None)
|
| 156 |
+
if val:
|
| 157 |
+
# Treat the finally/orelse part as its own statement.
|
| 158 |
+
values.append(val[0].lineno - 1 - 1)
|
| 159 |
+
values.sort()
|
| 160 |
+
insert_index = bisect_right(values, lineno)
|
| 161 |
+
start = values[insert_index - 1]
|
| 162 |
+
if insert_index >= len(values):
|
| 163 |
+
end = None
|
| 164 |
+
else:
|
| 165 |
+
end = values[insert_index]
|
| 166 |
+
return start, end
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def getstatementrange_ast(
|
| 170 |
+
lineno: int,
|
| 171 |
+
source: Source,
|
| 172 |
+
assertion: bool = False,
|
| 173 |
+
astnode: ast.AST | None = None,
|
| 174 |
+
) -> tuple[ast.AST, int, int]:
|
| 175 |
+
if astnode is None:
|
| 176 |
+
content = str(source)
|
| 177 |
+
# See #4260:
|
| 178 |
+
# Don't produce duplicate warnings when compiling source to find AST.
|
| 179 |
+
with warnings.catch_warnings():
|
| 180 |
+
warnings.simplefilter("ignore")
|
| 181 |
+
astnode = ast.parse(content, "source", "exec")
|
| 182 |
+
|
| 183 |
+
start, end = get_statement_startend2(lineno, astnode)
|
| 184 |
+
# We need to correct the end:
|
| 185 |
+
# - ast-parsing strips comments
|
| 186 |
+
# - there might be empty lines
|
| 187 |
+
# - we might have lesser indented code blocks at the end
|
| 188 |
+
if end is None:
|
| 189 |
+
end = len(source.lines)
|
| 190 |
+
|
| 191 |
+
if end > start + 1:
|
| 192 |
+
# Make sure we don't span differently indented code blocks
|
| 193 |
+
# by using the BlockFinder helper used which inspect.getsource() uses itself.
|
| 194 |
+
block_finder = inspect.BlockFinder()
|
| 195 |
+
# If we start with an indented line, put blockfinder to "started" mode.
|
| 196 |
+
block_finder.started = (
|
| 197 |
+
bool(source.lines[start]) and source.lines[start][0].isspace()
|
| 198 |
+
)
|
| 199 |
+
it = ((x + "\n") for x in source.lines[start:end])
|
| 200 |
+
try:
|
| 201 |
+
for tok in tokenize.generate_tokens(lambda: next(it)):
|
| 202 |
+
block_finder.tokeneater(*tok)
|
| 203 |
+
except (inspect.EndOfBlock, IndentationError):
|
| 204 |
+
end = block_finder.last + start
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
# The end might still point to a comment or empty line, correct it.
|
| 209 |
+
while end:
|
| 210 |
+
line = source.lines[end - 1].lstrip()
|
| 211 |
+
if line.startswith("#") or not line:
|
| 212 |
+
end -= 1
|
| 213 |
+
else:
|
| 214 |
+
break
|
| 215 |
+
return astnode, start, end
|
.venv/lib/python3.11/site-packages/_pytest/_io/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .terminalwriter import get_terminal_width
|
| 4 |
+
from .terminalwriter import TerminalWriter
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
"TerminalWriter",
|
| 9 |
+
"get_terminal_width",
|
| 10 |
+
]
|
.venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (415 Bytes). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/pprint.cpython-311.pyc
ADDED
|
Binary file (25 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/saferepr.cpython-311.pyc
ADDED
|
Binary file (6.66 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/terminalwriter.cpython-311.pyc
ADDED
|
Binary file (12.8 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_io/__pycache__/wcwidth.cpython-311.pyc
ADDED
|
Binary file (1.94 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/_io/pprint.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
# This module was imported from the cpython standard library
|
| 3 |
+
# (https://github.com/python/cpython/) at commit
|
| 4 |
+
# c5140945c723ae6c4b7ee81ff720ac8ea4b52cfd (python3.12).
|
| 5 |
+
#
|
| 6 |
+
#
|
| 7 |
+
# Original Author: Fred L. Drake, Jr.
|
| 8 |
+
# fdrake@acm.org
|
| 9 |
+
#
|
| 10 |
+
# This is a simple little module I wrote to make life easier. I didn't
|
| 11 |
+
# see anything quite like it in the library, though I may have overlooked
|
| 12 |
+
# something. I wrote this when I was trying to read some heavily nested
|
| 13 |
+
# tuples with fairly non-descriptive content. This is modeled very much
|
| 14 |
+
# after Lisp/Scheme - style pretty-printing of lists. If you find it
|
| 15 |
+
# useful, thank small children who sleep at night.
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import collections as _collections
|
| 19 |
+
import dataclasses as _dataclasses
|
| 20 |
+
from io import StringIO as _StringIO
|
| 21 |
+
import re
|
| 22 |
+
import types as _types
|
| 23 |
+
from typing import Any
|
| 24 |
+
from typing import Callable
|
| 25 |
+
from typing import IO
|
| 26 |
+
from typing import Iterator
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class _safe_key:
|
| 30 |
+
"""Helper function for key functions when sorting unorderable objects.
|
| 31 |
+
|
| 32 |
+
The wrapped-object will fallback to a Py2.x style comparison for
|
| 33 |
+
unorderable types (sorting first comparing the type name and then by
|
| 34 |
+
the obj ids). Does not work recursively, so dict.items() must have
|
| 35 |
+
_safe_key applied to both the key and the value.
|
| 36 |
+
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
__slots__ = ["obj"]
|
| 40 |
+
|
| 41 |
+
def __init__(self, obj):
|
| 42 |
+
self.obj = obj
|
| 43 |
+
|
| 44 |
+
def __lt__(self, other):
|
| 45 |
+
try:
|
| 46 |
+
return self.obj < other.obj
|
| 47 |
+
except TypeError:
|
| 48 |
+
return (str(type(self.obj)), id(self.obj)) < (
|
| 49 |
+
str(type(other.obj)),
|
| 50 |
+
id(other.obj),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _safe_tuple(t):
|
| 55 |
+
"""Helper function for comparing 2-tuples"""
|
| 56 |
+
return _safe_key(t[0]), _safe_key(t[1])
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class PrettyPrinter:
|
| 60 |
+
def __init__(
|
| 61 |
+
self,
|
| 62 |
+
indent: int = 4,
|
| 63 |
+
width: int = 80,
|
| 64 |
+
depth: int | None = None,
|
| 65 |
+
) -> None:
|
| 66 |
+
"""Handle pretty printing operations onto a stream using a set of
|
| 67 |
+
configured parameters.
|
| 68 |
+
|
| 69 |
+
indent
|
| 70 |
+
Number of spaces to indent for each level of nesting.
|
| 71 |
+
|
| 72 |
+
width
|
| 73 |
+
Attempted maximum number of columns in the output.
|
| 74 |
+
|
| 75 |
+
depth
|
| 76 |
+
The maximum depth to print out nested structures.
|
| 77 |
+
|
| 78 |
+
"""
|
| 79 |
+
if indent < 0:
|
| 80 |
+
raise ValueError("indent must be >= 0")
|
| 81 |
+
if depth is not None and depth <= 0:
|
| 82 |
+
raise ValueError("depth must be > 0")
|
| 83 |
+
if not width:
|
| 84 |
+
raise ValueError("width must be != 0")
|
| 85 |
+
self._depth = depth
|
| 86 |
+
self._indent_per_level = indent
|
| 87 |
+
self._width = width
|
| 88 |
+
|
| 89 |
+
def pformat(self, object: Any) -> str:
|
| 90 |
+
sio = _StringIO()
|
| 91 |
+
self._format(object, sio, 0, 0, set(), 0)
|
| 92 |
+
return sio.getvalue()
|
| 93 |
+
|
| 94 |
+
def _format(
|
| 95 |
+
self,
|
| 96 |
+
object: Any,
|
| 97 |
+
stream: IO[str],
|
| 98 |
+
indent: int,
|
| 99 |
+
allowance: int,
|
| 100 |
+
context: set[int],
|
| 101 |
+
level: int,
|
| 102 |
+
) -> None:
|
| 103 |
+
objid = id(object)
|
| 104 |
+
if objid in context:
|
| 105 |
+
stream.write(_recursion(object))
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
p = self._dispatch.get(type(object).__repr__, None)
|
| 109 |
+
if p is not None:
|
| 110 |
+
context.add(objid)
|
| 111 |
+
p(self, object, stream, indent, allowance, context, level + 1)
|
| 112 |
+
context.remove(objid)
|
| 113 |
+
elif (
|
| 114 |
+
_dataclasses.is_dataclass(object) # type:ignore[unreachable]
|
| 115 |
+
and not isinstance(object, type)
|
| 116 |
+
and object.__dataclass_params__.repr
|
| 117 |
+
and
|
| 118 |
+
# Check dataclass has generated repr method.
|
| 119 |
+
hasattr(object.__repr__, "__wrapped__")
|
| 120 |
+
and "__create_fn__" in object.__repr__.__wrapped__.__qualname__
|
| 121 |
+
):
|
| 122 |
+
context.add(objid) # type:ignore[unreachable]
|
| 123 |
+
self._pprint_dataclass(
|
| 124 |
+
object, stream, indent, allowance, context, level + 1
|
| 125 |
+
)
|
| 126 |
+
context.remove(objid)
|
| 127 |
+
else:
|
| 128 |
+
stream.write(self._repr(object, context, level))
|
| 129 |
+
|
| 130 |
+
def _pprint_dataclass(
|
| 131 |
+
self,
|
| 132 |
+
object: Any,
|
| 133 |
+
stream: IO[str],
|
| 134 |
+
indent: int,
|
| 135 |
+
allowance: int,
|
| 136 |
+
context: set[int],
|
| 137 |
+
level: int,
|
| 138 |
+
) -> None:
|
| 139 |
+
cls_name = object.__class__.__name__
|
| 140 |
+
items = [
|
| 141 |
+
(f.name, getattr(object, f.name))
|
| 142 |
+
for f in _dataclasses.fields(object)
|
| 143 |
+
if f.repr
|
| 144 |
+
]
|
| 145 |
+
stream.write(cls_name + "(")
|
| 146 |
+
self._format_namespace_items(items, stream, indent, allowance, context, level)
|
| 147 |
+
stream.write(")")
|
| 148 |
+
|
| 149 |
+
_dispatch: dict[
|
| 150 |
+
Callable[..., str],
|
| 151 |
+
Callable[[PrettyPrinter, Any, IO[str], int, int, set[int], int], None],
|
| 152 |
+
] = {}
|
| 153 |
+
|
| 154 |
+
def _pprint_dict(
|
| 155 |
+
self,
|
| 156 |
+
object: Any,
|
| 157 |
+
stream: IO[str],
|
| 158 |
+
indent: int,
|
| 159 |
+
allowance: int,
|
| 160 |
+
context: set[int],
|
| 161 |
+
level: int,
|
| 162 |
+
) -> None:
|
| 163 |
+
write = stream.write
|
| 164 |
+
write("{")
|
| 165 |
+
items = sorted(object.items(), key=_safe_tuple)
|
| 166 |
+
self._format_dict_items(items, stream, indent, allowance, context, level)
|
| 167 |
+
write("}")
|
| 168 |
+
|
| 169 |
+
_dispatch[dict.__repr__] = _pprint_dict
|
| 170 |
+
|
| 171 |
+
def _pprint_ordered_dict(
|
| 172 |
+
self,
|
| 173 |
+
object: Any,
|
| 174 |
+
stream: IO[str],
|
| 175 |
+
indent: int,
|
| 176 |
+
allowance: int,
|
| 177 |
+
context: set[int],
|
| 178 |
+
level: int,
|
| 179 |
+
) -> None:
|
| 180 |
+
if not len(object):
|
| 181 |
+
stream.write(repr(object))
|
| 182 |
+
return
|
| 183 |
+
cls = object.__class__
|
| 184 |
+
stream.write(cls.__name__ + "(")
|
| 185 |
+
self._pprint_dict(object, stream, indent, allowance, context, level)
|
| 186 |
+
stream.write(")")
|
| 187 |
+
|
| 188 |
+
_dispatch[_collections.OrderedDict.__repr__] = _pprint_ordered_dict
|
| 189 |
+
|
| 190 |
+
def _pprint_list(
|
| 191 |
+
self,
|
| 192 |
+
object: Any,
|
| 193 |
+
stream: IO[str],
|
| 194 |
+
indent: int,
|
| 195 |
+
allowance: int,
|
| 196 |
+
context: set[int],
|
| 197 |
+
level: int,
|
| 198 |
+
) -> None:
|
| 199 |
+
stream.write("[")
|
| 200 |
+
self._format_items(object, stream, indent, allowance, context, level)
|
| 201 |
+
stream.write("]")
|
| 202 |
+
|
| 203 |
+
_dispatch[list.__repr__] = _pprint_list
|
| 204 |
+
|
| 205 |
+
def _pprint_tuple(
|
| 206 |
+
self,
|
| 207 |
+
object: Any,
|
| 208 |
+
stream: IO[str],
|
| 209 |
+
indent: int,
|
| 210 |
+
allowance: int,
|
| 211 |
+
context: set[int],
|
| 212 |
+
level: int,
|
| 213 |
+
) -> None:
|
| 214 |
+
stream.write("(")
|
| 215 |
+
self._format_items(object, stream, indent, allowance, context, level)
|
| 216 |
+
stream.write(")")
|
| 217 |
+
|
| 218 |
+
_dispatch[tuple.__repr__] = _pprint_tuple
|
| 219 |
+
|
| 220 |
+
def _pprint_set(
|
| 221 |
+
self,
|
| 222 |
+
object: Any,
|
| 223 |
+
stream: IO[str],
|
| 224 |
+
indent: int,
|
| 225 |
+
allowance: int,
|
| 226 |
+
context: set[int],
|
| 227 |
+
level: int,
|
| 228 |
+
) -> None:
|
| 229 |
+
if not len(object):
|
| 230 |
+
stream.write(repr(object))
|
| 231 |
+
return
|
| 232 |
+
typ = object.__class__
|
| 233 |
+
if typ is set:
|
| 234 |
+
stream.write("{")
|
| 235 |
+
endchar = "}"
|
| 236 |
+
else:
|
| 237 |
+
stream.write(typ.__name__ + "({")
|
| 238 |
+
endchar = "})"
|
| 239 |
+
object = sorted(object, key=_safe_key)
|
| 240 |
+
self._format_items(object, stream, indent, allowance, context, level)
|
| 241 |
+
stream.write(endchar)
|
| 242 |
+
|
| 243 |
+
_dispatch[set.__repr__] = _pprint_set
|
| 244 |
+
_dispatch[frozenset.__repr__] = _pprint_set
|
| 245 |
+
|
| 246 |
+
def _pprint_str(
|
| 247 |
+
self,
|
| 248 |
+
object: Any,
|
| 249 |
+
stream: IO[str],
|
| 250 |
+
indent: int,
|
| 251 |
+
allowance: int,
|
| 252 |
+
context: set[int],
|
| 253 |
+
level: int,
|
| 254 |
+
) -> None:
|
| 255 |
+
write = stream.write
|
| 256 |
+
if not len(object):
|
| 257 |
+
write(repr(object))
|
| 258 |
+
return
|
| 259 |
+
chunks = []
|
| 260 |
+
lines = object.splitlines(True)
|
| 261 |
+
if level == 1:
|
| 262 |
+
indent += 1
|
| 263 |
+
allowance += 1
|
| 264 |
+
max_width1 = max_width = self._width - indent
|
| 265 |
+
for i, line in enumerate(lines):
|
| 266 |
+
rep = repr(line)
|
| 267 |
+
if i == len(lines) - 1:
|
| 268 |
+
max_width1 -= allowance
|
| 269 |
+
if len(rep) <= max_width1:
|
| 270 |
+
chunks.append(rep)
|
| 271 |
+
else:
|
| 272 |
+
# A list of alternating (non-space, space) strings
|
| 273 |
+
parts = re.findall(r"\S*\s*", line)
|
| 274 |
+
assert parts
|
| 275 |
+
assert not parts[-1]
|
| 276 |
+
parts.pop() # drop empty last part
|
| 277 |
+
max_width2 = max_width
|
| 278 |
+
current = ""
|
| 279 |
+
for j, part in enumerate(parts):
|
| 280 |
+
candidate = current + part
|
| 281 |
+
if j == len(parts) - 1 and i == len(lines) - 1:
|
| 282 |
+
max_width2 -= allowance
|
| 283 |
+
if len(repr(candidate)) > max_width2:
|
| 284 |
+
if current:
|
| 285 |
+
chunks.append(repr(current))
|
| 286 |
+
current = part
|
| 287 |
+
else:
|
| 288 |
+
current = candidate
|
| 289 |
+
if current:
|
| 290 |
+
chunks.append(repr(current))
|
| 291 |
+
if len(chunks) == 1:
|
| 292 |
+
write(rep)
|
| 293 |
+
return
|
| 294 |
+
if level == 1:
|
| 295 |
+
write("(")
|
| 296 |
+
for i, rep in enumerate(chunks):
|
| 297 |
+
if i > 0:
|
| 298 |
+
write("\n" + " " * indent)
|
| 299 |
+
write(rep)
|
| 300 |
+
if level == 1:
|
| 301 |
+
write(")")
|
| 302 |
+
|
| 303 |
+
_dispatch[str.__repr__] = _pprint_str
|
| 304 |
+
|
| 305 |
+
def _pprint_bytes(
|
| 306 |
+
self,
|
| 307 |
+
object: Any,
|
| 308 |
+
stream: IO[str],
|
| 309 |
+
indent: int,
|
| 310 |
+
allowance: int,
|
| 311 |
+
context: set[int],
|
| 312 |
+
level: int,
|
| 313 |
+
) -> None:
|
| 314 |
+
write = stream.write
|
| 315 |
+
if len(object) <= 4:
|
| 316 |
+
write(repr(object))
|
| 317 |
+
return
|
| 318 |
+
parens = level == 1
|
| 319 |
+
if parens:
|
| 320 |
+
indent += 1
|
| 321 |
+
allowance += 1
|
| 322 |
+
write("(")
|
| 323 |
+
delim = ""
|
| 324 |
+
for rep in _wrap_bytes_repr(object, self._width - indent, allowance):
|
| 325 |
+
write(delim)
|
| 326 |
+
write(rep)
|
| 327 |
+
if not delim:
|
| 328 |
+
delim = "\n" + " " * indent
|
| 329 |
+
if parens:
|
| 330 |
+
write(")")
|
| 331 |
+
|
| 332 |
+
_dispatch[bytes.__repr__] = _pprint_bytes
|
| 333 |
+
|
| 334 |
+
def _pprint_bytearray(
|
| 335 |
+
self,
|
| 336 |
+
object: Any,
|
| 337 |
+
stream: IO[str],
|
| 338 |
+
indent: int,
|
| 339 |
+
allowance: int,
|
| 340 |
+
context: set[int],
|
| 341 |
+
level: int,
|
| 342 |
+
) -> None:
|
| 343 |
+
write = stream.write
|
| 344 |
+
write("bytearray(")
|
| 345 |
+
self._pprint_bytes(
|
| 346 |
+
bytes(object), stream, indent + 10, allowance + 1, context, level + 1
|
| 347 |
+
)
|
| 348 |
+
write(")")
|
| 349 |
+
|
| 350 |
+
_dispatch[bytearray.__repr__] = _pprint_bytearray
|
| 351 |
+
|
| 352 |
+
def _pprint_mappingproxy(
|
| 353 |
+
self,
|
| 354 |
+
object: Any,
|
| 355 |
+
stream: IO[str],
|
| 356 |
+
indent: int,
|
| 357 |
+
allowance: int,
|
| 358 |
+
context: set[int],
|
| 359 |
+
level: int,
|
| 360 |
+
) -> None:
|
| 361 |
+
stream.write("mappingproxy(")
|
| 362 |
+
self._format(object.copy(), stream, indent, allowance, context, level)
|
| 363 |
+
stream.write(")")
|
| 364 |
+
|
| 365 |
+
_dispatch[_types.MappingProxyType.__repr__] = _pprint_mappingproxy
|
| 366 |
+
|
| 367 |
+
def _pprint_simplenamespace(
|
| 368 |
+
self,
|
| 369 |
+
object: Any,
|
| 370 |
+
stream: IO[str],
|
| 371 |
+
indent: int,
|
| 372 |
+
allowance: int,
|
| 373 |
+
context: set[int],
|
| 374 |
+
level: int,
|
| 375 |
+
) -> None:
|
| 376 |
+
if type(object) is _types.SimpleNamespace:
|
| 377 |
+
# The SimpleNamespace repr is "namespace" instead of the class
|
| 378 |
+
# name, so we do the same here. For subclasses; use the class name.
|
| 379 |
+
cls_name = "namespace"
|
| 380 |
+
else:
|
| 381 |
+
cls_name = object.__class__.__name__
|
| 382 |
+
items = object.__dict__.items()
|
| 383 |
+
stream.write(cls_name + "(")
|
| 384 |
+
self._format_namespace_items(items, stream, indent, allowance, context, level)
|
| 385 |
+
stream.write(")")
|
| 386 |
+
|
| 387 |
+
_dispatch[_types.SimpleNamespace.__repr__] = _pprint_simplenamespace
|
| 388 |
+
|
| 389 |
+
def _format_dict_items(
|
| 390 |
+
self,
|
| 391 |
+
items: list[tuple[Any, Any]],
|
| 392 |
+
stream: IO[str],
|
| 393 |
+
indent: int,
|
| 394 |
+
allowance: int,
|
| 395 |
+
context: set[int],
|
| 396 |
+
level: int,
|
| 397 |
+
) -> None:
|
| 398 |
+
if not items:
|
| 399 |
+
return
|
| 400 |
+
|
| 401 |
+
write = stream.write
|
| 402 |
+
item_indent = indent + self._indent_per_level
|
| 403 |
+
delimnl = "\n" + " " * item_indent
|
| 404 |
+
for key, ent in items:
|
| 405 |
+
write(delimnl)
|
| 406 |
+
write(self._repr(key, context, level))
|
| 407 |
+
write(": ")
|
| 408 |
+
self._format(ent, stream, item_indent, 1, context, level)
|
| 409 |
+
write(",")
|
| 410 |
+
|
| 411 |
+
write("\n" + " " * indent)
|
| 412 |
+
|
| 413 |
+
def _format_namespace_items(
|
| 414 |
+
self,
|
| 415 |
+
items: list[tuple[Any, Any]],
|
| 416 |
+
stream: IO[str],
|
| 417 |
+
indent: int,
|
| 418 |
+
allowance: int,
|
| 419 |
+
context: set[int],
|
| 420 |
+
level: int,
|
| 421 |
+
) -> None:
|
| 422 |
+
if not items:
|
| 423 |
+
return
|
| 424 |
+
|
| 425 |
+
write = stream.write
|
| 426 |
+
item_indent = indent + self._indent_per_level
|
| 427 |
+
delimnl = "\n" + " " * item_indent
|
| 428 |
+
for key, ent in items:
|
| 429 |
+
write(delimnl)
|
| 430 |
+
write(key)
|
| 431 |
+
write("=")
|
| 432 |
+
if id(ent) in context:
|
| 433 |
+
# Special-case representation of recursion to match standard
|
| 434 |
+
# recursive dataclass repr.
|
| 435 |
+
write("...")
|
| 436 |
+
else:
|
| 437 |
+
self._format(
|
| 438 |
+
ent,
|
| 439 |
+
stream,
|
| 440 |
+
item_indent + len(key) + 1,
|
| 441 |
+
1,
|
| 442 |
+
context,
|
| 443 |
+
level,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
write(",")
|
| 447 |
+
|
| 448 |
+
write("\n" + " " * indent)
|
| 449 |
+
|
| 450 |
+
def _format_items(
|
| 451 |
+
self,
|
| 452 |
+
items: list[Any],
|
| 453 |
+
stream: IO[str],
|
| 454 |
+
indent: int,
|
| 455 |
+
allowance: int,
|
| 456 |
+
context: set[int],
|
| 457 |
+
level: int,
|
| 458 |
+
) -> None:
|
| 459 |
+
if not items:
|
| 460 |
+
return
|
| 461 |
+
|
| 462 |
+
write = stream.write
|
| 463 |
+
item_indent = indent + self._indent_per_level
|
| 464 |
+
delimnl = "\n" + " " * item_indent
|
| 465 |
+
|
| 466 |
+
for item in items:
|
| 467 |
+
write(delimnl)
|
| 468 |
+
self._format(item, stream, item_indent, 1, context, level)
|
| 469 |
+
write(",")
|
| 470 |
+
|
| 471 |
+
write("\n" + " " * indent)
|
| 472 |
+
|
| 473 |
+
def _repr(self, object: Any, context: set[int], level: int) -> str:
|
| 474 |
+
return self._safe_repr(object, context.copy(), self._depth, level)
|
| 475 |
+
|
| 476 |
+
def _pprint_default_dict(
|
| 477 |
+
self,
|
| 478 |
+
object: Any,
|
| 479 |
+
stream: IO[str],
|
| 480 |
+
indent: int,
|
| 481 |
+
allowance: int,
|
| 482 |
+
context: set[int],
|
| 483 |
+
level: int,
|
| 484 |
+
) -> None:
|
| 485 |
+
rdf = self._repr(object.default_factory, context, level)
|
| 486 |
+
stream.write(f"{object.__class__.__name__}({rdf}, ")
|
| 487 |
+
self._pprint_dict(object, stream, indent, allowance, context, level)
|
| 488 |
+
stream.write(")")
|
| 489 |
+
|
| 490 |
+
_dispatch[_collections.defaultdict.__repr__] = _pprint_default_dict
|
| 491 |
+
|
| 492 |
+
def _pprint_counter(
|
| 493 |
+
self,
|
| 494 |
+
object: Any,
|
| 495 |
+
stream: IO[str],
|
| 496 |
+
indent: int,
|
| 497 |
+
allowance: int,
|
| 498 |
+
context: set[int],
|
| 499 |
+
level: int,
|
| 500 |
+
) -> None:
|
| 501 |
+
stream.write(object.__class__.__name__ + "(")
|
| 502 |
+
|
| 503 |
+
if object:
|
| 504 |
+
stream.write("{")
|
| 505 |
+
items = object.most_common()
|
| 506 |
+
self._format_dict_items(items, stream, indent, allowance, context, level)
|
| 507 |
+
stream.write("}")
|
| 508 |
+
|
| 509 |
+
stream.write(")")
|
| 510 |
+
|
| 511 |
+
_dispatch[_collections.Counter.__repr__] = _pprint_counter
|
| 512 |
+
|
| 513 |
+
def _pprint_chain_map(
|
| 514 |
+
self,
|
| 515 |
+
object: Any,
|
| 516 |
+
stream: IO[str],
|
| 517 |
+
indent: int,
|
| 518 |
+
allowance: int,
|
| 519 |
+
context: set[int],
|
| 520 |
+
level: int,
|
| 521 |
+
) -> None:
|
| 522 |
+
if not len(object.maps) or (len(object.maps) == 1 and not len(object.maps[0])):
|
| 523 |
+
stream.write(repr(object))
|
| 524 |
+
return
|
| 525 |
+
|
| 526 |
+
stream.write(object.__class__.__name__ + "(")
|
| 527 |
+
self._format_items(object.maps, stream, indent, allowance, context, level)
|
| 528 |
+
stream.write(")")
|
| 529 |
+
|
| 530 |
+
_dispatch[_collections.ChainMap.__repr__] = _pprint_chain_map
|
| 531 |
+
|
| 532 |
+
def _pprint_deque(
|
| 533 |
+
self,
|
| 534 |
+
object: Any,
|
| 535 |
+
stream: IO[str],
|
| 536 |
+
indent: int,
|
| 537 |
+
allowance: int,
|
| 538 |
+
context: set[int],
|
| 539 |
+
level: int,
|
| 540 |
+
) -> None:
|
| 541 |
+
stream.write(object.__class__.__name__ + "(")
|
| 542 |
+
if object.maxlen is not None:
|
| 543 |
+
stream.write("maxlen=%d, " % object.maxlen)
|
| 544 |
+
stream.write("[")
|
| 545 |
+
|
| 546 |
+
self._format_items(object, stream, indent, allowance + 1, context, level)
|
| 547 |
+
stream.write("])")
|
| 548 |
+
|
| 549 |
+
_dispatch[_collections.deque.__repr__] = _pprint_deque
|
| 550 |
+
|
| 551 |
+
def _pprint_user_dict(
|
| 552 |
+
self,
|
| 553 |
+
object: Any,
|
| 554 |
+
stream: IO[str],
|
| 555 |
+
indent: int,
|
| 556 |
+
allowance: int,
|
| 557 |
+
context: set[int],
|
| 558 |
+
level: int,
|
| 559 |
+
) -> None:
|
| 560 |
+
self._format(object.data, stream, indent, allowance, context, level - 1)
|
| 561 |
+
|
| 562 |
+
_dispatch[_collections.UserDict.__repr__] = _pprint_user_dict
|
| 563 |
+
|
| 564 |
+
def _pprint_user_list(
|
| 565 |
+
self,
|
| 566 |
+
object: Any,
|
| 567 |
+
stream: IO[str],
|
| 568 |
+
indent: int,
|
| 569 |
+
allowance: int,
|
| 570 |
+
context: set[int],
|
| 571 |
+
level: int,
|
| 572 |
+
) -> None:
|
| 573 |
+
self._format(object.data, stream, indent, allowance, context, level - 1)
|
| 574 |
+
|
| 575 |
+
_dispatch[_collections.UserList.__repr__] = _pprint_user_list
|
| 576 |
+
|
| 577 |
+
def _pprint_user_string(
|
| 578 |
+
self,
|
| 579 |
+
object: Any,
|
| 580 |
+
stream: IO[str],
|
| 581 |
+
indent: int,
|
| 582 |
+
allowance: int,
|
| 583 |
+
context: set[int],
|
| 584 |
+
level: int,
|
| 585 |
+
) -> None:
|
| 586 |
+
self._format(object.data, stream, indent, allowance, context, level - 1)
|
| 587 |
+
|
| 588 |
+
_dispatch[_collections.UserString.__repr__] = _pprint_user_string
|
| 589 |
+
|
| 590 |
+
def _safe_repr(
|
| 591 |
+
self, object: Any, context: set[int], maxlevels: int | None, level: int
|
| 592 |
+
) -> str:
|
| 593 |
+
typ = type(object)
|
| 594 |
+
if typ in _builtin_scalars:
|
| 595 |
+
return repr(object)
|
| 596 |
+
|
| 597 |
+
r = getattr(typ, "__repr__", None)
|
| 598 |
+
|
| 599 |
+
if issubclass(typ, dict) and r is dict.__repr__:
|
| 600 |
+
if not object:
|
| 601 |
+
return "{}"
|
| 602 |
+
objid = id(object)
|
| 603 |
+
if maxlevels and level >= maxlevels:
|
| 604 |
+
return "{...}"
|
| 605 |
+
if objid in context:
|
| 606 |
+
return _recursion(object)
|
| 607 |
+
context.add(objid)
|
| 608 |
+
components: list[str] = []
|
| 609 |
+
append = components.append
|
| 610 |
+
level += 1
|
| 611 |
+
for k, v in sorted(object.items(), key=_safe_tuple):
|
| 612 |
+
krepr = self._safe_repr(k, context, maxlevels, level)
|
| 613 |
+
vrepr = self._safe_repr(v, context, maxlevels, level)
|
| 614 |
+
append(f"{krepr}: {vrepr}")
|
| 615 |
+
context.remove(objid)
|
| 616 |
+
return "{{{}}}".format(", ".join(components))
|
| 617 |
+
|
| 618 |
+
if (issubclass(typ, list) and r is list.__repr__) or (
|
| 619 |
+
issubclass(typ, tuple) and r is tuple.__repr__
|
| 620 |
+
):
|
| 621 |
+
if issubclass(typ, list):
|
| 622 |
+
if not object:
|
| 623 |
+
return "[]"
|
| 624 |
+
format = "[%s]"
|
| 625 |
+
elif len(object) == 1:
|
| 626 |
+
format = "(%s,)"
|
| 627 |
+
else:
|
| 628 |
+
if not object:
|
| 629 |
+
return "()"
|
| 630 |
+
format = "(%s)"
|
| 631 |
+
objid = id(object)
|
| 632 |
+
if maxlevels and level >= maxlevels:
|
| 633 |
+
return format % "..."
|
| 634 |
+
if objid in context:
|
| 635 |
+
return _recursion(object)
|
| 636 |
+
context.add(objid)
|
| 637 |
+
components = []
|
| 638 |
+
append = components.append
|
| 639 |
+
level += 1
|
| 640 |
+
for o in object:
|
| 641 |
+
orepr = self._safe_repr(o, context, maxlevels, level)
|
| 642 |
+
append(orepr)
|
| 643 |
+
context.remove(objid)
|
| 644 |
+
return format % ", ".join(components)
|
| 645 |
+
|
| 646 |
+
return repr(object)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
_builtin_scalars = frozenset(
|
| 650 |
+
{str, bytes, bytearray, float, complex, bool, type(None), int}
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def _recursion(object: Any) -> str:
|
| 655 |
+
return f"<Recursion on {type(object).__name__} with id={id(object)}>"
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def _wrap_bytes_repr(object: Any, width: int, allowance: int) -> Iterator[str]:
|
| 659 |
+
current = b""
|
| 660 |
+
last = len(object) // 4 * 4
|
| 661 |
+
for i in range(0, len(object), 4):
|
| 662 |
+
part = object[i : i + 4]
|
| 663 |
+
candidate = current + part
|
| 664 |
+
if i == last:
|
| 665 |
+
width -= allowance
|
| 666 |
+
if len(repr(candidate)) > width:
|
| 667 |
+
if current:
|
| 668 |
+
yield repr(current)
|
| 669 |
+
current = part
|
| 670 |
+
else:
|
| 671 |
+
current = candidate
|
| 672 |
+
if current:
|
| 673 |
+
yield repr(current)
|
.venv/lib/python3.11/site-packages/_pytest/_io/saferepr.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import pprint
|
| 4 |
+
import reprlib
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _try_repr_or_str(obj: object) -> str:
|
| 8 |
+
try:
|
| 9 |
+
return repr(obj)
|
| 10 |
+
except (KeyboardInterrupt, SystemExit):
|
| 11 |
+
raise
|
| 12 |
+
except BaseException:
|
| 13 |
+
return f'{type(obj).__name__}("{obj}")'
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _format_repr_exception(exc: BaseException, obj: object) -> str:
|
| 17 |
+
try:
|
| 18 |
+
exc_info = _try_repr_or_str(exc)
|
| 19 |
+
except (KeyboardInterrupt, SystemExit):
|
| 20 |
+
raise
|
| 21 |
+
except BaseException as inner_exc:
|
| 22 |
+
exc_info = f"unpresentable exception ({_try_repr_or_str(inner_exc)})"
|
| 23 |
+
return (
|
| 24 |
+
f"<[{exc_info} raised in repr()] {type(obj).__name__} object at 0x{id(obj):x}>"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _ellipsize(s: str, maxsize: int) -> str:
|
| 29 |
+
if len(s) > maxsize:
|
| 30 |
+
i = max(0, (maxsize - 3) // 2)
|
| 31 |
+
j = max(0, maxsize - 3 - i)
|
| 32 |
+
return s[:i] + "..." + s[len(s) - j :]
|
| 33 |
+
return s
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class SafeRepr(reprlib.Repr):
|
| 37 |
+
"""
|
| 38 |
+
repr.Repr that limits the resulting size of repr() and includes
|
| 39 |
+
information on exceptions raised during the call.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, maxsize: int | None, use_ascii: bool = False) -> None:
|
| 43 |
+
"""
|
| 44 |
+
:param maxsize:
|
| 45 |
+
If not None, will truncate the resulting repr to that specific size, using ellipsis
|
| 46 |
+
somewhere in the middle to hide the extra text.
|
| 47 |
+
If None, will not impose any size limits on the returning repr.
|
| 48 |
+
"""
|
| 49 |
+
super().__init__()
|
| 50 |
+
# ``maxstring`` is used by the superclass, and needs to be an int; using a
|
| 51 |
+
# very large number in case maxsize is None, meaning we want to disable
|
| 52 |
+
# truncation.
|
| 53 |
+
self.maxstring = maxsize if maxsize is not None else 1_000_000_000
|
| 54 |
+
self.maxsize = maxsize
|
| 55 |
+
self.use_ascii = use_ascii
|
| 56 |
+
|
| 57 |
+
def repr(self, x: object) -> str:
|
| 58 |
+
try:
|
| 59 |
+
if self.use_ascii:
|
| 60 |
+
s = ascii(x)
|
| 61 |
+
else:
|
| 62 |
+
s = super().repr(x)
|
| 63 |
+
except (KeyboardInterrupt, SystemExit):
|
| 64 |
+
raise
|
| 65 |
+
except BaseException as exc:
|
| 66 |
+
s = _format_repr_exception(exc, x)
|
| 67 |
+
if self.maxsize is not None:
|
| 68 |
+
s = _ellipsize(s, self.maxsize)
|
| 69 |
+
return s
|
| 70 |
+
|
| 71 |
+
def repr_instance(self, x: object, level: int) -> str:
|
| 72 |
+
try:
|
| 73 |
+
s = repr(x)
|
| 74 |
+
except (KeyboardInterrupt, SystemExit):
|
| 75 |
+
raise
|
| 76 |
+
except BaseException as exc:
|
| 77 |
+
s = _format_repr_exception(exc, x)
|
| 78 |
+
if self.maxsize is not None:
|
| 79 |
+
s = _ellipsize(s, self.maxsize)
|
| 80 |
+
return s
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def safeformat(obj: object) -> str:
|
| 84 |
+
"""Return a pretty printed string for the given object.
|
| 85 |
+
|
| 86 |
+
Failing __repr__ functions of user instances will be represented
|
| 87 |
+
with a short exception info.
|
| 88 |
+
"""
|
| 89 |
+
try:
|
| 90 |
+
return pprint.pformat(obj)
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
return _format_repr_exception(exc, obj)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# Maximum size of overall repr of objects to display during assertion errors.
|
| 96 |
+
DEFAULT_REPR_MAX_SIZE = 240
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def saferepr(
|
| 100 |
+
obj: object, maxsize: int | None = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
|
| 101 |
+
) -> str:
|
| 102 |
+
"""Return a size-limited safe repr-string for the given object.
|
| 103 |
+
|
| 104 |
+
Failing __repr__ functions of user instances will be represented
|
| 105 |
+
with a short exception info and 'saferepr' generally takes
|
| 106 |
+
care to never raise exceptions itself.
|
| 107 |
+
|
| 108 |
+
This function is a wrapper around the Repr/reprlib functionality of the
|
| 109 |
+
stdlib.
|
| 110 |
+
"""
|
| 111 |
+
return SafeRepr(maxsize, use_ascii).repr(obj)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str:
|
| 115 |
+
"""Return an unlimited-size safe repr-string for the given object.
|
| 116 |
+
|
| 117 |
+
As with saferepr, failing __repr__ functions of user instances
|
| 118 |
+
will be represented with a short exception info.
|
| 119 |
+
|
| 120 |
+
This function is a wrapper around simple repr.
|
| 121 |
+
|
| 122 |
+
Note: a cleaner solution would be to alter ``saferepr``this way
|
| 123 |
+
when maxsize=None, but that might affect some other code.
|
| 124 |
+
"""
|
| 125 |
+
try:
|
| 126 |
+
if use_ascii:
|
| 127 |
+
return ascii(obj)
|
| 128 |
+
return repr(obj)
|
| 129 |
+
except Exception as exc:
|
| 130 |
+
return _format_repr_exception(exc, obj)
|
.venv/lib/python3.11/site-packages/_pytest/_io/terminalwriter.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helper functions for writing to terminals and files."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import sys
|
| 8 |
+
from typing import final
|
| 9 |
+
from typing import Literal
|
| 10 |
+
from typing import Sequence
|
| 11 |
+
from typing import TextIO
|
| 12 |
+
from typing import TYPE_CHECKING
|
| 13 |
+
|
| 14 |
+
from ..compat import assert_never
|
| 15 |
+
from .wcwidth import wcswidth
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from pygments.formatter import Formatter
|
| 20 |
+
from pygments.lexer import Lexer
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# This code was initially copied from py 1.8.1, file _io/terminalwriter.py.
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def get_terminal_width() -> int:
|
| 27 |
+
width, _ = shutil.get_terminal_size(fallback=(80, 24))
|
| 28 |
+
|
| 29 |
+
# The Windows get_terminal_size may be bogus, let's sanify a bit.
|
| 30 |
+
if width < 40:
|
| 31 |
+
width = 80
|
| 32 |
+
|
| 33 |
+
return width
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def should_do_markup(file: TextIO) -> bool:
|
| 37 |
+
if os.environ.get("PY_COLORS") == "1":
|
| 38 |
+
return True
|
| 39 |
+
if os.environ.get("PY_COLORS") == "0":
|
| 40 |
+
return False
|
| 41 |
+
if os.environ.get("NO_COLOR"):
|
| 42 |
+
return False
|
| 43 |
+
if os.environ.get("FORCE_COLOR"):
|
| 44 |
+
return True
|
| 45 |
+
return (
|
| 46 |
+
hasattr(file, "isatty") and file.isatty() and os.environ.get("TERM") != "dumb"
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@final
|
| 51 |
+
class TerminalWriter:
|
| 52 |
+
_esctable = dict(
|
| 53 |
+
black=30,
|
| 54 |
+
red=31,
|
| 55 |
+
green=32,
|
| 56 |
+
yellow=33,
|
| 57 |
+
blue=34,
|
| 58 |
+
purple=35,
|
| 59 |
+
cyan=36,
|
| 60 |
+
white=37,
|
| 61 |
+
Black=40,
|
| 62 |
+
Red=41,
|
| 63 |
+
Green=42,
|
| 64 |
+
Yellow=43,
|
| 65 |
+
Blue=44,
|
| 66 |
+
Purple=45,
|
| 67 |
+
Cyan=46,
|
| 68 |
+
White=47,
|
| 69 |
+
bold=1,
|
| 70 |
+
light=2,
|
| 71 |
+
blink=5,
|
| 72 |
+
invert=7,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
def __init__(self, file: TextIO | None = None) -> None:
|
| 76 |
+
if file is None:
|
| 77 |
+
file = sys.stdout
|
| 78 |
+
if hasattr(file, "isatty") and file.isatty() and sys.platform == "win32":
|
| 79 |
+
try:
|
| 80 |
+
import colorama
|
| 81 |
+
except ImportError:
|
| 82 |
+
pass
|
| 83 |
+
else:
|
| 84 |
+
file = colorama.AnsiToWin32(file).stream
|
| 85 |
+
assert file is not None
|
| 86 |
+
self._file = file
|
| 87 |
+
self.hasmarkup = should_do_markup(file)
|
| 88 |
+
self._current_line = ""
|
| 89 |
+
self._terminal_width: int | None = None
|
| 90 |
+
self.code_highlight = True
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def fullwidth(self) -> int:
|
| 94 |
+
if self._terminal_width is not None:
|
| 95 |
+
return self._terminal_width
|
| 96 |
+
return get_terminal_width()
|
| 97 |
+
|
| 98 |
+
@fullwidth.setter
|
| 99 |
+
def fullwidth(self, value: int) -> None:
|
| 100 |
+
self._terminal_width = value
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def width_of_current_line(self) -> int:
|
| 104 |
+
"""Return an estimate of the width so far in the current line."""
|
| 105 |
+
return wcswidth(self._current_line)
|
| 106 |
+
|
| 107 |
+
def markup(self, text: str, **markup: bool) -> str:
|
| 108 |
+
for name in markup:
|
| 109 |
+
if name not in self._esctable:
|
| 110 |
+
raise ValueError(f"unknown markup: {name!r}")
|
| 111 |
+
if self.hasmarkup:
|
| 112 |
+
esc = [self._esctable[name] for name, on in markup.items() if on]
|
| 113 |
+
if esc:
|
| 114 |
+
text = "".join(f"\x1b[{cod}m" for cod in esc) + text + "\x1b[0m"
|
| 115 |
+
return text
|
| 116 |
+
|
| 117 |
+
def sep(
|
| 118 |
+
self,
|
| 119 |
+
sepchar: str,
|
| 120 |
+
title: str | None = None,
|
| 121 |
+
fullwidth: int | None = None,
|
| 122 |
+
**markup: bool,
|
| 123 |
+
) -> None:
|
| 124 |
+
if fullwidth is None:
|
| 125 |
+
fullwidth = self.fullwidth
|
| 126 |
+
# The goal is to have the line be as long as possible
|
| 127 |
+
# under the condition that len(line) <= fullwidth.
|
| 128 |
+
if sys.platform == "win32":
|
| 129 |
+
# If we print in the last column on windows we are on a
|
| 130 |
+
# new line but there is no way to verify/neutralize this
|
| 131 |
+
# (we may not know the exact line width).
|
| 132 |
+
# So let's be defensive to avoid empty lines in the output.
|
| 133 |
+
fullwidth -= 1
|
| 134 |
+
if title is not None:
|
| 135 |
+
# we want 2 + 2*len(fill) + len(title) <= fullwidth
|
| 136 |
+
# i.e. 2 + 2*len(sepchar)*N + len(title) <= fullwidth
|
| 137 |
+
# 2*len(sepchar)*N <= fullwidth - len(title) - 2
|
| 138 |
+
# N <= (fullwidth - len(title) - 2) // (2*len(sepchar))
|
| 139 |
+
N = max((fullwidth - len(title) - 2) // (2 * len(sepchar)), 1)
|
| 140 |
+
fill = sepchar * N
|
| 141 |
+
line = f"{fill} {title} {fill}"
|
| 142 |
+
else:
|
| 143 |
+
# we want len(sepchar)*N <= fullwidth
|
| 144 |
+
# i.e. N <= fullwidth // len(sepchar)
|
| 145 |
+
line = sepchar * (fullwidth // len(sepchar))
|
| 146 |
+
# In some situations there is room for an extra sepchar at the right,
|
| 147 |
+
# in particular if we consider that with a sepchar like "_ " the
|
| 148 |
+
# trailing space is not important at the end of the line.
|
| 149 |
+
if len(line) + len(sepchar.rstrip()) <= fullwidth:
|
| 150 |
+
line += sepchar.rstrip()
|
| 151 |
+
|
| 152 |
+
self.line(line, **markup)
|
| 153 |
+
|
| 154 |
+
def write(self, msg: str, *, flush: bool = False, **markup: bool) -> None:
|
| 155 |
+
if msg:
|
| 156 |
+
current_line = msg.rsplit("\n", 1)[-1]
|
| 157 |
+
if "\n" in msg:
|
| 158 |
+
self._current_line = current_line
|
| 159 |
+
else:
|
| 160 |
+
self._current_line += current_line
|
| 161 |
+
|
| 162 |
+
msg = self.markup(msg, **markup)
|
| 163 |
+
|
| 164 |
+
try:
|
| 165 |
+
self._file.write(msg)
|
| 166 |
+
except UnicodeEncodeError:
|
| 167 |
+
# Some environments don't support printing general Unicode
|
| 168 |
+
# strings, due to misconfiguration or otherwise; in that case,
|
| 169 |
+
# print the string escaped to ASCII.
|
| 170 |
+
# When the Unicode situation improves we should consider
|
| 171 |
+
# letting the error propagate instead of masking it (see #7475
|
| 172 |
+
# for one brief attempt).
|
| 173 |
+
msg = msg.encode("unicode-escape").decode("ascii")
|
| 174 |
+
self._file.write(msg)
|
| 175 |
+
|
| 176 |
+
if flush:
|
| 177 |
+
self.flush()
|
| 178 |
+
|
| 179 |
+
def line(self, s: str = "", **markup: bool) -> None:
|
| 180 |
+
self.write(s, **markup)
|
| 181 |
+
self.write("\n")
|
| 182 |
+
|
| 183 |
+
def flush(self) -> None:
|
| 184 |
+
self._file.flush()
|
| 185 |
+
|
| 186 |
+
def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> None:
|
| 187 |
+
"""Write lines of source code possibly highlighted.
|
| 188 |
+
|
| 189 |
+
Keeping this private for now because the API is clunky. We should discuss how
|
| 190 |
+
to evolve the terminal writer so we can have more precise color support, for example
|
| 191 |
+
being able to write part of a line in one color and the rest in another, and so on.
|
| 192 |
+
"""
|
| 193 |
+
if indents and len(indents) != len(lines):
|
| 194 |
+
raise ValueError(
|
| 195 |
+
f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
|
| 196 |
+
)
|
| 197 |
+
if not indents:
|
| 198 |
+
indents = [""] * len(lines)
|
| 199 |
+
source = "\n".join(lines)
|
| 200 |
+
new_lines = self._highlight(source).splitlines()
|
| 201 |
+
for indent, new_line in zip(indents, new_lines):
|
| 202 |
+
self.line(indent + new_line)
|
| 203 |
+
|
| 204 |
+
def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer | None:
|
| 205 |
+
try:
|
| 206 |
+
if lexer == "python":
|
| 207 |
+
from pygments.lexers.python import PythonLexer
|
| 208 |
+
|
| 209 |
+
return PythonLexer()
|
| 210 |
+
elif lexer == "diff":
|
| 211 |
+
from pygments.lexers.diff import DiffLexer
|
| 212 |
+
|
| 213 |
+
return DiffLexer()
|
| 214 |
+
else:
|
| 215 |
+
assert_never(lexer)
|
| 216 |
+
except ModuleNotFoundError:
|
| 217 |
+
return None
|
| 218 |
+
|
| 219 |
+
def _get_pygments_formatter(self) -> Formatter | None:
|
| 220 |
+
try:
|
| 221 |
+
import pygments.util
|
| 222 |
+
except ModuleNotFoundError:
|
| 223 |
+
return None
|
| 224 |
+
|
| 225 |
+
from _pytest.config.exceptions import UsageError
|
| 226 |
+
|
| 227 |
+
theme = os.getenv("PYTEST_THEME")
|
| 228 |
+
theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
from pygments.formatters.terminal import TerminalFormatter
|
| 232 |
+
|
| 233 |
+
return TerminalFormatter(bg=theme_mode, style=theme)
|
| 234 |
+
|
| 235 |
+
except pygments.util.ClassNotFound as e:
|
| 236 |
+
raise UsageError(
|
| 237 |
+
f"PYTEST_THEME environment variable has an invalid value: '{theme}'. "
|
| 238 |
+
"Hint: See available pygments styles with `pygmentize -L styles`."
|
| 239 |
+
) from e
|
| 240 |
+
except pygments.util.OptionError as e:
|
| 241 |
+
raise UsageError(
|
| 242 |
+
f"PYTEST_THEME_MODE environment variable has an invalid value: '{theme_mode}'. "
|
| 243 |
+
"The allowed values are 'dark' (default) and 'light'."
|
| 244 |
+
) from e
|
| 245 |
+
|
| 246 |
+
def _highlight(
|
| 247 |
+
self, source: str, lexer: Literal["diff", "python"] = "python"
|
| 248 |
+
) -> str:
|
| 249 |
+
"""Highlight the given source if we have markup support."""
|
| 250 |
+
if not source or not self.hasmarkup or not self.code_highlight:
|
| 251 |
+
return source
|
| 252 |
+
|
| 253 |
+
pygments_lexer = self._get_pygments_lexer(lexer)
|
| 254 |
+
if pygments_lexer is None:
|
| 255 |
+
return source
|
| 256 |
+
|
| 257 |
+
pygments_formatter = self._get_pygments_formatter()
|
| 258 |
+
if pygments_formatter is None:
|
| 259 |
+
return source
|
| 260 |
+
|
| 261 |
+
from pygments import highlight
|
| 262 |
+
|
| 263 |
+
highlighted: str = highlight(source, pygments_lexer, pygments_formatter)
|
| 264 |
+
# pygments terminal formatter may add a newline when there wasn't one.
|
| 265 |
+
# We don't want this, remove.
|
| 266 |
+
if highlighted[-1] == "\n" and source[-1] != "\n":
|
| 267 |
+
highlighted = highlighted[:-1]
|
| 268 |
+
|
| 269 |
+
# Some lexers will not set the initial color explicitly
|
| 270 |
+
# which may lead to the previous color being propagated to the
|
| 271 |
+
# start of the expression, so reset first.
|
| 272 |
+
highlighted = "\x1b[0m" + highlighted
|
| 273 |
+
|
| 274 |
+
return highlighted
|
.venv/lib/python3.11/site-packages/_pytest/_io/wcwidth.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
import unicodedata
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@lru_cache(100)
|
| 8 |
+
def wcwidth(c: str) -> int:
|
| 9 |
+
"""Determine how many columns are needed to display a character in a terminal.
|
| 10 |
+
|
| 11 |
+
Returns -1 if the character is not printable.
|
| 12 |
+
Returns 0, 1 or 2 for other characters.
|
| 13 |
+
"""
|
| 14 |
+
o = ord(c)
|
| 15 |
+
|
| 16 |
+
# ASCII fast path.
|
| 17 |
+
if 0x20 <= o < 0x07F:
|
| 18 |
+
return 1
|
| 19 |
+
|
| 20 |
+
# Some Cf/Zp/Zl characters which should be zero-width.
|
| 21 |
+
if (
|
| 22 |
+
o == 0x0000
|
| 23 |
+
or 0x200B <= o <= 0x200F
|
| 24 |
+
or 0x2028 <= o <= 0x202E
|
| 25 |
+
or 0x2060 <= o <= 0x2063
|
| 26 |
+
):
|
| 27 |
+
return 0
|
| 28 |
+
|
| 29 |
+
category = unicodedata.category(c)
|
| 30 |
+
|
| 31 |
+
# Control characters.
|
| 32 |
+
if category == "Cc":
|
| 33 |
+
return -1
|
| 34 |
+
|
| 35 |
+
# Combining characters with zero width.
|
| 36 |
+
if category in ("Me", "Mn"):
|
| 37 |
+
return 0
|
| 38 |
+
|
| 39 |
+
# Full/Wide east asian characters.
|
| 40 |
+
if unicodedata.east_asian_width(c) in ("F", "W"):
|
| 41 |
+
return 2
|
| 42 |
+
|
| 43 |
+
return 1
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def wcswidth(s: str) -> int:
|
| 47 |
+
"""Determine how many columns are needed to display a string in a terminal.
|
| 48 |
+
|
| 49 |
+
Returns -1 if the string contains non-printable characters.
|
| 50 |
+
"""
|
| 51 |
+
width = 0
|
| 52 |
+
for c in unicodedata.normalize("NFC", s):
|
| 53 |
+
wc = wcwidth(c)
|
| 54 |
+
if wc < 0:
|
| 55 |
+
return -1
|
| 56 |
+
width += wc
|
| 57 |
+
return width
|
.venv/lib/python3.11/site-packages/_pytest/_version.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file generated by setuptools_scm
|
| 2 |
+
# don't change, don't track in version control
|
| 3 |
+
TYPE_CHECKING = False
|
| 4 |
+
if TYPE_CHECKING:
|
| 5 |
+
from typing import Tuple, Union
|
| 6 |
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
| 7 |
+
else:
|
| 8 |
+
VERSION_TUPLE = object
|
| 9 |
+
|
| 10 |
+
version: str
|
| 11 |
+
__version__: str
|
| 12 |
+
__version_tuple__: VERSION_TUPLE
|
| 13 |
+
version_tuple: VERSION_TUPLE
|
| 14 |
+
|
| 15 |
+
__version__ = version = '8.3.4'
|
| 16 |
+
__version_tuple__ = version_tuple = (8, 3, 4)
|
.venv/lib/python3.11/site-packages/_pytest/assertion/__init__.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Support for presenting detailed information in failing assertions."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from typing import Any
|
| 8 |
+
from typing import Generator
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
|
| 11 |
+
from _pytest.assertion import rewrite
|
| 12 |
+
from _pytest.assertion import truncate
|
| 13 |
+
from _pytest.assertion import util
|
| 14 |
+
from _pytest.assertion.rewrite import assertstate_key
|
| 15 |
+
from _pytest.config import Config
|
| 16 |
+
from _pytest.config import hookimpl
|
| 17 |
+
from _pytest.config.argparsing import Parser
|
| 18 |
+
from _pytest.nodes import Item
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from _pytest.main import Session
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 26 |
+
group = parser.getgroup("debugconfig")
|
| 27 |
+
group.addoption(
|
| 28 |
+
"--assert",
|
| 29 |
+
action="store",
|
| 30 |
+
dest="assertmode",
|
| 31 |
+
choices=("rewrite", "plain"),
|
| 32 |
+
default="rewrite",
|
| 33 |
+
metavar="MODE",
|
| 34 |
+
help=(
|
| 35 |
+
"Control assertion debugging tools.\n"
|
| 36 |
+
"'plain' performs no assertion debugging.\n"
|
| 37 |
+
"'rewrite' (the default) rewrites assert statements in test modules"
|
| 38 |
+
" on import to provide assert expression information."
|
| 39 |
+
),
|
| 40 |
+
)
|
| 41 |
+
parser.addini(
|
| 42 |
+
"enable_assertion_pass_hook",
|
| 43 |
+
type="bool",
|
| 44 |
+
default=False,
|
| 45 |
+
help="Enables the pytest_assertion_pass hook. "
|
| 46 |
+
"Make sure to delete any previously generated pyc cache files.",
|
| 47 |
+
)
|
| 48 |
+
Config._add_verbosity_ini(
|
| 49 |
+
parser,
|
| 50 |
+
Config.VERBOSITY_ASSERTIONS,
|
| 51 |
+
help=(
|
| 52 |
+
"Specify a verbosity level for assertions, overriding the main level. "
|
| 53 |
+
"Higher levels will provide more detailed explanation when an assertion fails."
|
| 54 |
+
),
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def register_assert_rewrite(*names: str) -> None:
|
| 59 |
+
"""Register one or more module names to be rewritten on import.
|
| 60 |
+
|
| 61 |
+
This function will make sure that this module or all modules inside
|
| 62 |
+
the package will get their assert statements rewritten.
|
| 63 |
+
Thus you should make sure to call this before the module is
|
| 64 |
+
actually imported, usually in your __init__.py if you are a plugin
|
| 65 |
+
using a package.
|
| 66 |
+
|
| 67 |
+
:param names: The module names to register.
|
| 68 |
+
"""
|
| 69 |
+
for name in names:
|
| 70 |
+
if not isinstance(name, str):
|
| 71 |
+
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
|
| 72 |
+
raise TypeError(msg.format(repr(names)))
|
| 73 |
+
for hook in sys.meta_path:
|
| 74 |
+
if isinstance(hook, rewrite.AssertionRewritingHook):
|
| 75 |
+
importhook = hook
|
| 76 |
+
break
|
| 77 |
+
else:
|
| 78 |
+
# TODO(typing): Add a protocol for mark_rewrite() and use it
|
| 79 |
+
# for importhook and for PytestPluginManager.rewrite_hook.
|
| 80 |
+
importhook = DummyRewriteHook() # type: ignore
|
| 81 |
+
importhook.mark_rewrite(*names)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class DummyRewriteHook:
|
| 85 |
+
"""A no-op import hook for when rewriting is disabled."""
|
| 86 |
+
|
| 87 |
+
def mark_rewrite(self, *names: str) -> None:
|
| 88 |
+
pass
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class AssertionState:
|
| 92 |
+
"""State for the assertion plugin."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, config: Config, mode) -> None:
|
| 95 |
+
self.mode = mode
|
| 96 |
+
self.trace = config.trace.root.get("assertion")
|
| 97 |
+
self.hook: rewrite.AssertionRewritingHook | None = None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
|
| 101 |
+
"""Try to install the rewrite hook, raise SystemError if it fails."""
|
| 102 |
+
config.stash[assertstate_key] = AssertionState(config, "rewrite")
|
| 103 |
+
config.stash[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
|
| 104 |
+
sys.meta_path.insert(0, hook)
|
| 105 |
+
config.stash[assertstate_key].trace("installed rewrite import hook")
|
| 106 |
+
|
| 107 |
+
def undo() -> None:
|
| 108 |
+
hook = config.stash[assertstate_key].hook
|
| 109 |
+
if hook is not None and hook in sys.meta_path:
|
| 110 |
+
sys.meta_path.remove(hook)
|
| 111 |
+
|
| 112 |
+
config.add_cleanup(undo)
|
| 113 |
+
return hook
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def pytest_collection(session: Session) -> None:
|
| 117 |
+
# This hook is only called when test modules are collected
|
| 118 |
+
# so for example not in the managing process of pytest-xdist
|
| 119 |
+
# (which does not collect test modules).
|
| 120 |
+
assertstate = session.config.stash.get(assertstate_key, None)
|
| 121 |
+
if assertstate:
|
| 122 |
+
if assertstate.hook is not None:
|
| 123 |
+
assertstate.hook.set_session(session)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@hookimpl(wrapper=True, tryfirst=True)
|
| 127 |
+
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
|
| 128 |
+
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
|
| 129 |
+
|
| 130 |
+
The rewrite module will use util._reprcompare if it exists to use custom
|
| 131 |
+
reporting via the pytest_assertrepr_compare hook. This sets up this custom
|
| 132 |
+
comparison for the test.
|
| 133 |
+
"""
|
| 134 |
+
ihook = item.ihook
|
| 135 |
+
|
| 136 |
+
def callbinrepr(op, left: object, right: object) -> str | None:
|
| 137 |
+
"""Call the pytest_assertrepr_compare hook and prepare the result.
|
| 138 |
+
|
| 139 |
+
This uses the first result from the hook and then ensures the
|
| 140 |
+
following:
|
| 141 |
+
* Overly verbose explanations are truncated unless configured otherwise
|
| 142 |
+
(eg. if running in verbose mode).
|
| 143 |
+
* Embedded newlines are escaped to help util.format_explanation()
|
| 144 |
+
later.
|
| 145 |
+
* If the rewrite mode is used embedded %-characters are replaced
|
| 146 |
+
to protect later % formatting.
|
| 147 |
+
|
| 148 |
+
The result can be formatted by util.format_explanation() for
|
| 149 |
+
pretty printing.
|
| 150 |
+
"""
|
| 151 |
+
hook_result = ihook.pytest_assertrepr_compare(
|
| 152 |
+
config=item.config, op=op, left=left, right=right
|
| 153 |
+
)
|
| 154 |
+
for new_expl in hook_result:
|
| 155 |
+
if new_expl:
|
| 156 |
+
new_expl = truncate.truncate_if_required(new_expl, item)
|
| 157 |
+
new_expl = [line.replace("\n", "\\n") for line in new_expl]
|
| 158 |
+
res = "\n~".join(new_expl)
|
| 159 |
+
if item.config.getvalue("assertmode") == "rewrite":
|
| 160 |
+
res = res.replace("%", "%%")
|
| 161 |
+
return res
|
| 162 |
+
return None
|
| 163 |
+
|
| 164 |
+
saved_assert_hooks = util._reprcompare, util._assertion_pass
|
| 165 |
+
util._reprcompare = callbinrepr
|
| 166 |
+
util._config = item.config
|
| 167 |
+
|
| 168 |
+
if ihook.pytest_assertion_pass.get_hookimpls():
|
| 169 |
+
|
| 170 |
+
def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:
|
| 171 |
+
ihook.pytest_assertion_pass(item=item, lineno=lineno, orig=orig, expl=expl)
|
| 172 |
+
|
| 173 |
+
util._assertion_pass = call_assertion_pass_hook
|
| 174 |
+
|
| 175 |
+
try:
|
| 176 |
+
return (yield)
|
| 177 |
+
finally:
|
| 178 |
+
util._reprcompare, util._assertion_pass = saved_assert_hooks
|
| 179 |
+
util._config = None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def pytest_sessionfinish(session: Session) -> None:
|
| 183 |
+
assertstate = session.config.stash.get(assertstate_key, None)
|
| 184 |
+
if assertstate:
|
| 185 |
+
if assertstate.hook is not None:
|
| 186 |
+
assertstate.hook.set_session(None)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def pytest_assertrepr_compare(
|
| 190 |
+
config: Config, op: str, left: Any, right: Any
|
| 191 |
+
) -> list[str] | None:
|
| 192 |
+
return util.assertrepr_compare(config=config, op=op, left=left, right=right)
|
.venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (10.5 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/rewrite.cpython-311.pyc
ADDED
|
Binary file (66.4 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/truncate.cpython-311.pyc
ADDED
|
Binary file (4.1 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/assertion/__pycache__/util.cpython-311.pyc
ADDED
|
Binary file (28.2 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/assertion/rewrite.py
ADDED
|
@@ -0,0 +1,1211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rewrite assertion AST to produce nice error messages."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import ast
|
| 6 |
+
from collections import defaultdict
|
| 7 |
+
import errno
|
| 8 |
+
import functools
|
| 9 |
+
import importlib.abc
|
| 10 |
+
import importlib.machinery
|
| 11 |
+
import importlib.util
|
| 12 |
+
import io
|
| 13 |
+
import itertools
|
| 14 |
+
import marshal
|
| 15 |
+
import os
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from pathlib import PurePath
|
| 18 |
+
import struct
|
| 19 |
+
import sys
|
| 20 |
+
import tokenize
|
| 21 |
+
import types
|
| 22 |
+
from typing import Callable
|
| 23 |
+
from typing import IO
|
| 24 |
+
from typing import Iterable
|
| 25 |
+
from typing import Iterator
|
| 26 |
+
from typing import Sequence
|
| 27 |
+
from typing import TYPE_CHECKING
|
| 28 |
+
|
| 29 |
+
from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE
|
| 30 |
+
from _pytest._io.saferepr import saferepr
|
| 31 |
+
from _pytest._version import version
|
| 32 |
+
from _pytest.assertion import util
|
| 33 |
+
from _pytest.config import Config
|
| 34 |
+
from _pytest.main import Session
|
| 35 |
+
from _pytest.pathlib import absolutepath
|
| 36 |
+
from _pytest.pathlib import fnmatch_ex
|
| 37 |
+
from _pytest.stash import StashKey
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# fmt: off
|
| 41 |
+
from _pytest.assertion.util import format_explanation as _format_explanation # noqa:F401, isort:skip
|
| 42 |
+
# fmt:on
|
| 43 |
+
|
| 44 |
+
if TYPE_CHECKING:
|
| 45 |
+
from _pytest.assertion import AssertionState
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Sentinel:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
assertstate_key = StashKey["AssertionState"]()
|
| 53 |
+
|
| 54 |
+
# pytest caches rewritten pycs in pycache dirs
|
| 55 |
+
PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}"
|
| 56 |
+
PYC_EXT = ".py" + (__debug__ and "c" or "o")
|
| 57 |
+
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
|
| 58 |
+
|
| 59 |
+
# Special marker that denotes we have just left a scope definition
|
| 60 |
+
_SCOPE_END_MARKER = Sentinel()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
| 64 |
+
"""PEP302/PEP451 import hook which rewrites asserts."""
|
| 65 |
+
|
| 66 |
+
def __init__(self, config: Config) -> None:
|
| 67 |
+
self.config = config
|
| 68 |
+
try:
|
| 69 |
+
self.fnpats = config.getini("python_files")
|
| 70 |
+
except ValueError:
|
| 71 |
+
self.fnpats = ["test_*.py", "*_test.py"]
|
| 72 |
+
self.session: Session | None = None
|
| 73 |
+
self._rewritten_names: dict[str, Path] = {}
|
| 74 |
+
self._must_rewrite: set[str] = set()
|
| 75 |
+
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
|
| 76 |
+
# which might result in infinite recursion (#3506)
|
| 77 |
+
self._writing_pyc = False
|
| 78 |
+
self._basenames_to_check_rewrite = {"conftest"}
|
| 79 |
+
self._marked_for_rewrite_cache: dict[str, bool] = {}
|
| 80 |
+
self._session_paths_checked = False
|
| 81 |
+
|
| 82 |
+
def set_session(self, session: Session | None) -> None:
|
| 83 |
+
self.session = session
|
| 84 |
+
self._session_paths_checked = False
|
| 85 |
+
|
| 86 |
+
# Indirection so we can mock calls to find_spec originated from the hook during testing
|
| 87 |
+
_find_spec = importlib.machinery.PathFinder.find_spec
|
| 88 |
+
|
| 89 |
+
def find_spec(
|
| 90 |
+
self,
|
| 91 |
+
name: str,
|
| 92 |
+
path: Sequence[str | bytes] | None = None,
|
| 93 |
+
target: types.ModuleType | None = None,
|
| 94 |
+
) -> importlib.machinery.ModuleSpec | None:
|
| 95 |
+
if self._writing_pyc:
|
| 96 |
+
return None
|
| 97 |
+
state = self.config.stash[assertstate_key]
|
| 98 |
+
if self._early_rewrite_bailout(name, state):
|
| 99 |
+
return None
|
| 100 |
+
state.trace(f"find_module called for: {name}")
|
| 101 |
+
|
| 102 |
+
# Type ignored because mypy is confused about the `self` binding here.
|
| 103 |
+
spec = self._find_spec(name, path) # type: ignore
|
| 104 |
+
|
| 105 |
+
if spec is None and path is not None:
|
| 106 |
+
# With --import-mode=importlib, PathFinder cannot find spec without modifying `sys.path`,
|
| 107 |
+
# causing inability to assert rewriting (#12659).
|
| 108 |
+
# At this point, try using the file path to find the module spec.
|
| 109 |
+
for _path_str in path:
|
| 110 |
+
spec = importlib.util.spec_from_file_location(name, _path_str)
|
| 111 |
+
if spec is not None:
|
| 112 |
+
break
|
| 113 |
+
|
| 114 |
+
if (
|
| 115 |
+
# the import machinery could not find a file to import
|
| 116 |
+
spec is None
|
| 117 |
+
# this is a namespace package (without `__init__.py`)
|
| 118 |
+
# there's nothing to rewrite there
|
| 119 |
+
or spec.origin is None
|
| 120 |
+
# we can only rewrite source files
|
| 121 |
+
or not isinstance(spec.loader, importlib.machinery.SourceFileLoader)
|
| 122 |
+
# if the file doesn't exist, we can't rewrite it
|
| 123 |
+
or not os.path.exists(spec.origin)
|
| 124 |
+
):
|
| 125 |
+
return None
|
| 126 |
+
else:
|
| 127 |
+
fn = spec.origin
|
| 128 |
+
|
| 129 |
+
if not self._should_rewrite(name, fn, state):
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
return importlib.util.spec_from_file_location(
|
| 133 |
+
name,
|
| 134 |
+
fn,
|
| 135 |
+
loader=self,
|
| 136 |
+
submodule_search_locations=spec.submodule_search_locations,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def create_module(
|
| 140 |
+
self, spec: importlib.machinery.ModuleSpec
|
| 141 |
+
) -> types.ModuleType | None:
|
| 142 |
+
return None # default behaviour is fine
|
| 143 |
+
|
| 144 |
+
def exec_module(self, module: types.ModuleType) -> None:
|
| 145 |
+
assert module.__spec__ is not None
|
| 146 |
+
assert module.__spec__.origin is not None
|
| 147 |
+
fn = Path(module.__spec__.origin)
|
| 148 |
+
state = self.config.stash[assertstate_key]
|
| 149 |
+
|
| 150 |
+
self._rewritten_names[module.__name__] = fn
|
| 151 |
+
|
| 152 |
+
# The requested module looks like a test file, so rewrite it. This is
|
| 153 |
+
# the most magical part of the process: load the source, rewrite the
|
| 154 |
+
# asserts, and load the rewritten source. We also cache the rewritten
|
| 155 |
+
# module code in a special pyc. We must be aware of the possibility of
|
| 156 |
+
# concurrent pytest processes rewriting and loading pycs. To avoid
|
| 157 |
+
# tricky race conditions, we maintain the following invariant: The
|
| 158 |
+
# cached pyc is always a complete, valid pyc. Operations on it must be
|
| 159 |
+
# atomic. POSIX's atomic rename comes in handy.
|
| 160 |
+
write = not sys.dont_write_bytecode
|
| 161 |
+
cache_dir = get_cache_dir(fn)
|
| 162 |
+
if write:
|
| 163 |
+
ok = try_makedirs(cache_dir)
|
| 164 |
+
if not ok:
|
| 165 |
+
write = False
|
| 166 |
+
state.trace(f"read only directory: {cache_dir}")
|
| 167 |
+
|
| 168 |
+
cache_name = fn.name[:-3] + PYC_TAIL
|
| 169 |
+
pyc = cache_dir / cache_name
|
| 170 |
+
# Notice that even if we're in a read-only directory, I'm going
|
| 171 |
+
# to check for a cached pyc. This may not be optimal...
|
| 172 |
+
co = _read_pyc(fn, pyc, state.trace)
|
| 173 |
+
if co is None:
|
| 174 |
+
state.trace(f"rewriting {fn!r}")
|
| 175 |
+
source_stat, co = _rewrite_test(fn, self.config)
|
| 176 |
+
if write:
|
| 177 |
+
self._writing_pyc = True
|
| 178 |
+
try:
|
| 179 |
+
_write_pyc(state, co, source_stat, pyc)
|
| 180 |
+
finally:
|
| 181 |
+
self._writing_pyc = False
|
| 182 |
+
else:
|
| 183 |
+
state.trace(f"found cached rewritten pyc for {fn}")
|
| 184 |
+
exec(co, module.__dict__)
|
| 185 |
+
|
| 186 |
+
def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool:
|
| 187 |
+
"""A fast way to get out of rewriting modules.
|
| 188 |
+
|
| 189 |
+
Profiling has shown that the call to PathFinder.find_spec (inside of
|
| 190 |
+
the find_spec from this class) is a major slowdown, so, this method
|
| 191 |
+
tries to filter what we're sure won't be rewritten before getting to
|
| 192 |
+
it.
|
| 193 |
+
"""
|
| 194 |
+
if self.session is not None and not self._session_paths_checked:
|
| 195 |
+
self._session_paths_checked = True
|
| 196 |
+
for initial_path in self.session._initialpaths:
|
| 197 |
+
# Make something as c:/projects/my_project/path.py ->
|
| 198 |
+
# ['c:', 'projects', 'my_project', 'path.py']
|
| 199 |
+
parts = str(initial_path).split(os.sep)
|
| 200 |
+
# add 'path' to basenames to be checked.
|
| 201 |
+
self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0])
|
| 202 |
+
|
| 203 |
+
# Note: conftest already by default in _basenames_to_check_rewrite.
|
| 204 |
+
parts = name.split(".")
|
| 205 |
+
if parts[-1] in self._basenames_to_check_rewrite:
|
| 206 |
+
return False
|
| 207 |
+
|
| 208 |
+
# For matching the name it must be as if it was a filename.
|
| 209 |
+
path = PurePath(*parts).with_suffix(".py")
|
| 210 |
+
|
| 211 |
+
for pat in self.fnpats:
|
| 212 |
+
# if the pattern contains subdirectories ("tests/**.py" for example) we can't bail out based
|
| 213 |
+
# on the name alone because we need to match against the full path
|
| 214 |
+
if os.path.dirname(pat):
|
| 215 |
+
return False
|
| 216 |
+
if fnmatch_ex(pat, path):
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
if self._is_marked_for_rewrite(name, state):
|
| 220 |
+
return False
|
| 221 |
+
|
| 222 |
+
state.trace(f"early skip of rewriting module: {name}")
|
| 223 |
+
return True
|
| 224 |
+
|
| 225 |
+
def _should_rewrite(self, name: str, fn: str, state: AssertionState) -> bool:
|
| 226 |
+
# always rewrite conftest files
|
| 227 |
+
if os.path.basename(fn) == "conftest.py":
|
| 228 |
+
state.trace(f"rewriting conftest file: {fn!r}")
|
| 229 |
+
return True
|
| 230 |
+
|
| 231 |
+
if self.session is not None:
|
| 232 |
+
if self.session.isinitpath(absolutepath(fn)):
|
| 233 |
+
state.trace(f"matched test file (was specified on cmdline): {fn!r}")
|
| 234 |
+
return True
|
| 235 |
+
|
| 236 |
+
# modules not passed explicitly on the command line are only
|
| 237 |
+
# rewritten if they match the naming convention for test files
|
| 238 |
+
fn_path = PurePath(fn)
|
| 239 |
+
for pat in self.fnpats:
|
| 240 |
+
if fnmatch_ex(pat, fn_path):
|
| 241 |
+
state.trace(f"matched test file {fn!r}")
|
| 242 |
+
return True
|
| 243 |
+
|
| 244 |
+
return self._is_marked_for_rewrite(name, state)
|
| 245 |
+
|
| 246 |
+
def _is_marked_for_rewrite(self, name: str, state: AssertionState) -> bool:
|
| 247 |
+
try:
|
| 248 |
+
return self._marked_for_rewrite_cache[name]
|
| 249 |
+
except KeyError:
|
| 250 |
+
for marked in self._must_rewrite:
|
| 251 |
+
if name == marked or name.startswith(marked + "."):
|
| 252 |
+
state.trace(f"matched marked file {name!r} (from {marked!r})")
|
| 253 |
+
self._marked_for_rewrite_cache[name] = True
|
| 254 |
+
return True
|
| 255 |
+
|
| 256 |
+
self._marked_for_rewrite_cache[name] = False
|
| 257 |
+
return False
|
| 258 |
+
|
| 259 |
+
def mark_rewrite(self, *names: str) -> None:
|
| 260 |
+
"""Mark import names as needing to be rewritten.
|
| 261 |
+
|
| 262 |
+
The named module or package as well as any nested modules will
|
| 263 |
+
be rewritten on import.
|
| 264 |
+
"""
|
| 265 |
+
already_imported = (
|
| 266 |
+
set(names).intersection(sys.modules).difference(self._rewritten_names)
|
| 267 |
+
)
|
| 268 |
+
for name in already_imported:
|
| 269 |
+
mod = sys.modules[name]
|
| 270 |
+
if not AssertionRewriter.is_rewrite_disabled(
|
| 271 |
+
mod.__doc__ or ""
|
| 272 |
+
) and not isinstance(mod.__loader__, type(self)):
|
| 273 |
+
self._warn_already_imported(name)
|
| 274 |
+
self._must_rewrite.update(names)
|
| 275 |
+
self._marked_for_rewrite_cache.clear()
|
| 276 |
+
|
| 277 |
+
def _warn_already_imported(self, name: str) -> None:
|
| 278 |
+
from _pytest.warning_types import PytestAssertRewriteWarning
|
| 279 |
+
|
| 280 |
+
self.config.issue_config_time_warning(
|
| 281 |
+
PytestAssertRewriteWarning(
|
| 282 |
+
f"Module already imported so cannot be rewritten: {name}"
|
| 283 |
+
),
|
| 284 |
+
stacklevel=5,
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
def get_data(self, pathname: str | bytes) -> bytes:
|
| 288 |
+
"""Optional PEP302 get_data API."""
|
| 289 |
+
with open(pathname, "rb") as f:
|
| 290 |
+
return f.read()
|
| 291 |
+
|
| 292 |
+
if sys.version_info >= (3, 10):
|
| 293 |
+
if sys.version_info >= (3, 12):
|
| 294 |
+
from importlib.resources.abc import TraversableResources
|
| 295 |
+
else:
|
| 296 |
+
from importlib.abc import TraversableResources
|
| 297 |
+
|
| 298 |
+
def get_resource_reader(self, name: str) -> TraversableResources:
|
| 299 |
+
if sys.version_info < (3, 11):
|
| 300 |
+
from importlib.readers import FileReader
|
| 301 |
+
else:
|
| 302 |
+
from importlib.resources.readers import FileReader
|
| 303 |
+
|
| 304 |
+
return FileReader(types.SimpleNamespace(path=self._rewritten_names[name]))
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def _write_pyc_fp(
|
| 308 |
+
fp: IO[bytes], source_stat: os.stat_result, co: types.CodeType
|
| 309 |
+
) -> None:
|
| 310 |
+
# Technically, we don't have to have the same pyc format as
|
| 311 |
+
# (C)Python, since these "pycs" should never be seen by builtin
|
| 312 |
+
# import. However, there's little reason to deviate.
|
| 313 |
+
fp.write(importlib.util.MAGIC_NUMBER)
|
| 314 |
+
# https://www.python.org/dev/peps/pep-0552/
|
| 315 |
+
flags = b"\x00\x00\x00\x00"
|
| 316 |
+
fp.write(flags)
|
| 317 |
+
# as of now, bytecode header expects 32-bit numbers for size and mtime (#4903)
|
| 318 |
+
mtime = int(source_stat.st_mtime) & 0xFFFFFFFF
|
| 319 |
+
size = source_stat.st_size & 0xFFFFFFFF
|
| 320 |
+
# "<LL" stands for 2 unsigned longs, little-endian.
|
| 321 |
+
fp.write(struct.pack("<LL", mtime, size))
|
| 322 |
+
fp.write(marshal.dumps(co))
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _write_pyc(
|
| 326 |
+
state: AssertionState,
|
| 327 |
+
co: types.CodeType,
|
| 328 |
+
source_stat: os.stat_result,
|
| 329 |
+
pyc: Path,
|
| 330 |
+
) -> bool:
|
| 331 |
+
proc_pyc = f"{pyc}.{os.getpid()}"
|
| 332 |
+
try:
|
| 333 |
+
with open(proc_pyc, "wb") as fp:
|
| 334 |
+
_write_pyc_fp(fp, source_stat, co)
|
| 335 |
+
except OSError as e:
|
| 336 |
+
state.trace(f"error writing pyc file at {proc_pyc}: errno={e.errno}")
|
| 337 |
+
return False
|
| 338 |
+
|
| 339 |
+
try:
|
| 340 |
+
os.replace(proc_pyc, pyc)
|
| 341 |
+
except OSError as e:
|
| 342 |
+
state.trace(f"error writing pyc file at {pyc}: {e}")
|
| 343 |
+
# we ignore any failure to write the cache file
|
| 344 |
+
# there are many reasons, permission-denied, pycache dir being a
|
| 345 |
+
# file etc.
|
| 346 |
+
return False
|
| 347 |
+
return True
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeType]:
|
| 351 |
+
"""Read and rewrite *fn* and return the code object."""
|
| 352 |
+
stat = os.stat(fn)
|
| 353 |
+
source = fn.read_bytes()
|
| 354 |
+
strfn = str(fn)
|
| 355 |
+
tree = ast.parse(source, filename=strfn)
|
| 356 |
+
rewrite_asserts(tree, source, strfn, config)
|
| 357 |
+
co = compile(tree, strfn, "exec", dont_inherit=True)
|
| 358 |
+
return stat, co
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _read_pyc(
|
| 362 |
+
source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None
|
| 363 |
+
) -> types.CodeType | None:
|
| 364 |
+
"""Possibly read a pytest pyc containing rewritten code.
|
| 365 |
+
|
| 366 |
+
Return rewritten code if successful or None if not.
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
fp = open(pyc, "rb")
|
| 370 |
+
except OSError:
|
| 371 |
+
return None
|
| 372 |
+
with fp:
|
| 373 |
+
try:
|
| 374 |
+
stat_result = os.stat(source)
|
| 375 |
+
mtime = int(stat_result.st_mtime)
|
| 376 |
+
size = stat_result.st_size
|
| 377 |
+
data = fp.read(16)
|
| 378 |
+
except OSError as e:
|
| 379 |
+
trace(f"_read_pyc({source}): OSError {e}")
|
| 380 |
+
return None
|
| 381 |
+
# Check for invalid or out of date pyc file.
|
| 382 |
+
if len(data) != (16):
|
| 383 |
+
trace(f"_read_pyc({source}): invalid pyc (too short)")
|
| 384 |
+
return None
|
| 385 |
+
if data[:4] != importlib.util.MAGIC_NUMBER:
|
| 386 |
+
trace(f"_read_pyc({source}): invalid pyc (bad magic number)")
|
| 387 |
+
return None
|
| 388 |
+
if data[4:8] != b"\x00\x00\x00\x00":
|
| 389 |
+
trace(f"_read_pyc({source}): invalid pyc (unsupported flags)")
|
| 390 |
+
return None
|
| 391 |
+
mtime_data = data[8:12]
|
| 392 |
+
if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF:
|
| 393 |
+
trace(f"_read_pyc({source}): out of date")
|
| 394 |
+
return None
|
| 395 |
+
size_data = data[12:16]
|
| 396 |
+
if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF:
|
| 397 |
+
trace(f"_read_pyc({source}): invalid pyc (incorrect size)")
|
| 398 |
+
return None
|
| 399 |
+
try:
|
| 400 |
+
co = marshal.load(fp)
|
| 401 |
+
except Exception as e:
|
| 402 |
+
trace(f"_read_pyc({source}): marshal.load error {e}")
|
| 403 |
+
return None
|
| 404 |
+
if not isinstance(co, types.CodeType):
|
| 405 |
+
trace(f"_read_pyc({source}): not a code object")
|
| 406 |
+
return None
|
| 407 |
+
return co
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def rewrite_asserts(
|
| 411 |
+
mod: ast.Module,
|
| 412 |
+
source: bytes,
|
| 413 |
+
module_path: str | None = None,
|
| 414 |
+
config: Config | None = None,
|
| 415 |
+
) -> None:
|
| 416 |
+
"""Rewrite the assert statements in mod."""
|
| 417 |
+
AssertionRewriter(module_path, config, source).run(mod)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _saferepr(obj: object) -> str:
|
| 421 |
+
r"""Get a safe repr of an object for assertion error messages.
|
| 422 |
+
|
| 423 |
+
The assertion formatting (util.format_explanation()) requires
|
| 424 |
+
newlines to be escaped since they are a special character for it.
|
| 425 |
+
Normally assertion.util.format_explanation() does this but for a
|
| 426 |
+
custom repr it is possible to contain one of the special escape
|
| 427 |
+
sequences, especially '\n{' and '\n}' are likely to be present in
|
| 428 |
+
JSON reprs.
|
| 429 |
+
"""
|
| 430 |
+
if isinstance(obj, types.MethodType):
|
| 431 |
+
# for bound methods, skip redundant <bound method ...> information
|
| 432 |
+
return obj.__name__
|
| 433 |
+
|
| 434 |
+
maxsize = _get_maxsize_for_saferepr(util._config)
|
| 435 |
+
return saferepr(obj, maxsize=maxsize).replace("\n", "\\n")
|
| 436 |
+
|
| 437 |
+
|
| 438 |
+
def _get_maxsize_for_saferepr(config: Config | None) -> int | None:
|
| 439 |
+
"""Get `maxsize` configuration for saferepr based on the given config object."""
|
| 440 |
+
if config is None:
|
| 441 |
+
verbosity = 0
|
| 442 |
+
else:
|
| 443 |
+
verbosity = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
|
| 444 |
+
if verbosity >= 2:
|
| 445 |
+
return None
|
| 446 |
+
if verbosity >= 1:
|
| 447 |
+
return DEFAULT_REPR_MAX_SIZE * 10
|
| 448 |
+
return DEFAULT_REPR_MAX_SIZE
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _format_assertmsg(obj: object) -> str:
|
| 452 |
+
r"""Format the custom assertion message given.
|
| 453 |
+
|
| 454 |
+
For strings this simply replaces newlines with '\n~' so that
|
| 455 |
+
util.format_explanation() will preserve them instead of escaping
|
| 456 |
+
newlines. For other objects saferepr() is used first.
|
| 457 |
+
"""
|
| 458 |
+
# reprlib appears to have a bug which means that if a string
|
| 459 |
+
# contains a newline it gets escaped, however if an object has a
|
| 460 |
+
# .__repr__() which contains newlines it does not get escaped.
|
| 461 |
+
# However in either case we want to preserve the newline.
|
| 462 |
+
replaces = [("\n", "\n~"), ("%", "%%")]
|
| 463 |
+
if not isinstance(obj, str):
|
| 464 |
+
obj = saferepr(obj, _get_maxsize_for_saferepr(util._config))
|
| 465 |
+
replaces.append(("\\n", "\n~"))
|
| 466 |
+
|
| 467 |
+
for r1, r2 in replaces:
|
| 468 |
+
obj = obj.replace(r1, r2)
|
| 469 |
+
|
| 470 |
+
return obj
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _should_repr_global_name(obj: object) -> bool:
|
| 474 |
+
if callable(obj):
|
| 475 |
+
return False
|
| 476 |
+
|
| 477 |
+
try:
|
| 478 |
+
return not hasattr(obj, "__name__")
|
| 479 |
+
except Exception:
|
| 480 |
+
return True
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _format_boolop(explanations: Iterable[str], is_or: bool) -> str:
|
| 484 |
+
explanation = "(" + (is_or and " or " or " and ").join(explanations) + ")"
|
| 485 |
+
return explanation.replace("%", "%%")
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def _call_reprcompare(
|
| 489 |
+
ops: Sequence[str],
|
| 490 |
+
results: Sequence[bool],
|
| 491 |
+
expls: Sequence[str],
|
| 492 |
+
each_obj: Sequence[object],
|
| 493 |
+
) -> str:
|
| 494 |
+
for i, res, expl in zip(range(len(ops)), results, expls):
|
| 495 |
+
try:
|
| 496 |
+
done = not res
|
| 497 |
+
except Exception:
|
| 498 |
+
done = True
|
| 499 |
+
if done:
|
| 500 |
+
break
|
| 501 |
+
if util._reprcompare is not None:
|
| 502 |
+
custom = util._reprcompare(ops[i], each_obj[i], each_obj[i + 1])
|
| 503 |
+
if custom is not None:
|
| 504 |
+
return custom
|
| 505 |
+
return expl
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None:
|
| 509 |
+
if util._assertion_pass is not None:
|
| 510 |
+
util._assertion_pass(lineno, orig, expl)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def _check_if_assertion_pass_impl() -> bool:
|
| 514 |
+
"""Check if any plugins implement the pytest_assertion_pass hook
|
| 515 |
+
in order not to generate explanation unnecessarily (might be expensive)."""
|
| 516 |
+
return True if util._assertion_pass else False
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
UNARY_MAP = {ast.Not: "not %s", ast.Invert: "~%s", ast.USub: "-%s", ast.UAdd: "+%s"}
|
| 520 |
+
|
| 521 |
+
BINOP_MAP = {
|
| 522 |
+
ast.BitOr: "|",
|
| 523 |
+
ast.BitXor: "^",
|
| 524 |
+
ast.BitAnd: "&",
|
| 525 |
+
ast.LShift: "<<",
|
| 526 |
+
ast.RShift: ">>",
|
| 527 |
+
ast.Add: "+",
|
| 528 |
+
ast.Sub: "-",
|
| 529 |
+
ast.Mult: "*",
|
| 530 |
+
ast.Div: "/",
|
| 531 |
+
ast.FloorDiv: "//",
|
| 532 |
+
ast.Mod: "%%", # escaped for string formatting
|
| 533 |
+
ast.Eq: "==",
|
| 534 |
+
ast.NotEq: "!=",
|
| 535 |
+
ast.Lt: "<",
|
| 536 |
+
ast.LtE: "<=",
|
| 537 |
+
ast.Gt: ">",
|
| 538 |
+
ast.GtE: ">=",
|
| 539 |
+
ast.Pow: "**",
|
| 540 |
+
ast.Is: "is",
|
| 541 |
+
ast.IsNot: "is not",
|
| 542 |
+
ast.In: "in",
|
| 543 |
+
ast.NotIn: "not in",
|
| 544 |
+
ast.MatMult: "@",
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
|
| 549 |
+
"""Recursively yield node and all its children in depth-first order."""
|
| 550 |
+
yield node
|
| 551 |
+
for child in ast.iter_child_nodes(node):
|
| 552 |
+
yield from traverse_node(child)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
@functools.lru_cache(maxsize=1)
|
| 556 |
+
def _get_assertion_exprs(src: bytes) -> dict[int, str]:
|
| 557 |
+
"""Return a mapping from {lineno: "assertion test expression"}."""
|
| 558 |
+
ret: dict[int, str] = {}
|
| 559 |
+
|
| 560 |
+
depth = 0
|
| 561 |
+
lines: list[str] = []
|
| 562 |
+
assert_lineno: int | None = None
|
| 563 |
+
seen_lines: set[int] = set()
|
| 564 |
+
|
| 565 |
+
def _write_and_reset() -> None:
|
| 566 |
+
nonlocal depth, lines, assert_lineno, seen_lines
|
| 567 |
+
assert assert_lineno is not None
|
| 568 |
+
ret[assert_lineno] = "".join(lines).rstrip().rstrip("\\")
|
| 569 |
+
depth = 0
|
| 570 |
+
lines = []
|
| 571 |
+
assert_lineno = None
|
| 572 |
+
seen_lines = set()
|
| 573 |
+
|
| 574 |
+
tokens = tokenize.tokenize(io.BytesIO(src).readline)
|
| 575 |
+
for tp, source, (lineno, offset), _, line in tokens:
|
| 576 |
+
if tp == tokenize.NAME and source == "assert":
|
| 577 |
+
assert_lineno = lineno
|
| 578 |
+
elif assert_lineno is not None:
|
| 579 |
+
# keep track of depth for the assert-message `,` lookup
|
| 580 |
+
if tp == tokenize.OP and source in "([{":
|
| 581 |
+
depth += 1
|
| 582 |
+
elif tp == tokenize.OP and source in ")]}":
|
| 583 |
+
depth -= 1
|
| 584 |
+
|
| 585 |
+
if not lines:
|
| 586 |
+
lines.append(line[offset:])
|
| 587 |
+
seen_lines.add(lineno)
|
| 588 |
+
# a non-nested comma separates the expression from the message
|
| 589 |
+
elif depth == 0 and tp == tokenize.OP and source == ",":
|
| 590 |
+
# one line assert with message
|
| 591 |
+
if lineno in seen_lines and len(lines) == 1:
|
| 592 |
+
offset_in_trimmed = offset + len(lines[-1]) - len(line)
|
| 593 |
+
lines[-1] = lines[-1][:offset_in_trimmed]
|
| 594 |
+
# multi-line assert with message
|
| 595 |
+
elif lineno in seen_lines:
|
| 596 |
+
lines[-1] = lines[-1][:offset]
|
| 597 |
+
# multi line assert with escaped newline before message
|
| 598 |
+
else:
|
| 599 |
+
lines.append(line[:offset])
|
| 600 |
+
_write_and_reset()
|
| 601 |
+
elif tp in {tokenize.NEWLINE, tokenize.ENDMARKER}:
|
| 602 |
+
_write_and_reset()
|
| 603 |
+
elif lines and lineno not in seen_lines:
|
| 604 |
+
lines.append(line)
|
| 605 |
+
seen_lines.add(lineno)
|
| 606 |
+
|
| 607 |
+
return ret
|
| 608 |
+
|
| 609 |
+
|
| 610 |
+
class AssertionRewriter(ast.NodeVisitor):
|
| 611 |
+
"""Assertion rewriting implementation.
|
| 612 |
+
|
| 613 |
+
The main entrypoint is to call .run() with an ast.Module instance,
|
| 614 |
+
this will then find all the assert statements and rewrite them to
|
| 615 |
+
provide intermediate values and a detailed assertion error. See
|
| 616 |
+
http://pybites.blogspot.be/2011/07/behind-scenes-of-pytests-new-assertion.html
|
| 617 |
+
for an overview of how this works.
|
| 618 |
+
|
| 619 |
+
The entry point here is .run() which will iterate over all the
|
| 620 |
+
statements in an ast.Module and for each ast.Assert statement it
|
| 621 |
+
finds call .visit() with it. Then .visit_Assert() takes over and
|
| 622 |
+
is responsible for creating new ast statements to replace the
|
| 623 |
+
original assert statement: it rewrites the test of an assertion
|
| 624 |
+
to provide intermediate values and replace it with an if statement
|
| 625 |
+
which raises an assertion error with a detailed explanation in
|
| 626 |
+
case the expression is false and calls pytest_assertion_pass hook
|
| 627 |
+
if expression is true.
|
| 628 |
+
|
| 629 |
+
For this .visit_Assert() uses the visitor pattern to visit all the
|
| 630 |
+
AST nodes of the ast.Assert.test field, each visit call returning
|
| 631 |
+
an AST node and the corresponding explanation string. During this
|
| 632 |
+
state is kept in several instance attributes:
|
| 633 |
+
|
| 634 |
+
:statements: All the AST statements which will replace the assert
|
| 635 |
+
statement.
|
| 636 |
+
|
| 637 |
+
:variables: This is populated by .variable() with each variable
|
| 638 |
+
used by the statements so that they can all be set to None at
|
| 639 |
+
the end of the statements.
|
| 640 |
+
|
| 641 |
+
:variable_counter: Counter to create new unique variables needed
|
| 642 |
+
by statements. Variables are created using .variable() and
|
| 643 |
+
have the form of "@py_assert0".
|
| 644 |
+
|
| 645 |
+
:expl_stmts: The AST statements which will be executed to get
|
| 646 |
+
data from the assertion. This is the code which will construct
|
| 647 |
+
the detailed assertion message that is used in the AssertionError
|
| 648 |
+
or for the pytest_assertion_pass hook.
|
| 649 |
+
|
| 650 |
+
:explanation_specifiers: A dict filled by .explanation_param()
|
| 651 |
+
with %-formatting placeholders and their corresponding
|
| 652 |
+
expressions to use in the building of an assertion message.
|
| 653 |
+
This is used by .pop_format_context() to build a message.
|
| 654 |
+
|
| 655 |
+
:stack: A stack of the explanation_specifiers dicts maintained by
|
| 656 |
+
.push_format_context() and .pop_format_context() which allows
|
| 657 |
+
to build another %-formatted string while already building one.
|
| 658 |
+
|
| 659 |
+
:scope: A tuple containing the current scope used for variables_overwrite.
|
| 660 |
+
|
| 661 |
+
:variables_overwrite: A dict filled with references to variables
|
| 662 |
+
that change value within an assert. This happens when a variable is
|
| 663 |
+
reassigned with the walrus operator
|
| 664 |
+
|
| 665 |
+
This state, except the variables_overwrite, is reset on every new assert
|
| 666 |
+
statement visited and used by the other visitors.
|
| 667 |
+
"""
|
| 668 |
+
|
| 669 |
+
def __init__(
|
| 670 |
+
self, module_path: str | None, config: Config | None, source: bytes
|
| 671 |
+
) -> None:
|
| 672 |
+
super().__init__()
|
| 673 |
+
self.module_path = module_path
|
| 674 |
+
self.config = config
|
| 675 |
+
if config is not None:
|
| 676 |
+
self.enable_assertion_pass_hook = config.getini(
|
| 677 |
+
"enable_assertion_pass_hook"
|
| 678 |
+
)
|
| 679 |
+
else:
|
| 680 |
+
self.enable_assertion_pass_hook = False
|
| 681 |
+
self.source = source
|
| 682 |
+
self.scope: tuple[ast.AST, ...] = ()
|
| 683 |
+
self.variables_overwrite: defaultdict[tuple[ast.AST, ...], dict[str, str]] = (
|
| 684 |
+
defaultdict(dict)
|
| 685 |
+
)
|
| 686 |
+
|
| 687 |
+
def run(self, mod: ast.Module) -> None:
|
| 688 |
+
"""Find all assert statements in *mod* and rewrite them."""
|
| 689 |
+
if not mod.body:
|
| 690 |
+
# Nothing to do.
|
| 691 |
+
return
|
| 692 |
+
|
| 693 |
+
# We'll insert some special imports at the top of the module, but after any
|
| 694 |
+
# docstrings and __future__ imports, so first figure out where that is.
|
| 695 |
+
doc = getattr(mod, "docstring", None)
|
| 696 |
+
expect_docstring = doc is None
|
| 697 |
+
if doc is not None and self.is_rewrite_disabled(doc):
|
| 698 |
+
return
|
| 699 |
+
pos = 0
|
| 700 |
+
item = None
|
| 701 |
+
for item in mod.body:
|
| 702 |
+
if (
|
| 703 |
+
expect_docstring
|
| 704 |
+
and isinstance(item, ast.Expr)
|
| 705 |
+
and isinstance(item.value, ast.Constant)
|
| 706 |
+
and isinstance(item.value.value, str)
|
| 707 |
+
):
|
| 708 |
+
doc = item.value.value
|
| 709 |
+
if self.is_rewrite_disabled(doc):
|
| 710 |
+
return
|
| 711 |
+
expect_docstring = False
|
| 712 |
+
elif (
|
| 713 |
+
isinstance(item, ast.ImportFrom)
|
| 714 |
+
and item.level == 0
|
| 715 |
+
and item.module == "__future__"
|
| 716 |
+
):
|
| 717 |
+
pass
|
| 718 |
+
else:
|
| 719 |
+
break
|
| 720 |
+
pos += 1
|
| 721 |
+
# Special case: for a decorated function, set the lineno to that of the
|
| 722 |
+
# first decorator, not the `def`. Issue #4984.
|
| 723 |
+
if isinstance(item, ast.FunctionDef) and item.decorator_list:
|
| 724 |
+
lineno = item.decorator_list[0].lineno
|
| 725 |
+
else:
|
| 726 |
+
lineno = item.lineno
|
| 727 |
+
# Now actually insert the special imports.
|
| 728 |
+
if sys.version_info >= (3, 10):
|
| 729 |
+
aliases = [
|
| 730 |
+
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
|
| 731 |
+
ast.alias(
|
| 732 |
+
"_pytest.assertion.rewrite",
|
| 733 |
+
"@pytest_ar",
|
| 734 |
+
lineno=lineno,
|
| 735 |
+
col_offset=0,
|
| 736 |
+
),
|
| 737 |
+
]
|
| 738 |
+
else:
|
| 739 |
+
aliases = [
|
| 740 |
+
ast.alias("builtins", "@py_builtins"),
|
| 741 |
+
ast.alias("_pytest.assertion.rewrite", "@pytest_ar"),
|
| 742 |
+
]
|
| 743 |
+
imports = [
|
| 744 |
+
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
|
| 745 |
+
]
|
| 746 |
+
mod.body[pos:pos] = imports
|
| 747 |
+
|
| 748 |
+
# Collect asserts.
|
| 749 |
+
self.scope = (mod,)
|
| 750 |
+
nodes: list[ast.AST | Sentinel] = [mod]
|
| 751 |
+
while nodes:
|
| 752 |
+
node = nodes.pop()
|
| 753 |
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
| 754 |
+
self.scope = tuple((*self.scope, node))
|
| 755 |
+
nodes.append(_SCOPE_END_MARKER)
|
| 756 |
+
if node == _SCOPE_END_MARKER:
|
| 757 |
+
self.scope = self.scope[:-1]
|
| 758 |
+
continue
|
| 759 |
+
assert isinstance(node, ast.AST)
|
| 760 |
+
for name, field in ast.iter_fields(node):
|
| 761 |
+
if isinstance(field, list):
|
| 762 |
+
new: list[ast.AST] = []
|
| 763 |
+
for i, child in enumerate(field):
|
| 764 |
+
if isinstance(child, ast.Assert):
|
| 765 |
+
# Transform assert.
|
| 766 |
+
new.extend(self.visit(child))
|
| 767 |
+
else:
|
| 768 |
+
new.append(child)
|
| 769 |
+
if isinstance(child, ast.AST):
|
| 770 |
+
nodes.append(child)
|
| 771 |
+
setattr(node, name, new)
|
| 772 |
+
elif (
|
| 773 |
+
isinstance(field, ast.AST)
|
| 774 |
+
# Don't recurse into expressions as they can't contain
|
| 775 |
+
# asserts.
|
| 776 |
+
and not isinstance(field, ast.expr)
|
| 777 |
+
):
|
| 778 |
+
nodes.append(field)
|
| 779 |
+
|
| 780 |
+
@staticmethod
|
| 781 |
+
def is_rewrite_disabled(docstring: str) -> bool:
|
| 782 |
+
return "PYTEST_DONT_REWRITE" in docstring
|
| 783 |
+
|
| 784 |
+
def variable(self) -> str:
|
| 785 |
+
"""Get a new variable."""
|
| 786 |
+
# Use a character invalid in python identifiers to avoid clashing.
|
| 787 |
+
name = "@py_assert" + str(next(self.variable_counter))
|
| 788 |
+
self.variables.append(name)
|
| 789 |
+
return name
|
| 790 |
+
|
| 791 |
+
def assign(self, expr: ast.expr) -> ast.Name:
|
| 792 |
+
"""Give *expr* a name."""
|
| 793 |
+
name = self.variable()
|
| 794 |
+
self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr))
|
| 795 |
+
return ast.copy_location(ast.Name(name, ast.Load()), expr)
|
| 796 |
+
|
| 797 |
+
def display(self, expr: ast.expr) -> ast.expr:
|
| 798 |
+
"""Call saferepr on the expression."""
|
| 799 |
+
return self.helper("_saferepr", expr)
|
| 800 |
+
|
| 801 |
+
def helper(self, name: str, *args: ast.expr) -> ast.expr:
|
| 802 |
+
"""Call a helper in this module."""
|
| 803 |
+
py_name = ast.Name("@pytest_ar", ast.Load())
|
| 804 |
+
attr = ast.Attribute(py_name, name, ast.Load())
|
| 805 |
+
return ast.Call(attr, list(args), [])
|
| 806 |
+
|
| 807 |
+
def builtin(self, name: str) -> ast.Attribute:
|
| 808 |
+
"""Return the builtin called *name*."""
|
| 809 |
+
builtin_name = ast.Name("@py_builtins", ast.Load())
|
| 810 |
+
return ast.Attribute(builtin_name, name, ast.Load())
|
| 811 |
+
|
| 812 |
+
def explanation_param(self, expr: ast.expr) -> str:
|
| 813 |
+
"""Return a new named %-formatting placeholder for expr.
|
| 814 |
+
|
| 815 |
+
This creates a %-formatting placeholder for expr in the
|
| 816 |
+
current formatting context, e.g. ``%(py0)s``. The placeholder
|
| 817 |
+
and expr are placed in the current format context so that it
|
| 818 |
+
can be used on the next call to .pop_format_context().
|
| 819 |
+
"""
|
| 820 |
+
specifier = "py" + str(next(self.variable_counter))
|
| 821 |
+
self.explanation_specifiers[specifier] = expr
|
| 822 |
+
return "%(" + specifier + ")s"
|
| 823 |
+
|
| 824 |
+
def push_format_context(self) -> None:
|
| 825 |
+
"""Create a new formatting context.
|
| 826 |
+
|
| 827 |
+
The format context is used for when an explanation wants to
|
| 828 |
+
have a variable value formatted in the assertion message. In
|
| 829 |
+
this case the value required can be added using
|
| 830 |
+
.explanation_param(). Finally .pop_format_context() is used
|
| 831 |
+
to format a string of %-formatted values as added by
|
| 832 |
+
.explanation_param().
|
| 833 |
+
"""
|
| 834 |
+
self.explanation_specifiers: dict[str, ast.expr] = {}
|
| 835 |
+
self.stack.append(self.explanation_specifiers)
|
| 836 |
+
|
| 837 |
+
def pop_format_context(self, expl_expr: ast.expr) -> ast.Name:
|
| 838 |
+
"""Format the %-formatted string with current format context.
|
| 839 |
+
|
| 840 |
+
The expl_expr should be an str ast.expr instance constructed from
|
| 841 |
+
the %-placeholders created by .explanation_param(). This will
|
| 842 |
+
add the required code to format said string to .expl_stmts and
|
| 843 |
+
return the ast.Name instance of the formatted string.
|
| 844 |
+
"""
|
| 845 |
+
current = self.stack.pop()
|
| 846 |
+
if self.stack:
|
| 847 |
+
self.explanation_specifiers = self.stack[-1]
|
| 848 |
+
keys: list[ast.expr | None] = [ast.Constant(key) for key in current.keys()]
|
| 849 |
+
format_dict = ast.Dict(keys, list(current.values()))
|
| 850 |
+
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
|
| 851 |
+
name = "@py_format" + str(next(self.variable_counter))
|
| 852 |
+
if self.enable_assertion_pass_hook:
|
| 853 |
+
self.format_variables.append(name)
|
| 854 |
+
self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form))
|
| 855 |
+
return ast.Name(name, ast.Load())
|
| 856 |
+
|
| 857 |
+
def generic_visit(self, node: ast.AST) -> tuple[ast.Name, str]:
|
| 858 |
+
"""Handle expressions we don't have custom code for."""
|
| 859 |
+
assert isinstance(node, ast.expr)
|
| 860 |
+
res = self.assign(node)
|
| 861 |
+
return res, self.explanation_param(self.display(res))
|
| 862 |
+
|
| 863 |
+
def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]:
|
| 864 |
+
"""Return the AST statements to replace the ast.Assert instance.
|
| 865 |
+
|
| 866 |
+
This rewrites the test of an assertion to provide
|
| 867 |
+
intermediate values and replace it with an if statement which
|
| 868 |
+
raises an assertion error with a detailed explanation in case
|
| 869 |
+
the expression is false.
|
| 870 |
+
"""
|
| 871 |
+
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
|
| 872 |
+
import warnings
|
| 873 |
+
|
| 874 |
+
from _pytest.warning_types import PytestAssertRewriteWarning
|
| 875 |
+
|
| 876 |
+
# TODO: This assert should not be needed.
|
| 877 |
+
assert self.module_path is not None
|
| 878 |
+
warnings.warn_explicit(
|
| 879 |
+
PytestAssertRewriteWarning(
|
| 880 |
+
"assertion is always true, perhaps remove parentheses?"
|
| 881 |
+
),
|
| 882 |
+
category=None,
|
| 883 |
+
filename=self.module_path,
|
| 884 |
+
lineno=assert_.lineno,
|
| 885 |
+
)
|
| 886 |
+
|
| 887 |
+
self.statements: list[ast.stmt] = []
|
| 888 |
+
self.variables: list[str] = []
|
| 889 |
+
self.variable_counter = itertools.count()
|
| 890 |
+
|
| 891 |
+
if self.enable_assertion_pass_hook:
|
| 892 |
+
self.format_variables: list[str] = []
|
| 893 |
+
|
| 894 |
+
self.stack: list[dict[str, ast.expr]] = []
|
| 895 |
+
self.expl_stmts: list[ast.stmt] = []
|
| 896 |
+
self.push_format_context()
|
| 897 |
+
# Rewrite assert into a bunch of statements.
|
| 898 |
+
top_condition, explanation = self.visit(assert_.test)
|
| 899 |
+
|
| 900 |
+
negation = ast.UnaryOp(ast.Not(), top_condition)
|
| 901 |
+
|
| 902 |
+
if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook
|
| 903 |
+
msg = self.pop_format_context(ast.Constant(explanation))
|
| 904 |
+
|
| 905 |
+
# Failed
|
| 906 |
+
if assert_.msg:
|
| 907 |
+
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
| 908 |
+
gluestr = "\n>assert "
|
| 909 |
+
else:
|
| 910 |
+
assertmsg = ast.Constant("")
|
| 911 |
+
gluestr = "assert "
|
| 912 |
+
err_explanation = ast.BinOp(ast.Constant(gluestr), ast.Add(), msg)
|
| 913 |
+
err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
|
| 914 |
+
err_name = ast.Name("AssertionError", ast.Load())
|
| 915 |
+
fmt = self.helper("_format_explanation", err_msg)
|
| 916 |
+
exc = ast.Call(err_name, [fmt], [])
|
| 917 |
+
raise_ = ast.Raise(exc, None)
|
| 918 |
+
statements_fail = []
|
| 919 |
+
statements_fail.extend(self.expl_stmts)
|
| 920 |
+
statements_fail.append(raise_)
|
| 921 |
+
|
| 922 |
+
# Passed
|
| 923 |
+
fmt_pass = self.helper("_format_explanation", msg)
|
| 924 |
+
orig = _get_assertion_exprs(self.source)[assert_.lineno]
|
| 925 |
+
hook_call_pass = ast.Expr(
|
| 926 |
+
self.helper(
|
| 927 |
+
"_call_assertion_pass",
|
| 928 |
+
ast.Constant(assert_.lineno),
|
| 929 |
+
ast.Constant(orig),
|
| 930 |
+
fmt_pass,
|
| 931 |
+
)
|
| 932 |
+
)
|
| 933 |
+
# If any hooks implement assert_pass hook
|
| 934 |
+
hook_impl_test = ast.If(
|
| 935 |
+
self.helper("_check_if_assertion_pass_impl"),
|
| 936 |
+
[*self.expl_stmts, hook_call_pass],
|
| 937 |
+
[],
|
| 938 |
+
)
|
| 939 |
+
statements_pass: list[ast.stmt] = [hook_impl_test]
|
| 940 |
+
|
| 941 |
+
# Test for assertion condition
|
| 942 |
+
main_test = ast.If(negation, statements_fail, statements_pass)
|
| 943 |
+
self.statements.append(main_test)
|
| 944 |
+
if self.format_variables:
|
| 945 |
+
variables: list[ast.expr] = [
|
| 946 |
+
ast.Name(name, ast.Store()) for name in self.format_variables
|
| 947 |
+
]
|
| 948 |
+
clear_format = ast.Assign(variables, ast.Constant(None))
|
| 949 |
+
self.statements.append(clear_format)
|
| 950 |
+
|
| 951 |
+
else: # Original assertion rewriting
|
| 952 |
+
# Create failure message.
|
| 953 |
+
body = self.expl_stmts
|
| 954 |
+
self.statements.append(ast.If(negation, body, []))
|
| 955 |
+
if assert_.msg:
|
| 956 |
+
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
| 957 |
+
explanation = "\n>assert " + explanation
|
| 958 |
+
else:
|
| 959 |
+
assertmsg = ast.Constant("")
|
| 960 |
+
explanation = "assert " + explanation
|
| 961 |
+
template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation))
|
| 962 |
+
msg = self.pop_format_context(template)
|
| 963 |
+
fmt = self.helper("_format_explanation", msg)
|
| 964 |
+
err_name = ast.Name("AssertionError", ast.Load())
|
| 965 |
+
exc = ast.Call(err_name, [fmt], [])
|
| 966 |
+
raise_ = ast.Raise(exc, None)
|
| 967 |
+
|
| 968 |
+
body.append(raise_)
|
| 969 |
+
|
| 970 |
+
# Clear temporary variables by setting them to None.
|
| 971 |
+
if self.variables:
|
| 972 |
+
variables = [ast.Name(name, ast.Store()) for name in self.variables]
|
| 973 |
+
clear = ast.Assign(variables, ast.Constant(None))
|
| 974 |
+
self.statements.append(clear)
|
| 975 |
+
# Fix locations (line numbers/column offsets).
|
| 976 |
+
for stmt in self.statements:
|
| 977 |
+
for node in traverse_node(stmt):
|
| 978 |
+
if getattr(node, "lineno", None) is None:
|
| 979 |
+
# apply the assertion location to all generated ast nodes without source location
|
| 980 |
+
# and preserve the location of existing nodes or generated nodes with an correct location.
|
| 981 |
+
ast.copy_location(node, assert_)
|
| 982 |
+
return self.statements
|
| 983 |
+
|
| 984 |
+
def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]:
|
| 985 |
+
# This method handles the 'walrus operator' repr of the target
|
| 986 |
+
# name if it's a local variable or _should_repr_global_name()
|
| 987 |
+
# thinks it's acceptable.
|
| 988 |
+
locs = ast.Call(self.builtin("locals"), [], [])
|
| 989 |
+
target_id = name.target.id
|
| 990 |
+
inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs])
|
| 991 |
+
dorepr = self.helper("_should_repr_global_name", name)
|
| 992 |
+
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
| 993 |
+
expr = ast.IfExp(test, self.display(name), ast.Constant(target_id))
|
| 994 |
+
return name, self.explanation_param(expr)
|
| 995 |
+
|
| 996 |
+
def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]:
|
| 997 |
+
# Display the repr of the name if it's a local variable or
|
| 998 |
+
# _should_repr_global_name() thinks it's acceptable.
|
| 999 |
+
locs = ast.Call(self.builtin("locals"), [], [])
|
| 1000 |
+
inlocs = ast.Compare(ast.Constant(name.id), [ast.In()], [locs])
|
| 1001 |
+
dorepr = self.helper("_should_repr_global_name", name)
|
| 1002 |
+
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
| 1003 |
+
expr = ast.IfExp(test, self.display(name), ast.Constant(name.id))
|
| 1004 |
+
return name, self.explanation_param(expr)
|
| 1005 |
+
|
| 1006 |
+
def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
|
| 1007 |
+
res_var = self.variable()
|
| 1008 |
+
expl_list = self.assign(ast.List([], ast.Load()))
|
| 1009 |
+
app = ast.Attribute(expl_list, "append", ast.Load())
|
| 1010 |
+
is_or = int(isinstance(boolop.op, ast.Or))
|
| 1011 |
+
body = save = self.statements
|
| 1012 |
+
fail_save = self.expl_stmts
|
| 1013 |
+
levels = len(boolop.values) - 1
|
| 1014 |
+
self.push_format_context()
|
| 1015 |
+
# Process each operand, short-circuiting if needed.
|
| 1016 |
+
for i, v in enumerate(boolop.values):
|
| 1017 |
+
if i:
|
| 1018 |
+
fail_inner: list[ast.stmt] = []
|
| 1019 |
+
# cond is set in a prior loop iteration below
|
| 1020 |
+
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821
|
| 1021 |
+
self.expl_stmts = fail_inner
|
| 1022 |
+
# Check if the left operand is a ast.NamedExpr and the value has already been visited
|
| 1023 |
+
if (
|
| 1024 |
+
isinstance(v, ast.Compare)
|
| 1025 |
+
and isinstance(v.left, ast.NamedExpr)
|
| 1026 |
+
and v.left.target.id
|
| 1027 |
+
in [
|
| 1028 |
+
ast_expr.id
|
| 1029 |
+
for ast_expr in boolop.values[:i]
|
| 1030 |
+
if hasattr(ast_expr, "id")
|
| 1031 |
+
]
|
| 1032 |
+
):
|
| 1033 |
+
pytest_temp = self.variable()
|
| 1034 |
+
self.variables_overwrite[self.scope][v.left.target.id] = v.left # type:ignore[assignment]
|
| 1035 |
+
v.left.target.id = pytest_temp
|
| 1036 |
+
self.push_format_context()
|
| 1037 |
+
res, expl = self.visit(v)
|
| 1038 |
+
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
|
| 1039 |
+
expl_format = self.pop_format_context(ast.Constant(expl))
|
| 1040 |
+
call = ast.Call(app, [expl_format], [])
|
| 1041 |
+
self.expl_stmts.append(ast.Expr(call))
|
| 1042 |
+
if i < levels:
|
| 1043 |
+
cond: ast.expr = res
|
| 1044 |
+
if is_or:
|
| 1045 |
+
cond = ast.UnaryOp(ast.Not(), cond)
|
| 1046 |
+
inner: list[ast.stmt] = []
|
| 1047 |
+
self.statements.append(ast.If(cond, inner, []))
|
| 1048 |
+
self.statements = body = inner
|
| 1049 |
+
self.statements = save
|
| 1050 |
+
self.expl_stmts = fail_save
|
| 1051 |
+
expl_template = self.helper("_format_boolop", expl_list, ast.Constant(is_or))
|
| 1052 |
+
expl = self.pop_format_context(expl_template)
|
| 1053 |
+
return ast.Name(res_var, ast.Load()), self.explanation_param(expl)
|
| 1054 |
+
|
| 1055 |
+
def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]:
|
| 1056 |
+
pattern = UNARY_MAP[unary.op.__class__]
|
| 1057 |
+
operand_res, operand_expl = self.visit(unary.operand)
|
| 1058 |
+
res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary))
|
| 1059 |
+
return res, pattern % (operand_expl,)
|
| 1060 |
+
|
| 1061 |
+
def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]:
|
| 1062 |
+
symbol = BINOP_MAP[binop.op.__class__]
|
| 1063 |
+
left_expr, left_expl = self.visit(binop.left)
|
| 1064 |
+
right_expr, right_expl = self.visit(binop.right)
|
| 1065 |
+
explanation = f"({left_expl} {symbol} {right_expl})"
|
| 1066 |
+
res = self.assign(
|
| 1067 |
+
ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop)
|
| 1068 |
+
)
|
| 1069 |
+
return res, explanation
|
| 1070 |
+
|
| 1071 |
+
def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]:
|
| 1072 |
+
new_func, func_expl = self.visit(call.func)
|
| 1073 |
+
arg_expls = []
|
| 1074 |
+
new_args = []
|
| 1075 |
+
new_kwargs = []
|
| 1076 |
+
for arg in call.args:
|
| 1077 |
+
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get(
|
| 1078 |
+
self.scope, {}
|
| 1079 |
+
):
|
| 1080 |
+
arg = self.variables_overwrite[self.scope][arg.id] # type:ignore[assignment]
|
| 1081 |
+
res, expl = self.visit(arg)
|
| 1082 |
+
arg_expls.append(expl)
|
| 1083 |
+
new_args.append(res)
|
| 1084 |
+
for keyword in call.keywords:
|
| 1085 |
+
if isinstance(
|
| 1086 |
+
keyword.value, ast.Name
|
| 1087 |
+
) and keyword.value.id in self.variables_overwrite.get(self.scope, {}):
|
| 1088 |
+
keyword.value = self.variables_overwrite[self.scope][keyword.value.id] # type:ignore[assignment]
|
| 1089 |
+
res, expl = self.visit(keyword.value)
|
| 1090 |
+
new_kwargs.append(ast.keyword(keyword.arg, res))
|
| 1091 |
+
if keyword.arg:
|
| 1092 |
+
arg_expls.append(keyword.arg + "=" + expl)
|
| 1093 |
+
else: # **args have `arg` keywords with an .arg of None
|
| 1094 |
+
arg_expls.append("**" + expl)
|
| 1095 |
+
|
| 1096 |
+
expl = "{}({})".format(func_expl, ", ".join(arg_expls))
|
| 1097 |
+
new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call)
|
| 1098 |
+
res = self.assign(new_call)
|
| 1099 |
+
res_expl = self.explanation_param(self.display(res))
|
| 1100 |
+
outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}"
|
| 1101 |
+
return res, outer_expl
|
| 1102 |
+
|
| 1103 |
+
def visit_Starred(self, starred: ast.Starred) -> tuple[ast.Starred, str]:
|
| 1104 |
+
# A Starred node can appear in a function call.
|
| 1105 |
+
res, expl = self.visit(starred.value)
|
| 1106 |
+
new_starred = ast.Starred(res, starred.ctx)
|
| 1107 |
+
return new_starred, "*" + expl
|
| 1108 |
+
|
| 1109 |
+
def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]:
|
| 1110 |
+
if not isinstance(attr.ctx, ast.Load):
|
| 1111 |
+
return self.generic_visit(attr)
|
| 1112 |
+
value, value_expl = self.visit(attr.value)
|
| 1113 |
+
res = self.assign(
|
| 1114 |
+
ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr)
|
| 1115 |
+
)
|
| 1116 |
+
res_expl = self.explanation_param(self.display(res))
|
| 1117 |
+
pat = "%s\n{%s = %s.%s\n}"
|
| 1118 |
+
expl = pat % (res_expl, res_expl, value_expl, attr.attr)
|
| 1119 |
+
return res, expl
|
| 1120 |
+
|
| 1121 |
+
def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
|
| 1122 |
+
self.push_format_context()
|
| 1123 |
+
# We first check if we have overwritten a variable in the previous assert
|
| 1124 |
+
if isinstance(
|
| 1125 |
+
comp.left, ast.Name
|
| 1126 |
+
) and comp.left.id in self.variables_overwrite.get(self.scope, {}):
|
| 1127 |
+
comp.left = self.variables_overwrite[self.scope][comp.left.id] # type:ignore[assignment]
|
| 1128 |
+
if isinstance(comp.left, ast.NamedExpr):
|
| 1129 |
+
self.variables_overwrite[self.scope][comp.left.target.id] = comp.left # type:ignore[assignment]
|
| 1130 |
+
left_res, left_expl = self.visit(comp.left)
|
| 1131 |
+
if isinstance(comp.left, (ast.Compare, ast.BoolOp)):
|
| 1132 |
+
left_expl = f"({left_expl})"
|
| 1133 |
+
res_variables = [self.variable() for i in range(len(comp.ops))]
|
| 1134 |
+
load_names: list[ast.expr] = [ast.Name(v, ast.Load()) for v in res_variables]
|
| 1135 |
+
store_names = [ast.Name(v, ast.Store()) for v in res_variables]
|
| 1136 |
+
it = zip(range(len(comp.ops)), comp.ops, comp.comparators)
|
| 1137 |
+
expls: list[ast.expr] = []
|
| 1138 |
+
syms: list[ast.expr] = []
|
| 1139 |
+
results = [left_res]
|
| 1140 |
+
for i, op, next_operand in it:
|
| 1141 |
+
if (
|
| 1142 |
+
isinstance(next_operand, ast.NamedExpr)
|
| 1143 |
+
and isinstance(left_res, ast.Name)
|
| 1144 |
+
and next_operand.target.id == left_res.id
|
| 1145 |
+
):
|
| 1146 |
+
next_operand.target.id = self.variable()
|
| 1147 |
+
self.variables_overwrite[self.scope][left_res.id] = next_operand # type:ignore[assignment]
|
| 1148 |
+
next_res, next_expl = self.visit(next_operand)
|
| 1149 |
+
if isinstance(next_operand, (ast.Compare, ast.BoolOp)):
|
| 1150 |
+
next_expl = f"({next_expl})"
|
| 1151 |
+
results.append(next_res)
|
| 1152 |
+
sym = BINOP_MAP[op.__class__]
|
| 1153 |
+
syms.append(ast.Constant(sym))
|
| 1154 |
+
expl = f"{left_expl} {sym} {next_expl}"
|
| 1155 |
+
expls.append(ast.Constant(expl))
|
| 1156 |
+
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
|
| 1157 |
+
self.statements.append(ast.Assign([store_names[i]], res_expr))
|
| 1158 |
+
left_res, left_expl = next_res, next_expl
|
| 1159 |
+
# Use pytest.assertion.util._reprcompare if that's available.
|
| 1160 |
+
expl_call = self.helper(
|
| 1161 |
+
"_call_reprcompare",
|
| 1162 |
+
ast.Tuple(syms, ast.Load()),
|
| 1163 |
+
ast.Tuple(load_names, ast.Load()),
|
| 1164 |
+
ast.Tuple(expls, ast.Load()),
|
| 1165 |
+
ast.Tuple(results, ast.Load()),
|
| 1166 |
+
)
|
| 1167 |
+
if len(comp.ops) > 1:
|
| 1168 |
+
res: ast.expr = ast.BoolOp(ast.And(), load_names)
|
| 1169 |
+
else:
|
| 1170 |
+
res = load_names[0]
|
| 1171 |
+
|
| 1172 |
+
return res, self.explanation_param(self.pop_format_context(expl_call))
|
| 1173 |
+
|
| 1174 |
+
|
| 1175 |
+
def try_makedirs(cache_dir: Path) -> bool:
|
| 1176 |
+
"""Attempt to create the given directory and sub-directories exist.
|
| 1177 |
+
|
| 1178 |
+
Returns True if successful or if it already exists.
|
| 1179 |
+
"""
|
| 1180 |
+
try:
|
| 1181 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 1182 |
+
except (FileNotFoundError, NotADirectoryError, FileExistsError):
|
| 1183 |
+
# One of the path components was not a directory:
|
| 1184 |
+
# - we're in a zip file
|
| 1185 |
+
# - it is a file
|
| 1186 |
+
return False
|
| 1187 |
+
except PermissionError:
|
| 1188 |
+
return False
|
| 1189 |
+
except OSError as e:
|
| 1190 |
+
# as of now, EROFS doesn't have an equivalent OSError-subclass
|
| 1191 |
+
#
|
| 1192 |
+
# squashfuse_ll returns ENOSYS "OSError: [Errno 38] Function not
|
| 1193 |
+
# implemented" for a read-only error
|
| 1194 |
+
if e.errno in {errno.EROFS, errno.ENOSYS}:
|
| 1195 |
+
return False
|
| 1196 |
+
raise
|
| 1197 |
+
return True
|
| 1198 |
+
|
| 1199 |
+
|
| 1200 |
+
def get_cache_dir(file_path: Path) -> Path:
|
| 1201 |
+
"""Return the cache directory to write .pyc files for the given .py file path."""
|
| 1202 |
+
if sys.pycache_prefix:
|
| 1203 |
+
# given:
|
| 1204 |
+
# prefix = '/tmp/pycs'
|
| 1205 |
+
# path = '/home/user/proj/test_app.py'
|
| 1206 |
+
# we want:
|
| 1207 |
+
# '/tmp/pycs/home/user/proj'
|
| 1208 |
+
return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1])
|
| 1209 |
+
else:
|
| 1210 |
+
# classic pycache directory
|
| 1211 |
+
return file_path.parent / "__pycache__"
|
.venv/lib/python3.11/site-packages/_pytest/assertion/truncate.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Utilities for truncating assertion output.
|
| 2 |
+
|
| 3 |
+
Current default behaviour is to truncate assertion explanations at
|
| 4 |
+
terminal lines, unless running with an assertions verbosity level of at least 2 or running on CI.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from _pytest.assertion import util
|
| 10 |
+
from _pytest.config import Config
|
| 11 |
+
from _pytest.nodes import Item
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
DEFAULT_MAX_LINES = 8
|
| 15 |
+
DEFAULT_MAX_CHARS = 8 * 80
|
| 16 |
+
USAGE_MSG = "use '-vv' to show"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def truncate_if_required(
|
| 20 |
+
explanation: list[str], item: Item, max_length: int | None = None
|
| 21 |
+
) -> list[str]:
|
| 22 |
+
"""Truncate this assertion explanation if the given test item is eligible."""
|
| 23 |
+
if _should_truncate_item(item):
|
| 24 |
+
return _truncate_explanation(explanation)
|
| 25 |
+
return explanation
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _should_truncate_item(item: Item) -> bool:
|
| 29 |
+
"""Whether or not this test item is eligible for truncation."""
|
| 30 |
+
verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
|
| 31 |
+
return verbose < 2 and not util.running_on_ci()
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _truncate_explanation(
|
| 35 |
+
input_lines: list[str],
|
| 36 |
+
max_lines: int | None = None,
|
| 37 |
+
max_chars: int | None = None,
|
| 38 |
+
) -> list[str]:
|
| 39 |
+
"""Truncate given list of strings that makes up the assertion explanation.
|
| 40 |
+
|
| 41 |
+
Truncates to either 8 lines, or 640 characters - whichever the input reaches
|
| 42 |
+
first, taking the truncation explanation into account. The remaining lines
|
| 43 |
+
will be replaced by a usage message.
|
| 44 |
+
"""
|
| 45 |
+
if max_lines is None:
|
| 46 |
+
max_lines = DEFAULT_MAX_LINES
|
| 47 |
+
if max_chars is None:
|
| 48 |
+
max_chars = DEFAULT_MAX_CHARS
|
| 49 |
+
|
| 50 |
+
# Check if truncation required
|
| 51 |
+
input_char_count = len("".join(input_lines))
|
| 52 |
+
# The length of the truncation explanation depends on the number of lines
|
| 53 |
+
# removed but is at least 68 characters:
|
| 54 |
+
# The real value is
|
| 55 |
+
# 64 (for the base message:
|
| 56 |
+
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
|
| 57 |
+
# )
|
| 58 |
+
# + 1 (for plural)
|
| 59 |
+
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
|
| 60 |
+
# + 3 for the '...' added to the truncated line
|
| 61 |
+
# But if there's more than 100 lines it's very likely that we're going to
|
| 62 |
+
# truncate, so we don't need the exact value using log10.
|
| 63 |
+
tolerable_max_chars = (
|
| 64 |
+
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
|
| 65 |
+
)
|
| 66 |
+
# The truncation explanation add two lines to the output
|
| 67 |
+
tolerable_max_lines = max_lines + 2
|
| 68 |
+
if (
|
| 69 |
+
len(input_lines) <= tolerable_max_lines
|
| 70 |
+
and input_char_count <= tolerable_max_chars
|
| 71 |
+
):
|
| 72 |
+
return input_lines
|
| 73 |
+
# Truncate first to max_lines, and then truncate to max_chars if necessary
|
| 74 |
+
truncated_explanation = input_lines[:max_lines]
|
| 75 |
+
truncated_char = True
|
| 76 |
+
# We reevaluate the need to truncate chars following removal of some lines
|
| 77 |
+
if len("".join(truncated_explanation)) > tolerable_max_chars:
|
| 78 |
+
truncated_explanation = _truncate_by_char_count(
|
| 79 |
+
truncated_explanation, max_chars
|
| 80 |
+
)
|
| 81 |
+
else:
|
| 82 |
+
truncated_char = False
|
| 83 |
+
|
| 84 |
+
truncated_line_count = len(input_lines) - len(truncated_explanation)
|
| 85 |
+
if truncated_explanation[-1]:
|
| 86 |
+
# Add ellipsis and take into account part-truncated final line
|
| 87 |
+
truncated_explanation[-1] = truncated_explanation[-1] + "..."
|
| 88 |
+
if truncated_char:
|
| 89 |
+
# It's possible that we did not remove any char from this line
|
| 90 |
+
truncated_line_count += 1
|
| 91 |
+
else:
|
| 92 |
+
# Add proper ellipsis when we were able to fit a full line exactly
|
| 93 |
+
truncated_explanation[-1] = "..."
|
| 94 |
+
return [
|
| 95 |
+
*truncated_explanation,
|
| 96 |
+
"",
|
| 97 |
+
f"...Full output truncated ({truncated_line_count} line"
|
| 98 |
+
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
|
| 99 |
+
]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _truncate_by_char_count(input_lines: list[str], max_chars: int) -> list[str]:
|
| 103 |
+
# Find point at which input length exceeds total allowed length
|
| 104 |
+
iterated_char_count = 0
|
| 105 |
+
for iterated_index, input_line in enumerate(input_lines):
|
| 106 |
+
if iterated_char_count + len(input_line) > max_chars:
|
| 107 |
+
break
|
| 108 |
+
iterated_char_count += len(input_line)
|
| 109 |
+
|
| 110 |
+
# Create truncated explanation with modified final line
|
| 111 |
+
truncated_result = input_lines[:iterated_index]
|
| 112 |
+
final_line = input_lines[iterated_index]
|
| 113 |
+
if final_line:
|
| 114 |
+
final_line_truncate_point = max_chars - iterated_char_count
|
| 115 |
+
final_line = final_line[:final_line_truncate_point]
|
| 116 |
+
truncated_result.append(final_line)
|
| 117 |
+
return truncated_result
|
.venv/lib/python3.11/site-packages/_pytest/assertion/util.py
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Utilities for assertion debugging."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import collections.abc
|
| 7 |
+
import os
|
| 8 |
+
import pprint
|
| 9 |
+
from typing import AbstractSet
|
| 10 |
+
from typing import Any
|
| 11 |
+
from typing import Callable
|
| 12 |
+
from typing import Iterable
|
| 13 |
+
from typing import Literal
|
| 14 |
+
from typing import Mapping
|
| 15 |
+
from typing import Protocol
|
| 16 |
+
from typing import Sequence
|
| 17 |
+
from unicodedata import normalize
|
| 18 |
+
|
| 19 |
+
from _pytest import outcomes
|
| 20 |
+
import _pytest._code
|
| 21 |
+
from _pytest._io.pprint import PrettyPrinter
|
| 22 |
+
from _pytest._io.saferepr import saferepr
|
| 23 |
+
from _pytest._io.saferepr import saferepr_unlimited
|
| 24 |
+
from _pytest.config import Config
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
# The _reprcompare attribute on the util module is used by the new assertion
|
| 28 |
+
# interpretation code and assertion rewriter to detect this plugin was
|
| 29 |
+
# loaded and in turn call the hooks defined here as part of the
|
| 30 |
+
# DebugInterpreter.
|
| 31 |
+
_reprcompare: Callable[[str, object, object], str | None] | None = None
|
| 32 |
+
|
| 33 |
+
# Works similarly as _reprcompare attribute. Is populated with the hook call
|
| 34 |
+
# when pytest_runtest_setup is called.
|
| 35 |
+
_assertion_pass: Callable[[int, str, str], None] | None = None
|
| 36 |
+
|
| 37 |
+
# Config object which is assigned during pytest_runtest_protocol.
|
| 38 |
+
_config: Config | None = None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class _HighlightFunc(Protocol):
|
| 42 |
+
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
|
| 43 |
+
"""Apply highlighting to the given source."""
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def format_explanation(explanation: str) -> str:
|
| 47 |
+
r"""Format an explanation.
|
| 48 |
+
|
| 49 |
+
Normally all embedded newlines are escaped, however there are
|
| 50 |
+
three exceptions: \n{, \n} and \n~. The first two are intended
|
| 51 |
+
cover nested explanations, see function and attribute explanations
|
| 52 |
+
for examples (.visit_Call(), visit_Attribute()). The last one is
|
| 53 |
+
for when one explanation needs to span multiple lines, e.g. when
|
| 54 |
+
displaying diffs.
|
| 55 |
+
"""
|
| 56 |
+
lines = _split_explanation(explanation)
|
| 57 |
+
result = _format_lines(lines)
|
| 58 |
+
return "\n".join(result)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _split_explanation(explanation: str) -> list[str]:
|
| 62 |
+
r"""Return a list of individual lines in the explanation.
|
| 63 |
+
|
| 64 |
+
This will return a list of lines split on '\n{', '\n}' and '\n~'.
|
| 65 |
+
Any other newlines will be escaped and appear in the line as the
|
| 66 |
+
literal '\n' characters.
|
| 67 |
+
"""
|
| 68 |
+
raw_lines = (explanation or "").split("\n")
|
| 69 |
+
lines = [raw_lines[0]]
|
| 70 |
+
for values in raw_lines[1:]:
|
| 71 |
+
if values and values[0] in ["{", "}", "~", ">"]:
|
| 72 |
+
lines.append(values)
|
| 73 |
+
else:
|
| 74 |
+
lines[-1] += "\\n" + values
|
| 75 |
+
return lines
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _format_lines(lines: Sequence[str]) -> list[str]:
|
| 79 |
+
"""Format the individual lines.
|
| 80 |
+
|
| 81 |
+
This will replace the '{', '}' and '~' characters of our mini formatting
|
| 82 |
+
language with the proper 'where ...', 'and ...' and ' + ...' text, taking
|
| 83 |
+
care of indentation along the way.
|
| 84 |
+
|
| 85 |
+
Return a list of formatted lines.
|
| 86 |
+
"""
|
| 87 |
+
result = list(lines[:1])
|
| 88 |
+
stack = [0]
|
| 89 |
+
stackcnt = [0]
|
| 90 |
+
for line in lines[1:]:
|
| 91 |
+
if line.startswith("{"):
|
| 92 |
+
if stackcnt[-1]:
|
| 93 |
+
s = "and "
|
| 94 |
+
else:
|
| 95 |
+
s = "where "
|
| 96 |
+
stack.append(len(result))
|
| 97 |
+
stackcnt[-1] += 1
|
| 98 |
+
stackcnt.append(0)
|
| 99 |
+
result.append(" +" + " " * (len(stack) - 1) + s + line[1:])
|
| 100 |
+
elif line.startswith("}"):
|
| 101 |
+
stack.pop()
|
| 102 |
+
stackcnt.pop()
|
| 103 |
+
result[stack[-1]] += line[1:]
|
| 104 |
+
else:
|
| 105 |
+
assert line[0] in ["~", ">"]
|
| 106 |
+
stack[-1] += 1
|
| 107 |
+
indent = len(stack) if line.startswith("~") else len(stack) - 1
|
| 108 |
+
result.append(" " * indent + line[1:])
|
| 109 |
+
assert len(stack) == 1
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def issequence(x: Any) -> bool:
|
| 114 |
+
return isinstance(x, collections.abc.Sequence) and not isinstance(x, str)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def istext(x: Any) -> bool:
|
| 118 |
+
return isinstance(x, str)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def isdict(x: Any) -> bool:
|
| 122 |
+
return isinstance(x, dict)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def isset(x: Any) -> bool:
|
| 126 |
+
return isinstance(x, (set, frozenset))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def isnamedtuple(obj: Any) -> bool:
|
| 130 |
+
return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def isdatacls(obj: Any) -> bool:
|
| 134 |
+
return getattr(obj, "__dataclass_fields__", None) is not None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def isattrs(obj: Any) -> bool:
|
| 138 |
+
return getattr(obj, "__attrs_attrs__", None) is not None
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def isiterable(obj: Any) -> bool:
|
| 142 |
+
try:
|
| 143 |
+
iter(obj)
|
| 144 |
+
return not istext(obj)
|
| 145 |
+
except Exception:
|
| 146 |
+
return False
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def has_default_eq(
|
| 150 |
+
obj: object,
|
| 151 |
+
) -> bool:
|
| 152 |
+
"""Check if an instance of an object contains the default eq
|
| 153 |
+
|
| 154 |
+
First, we check if the object's __eq__ attribute has __code__,
|
| 155 |
+
if so, we check the equally of the method code filename (__code__.co_filename)
|
| 156 |
+
to the default one generated by the dataclass and attr module
|
| 157 |
+
for dataclasses the default co_filename is <string>, for attrs class, the __eq__ should contain "attrs eq generated"
|
| 158 |
+
"""
|
| 159 |
+
# inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68
|
| 160 |
+
if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"):
|
| 161 |
+
code_filename = obj.__eq__.__code__.co_filename
|
| 162 |
+
|
| 163 |
+
if isattrs(obj):
|
| 164 |
+
return "attrs generated eq" in code_filename
|
| 165 |
+
|
| 166 |
+
return code_filename == "<string>" # data class
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def assertrepr_compare(
|
| 171 |
+
config, op: str, left: Any, right: Any, use_ascii: bool = False
|
| 172 |
+
) -> list[str] | None:
|
| 173 |
+
"""Return specialised explanations for some operators/operands."""
|
| 174 |
+
verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
|
| 175 |
+
|
| 176 |
+
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
|
| 177 |
+
# See issue #3246.
|
| 178 |
+
use_ascii = (
|
| 179 |
+
isinstance(left, str)
|
| 180 |
+
and isinstance(right, str)
|
| 181 |
+
and normalize("NFD", left) == normalize("NFD", right)
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if verbose > 1:
|
| 185 |
+
left_repr = saferepr_unlimited(left, use_ascii=use_ascii)
|
| 186 |
+
right_repr = saferepr_unlimited(right, use_ascii=use_ascii)
|
| 187 |
+
else:
|
| 188 |
+
# XXX: "15 chars indentation" is wrong
|
| 189 |
+
# ("E AssertionError: assert "); should use term width.
|
| 190 |
+
maxsize = (
|
| 191 |
+
80 - 15 - len(op) - 2
|
| 192 |
+
) // 2 # 15 chars indentation, 1 space around op
|
| 193 |
+
|
| 194 |
+
left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii)
|
| 195 |
+
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
|
| 196 |
+
|
| 197 |
+
summary = f"{left_repr} {op} {right_repr}"
|
| 198 |
+
highlighter = config.get_terminal_writer()._highlight
|
| 199 |
+
|
| 200 |
+
explanation = None
|
| 201 |
+
try:
|
| 202 |
+
if op == "==":
|
| 203 |
+
explanation = _compare_eq_any(left, right, highlighter, verbose)
|
| 204 |
+
elif op == "not in":
|
| 205 |
+
if istext(left) and istext(right):
|
| 206 |
+
explanation = _notin_text(left, right, verbose)
|
| 207 |
+
elif op == "!=":
|
| 208 |
+
if isset(left) and isset(right):
|
| 209 |
+
explanation = ["Both sets are equal"]
|
| 210 |
+
elif op == ">=":
|
| 211 |
+
if isset(left) and isset(right):
|
| 212 |
+
explanation = _compare_gte_set(left, right, highlighter, verbose)
|
| 213 |
+
elif op == "<=":
|
| 214 |
+
if isset(left) and isset(right):
|
| 215 |
+
explanation = _compare_lte_set(left, right, highlighter, verbose)
|
| 216 |
+
elif op == ">":
|
| 217 |
+
if isset(left) and isset(right):
|
| 218 |
+
explanation = _compare_gt_set(left, right, highlighter, verbose)
|
| 219 |
+
elif op == "<":
|
| 220 |
+
if isset(left) and isset(right):
|
| 221 |
+
explanation = _compare_lt_set(left, right, highlighter, verbose)
|
| 222 |
+
|
| 223 |
+
except outcomes.Exit:
|
| 224 |
+
raise
|
| 225 |
+
except Exception:
|
| 226 |
+
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
|
| 227 |
+
explanation = [
|
| 228 |
+
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
|
| 229 |
+
" Probably an object has a faulty __repr__.)",
|
| 230 |
+
]
|
| 231 |
+
|
| 232 |
+
if not explanation:
|
| 233 |
+
return None
|
| 234 |
+
|
| 235 |
+
if explanation[0] != "":
|
| 236 |
+
explanation = ["", *explanation]
|
| 237 |
+
return [summary, *explanation]
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def _compare_eq_any(
|
| 241 |
+
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0
|
| 242 |
+
) -> list[str]:
|
| 243 |
+
explanation = []
|
| 244 |
+
if istext(left) and istext(right):
|
| 245 |
+
explanation = _diff_text(left, right, verbose)
|
| 246 |
+
else:
|
| 247 |
+
from _pytest.python_api import ApproxBase
|
| 248 |
+
|
| 249 |
+
if isinstance(left, ApproxBase) or isinstance(right, ApproxBase):
|
| 250 |
+
# Although the common order should be obtained == expected, this ensures both ways
|
| 251 |
+
approx_side = left if isinstance(left, ApproxBase) else right
|
| 252 |
+
other_side = right if isinstance(left, ApproxBase) else left
|
| 253 |
+
|
| 254 |
+
explanation = approx_side._repr_compare(other_side)
|
| 255 |
+
elif type(left) is type(right) and (
|
| 256 |
+
isdatacls(left) or isattrs(left) or isnamedtuple(left)
|
| 257 |
+
):
|
| 258 |
+
# Note: unlike dataclasses/attrs, namedtuples compare only the
|
| 259 |
+
# field values, not the type or field names. But this branch
|
| 260 |
+
# intentionally only handles the same-type case, which was often
|
| 261 |
+
# used in older code bases before dataclasses/attrs were available.
|
| 262 |
+
explanation = _compare_eq_cls(left, right, highlighter, verbose)
|
| 263 |
+
elif issequence(left) and issequence(right):
|
| 264 |
+
explanation = _compare_eq_sequence(left, right, highlighter, verbose)
|
| 265 |
+
elif isset(left) and isset(right):
|
| 266 |
+
explanation = _compare_eq_set(left, right, highlighter, verbose)
|
| 267 |
+
elif isdict(left) and isdict(right):
|
| 268 |
+
explanation = _compare_eq_dict(left, right, highlighter, verbose)
|
| 269 |
+
|
| 270 |
+
if isiterable(left) and isiterable(right):
|
| 271 |
+
expl = _compare_eq_iterable(left, right, highlighter, verbose)
|
| 272 |
+
explanation.extend(expl)
|
| 273 |
+
|
| 274 |
+
return explanation
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def _diff_text(left: str, right: str, verbose: int = 0) -> list[str]:
|
| 278 |
+
"""Return the explanation for the diff between text.
|
| 279 |
+
|
| 280 |
+
Unless --verbose is used this will skip leading and trailing
|
| 281 |
+
characters which are identical to keep the diff minimal.
|
| 282 |
+
"""
|
| 283 |
+
from difflib import ndiff
|
| 284 |
+
|
| 285 |
+
explanation: list[str] = []
|
| 286 |
+
|
| 287 |
+
if verbose < 1:
|
| 288 |
+
i = 0 # just in case left or right has zero length
|
| 289 |
+
for i in range(min(len(left), len(right))):
|
| 290 |
+
if left[i] != right[i]:
|
| 291 |
+
break
|
| 292 |
+
if i > 42:
|
| 293 |
+
i -= 10 # Provide some context
|
| 294 |
+
explanation = [
|
| 295 |
+
f"Skipping {i} identical leading characters in diff, use -v to show"
|
| 296 |
+
]
|
| 297 |
+
left = left[i:]
|
| 298 |
+
right = right[i:]
|
| 299 |
+
if len(left) == len(right):
|
| 300 |
+
for i in range(len(left)):
|
| 301 |
+
if left[-i] != right[-i]:
|
| 302 |
+
break
|
| 303 |
+
if i > 42:
|
| 304 |
+
i -= 10 # Provide some context
|
| 305 |
+
explanation += [
|
| 306 |
+
f"Skipping {i} identical trailing "
|
| 307 |
+
"characters in diff, use -v to show"
|
| 308 |
+
]
|
| 309 |
+
left = left[:-i]
|
| 310 |
+
right = right[:-i]
|
| 311 |
+
keepends = True
|
| 312 |
+
if left.isspace() or right.isspace():
|
| 313 |
+
left = repr(str(left))
|
| 314 |
+
right = repr(str(right))
|
| 315 |
+
explanation += ["Strings contain only whitespace, escaping them using repr()"]
|
| 316 |
+
# "right" is the expected base against which we compare "left",
|
| 317 |
+
# see https://github.com/pytest-dev/pytest/issues/3333
|
| 318 |
+
explanation += [
|
| 319 |
+
line.strip("\n")
|
| 320 |
+
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
|
| 321 |
+
]
|
| 322 |
+
return explanation
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _compare_eq_iterable(
|
| 326 |
+
left: Iterable[Any],
|
| 327 |
+
right: Iterable[Any],
|
| 328 |
+
highlighter: _HighlightFunc,
|
| 329 |
+
verbose: int = 0,
|
| 330 |
+
) -> list[str]:
|
| 331 |
+
if verbose <= 0 and not running_on_ci():
|
| 332 |
+
return ["Use -v to get more diff"]
|
| 333 |
+
# dynamic import to speedup pytest
|
| 334 |
+
import difflib
|
| 335 |
+
|
| 336 |
+
left_formatting = PrettyPrinter().pformat(left).splitlines()
|
| 337 |
+
right_formatting = PrettyPrinter().pformat(right).splitlines()
|
| 338 |
+
|
| 339 |
+
explanation = ["", "Full diff:"]
|
| 340 |
+
# "right" is the expected base against which we compare "left",
|
| 341 |
+
# see https://github.com/pytest-dev/pytest/issues/3333
|
| 342 |
+
explanation.extend(
|
| 343 |
+
highlighter(
|
| 344 |
+
"\n".join(
|
| 345 |
+
line.rstrip()
|
| 346 |
+
for line in difflib.ndiff(right_formatting, left_formatting)
|
| 347 |
+
),
|
| 348 |
+
lexer="diff",
|
| 349 |
+
).splitlines()
|
| 350 |
+
)
|
| 351 |
+
return explanation
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _compare_eq_sequence(
|
| 355 |
+
left: Sequence[Any],
|
| 356 |
+
right: Sequence[Any],
|
| 357 |
+
highlighter: _HighlightFunc,
|
| 358 |
+
verbose: int = 0,
|
| 359 |
+
) -> list[str]:
|
| 360 |
+
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
|
| 361 |
+
explanation: list[str] = []
|
| 362 |
+
len_left = len(left)
|
| 363 |
+
len_right = len(right)
|
| 364 |
+
for i in range(min(len_left, len_right)):
|
| 365 |
+
if left[i] != right[i]:
|
| 366 |
+
if comparing_bytes:
|
| 367 |
+
# when comparing bytes, we want to see their ascii representation
|
| 368 |
+
# instead of their numeric values (#5260)
|
| 369 |
+
# using a slice gives us the ascii representation:
|
| 370 |
+
# >>> s = b'foo'
|
| 371 |
+
# >>> s[0]
|
| 372 |
+
# 102
|
| 373 |
+
# >>> s[0:1]
|
| 374 |
+
# b'f'
|
| 375 |
+
left_value = left[i : i + 1]
|
| 376 |
+
right_value = right[i : i + 1]
|
| 377 |
+
else:
|
| 378 |
+
left_value = left[i]
|
| 379 |
+
right_value = right[i]
|
| 380 |
+
|
| 381 |
+
explanation.append(
|
| 382 |
+
f"At index {i} diff:"
|
| 383 |
+
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
|
| 384 |
+
)
|
| 385 |
+
break
|
| 386 |
+
|
| 387 |
+
if comparing_bytes:
|
| 388 |
+
# when comparing bytes, it doesn't help to show the "sides contain one or more
|
| 389 |
+
# items" longer explanation, so skip it
|
| 390 |
+
|
| 391 |
+
return explanation
|
| 392 |
+
|
| 393 |
+
len_diff = len_left - len_right
|
| 394 |
+
if len_diff:
|
| 395 |
+
if len_diff > 0:
|
| 396 |
+
dir_with_more = "Left"
|
| 397 |
+
extra = saferepr(left[len_right])
|
| 398 |
+
else:
|
| 399 |
+
len_diff = 0 - len_diff
|
| 400 |
+
dir_with_more = "Right"
|
| 401 |
+
extra = saferepr(right[len_left])
|
| 402 |
+
|
| 403 |
+
if len_diff == 1:
|
| 404 |
+
explanation += [
|
| 405 |
+
f"{dir_with_more} contains one more item: {highlighter(extra)}"
|
| 406 |
+
]
|
| 407 |
+
else:
|
| 408 |
+
explanation += [
|
| 409 |
+
"%s contains %d more items, first extra item: %s"
|
| 410 |
+
% (dir_with_more, len_diff, highlighter(extra))
|
| 411 |
+
]
|
| 412 |
+
return explanation
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def _compare_eq_set(
|
| 416 |
+
left: AbstractSet[Any],
|
| 417 |
+
right: AbstractSet[Any],
|
| 418 |
+
highlighter: _HighlightFunc,
|
| 419 |
+
verbose: int = 0,
|
| 420 |
+
) -> list[str]:
|
| 421 |
+
explanation = []
|
| 422 |
+
explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
|
| 423 |
+
explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
|
| 424 |
+
return explanation
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _compare_gt_set(
|
| 428 |
+
left: AbstractSet[Any],
|
| 429 |
+
right: AbstractSet[Any],
|
| 430 |
+
highlighter: _HighlightFunc,
|
| 431 |
+
verbose: int = 0,
|
| 432 |
+
) -> list[str]:
|
| 433 |
+
explanation = _compare_gte_set(left, right, highlighter)
|
| 434 |
+
if not explanation:
|
| 435 |
+
return ["Both sets are equal"]
|
| 436 |
+
return explanation
|
| 437 |
+
|
| 438 |
+
|
| 439 |
+
def _compare_lt_set(
|
| 440 |
+
left: AbstractSet[Any],
|
| 441 |
+
right: AbstractSet[Any],
|
| 442 |
+
highlighter: _HighlightFunc,
|
| 443 |
+
verbose: int = 0,
|
| 444 |
+
) -> list[str]:
|
| 445 |
+
explanation = _compare_lte_set(left, right, highlighter)
|
| 446 |
+
if not explanation:
|
| 447 |
+
return ["Both sets are equal"]
|
| 448 |
+
return explanation
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _compare_gte_set(
|
| 452 |
+
left: AbstractSet[Any],
|
| 453 |
+
right: AbstractSet[Any],
|
| 454 |
+
highlighter: _HighlightFunc,
|
| 455 |
+
verbose: int = 0,
|
| 456 |
+
) -> list[str]:
|
| 457 |
+
return _set_one_sided_diff("right", right, left, highlighter)
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def _compare_lte_set(
|
| 461 |
+
left: AbstractSet[Any],
|
| 462 |
+
right: AbstractSet[Any],
|
| 463 |
+
highlighter: _HighlightFunc,
|
| 464 |
+
verbose: int = 0,
|
| 465 |
+
) -> list[str]:
|
| 466 |
+
return _set_one_sided_diff("left", left, right, highlighter)
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
def _set_one_sided_diff(
|
| 470 |
+
posn: str,
|
| 471 |
+
set1: AbstractSet[Any],
|
| 472 |
+
set2: AbstractSet[Any],
|
| 473 |
+
highlighter: _HighlightFunc,
|
| 474 |
+
) -> list[str]:
|
| 475 |
+
explanation = []
|
| 476 |
+
diff = set1 - set2
|
| 477 |
+
if diff:
|
| 478 |
+
explanation.append(f"Extra items in the {posn} set:")
|
| 479 |
+
for item in diff:
|
| 480 |
+
explanation.append(highlighter(saferepr(item)))
|
| 481 |
+
return explanation
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def _compare_eq_dict(
|
| 485 |
+
left: Mapping[Any, Any],
|
| 486 |
+
right: Mapping[Any, Any],
|
| 487 |
+
highlighter: _HighlightFunc,
|
| 488 |
+
verbose: int = 0,
|
| 489 |
+
) -> list[str]:
|
| 490 |
+
explanation: list[str] = []
|
| 491 |
+
set_left = set(left)
|
| 492 |
+
set_right = set(right)
|
| 493 |
+
common = set_left.intersection(set_right)
|
| 494 |
+
same = {k: left[k] for k in common if left[k] == right[k]}
|
| 495 |
+
if same and verbose < 2:
|
| 496 |
+
explanation += [f"Omitting {len(same)} identical items, use -vv to show"]
|
| 497 |
+
elif same:
|
| 498 |
+
explanation += ["Common items:"]
|
| 499 |
+
explanation += highlighter(pprint.pformat(same)).splitlines()
|
| 500 |
+
diff = {k for k in common if left[k] != right[k]}
|
| 501 |
+
if diff:
|
| 502 |
+
explanation += ["Differing items:"]
|
| 503 |
+
for k in diff:
|
| 504 |
+
explanation += [
|
| 505 |
+
highlighter(saferepr({k: left[k]}))
|
| 506 |
+
+ " != "
|
| 507 |
+
+ highlighter(saferepr({k: right[k]}))
|
| 508 |
+
]
|
| 509 |
+
extra_left = set_left - set_right
|
| 510 |
+
len_extra_left = len(extra_left)
|
| 511 |
+
if len_extra_left:
|
| 512 |
+
explanation.append(
|
| 513 |
+
"Left contains %d more item%s:"
|
| 514 |
+
% (len_extra_left, "" if len_extra_left == 1 else "s")
|
| 515 |
+
)
|
| 516 |
+
explanation.extend(
|
| 517 |
+
highlighter(pprint.pformat({k: left[k] for k in extra_left})).splitlines()
|
| 518 |
+
)
|
| 519 |
+
extra_right = set_right - set_left
|
| 520 |
+
len_extra_right = len(extra_right)
|
| 521 |
+
if len_extra_right:
|
| 522 |
+
explanation.append(
|
| 523 |
+
"Right contains %d more item%s:"
|
| 524 |
+
% (len_extra_right, "" if len_extra_right == 1 else "s")
|
| 525 |
+
)
|
| 526 |
+
explanation.extend(
|
| 527 |
+
highlighter(pprint.pformat({k: right[k] for k in extra_right})).splitlines()
|
| 528 |
+
)
|
| 529 |
+
return explanation
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def _compare_eq_cls(
|
| 533 |
+
left: Any, right: Any, highlighter: _HighlightFunc, verbose: int
|
| 534 |
+
) -> list[str]:
|
| 535 |
+
if not has_default_eq(left):
|
| 536 |
+
return []
|
| 537 |
+
if isdatacls(left):
|
| 538 |
+
import dataclasses
|
| 539 |
+
|
| 540 |
+
all_fields = dataclasses.fields(left)
|
| 541 |
+
fields_to_check = [info.name for info in all_fields if info.compare]
|
| 542 |
+
elif isattrs(left):
|
| 543 |
+
all_fields = left.__attrs_attrs__
|
| 544 |
+
fields_to_check = [field.name for field in all_fields if getattr(field, "eq")]
|
| 545 |
+
elif isnamedtuple(left):
|
| 546 |
+
fields_to_check = left._fields
|
| 547 |
+
else:
|
| 548 |
+
assert False
|
| 549 |
+
|
| 550 |
+
indent = " "
|
| 551 |
+
same = []
|
| 552 |
+
diff = []
|
| 553 |
+
for field in fields_to_check:
|
| 554 |
+
if getattr(left, field) == getattr(right, field):
|
| 555 |
+
same.append(field)
|
| 556 |
+
else:
|
| 557 |
+
diff.append(field)
|
| 558 |
+
|
| 559 |
+
explanation = []
|
| 560 |
+
if same or diff:
|
| 561 |
+
explanation += [""]
|
| 562 |
+
if same and verbose < 2:
|
| 563 |
+
explanation.append(f"Omitting {len(same)} identical items, use -vv to show")
|
| 564 |
+
elif same:
|
| 565 |
+
explanation += ["Matching attributes:"]
|
| 566 |
+
explanation += highlighter(pprint.pformat(same)).splitlines()
|
| 567 |
+
if diff:
|
| 568 |
+
explanation += ["Differing attributes:"]
|
| 569 |
+
explanation += highlighter(pprint.pformat(diff)).splitlines()
|
| 570 |
+
for field in diff:
|
| 571 |
+
field_left = getattr(left, field)
|
| 572 |
+
field_right = getattr(right, field)
|
| 573 |
+
explanation += [
|
| 574 |
+
"",
|
| 575 |
+
f"Drill down into differing attribute {field}:",
|
| 576 |
+
f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}",
|
| 577 |
+
]
|
| 578 |
+
explanation += [
|
| 579 |
+
indent + line
|
| 580 |
+
for line in _compare_eq_any(
|
| 581 |
+
field_left, field_right, highlighter, verbose
|
| 582 |
+
)
|
| 583 |
+
]
|
| 584 |
+
return explanation
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
def _notin_text(term: str, text: str, verbose: int = 0) -> list[str]:
|
| 588 |
+
index = text.find(term)
|
| 589 |
+
head = text[:index]
|
| 590 |
+
tail = text[index + len(term) :]
|
| 591 |
+
correct_text = head + tail
|
| 592 |
+
diff = _diff_text(text, correct_text, verbose)
|
| 593 |
+
newdiff = [f"{saferepr(term, maxsize=42)} is contained here:"]
|
| 594 |
+
for line in diff:
|
| 595 |
+
if line.startswith("Skipping"):
|
| 596 |
+
continue
|
| 597 |
+
if line.startswith("- "):
|
| 598 |
+
continue
|
| 599 |
+
if line.startswith("+ "):
|
| 600 |
+
newdiff.append(" " + line[2:])
|
| 601 |
+
else:
|
| 602 |
+
newdiff.append(line)
|
| 603 |
+
return newdiff
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def running_on_ci() -> bool:
|
| 607 |
+
"""Check if we're currently running on a CI system."""
|
| 608 |
+
env_vars = ["CI", "BUILD_NUMBER"]
|
| 609 |
+
return any(var in os.environ for var in env_vars)
|
.venv/lib/python3.11/site-packages/_pytest/capture.py
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Per-test stdout/stderr capturing mechanism."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import abc
|
| 7 |
+
import collections
|
| 8 |
+
import contextlib
|
| 9 |
+
import io
|
| 10 |
+
from io import UnsupportedOperation
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
from tempfile import TemporaryFile
|
| 14 |
+
from types import TracebackType
|
| 15 |
+
from typing import Any
|
| 16 |
+
from typing import AnyStr
|
| 17 |
+
from typing import BinaryIO
|
| 18 |
+
from typing import Final
|
| 19 |
+
from typing import final
|
| 20 |
+
from typing import Generator
|
| 21 |
+
from typing import Generic
|
| 22 |
+
from typing import Iterable
|
| 23 |
+
from typing import Iterator
|
| 24 |
+
from typing import Literal
|
| 25 |
+
from typing import NamedTuple
|
| 26 |
+
from typing import TextIO
|
| 27 |
+
from typing import TYPE_CHECKING
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if TYPE_CHECKING:
|
| 31 |
+
from typing_extensions import Self
|
| 32 |
+
|
| 33 |
+
from _pytest.config import Config
|
| 34 |
+
from _pytest.config import hookimpl
|
| 35 |
+
from _pytest.config.argparsing import Parser
|
| 36 |
+
from _pytest.deprecated import check_ispytest
|
| 37 |
+
from _pytest.fixtures import fixture
|
| 38 |
+
from _pytest.fixtures import SubRequest
|
| 39 |
+
from _pytest.nodes import Collector
|
| 40 |
+
from _pytest.nodes import File
|
| 41 |
+
from _pytest.nodes import Item
|
| 42 |
+
from _pytest.reports import CollectReport
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 49 |
+
group = parser.getgroup("general")
|
| 50 |
+
group._addoption(
|
| 51 |
+
"--capture",
|
| 52 |
+
action="store",
|
| 53 |
+
default="fd",
|
| 54 |
+
metavar="method",
|
| 55 |
+
choices=["fd", "sys", "no", "tee-sys"],
|
| 56 |
+
help="Per-test capturing method: one of fd|sys|no|tee-sys",
|
| 57 |
+
)
|
| 58 |
+
group._addoption(
|
| 59 |
+
"-s",
|
| 60 |
+
action="store_const",
|
| 61 |
+
const="no",
|
| 62 |
+
dest="capture",
|
| 63 |
+
help="Shortcut for --capture=no",
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _colorama_workaround() -> None:
|
| 68 |
+
"""Ensure colorama is imported so that it attaches to the correct stdio
|
| 69 |
+
handles on Windows.
|
| 70 |
+
|
| 71 |
+
colorama uses the terminal on import time. So if something does the
|
| 72 |
+
first import of colorama while I/O capture is active, colorama will
|
| 73 |
+
fail in various ways.
|
| 74 |
+
"""
|
| 75 |
+
if sys.platform.startswith("win32"):
|
| 76 |
+
try:
|
| 77 |
+
import colorama # noqa: F401
|
| 78 |
+
except ImportError:
|
| 79 |
+
pass
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _windowsconsoleio_workaround(stream: TextIO) -> None:
|
| 83 |
+
"""Workaround for Windows Unicode console handling.
|
| 84 |
+
|
| 85 |
+
Python 3.6 implemented Unicode console handling for Windows. This works
|
| 86 |
+
by reading/writing to the raw console handle using
|
| 87 |
+
``{Read,Write}ConsoleW``.
|
| 88 |
+
|
| 89 |
+
The problem is that we are going to ``dup2`` over the stdio file
|
| 90 |
+
descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the
|
| 91 |
+
handles used by Python to write to the console. Though there is still some
|
| 92 |
+
weirdness and the console handle seems to only be closed randomly and not
|
| 93 |
+
on the first call to ``CloseHandle``, or maybe it gets reopened with the
|
| 94 |
+
same handle value when we suspend capturing.
|
| 95 |
+
|
| 96 |
+
The workaround in this case will reopen stdio with a different fd which
|
| 97 |
+
also means a different handle by replicating the logic in
|
| 98 |
+
"Py_lifecycle.c:initstdio/create_stdio".
|
| 99 |
+
|
| 100 |
+
:param stream:
|
| 101 |
+
In practice ``sys.stdout`` or ``sys.stderr``, but given
|
| 102 |
+
here as parameter for unittesting purposes.
|
| 103 |
+
|
| 104 |
+
See https://github.com/pytest-dev/py/issues/103.
|
| 105 |
+
"""
|
| 106 |
+
if not sys.platform.startswith("win32") or hasattr(sys, "pypy_version_info"):
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
# Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666).
|
| 110 |
+
if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore]
|
| 111 |
+
return
|
| 112 |
+
|
| 113 |
+
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
|
| 114 |
+
|
| 115 |
+
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
def _reopen_stdio(f, mode):
|
| 119 |
+
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
|
| 120 |
+
buffering = 0
|
| 121 |
+
else:
|
| 122 |
+
buffering = -1
|
| 123 |
+
|
| 124 |
+
return io.TextIOWrapper(
|
| 125 |
+
open(os.dup(f.fileno()), mode, buffering),
|
| 126 |
+
f.encoding,
|
| 127 |
+
f.errors,
|
| 128 |
+
f.newlines,
|
| 129 |
+
f.line_buffering,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
sys.stdin = _reopen_stdio(sys.stdin, "rb")
|
| 133 |
+
sys.stdout = _reopen_stdio(sys.stdout, "wb")
|
| 134 |
+
sys.stderr = _reopen_stdio(sys.stderr, "wb")
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@hookimpl(wrapper=True)
|
| 138 |
+
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
|
| 139 |
+
ns = early_config.known_args_namespace
|
| 140 |
+
if ns.capture == "fd":
|
| 141 |
+
_windowsconsoleio_workaround(sys.stdout)
|
| 142 |
+
_colorama_workaround()
|
| 143 |
+
pluginmanager = early_config.pluginmanager
|
| 144 |
+
capman = CaptureManager(ns.capture)
|
| 145 |
+
pluginmanager.register(capman, "capturemanager")
|
| 146 |
+
|
| 147 |
+
# Make sure that capturemanager is properly reset at final shutdown.
|
| 148 |
+
early_config.add_cleanup(capman.stop_global_capturing)
|
| 149 |
+
|
| 150 |
+
# Finally trigger conftest loading but while capturing (issue #93).
|
| 151 |
+
capman.start_global_capturing()
|
| 152 |
+
try:
|
| 153 |
+
try:
|
| 154 |
+
yield
|
| 155 |
+
finally:
|
| 156 |
+
capman.suspend_global_capture()
|
| 157 |
+
except BaseException:
|
| 158 |
+
out, err = capman.read_global_capture()
|
| 159 |
+
sys.stdout.write(out)
|
| 160 |
+
sys.stderr.write(err)
|
| 161 |
+
raise
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# IO Helpers.
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class EncodedFile(io.TextIOWrapper):
|
| 168 |
+
__slots__ = ()
|
| 169 |
+
|
| 170 |
+
@property
|
| 171 |
+
def name(self) -> str:
|
| 172 |
+
# Ensure that file.name is a string. Workaround for a Python bug
|
| 173 |
+
# fixed in >=3.7.4: https://bugs.python.org/issue36015
|
| 174 |
+
return repr(self.buffer)
|
| 175 |
+
|
| 176 |
+
@property
|
| 177 |
+
def mode(self) -> str:
|
| 178 |
+
# TextIOWrapper doesn't expose a mode, but at least some of our
|
| 179 |
+
# tests check it.
|
| 180 |
+
return self.buffer.mode.replace("b", "")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class CaptureIO(io.TextIOWrapper):
|
| 184 |
+
def __init__(self) -> None:
|
| 185 |
+
super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True)
|
| 186 |
+
|
| 187 |
+
def getvalue(self) -> str:
|
| 188 |
+
assert isinstance(self.buffer, io.BytesIO)
|
| 189 |
+
return self.buffer.getvalue().decode("UTF-8")
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
class TeeCaptureIO(CaptureIO):
|
| 193 |
+
def __init__(self, other: TextIO) -> None:
|
| 194 |
+
self._other = other
|
| 195 |
+
super().__init__()
|
| 196 |
+
|
| 197 |
+
def write(self, s: str) -> int:
|
| 198 |
+
super().write(s)
|
| 199 |
+
return self._other.write(s)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class DontReadFromInput(TextIO):
|
| 203 |
+
@property
|
| 204 |
+
def encoding(self) -> str:
|
| 205 |
+
assert sys.__stdin__ is not None
|
| 206 |
+
return sys.__stdin__.encoding
|
| 207 |
+
|
| 208 |
+
def read(self, size: int = -1) -> str:
|
| 209 |
+
raise OSError(
|
| 210 |
+
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
readline = read
|
| 214 |
+
|
| 215 |
+
def __next__(self) -> str:
|
| 216 |
+
return self.readline()
|
| 217 |
+
|
| 218 |
+
def readlines(self, hint: int | None = -1) -> list[str]:
|
| 219 |
+
raise OSError(
|
| 220 |
+
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
def __iter__(self) -> Iterator[str]:
|
| 224 |
+
return self
|
| 225 |
+
|
| 226 |
+
def fileno(self) -> int:
|
| 227 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()")
|
| 228 |
+
|
| 229 |
+
def flush(self) -> None:
|
| 230 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no flush()")
|
| 231 |
+
|
| 232 |
+
def isatty(self) -> bool:
|
| 233 |
+
return False
|
| 234 |
+
|
| 235 |
+
def close(self) -> None:
|
| 236 |
+
pass
|
| 237 |
+
|
| 238 |
+
def readable(self) -> bool:
|
| 239 |
+
return False
|
| 240 |
+
|
| 241 |
+
def seek(self, offset: int, whence: int = 0) -> int:
|
| 242 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no seek(int)")
|
| 243 |
+
|
| 244 |
+
def seekable(self) -> bool:
|
| 245 |
+
return False
|
| 246 |
+
|
| 247 |
+
def tell(self) -> int:
|
| 248 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
|
| 249 |
+
|
| 250 |
+
def truncate(self, size: int | None = None) -> int:
|
| 251 |
+
raise UnsupportedOperation("cannot truncate stdin")
|
| 252 |
+
|
| 253 |
+
def write(self, data: str) -> int:
|
| 254 |
+
raise UnsupportedOperation("cannot write to stdin")
|
| 255 |
+
|
| 256 |
+
def writelines(self, lines: Iterable[str]) -> None:
|
| 257 |
+
raise UnsupportedOperation("Cannot write to stdin")
|
| 258 |
+
|
| 259 |
+
def writable(self) -> bool:
|
| 260 |
+
return False
|
| 261 |
+
|
| 262 |
+
def __enter__(self) -> Self:
|
| 263 |
+
return self
|
| 264 |
+
|
| 265 |
+
def __exit__(
|
| 266 |
+
self,
|
| 267 |
+
type: type[BaseException] | None,
|
| 268 |
+
value: BaseException | None,
|
| 269 |
+
traceback: TracebackType | None,
|
| 270 |
+
) -> None:
|
| 271 |
+
pass
|
| 272 |
+
|
| 273 |
+
@property
|
| 274 |
+
def buffer(self) -> BinaryIO:
|
| 275 |
+
# The str/bytes doesn't actually matter in this type, so OK to fake.
|
| 276 |
+
return self # type: ignore[return-value]
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# Capture classes.
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class CaptureBase(abc.ABC, Generic[AnyStr]):
|
| 283 |
+
EMPTY_BUFFER: AnyStr
|
| 284 |
+
|
| 285 |
+
@abc.abstractmethod
|
| 286 |
+
def __init__(self, fd: int) -> None:
|
| 287 |
+
raise NotImplementedError()
|
| 288 |
+
|
| 289 |
+
@abc.abstractmethod
|
| 290 |
+
def start(self) -> None:
|
| 291 |
+
raise NotImplementedError()
|
| 292 |
+
|
| 293 |
+
@abc.abstractmethod
|
| 294 |
+
def done(self) -> None:
|
| 295 |
+
raise NotImplementedError()
|
| 296 |
+
|
| 297 |
+
@abc.abstractmethod
|
| 298 |
+
def suspend(self) -> None:
|
| 299 |
+
raise NotImplementedError()
|
| 300 |
+
|
| 301 |
+
@abc.abstractmethod
|
| 302 |
+
def resume(self) -> None:
|
| 303 |
+
raise NotImplementedError()
|
| 304 |
+
|
| 305 |
+
@abc.abstractmethod
|
| 306 |
+
def writeorg(self, data: AnyStr) -> None:
|
| 307 |
+
raise NotImplementedError()
|
| 308 |
+
|
| 309 |
+
@abc.abstractmethod
|
| 310 |
+
def snap(self) -> AnyStr:
|
| 311 |
+
raise NotImplementedError()
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class NoCapture(CaptureBase[str]):
|
| 318 |
+
EMPTY_BUFFER = ""
|
| 319 |
+
|
| 320 |
+
def __init__(self, fd: int) -> None:
|
| 321 |
+
pass
|
| 322 |
+
|
| 323 |
+
def start(self) -> None:
|
| 324 |
+
pass
|
| 325 |
+
|
| 326 |
+
def done(self) -> None:
|
| 327 |
+
pass
|
| 328 |
+
|
| 329 |
+
def suspend(self) -> None:
|
| 330 |
+
pass
|
| 331 |
+
|
| 332 |
+
def resume(self) -> None:
|
| 333 |
+
pass
|
| 334 |
+
|
| 335 |
+
def snap(self) -> str:
|
| 336 |
+
return ""
|
| 337 |
+
|
| 338 |
+
def writeorg(self, data: str) -> None:
|
| 339 |
+
pass
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
class SysCaptureBase(CaptureBase[AnyStr]):
|
| 343 |
+
def __init__(
|
| 344 |
+
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
|
| 345 |
+
) -> None:
|
| 346 |
+
name = patchsysdict[fd]
|
| 347 |
+
self._old: TextIO = getattr(sys, name)
|
| 348 |
+
self.name = name
|
| 349 |
+
if tmpfile is None:
|
| 350 |
+
if name == "stdin":
|
| 351 |
+
tmpfile = DontReadFromInput()
|
| 352 |
+
else:
|
| 353 |
+
tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old)
|
| 354 |
+
self.tmpfile = tmpfile
|
| 355 |
+
self._state = "initialized"
|
| 356 |
+
|
| 357 |
+
def repr(self, class_name: str) -> str:
|
| 358 |
+
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
| 359 |
+
class_name,
|
| 360 |
+
self.name,
|
| 361 |
+
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
|
| 362 |
+
self._state,
|
| 363 |
+
self.tmpfile,
|
| 364 |
+
)
|
| 365 |
+
|
| 366 |
+
def __repr__(self) -> str:
|
| 367 |
+
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
| 368 |
+
self.__class__.__name__,
|
| 369 |
+
self.name,
|
| 370 |
+
hasattr(self, "_old") and repr(self._old) or "<UNSET>",
|
| 371 |
+
self._state,
|
| 372 |
+
self.tmpfile,
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
| 376 |
+
assert (
|
| 377 |
+
self._state in states
|
| 378 |
+
), "cannot {} in state {!r}: expected one of {}".format(
|
| 379 |
+
op, self._state, ", ".join(states)
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
def start(self) -> None:
|
| 383 |
+
self._assert_state("start", ("initialized",))
|
| 384 |
+
setattr(sys, self.name, self.tmpfile)
|
| 385 |
+
self._state = "started"
|
| 386 |
+
|
| 387 |
+
def done(self) -> None:
|
| 388 |
+
self._assert_state("done", ("initialized", "started", "suspended", "done"))
|
| 389 |
+
if self._state == "done":
|
| 390 |
+
return
|
| 391 |
+
setattr(sys, self.name, self._old)
|
| 392 |
+
del self._old
|
| 393 |
+
self.tmpfile.close()
|
| 394 |
+
self._state = "done"
|
| 395 |
+
|
| 396 |
+
def suspend(self) -> None:
|
| 397 |
+
self._assert_state("suspend", ("started", "suspended"))
|
| 398 |
+
setattr(sys, self.name, self._old)
|
| 399 |
+
self._state = "suspended"
|
| 400 |
+
|
| 401 |
+
def resume(self) -> None:
|
| 402 |
+
self._assert_state("resume", ("started", "suspended"))
|
| 403 |
+
if self._state == "started":
|
| 404 |
+
return
|
| 405 |
+
setattr(sys, self.name, self.tmpfile)
|
| 406 |
+
self._state = "started"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
class SysCaptureBinary(SysCaptureBase[bytes]):
|
| 410 |
+
EMPTY_BUFFER = b""
|
| 411 |
+
|
| 412 |
+
def snap(self) -> bytes:
|
| 413 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 414 |
+
self.tmpfile.seek(0)
|
| 415 |
+
res = self.tmpfile.buffer.read()
|
| 416 |
+
self.tmpfile.seek(0)
|
| 417 |
+
self.tmpfile.truncate()
|
| 418 |
+
return res
|
| 419 |
+
|
| 420 |
+
def writeorg(self, data: bytes) -> None:
|
| 421 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 422 |
+
self._old.flush()
|
| 423 |
+
self._old.buffer.write(data)
|
| 424 |
+
self._old.buffer.flush()
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
class SysCapture(SysCaptureBase[str]):
|
| 428 |
+
EMPTY_BUFFER = ""
|
| 429 |
+
|
| 430 |
+
def snap(self) -> str:
|
| 431 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 432 |
+
assert isinstance(self.tmpfile, CaptureIO)
|
| 433 |
+
res = self.tmpfile.getvalue()
|
| 434 |
+
self.tmpfile.seek(0)
|
| 435 |
+
self.tmpfile.truncate()
|
| 436 |
+
return res
|
| 437 |
+
|
| 438 |
+
def writeorg(self, data: str) -> None:
|
| 439 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 440 |
+
self._old.write(data)
|
| 441 |
+
self._old.flush()
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
class FDCaptureBase(CaptureBase[AnyStr]):
|
| 445 |
+
def __init__(self, targetfd: int) -> None:
|
| 446 |
+
self.targetfd = targetfd
|
| 447 |
+
|
| 448 |
+
try:
|
| 449 |
+
os.fstat(targetfd)
|
| 450 |
+
except OSError:
|
| 451 |
+
# FD capturing is conceptually simple -- create a temporary file,
|
| 452 |
+
# redirect the FD to it, redirect back when done. But when the
|
| 453 |
+
# target FD is invalid it throws a wrench into this lovely scheme.
|
| 454 |
+
#
|
| 455 |
+
# Tests themselves shouldn't care if the FD is valid, FD capturing
|
| 456 |
+
# should work regardless of external circumstances. So falling back
|
| 457 |
+
# to just sys capturing is not a good option.
|
| 458 |
+
#
|
| 459 |
+
# Further complications are the need to support suspend() and the
|
| 460 |
+
# possibility of FD reuse (e.g. the tmpfile getting the very same
|
| 461 |
+
# target FD). The following approach is robust, I believe.
|
| 462 |
+
self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR)
|
| 463 |
+
os.dup2(self.targetfd_invalid, targetfd)
|
| 464 |
+
else:
|
| 465 |
+
self.targetfd_invalid = None
|
| 466 |
+
self.targetfd_save = os.dup(targetfd)
|
| 467 |
+
|
| 468 |
+
if targetfd == 0:
|
| 469 |
+
self.tmpfile = open(os.devnull, encoding="utf-8")
|
| 470 |
+
self.syscapture: CaptureBase[str] = SysCapture(targetfd)
|
| 471 |
+
else:
|
| 472 |
+
self.tmpfile = EncodedFile(
|
| 473 |
+
TemporaryFile(buffering=0),
|
| 474 |
+
encoding="utf-8",
|
| 475 |
+
errors="replace",
|
| 476 |
+
newline="",
|
| 477 |
+
write_through=True,
|
| 478 |
+
)
|
| 479 |
+
if targetfd in patchsysdict:
|
| 480 |
+
self.syscapture = SysCapture(targetfd, self.tmpfile)
|
| 481 |
+
else:
|
| 482 |
+
self.syscapture = NoCapture(targetfd)
|
| 483 |
+
|
| 484 |
+
self._state = "initialized"
|
| 485 |
+
|
| 486 |
+
def __repr__(self) -> str:
|
| 487 |
+
return (
|
| 488 |
+
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
|
| 489 |
+
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
| 493 |
+
assert (
|
| 494 |
+
self._state in states
|
| 495 |
+
), "cannot {} in state {!r}: expected one of {}".format(
|
| 496 |
+
op, self._state, ", ".join(states)
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
def start(self) -> None:
|
| 500 |
+
"""Start capturing on targetfd using memorized tmpfile."""
|
| 501 |
+
self._assert_state("start", ("initialized",))
|
| 502 |
+
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
| 503 |
+
self.syscapture.start()
|
| 504 |
+
self._state = "started"
|
| 505 |
+
|
| 506 |
+
def done(self) -> None:
|
| 507 |
+
"""Stop capturing, restore streams, return original capture file,
|
| 508 |
+
seeked to position zero."""
|
| 509 |
+
self._assert_state("done", ("initialized", "started", "suspended", "done"))
|
| 510 |
+
if self._state == "done":
|
| 511 |
+
return
|
| 512 |
+
os.dup2(self.targetfd_save, self.targetfd)
|
| 513 |
+
os.close(self.targetfd_save)
|
| 514 |
+
if self.targetfd_invalid is not None:
|
| 515 |
+
if self.targetfd_invalid != self.targetfd:
|
| 516 |
+
os.close(self.targetfd)
|
| 517 |
+
os.close(self.targetfd_invalid)
|
| 518 |
+
self.syscapture.done()
|
| 519 |
+
self.tmpfile.close()
|
| 520 |
+
self._state = "done"
|
| 521 |
+
|
| 522 |
+
def suspend(self) -> None:
|
| 523 |
+
self._assert_state("suspend", ("started", "suspended"))
|
| 524 |
+
if self._state == "suspended":
|
| 525 |
+
return
|
| 526 |
+
self.syscapture.suspend()
|
| 527 |
+
os.dup2(self.targetfd_save, self.targetfd)
|
| 528 |
+
self._state = "suspended"
|
| 529 |
+
|
| 530 |
+
def resume(self) -> None:
|
| 531 |
+
self._assert_state("resume", ("started", "suspended"))
|
| 532 |
+
if self._state == "started":
|
| 533 |
+
return
|
| 534 |
+
self.syscapture.resume()
|
| 535 |
+
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
| 536 |
+
self._state = "started"
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
class FDCaptureBinary(FDCaptureBase[bytes]):
|
| 540 |
+
"""Capture IO to/from a given OS-level file descriptor.
|
| 541 |
+
|
| 542 |
+
snap() produces `bytes`.
|
| 543 |
+
"""
|
| 544 |
+
|
| 545 |
+
EMPTY_BUFFER = b""
|
| 546 |
+
|
| 547 |
+
def snap(self) -> bytes:
|
| 548 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 549 |
+
self.tmpfile.seek(0)
|
| 550 |
+
res = self.tmpfile.buffer.read()
|
| 551 |
+
self.tmpfile.seek(0)
|
| 552 |
+
self.tmpfile.truncate()
|
| 553 |
+
return res
|
| 554 |
+
|
| 555 |
+
def writeorg(self, data: bytes) -> None:
|
| 556 |
+
"""Write to original file descriptor."""
|
| 557 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 558 |
+
os.write(self.targetfd_save, data)
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
class FDCapture(FDCaptureBase[str]):
|
| 562 |
+
"""Capture IO to/from a given OS-level file descriptor.
|
| 563 |
+
|
| 564 |
+
snap() produces text.
|
| 565 |
+
"""
|
| 566 |
+
|
| 567 |
+
EMPTY_BUFFER = ""
|
| 568 |
+
|
| 569 |
+
def snap(self) -> str:
|
| 570 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 571 |
+
self.tmpfile.seek(0)
|
| 572 |
+
res = self.tmpfile.read()
|
| 573 |
+
self.tmpfile.seek(0)
|
| 574 |
+
self.tmpfile.truncate()
|
| 575 |
+
return res
|
| 576 |
+
|
| 577 |
+
def writeorg(self, data: str) -> None:
|
| 578 |
+
"""Write to original file descriptor."""
|
| 579 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 580 |
+
# XXX use encoding of original stream
|
| 581 |
+
os.write(self.targetfd_save, data.encode("utf-8"))
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
# MultiCapture
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
# Generic NamedTuple only supported since Python 3.11.
|
| 588 |
+
if sys.version_info >= (3, 11) or TYPE_CHECKING:
|
| 589 |
+
|
| 590 |
+
@final
|
| 591 |
+
class CaptureResult(NamedTuple, Generic[AnyStr]):
|
| 592 |
+
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
|
| 593 |
+
|
| 594 |
+
out: AnyStr
|
| 595 |
+
err: AnyStr
|
| 596 |
+
|
| 597 |
+
else:
|
| 598 |
+
|
| 599 |
+
class CaptureResult(
|
| 600 |
+
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
|
| 601 |
+
Generic[AnyStr],
|
| 602 |
+
):
|
| 603 |
+
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
|
| 604 |
+
|
| 605 |
+
__slots__ = ()
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
class MultiCapture(Generic[AnyStr]):
|
| 609 |
+
_state = None
|
| 610 |
+
_in_suspended = False
|
| 611 |
+
|
| 612 |
+
def __init__(
|
| 613 |
+
self,
|
| 614 |
+
in_: CaptureBase[AnyStr] | None,
|
| 615 |
+
out: CaptureBase[AnyStr] | None,
|
| 616 |
+
err: CaptureBase[AnyStr] | None,
|
| 617 |
+
) -> None:
|
| 618 |
+
self.in_: CaptureBase[AnyStr] | None = in_
|
| 619 |
+
self.out: CaptureBase[AnyStr] | None = out
|
| 620 |
+
self.err: CaptureBase[AnyStr] | None = err
|
| 621 |
+
|
| 622 |
+
def __repr__(self) -> str:
|
| 623 |
+
return (
|
| 624 |
+
f"<MultiCapture out={self.out!r} err={self.err!r} in_={self.in_!r} "
|
| 625 |
+
f"_state={self._state!r} _in_suspended={self._in_suspended!r}>"
|
| 626 |
+
)
|
| 627 |
+
|
| 628 |
+
def start_capturing(self) -> None:
|
| 629 |
+
self._state = "started"
|
| 630 |
+
if self.in_:
|
| 631 |
+
self.in_.start()
|
| 632 |
+
if self.out:
|
| 633 |
+
self.out.start()
|
| 634 |
+
if self.err:
|
| 635 |
+
self.err.start()
|
| 636 |
+
|
| 637 |
+
def pop_outerr_to_orig(self) -> tuple[AnyStr, AnyStr]:
|
| 638 |
+
"""Pop current snapshot out/err capture and flush to orig streams."""
|
| 639 |
+
out, err = self.readouterr()
|
| 640 |
+
if out:
|
| 641 |
+
assert self.out is not None
|
| 642 |
+
self.out.writeorg(out)
|
| 643 |
+
if err:
|
| 644 |
+
assert self.err is not None
|
| 645 |
+
self.err.writeorg(err)
|
| 646 |
+
return out, err
|
| 647 |
+
|
| 648 |
+
def suspend_capturing(self, in_: bool = False) -> None:
|
| 649 |
+
self._state = "suspended"
|
| 650 |
+
if self.out:
|
| 651 |
+
self.out.suspend()
|
| 652 |
+
if self.err:
|
| 653 |
+
self.err.suspend()
|
| 654 |
+
if in_ and self.in_:
|
| 655 |
+
self.in_.suspend()
|
| 656 |
+
self._in_suspended = True
|
| 657 |
+
|
| 658 |
+
def resume_capturing(self) -> None:
|
| 659 |
+
self._state = "started"
|
| 660 |
+
if self.out:
|
| 661 |
+
self.out.resume()
|
| 662 |
+
if self.err:
|
| 663 |
+
self.err.resume()
|
| 664 |
+
if self._in_suspended:
|
| 665 |
+
assert self.in_ is not None
|
| 666 |
+
self.in_.resume()
|
| 667 |
+
self._in_suspended = False
|
| 668 |
+
|
| 669 |
+
def stop_capturing(self) -> None:
|
| 670 |
+
"""Stop capturing and reset capturing streams."""
|
| 671 |
+
if self._state == "stopped":
|
| 672 |
+
raise ValueError("was already stopped")
|
| 673 |
+
self._state = "stopped"
|
| 674 |
+
if self.out:
|
| 675 |
+
self.out.done()
|
| 676 |
+
if self.err:
|
| 677 |
+
self.err.done()
|
| 678 |
+
if self.in_:
|
| 679 |
+
self.in_.done()
|
| 680 |
+
|
| 681 |
+
def is_started(self) -> bool:
|
| 682 |
+
"""Whether actively capturing -- not suspended or stopped."""
|
| 683 |
+
return self._state == "started"
|
| 684 |
+
|
| 685 |
+
def readouterr(self) -> CaptureResult[AnyStr]:
|
| 686 |
+
out = self.out.snap() if self.out else ""
|
| 687 |
+
err = self.err.snap() if self.err else ""
|
| 688 |
+
# TODO: This type error is real, need to fix.
|
| 689 |
+
return CaptureResult(out, err) # type: ignore[arg-type]
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def _get_multicapture(method: _CaptureMethod) -> MultiCapture[str]:
|
| 693 |
+
if method == "fd":
|
| 694 |
+
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
|
| 695 |
+
elif method == "sys":
|
| 696 |
+
return MultiCapture(in_=SysCapture(0), out=SysCapture(1), err=SysCapture(2))
|
| 697 |
+
elif method == "no":
|
| 698 |
+
return MultiCapture(in_=None, out=None, err=None)
|
| 699 |
+
elif method == "tee-sys":
|
| 700 |
+
return MultiCapture(
|
| 701 |
+
in_=None, out=SysCapture(1, tee=True), err=SysCapture(2, tee=True)
|
| 702 |
+
)
|
| 703 |
+
raise ValueError(f"unknown capturing method: {method!r}")
|
| 704 |
+
|
| 705 |
+
|
| 706 |
+
# CaptureManager and CaptureFixture
|
| 707 |
+
|
| 708 |
+
|
| 709 |
+
class CaptureManager:
|
| 710 |
+
"""The capture plugin.
|
| 711 |
+
|
| 712 |
+
Manages that the appropriate capture method is enabled/disabled during
|
| 713 |
+
collection and each test phase (setup, call, teardown). After each of
|
| 714 |
+
those points, the captured output is obtained and attached to the
|
| 715 |
+
collection/runtest report.
|
| 716 |
+
|
| 717 |
+
There are two levels of capture:
|
| 718 |
+
|
| 719 |
+
* global: enabled by default and can be suppressed by the ``-s``
|
| 720 |
+
option. This is always enabled/disabled during collection and each test
|
| 721 |
+
phase.
|
| 722 |
+
|
| 723 |
+
* fixture: when a test function or one of its fixture depend on the
|
| 724 |
+
``capsys`` or ``capfd`` fixtures. In this case special handling is
|
| 725 |
+
needed to ensure the fixtures take precedence over the global capture.
|
| 726 |
+
"""
|
| 727 |
+
|
| 728 |
+
def __init__(self, method: _CaptureMethod) -> None:
|
| 729 |
+
self._method: Final = method
|
| 730 |
+
self._global_capturing: MultiCapture[str] | None = None
|
| 731 |
+
self._capture_fixture: CaptureFixture[Any] | None = None
|
| 732 |
+
|
| 733 |
+
def __repr__(self) -> str:
|
| 734 |
+
return (
|
| 735 |
+
f"<CaptureManager _method={self._method!r} _global_capturing={self._global_capturing!r} "
|
| 736 |
+
f"_capture_fixture={self._capture_fixture!r}>"
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
def is_capturing(self) -> str | bool:
|
| 740 |
+
if self.is_globally_capturing():
|
| 741 |
+
return "global"
|
| 742 |
+
if self._capture_fixture:
|
| 743 |
+
return f"fixture {self._capture_fixture.request.fixturename}"
|
| 744 |
+
return False
|
| 745 |
+
|
| 746 |
+
# Global capturing control
|
| 747 |
+
|
| 748 |
+
def is_globally_capturing(self) -> bool:
|
| 749 |
+
return self._method != "no"
|
| 750 |
+
|
| 751 |
+
def start_global_capturing(self) -> None:
|
| 752 |
+
assert self._global_capturing is None
|
| 753 |
+
self._global_capturing = _get_multicapture(self._method)
|
| 754 |
+
self._global_capturing.start_capturing()
|
| 755 |
+
|
| 756 |
+
def stop_global_capturing(self) -> None:
|
| 757 |
+
if self._global_capturing is not None:
|
| 758 |
+
self._global_capturing.pop_outerr_to_orig()
|
| 759 |
+
self._global_capturing.stop_capturing()
|
| 760 |
+
self._global_capturing = None
|
| 761 |
+
|
| 762 |
+
def resume_global_capture(self) -> None:
|
| 763 |
+
# During teardown of the python process, and on rare occasions, capture
|
| 764 |
+
# attributes can be `None` while trying to resume global capture.
|
| 765 |
+
if self._global_capturing is not None:
|
| 766 |
+
self._global_capturing.resume_capturing()
|
| 767 |
+
|
| 768 |
+
def suspend_global_capture(self, in_: bool = False) -> None:
|
| 769 |
+
if self._global_capturing is not None:
|
| 770 |
+
self._global_capturing.suspend_capturing(in_=in_)
|
| 771 |
+
|
| 772 |
+
def suspend(self, in_: bool = False) -> None:
|
| 773 |
+
# Need to undo local capsys-et-al if it exists before disabling global capture.
|
| 774 |
+
self.suspend_fixture()
|
| 775 |
+
self.suspend_global_capture(in_)
|
| 776 |
+
|
| 777 |
+
def resume(self) -> None:
|
| 778 |
+
self.resume_global_capture()
|
| 779 |
+
self.resume_fixture()
|
| 780 |
+
|
| 781 |
+
def read_global_capture(self) -> CaptureResult[str]:
|
| 782 |
+
assert self._global_capturing is not None
|
| 783 |
+
return self._global_capturing.readouterr()
|
| 784 |
+
|
| 785 |
+
# Fixture Control
|
| 786 |
+
|
| 787 |
+
def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None:
|
| 788 |
+
if self._capture_fixture:
|
| 789 |
+
current_fixture = self._capture_fixture.request.fixturename
|
| 790 |
+
requested_fixture = capture_fixture.request.fixturename
|
| 791 |
+
capture_fixture.request.raiseerror(
|
| 792 |
+
f"cannot use {requested_fixture} and {current_fixture} at the same time"
|
| 793 |
+
)
|
| 794 |
+
self._capture_fixture = capture_fixture
|
| 795 |
+
|
| 796 |
+
def unset_fixture(self) -> None:
|
| 797 |
+
self._capture_fixture = None
|
| 798 |
+
|
| 799 |
+
def activate_fixture(self) -> None:
|
| 800 |
+
"""If the current item is using ``capsys`` or ``capfd``, activate
|
| 801 |
+
them so they take precedence over the global capture."""
|
| 802 |
+
if self._capture_fixture:
|
| 803 |
+
self._capture_fixture._start()
|
| 804 |
+
|
| 805 |
+
def deactivate_fixture(self) -> None:
|
| 806 |
+
"""Deactivate the ``capsys`` or ``capfd`` fixture of this item, if any."""
|
| 807 |
+
if self._capture_fixture:
|
| 808 |
+
self._capture_fixture.close()
|
| 809 |
+
|
| 810 |
+
def suspend_fixture(self) -> None:
|
| 811 |
+
if self._capture_fixture:
|
| 812 |
+
self._capture_fixture._suspend()
|
| 813 |
+
|
| 814 |
+
def resume_fixture(self) -> None:
|
| 815 |
+
if self._capture_fixture:
|
| 816 |
+
self._capture_fixture._resume()
|
| 817 |
+
|
| 818 |
+
# Helper context managers
|
| 819 |
+
|
| 820 |
+
@contextlib.contextmanager
|
| 821 |
+
def global_and_fixture_disabled(self) -> Generator[None]:
|
| 822 |
+
"""Context manager to temporarily disable global and current fixture capturing."""
|
| 823 |
+
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
|
| 824 |
+
if do_fixture:
|
| 825 |
+
self.suspend_fixture()
|
| 826 |
+
do_global = self._global_capturing and self._global_capturing.is_started()
|
| 827 |
+
if do_global:
|
| 828 |
+
self.suspend_global_capture()
|
| 829 |
+
try:
|
| 830 |
+
yield
|
| 831 |
+
finally:
|
| 832 |
+
if do_global:
|
| 833 |
+
self.resume_global_capture()
|
| 834 |
+
if do_fixture:
|
| 835 |
+
self.resume_fixture()
|
| 836 |
+
|
| 837 |
+
@contextlib.contextmanager
|
| 838 |
+
def item_capture(self, when: str, item: Item) -> Generator[None]:
|
| 839 |
+
self.resume_global_capture()
|
| 840 |
+
self.activate_fixture()
|
| 841 |
+
try:
|
| 842 |
+
yield
|
| 843 |
+
finally:
|
| 844 |
+
self.deactivate_fixture()
|
| 845 |
+
self.suspend_global_capture(in_=False)
|
| 846 |
+
|
| 847 |
+
out, err = self.read_global_capture()
|
| 848 |
+
item.add_report_section(when, "stdout", out)
|
| 849 |
+
item.add_report_section(when, "stderr", err)
|
| 850 |
+
|
| 851 |
+
# Hooks
|
| 852 |
+
|
| 853 |
+
@hookimpl(wrapper=True)
|
| 854 |
+
def pytest_make_collect_report(
|
| 855 |
+
self, collector: Collector
|
| 856 |
+
) -> Generator[None, CollectReport, CollectReport]:
|
| 857 |
+
if isinstance(collector, File):
|
| 858 |
+
self.resume_global_capture()
|
| 859 |
+
try:
|
| 860 |
+
rep = yield
|
| 861 |
+
finally:
|
| 862 |
+
self.suspend_global_capture()
|
| 863 |
+
out, err = self.read_global_capture()
|
| 864 |
+
if out:
|
| 865 |
+
rep.sections.append(("Captured stdout", out))
|
| 866 |
+
if err:
|
| 867 |
+
rep.sections.append(("Captured stderr", err))
|
| 868 |
+
else:
|
| 869 |
+
rep = yield
|
| 870 |
+
return rep
|
| 871 |
+
|
| 872 |
+
@hookimpl(wrapper=True)
|
| 873 |
+
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
|
| 874 |
+
with self.item_capture("setup", item):
|
| 875 |
+
return (yield)
|
| 876 |
+
|
| 877 |
+
@hookimpl(wrapper=True)
|
| 878 |
+
def pytest_runtest_call(self, item: Item) -> Generator[None]:
|
| 879 |
+
with self.item_capture("call", item):
|
| 880 |
+
return (yield)
|
| 881 |
+
|
| 882 |
+
@hookimpl(wrapper=True)
|
| 883 |
+
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
|
| 884 |
+
with self.item_capture("teardown", item):
|
| 885 |
+
return (yield)
|
| 886 |
+
|
| 887 |
+
@hookimpl(tryfirst=True)
|
| 888 |
+
def pytest_keyboard_interrupt(self) -> None:
|
| 889 |
+
self.stop_global_capturing()
|
| 890 |
+
|
| 891 |
+
@hookimpl(tryfirst=True)
|
| 892 |
+
def pytest_internalerror(self) -> None:
|
| 893 |
+
self.stop_global_capturing()
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
class CaptureFixture(Generic[AnyStr]):
|
| 897 |
+
"""Object returned by the :fixture:`capsys`, :fixture:`capsysbinary`,
|
| 898 |
+
:fixture:`capfd` and :fixture:`capfdbinary` fixtures."""
|
| 899 |
+
|
| 900 |
+
def __init__(
|
| 901 |
+
self,
|
| 902 |
+
captureclass: type[CaptureBase[AnyStr]],
|
| 903 |
+
request: SubRequest,
|
| 904 |
+
*,
|
| 905 |
+
_ispytest: bool = False,
|
| 906 |
+
) -> None:
|
| 907 |
+
check_ispytest(_ispytest)
|
| 908 |
+
self.captureclass: type[CaptureBase[AnyStr]] = captureclass
|
| 909 |
+
self.request = request
|
| 910 |
+
self._capture: MultiCapture[AnyStr] | None = None
|
| 911 |
+
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
|
| 912 |
+
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
|
| 913 |
+
|
| 914 |
+
def _start(self) -> None:
|
| 915 |
+
if self._capture is None:
|
| 916 |
+
self._capture = MultiCapture(
|
| 917 |
+
in_=None,
|
| 918 |
+
out=self.captureclass(1),
|
| 919 |
+
err=self.captureclass(2),
|
| 920 |
+
)
|
| 921 |
+
self._capture.start_capturing()
|
| 922 |
+
|
| 923 |
+
def close(self) -> None:
|
| 924 |
+
if self._capture is not None:
|
| 925 |
+
out, err = self._capture.pop_outerr_to_orig()
|
| 926 |
+
self._captured_out += out
|
| 927 |
+
self._captured_err += err
|
| 928 |
+
self._capture.stop_capturing()
|
| 929 |
+
self._capture = None
|
| 930 |
+
|
| 931 |
+
def readouterr(self) -> CaptureResult[AnyStr]:
|
| 932 |
+
"""Read and return the captured output so far, resetting the internal
|
| 933 |
+
buffer.
|
| 934 |
+
|
| 935 |
+
:returns:
|
| 936 |
+
The captured content as a namedtuple with ``out`` and ``err``
|
| 937 |
+
string attributes.
|
| 938 |
+
"""
|
| 939 |
+
captured_out, captured_err = self._captured_out, self._captured_err
|
| 940 |
+
if self._capture is not None:
|
| 941 |
+
out, err = self._capture.readouterr()
|
| 942 |
+
captured_out += out
|
| 943 |
+
captured_err += err
|
| 944 |
+
self._captured_out = self.captureclass.EMPTY_BUFFER
|
| 945 |
+
self._captured_err = self.captureclass.EMPTY_BUFFER
|
| 946 |
+
return CaptureResult(captured_out, captured_err)
|
| 947 |
+
|
| 948 |
+
def _suspend(self) -> None:
|
| 949 |
+
"""Suspend this fixture's own capturing temporarily."""
|
| 950 |
+
if self._capture is not None:
|
| 951 |
+
self._capture.suspend_capturing()
|
| 952 |
+
|
| 953 |
+
def _resume(self) -> None:
|
| 954 |
+
"""Resume this fixture's own capturing temporarily."""
|
| 955 |
+
if self._capture is not None:
|
| 956 |
+
self._capture.resume_capturing()
|
| 957 |
+
|
| 958 |
+
def _is_started(self) -> bool:
|
| 959 |
+
"""Whether actively capturing -- not disabled or closed."""
|
| 960 |
+
if self._capture is not None:
|
| 961 |
+
return self._capture.is_started()
|
| 962 |
+
return False
|
| 963 |
+
|
| 964 |
+
@contextlib.contextmanager
|
| 965 |
+
def disabled(self) -> Generator[None]:
|
| 966 |
+
"""Temporarily disable capturing while inside the ``with`` block."""
|
| 967 |
+
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
|
| 968 |
+
"capturemanager"
|
| 969 |
+
)
|
| 970 |
+
with capmanager.global_and_fixture_disabled():
|
| 971 |
+
yield
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
# The fixtures.
|
| 975 |
+
|
| 976 |
+
|
| 977 |
+
@fixture
|
| 978 |
+
def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
|
| 979 |
+
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
| 980 |
+
|
| 981 |
+
The captured output is made available via ``capsys.readouterr()`` method
|
| 982 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 983 |
+
``out`` and ``err`` will be ``text`` objects.
|
| 984 |
+
|
| 985 |
+
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
| 986 |
+
|
| 987 |
+
Example:
|
| 988 |
+
|
| 989 |
+
.. code-block:: python
|
| 990 |
+
|
| 991 |
+
def test_output(capsys):
|
| 992 |
+
print("hello")
|
| 993 |
+
captured = capsys.readouterr()
|
| 994 |
+
assert captured.out == "hello\n"
|
| 995 |
+
"""
|
| 996 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 997 |
+
capture_fixture = CaptureFixture(SysCapture, request, _ispytest=True)
|
| 998 |
+
capman.set_fixture(capture_fixture)
|
| 999 |
+
capture_fixture._start()
|
| 1000 |
+
yield capture_fixture
|
| 1001 |
+
capture_fixture.close()
|
| 1002 |
+
capman.unset_fixture()
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
@fixture
|
| 1006 |
+
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
|
| 1007 |
+
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
| 1008 |
+
|
| 1009 |
+
The captured output is made available via ``capsysbinary.readouterr()``
|
| 1010 |
+
method calls, which return a ``(out, err)`` namedtuple.
|
| 1011 |
+
``out`` and ``err`` will be ``bytes`` objects.
|
| 1012 |
+
|
| 1013 |
+
Returns an instance of :class:`CaptureFixture[bytes] <pytest.CaptureFixture>`.
|
| 1014 |
+
|
| 1015 |
+
Example:
|
| 1016 |
+
|
| 1017 |
+
.. code-block:: python
|
| 1018 |
+
|
| 1019 |
+
def test_output(capsysbinary):
|
| 1020 |
+
print("hello")
|
| 1021 |
+
captured = capsysbinary.readouterr()
|
| 1022 |
+
assert captured.out == b"hello\n"
|
| 1023 |
+
"""
|
| 1024 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1025 |
+
capture_fixture = CaptureFixture(SysCaptureBinary, request, _ispytest=True)
|
| 1026 |
+
capman.set_fixture(capture_fixture)
|
| 1027 |
+
capture_fixture._start()
|
| 1028 |
+
yield capture_fixture
|
| 1029 |
+
capture_fixture.close()
|
| 1030 |
+
capman.unset_fixture()
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
@fixture
|
| 1034 |
+
def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
|
| 1035 |
+
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
|
| 1036 |
+
|
| 1037 |
+
The captured output is made available via ``capfd.readouterr()`` method
|
| 1038 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1039 |
+
``out`` and ``err`` will be ``text`` objects.
|
| 1040 |
+
|
| 1041 |
+
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
| 1042 |
+
|
| 1043 |
+
Example:
|
| 1044 |
+
|
| 1045 |
+
.. code-block:: python
|
| 1046 |
+
|
| 1047 |
+
def test_system_echo(capfd):
|
| 1048 |
+
os.system('echo "hello"')
|
| 1049 |
+
captured = capfd.readouterr()
|
| 1050 |
+
assert captured.out == "hello\n"
|
| 1051 |
+
"""
|
| 1052 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1053 |
+
capture_fixture = CaptureFixture(FDCapture, request, _ispytest=True)
|
| 1054 |
+
capman.set_fixture(capture_fixture)
|
| 1055 |
+
capture_fixture._start()
|
| 1056 |
+
yield capture_fixture
|
| 1057 |
+
capture_fixture.close()
|
| 1058 |
+
capman.unset_fixture()
|
| 1059 |
+
|
| 1060 |
+
|
| 1061 |
+
@fixture
|
| 1062 |
+
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
|
| 1063 |
+
r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
|
| 1064 |
+
|
| 1065 |
+
The captured output is made available via ``capfd.readouterr()`` method
|
| 1066 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1067 |
+
``out`` and ``err`` will be ``byte`` objects.
|
| 1068 |
+
|
| 1069 |
+
Returns an instance of :class:`CaptureFixture[bytes] <pytest.CaptureFixture>`.
|
| 1070 |
+
|
| 1071 |
+
Example:
|
| 1072 |
+
|
| 1073 |
+
.. code-block:: python
|
| 1074 |
+
|
| 1075 |
+
def test_system_echo(capfdbinary):
|
| 1076 |
+
os.system('echo "hello"')
|
| 1077 |
+
captured = capfdbinary.readouterr()
|
| 1078 |
+
assert captured.out == b"hello\n"
|
| 1079 |
+
|
| 1080 |
+
"""
|
| 1081 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1082 |
+
capture_fixture = CaptureFixture(FDCaptureBinary, request, _ispytest=True)
|
| 1083 |
+
capman.set_fixture(capture_fixture)
|
| 1084 |
+
capture_fixture._start()
|
| 1085 |
+
yield capture_fixture
|
| 1086 |
+
capture_fixture.close()
|
| 1087 |
+
capman.unset_fixture()
|
.venv/lib/python3.11/site-packages/_pytest/debugging.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
# ruff: noqa: T100
|
| 3 |
+
"""Interactive debugging with PDB, the Python Debugger."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import argparse
|
| 8 |
+
import functools
|
| 9 |
+
import sys
|
| 10 |
+
import types
|
| 11 |
+
from typing import Any
|
| 12 |
+
from typing import Callable
|
| 13 |
+
from typing import Generator
|
| 14 |
+
import unittest
|
| 15 |
+
|
| 16 |
+
from _pytest import outcomes
|
| 17 |
+
from _pytest._code import ExceptionInfo
|
| 18 |
+
from _pytest.capture import CaptureManager
|
| 19 |
+
from _pytest.config import Config
|
| 20 |
+
from _pytest.config import ConftestImportFailure
|
| 21 |
+
from _pytest.config import hookimpl
|
| 22 |
+
from _pytest.config import PytestPluginManager
|
| 23 |
+
from _pytest.config.argparsing import Parser
|
| 24 |
+
from _pytest.config.exceptions import UsageError
|
| 25 |
+
from _pytest.nodes import Node
|
| 26 |
+
from _pytest.reports import BaseReport
|
| 27 |
+
from _pytest.runner import CallInfo
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _validate_usepdb_cls(value: str) -> tuple[str, str]:
|
| 31 |
+
"""Validate syntax of --pdbcls option."""
|
| 32 |
+
try:
|
| 33 |
+
modname, classname = value.split(":")
|
| 34 |
+
except ValueError as e:
|
| 35 |
+
raise argparse.ArgumentTypeError(
|
| 36 |
+
f"{value!r} is not in the format 'modname:classname'"
|
| 37 |
+
) from e
|
| 38 |
+
return (modname, classname)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 42 |
+
group = parser.getgroup("general")
|
| 43 |
+
group._addoption(
|
| 44 |
+
"--pdb",
|
| 45 |
+
dest="usepdb",
|
| 46 |
+
action="store_true",
|
| 47 |
+
help="Start the interactive Python debugger on errors or KeyboardInterrupt",
|
| 48 |
+
)
|
| 49 |
+
group._addoption(
|
| 50 |
+
"--pdbcls",
|
| 51 |
+
dest="usepdb_cls",
|
| 52 |
+
metavar="modulename:classname",
|
| 53 |
+
type=_validate_usepdb_cls,
|
| 54 |
+
help="Specify a custom interactive Python debugger for use with --pdb."
|
| 55 |
+
"For example: --pdbcls=IPython.terminal.debugger:TerminalPdb",
|
| 56 |
+
)
|
| 57 |
+
group._addoption(
|
| 58 |
+
"--trace",
|
| 59 |
+
dest="trace",
|
| 60 |
+
action="store_true",
|
| 61 |
+
help="Immediately break when running each test",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def pytest_configure(config: Config) -> None:
|
| 66 |
+
import pdb
|
| 67 |
+
|
| 68 |
+
if config.getvalue("trace"):
|
| 69 |
+
config.pluginmanager.register(PdbTrace(), "pdbtrace")
|
| 70 |
+
if config.getvalue("usepdb"):
|
| 71 |
+
config.pluginmanager.register(PdbInvoke(), "pdbinvoke")
|
| 72 |
+
|
| 73 |
+
pytestPDB._saved.append(
|
| 74 |
+
(pdb.set_trace, pytestPDB._pluginmanager, pytestPDB._config)
|
| 75 |
+
)
|
| 76 |
+
pdb.set_trace = pytestPDB.set_trace
|
| 77 |
+
pytestPDB._pluginmanager = config.pluginmanager
|
| 78 |
+
pytestPDB._config = config
|
| 79 |
+
|
| 80 |
+
# NOTE: not using pytest_unconfigure, since it might get called although
|
| 81 |
+
# pytest_configure was not (if another plugin raises UsageError).
|
| 82 |
+
def fin() -> None:
|
| 83 |
+
(
|
| 84 |
+
pdb.set_trace,
|
| 85 |
+
pytestPDB._pluginmanager,
|
| 86 |
+
pytestPDB._config,
|
| 87 |
+
) = pytestPDB._saved.pop()
|
| 88 |
+
|
| 89 |
+
config.add_cleanup(fin)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class pytestPDB:
|
| 93 |
+
"""Pseudo PDB that defers to the real pdb."""
|
| 94 |
+
|
| 95 |
+
_pluginmanager: PytestPluginManager | None = None
|
| 96 |
+
_config: Config | None = None
|
| 97 |
+
_saved: list[
|
| 98 |
+
tuple[Callable[..., None], PytestPluginManager | None, Config | None]
|
| 99 |
+
] = []
|
| 100 |
+
_recursive_debug = 0
|
| 101 |
+
_wrapped_pdb_cls: tuple[type[Any], type[Any]] | None = None
|
| 102 |
+
|
| 103 |
+
@classmethod
|
| 104 |
+
def _is_capturing(cls, capman: CaptureManager | None) -> str | bool:
|
| 105 |
+
if capman:
|
| 106 |
+
return capman.is_capturing()
|
| 107 |
+
return False
|
| 108 |
+
|
| 109 |
+
@classmethod
|
| 110 |
+
def _import_pdb_cls(cls, capman: CaptureManager | None):
|
| 111 |
+
if not cls._config:
|
| 112 |
+
import pdb
|
| 113 |
+
|
| 114 |
+
# Happens when using pytest.set_trace outside of a test.
|
| 115 |
+
return pdb.Pdb
|
| 116 |
+
|
| 117 |
+
usepdb_cls = cls._config.getvalue("usepdb_cls")
|
| 118 |
+
|
| 119 |
+
if cls._wrapped_pdb_cls and cls._wrapped_pdb_cls[0] == usepdb_cls:
|
| 120 |
+
return cls._wrapped_pdb_cls[1]
|
| 121 |
+
|
| 122 |
+
if usepdb_cls:
|
| 123 |
+
modname, classname = usepdb_cls
|
| 124 |
+
|
| 125 |
+
try:
|
| 126 |
+
__import__(modname)
|
| 127 |
+
mod = sys.modules[modname]
|
| 128 |
+
|
| 129 |
+
# Handle --pdbcls=pdb:pdb.Pdb (useful e.g. with pdbpp).
|
| 130 |
+
parts = classname.split(".")
|
| 131 |
+
pdb_cls = getattr(mod, parts[0])
|
| 132 |
+
for part in parts[1:]:
|
| 133 |
+
pdb_cls = getattr(pdb_cls, part)
|
| 134 |
+
except Exception as exc:
|
| 135 |
+
value = ":".join((modname, classname))
|
| 136 |
+
raise UsageError(
|
| 137 |
+
f"--pdbcls: could not import {value!r}: {exc}"
|
| 138 |
+
) from exc
|
| 139 |
+
else:
|
| 140 |
+
import pdb
|
| 141 |
+
|
| 142 |
+
pdb_cls = pdb.Pdb
|
| 143 |
+
|
| 144 |
+
wrapped_cls = cls._get_pdb_wrapper_class(pdb_cls, capman)
|
| 145 |
+
cls._wrapped_pdb_cls = (usepdb_cls, wrapped_cls)
|
| 146 |
+
return wrapped_cls
|
| 147 |
+
|
| 148 |
+
@classmethod
|
| 149 |
+
def _get_pdb_wrapper_class(cls, pdb_cls, capman: CaptureManager | None):
|
| 150 |
+
import _pytest.config
|
| 151 |
+
|
| 152 |
+
class PytestPdbWrapper(pdb_cls):
|
| 153 |
+
_pytest_capman = capman
|
| 154 |
+
_continued = False
|
| 155 |
+
|
| 156 |
+
def do_debug(self, arg):
|
| 157 |
+
cls._recursive_debug += 1
|
| 158 |
+
ret = super().do_debug(arg)
|
| 159 |
+
cls._recursive_debug -= 1
|
| 160 |
+
return ret
|
| 161 |
+
|
| 162 |
+
def do_continue(self, arg):
|
| 163 |
+
ret = super().do_continue(arg)
|
| 164 |
+
if cls._recursive_debug == 0:
|
| 165 |
+
assert cls._config is not None
|
| 166 |
+
tw = _pytest.config.create_terminal_writer(cls._config)
|
| 167 |
+
tw.line()
|
| 168 |
+
|
| 169 |
+
capman = self._pytest_capman
|
| 170 |
+
capturing = pytestPDB._is_capturing(capman)
|
| 171 |
+
if capturing:
|
| 172 |
+
if capturing == "global":
|
| 173 |
+
tw.sep(">", "PDB continue (IO-capturing resumed)")
|
| 174 |
+
else:
|
| 175 |
+
tw.sep(
|
| 176 |
+
">",
|
| 177 |
+
f"PDB continue (IO-capturing resumed for {capturing})",
|
| 178 |
+
)
|
| 179 |
+
assert capman is not None
|
| 180 |
+
capman.resume()
|
| 181 |
+
else:
|
| 182 |
+
tw.sep(">", "PDB continue")
|
| 183 |
+
assert cls._pluginmanager is not None
|
| 184 |
+
cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self)
|
| 185 |
+
self._continued = True
|
| 186 |
+
return ret
|
| 187 |
+
|
| 188 |
+
do_c = do_cont = do_continue
|
| 189 |
+
|
| 190 |
+
def do_quit(self, arg):
|
| 191 |
+
"""Raise Exit outcome when quit command is used in pdb.
|
| 192 |
+
|
| 193 |
+
This is a bit of a hack - it would be better if BdbQuit
|
| 194 |
+
could be handled, but this would require to wrap the
|
| 195 |
+
whole pytest run, and adjust the report etc.
|
| 196 |
+
"""
|
| 197 |
+
ret = super().do_quit(arg)
|
| 198 |
+
|
| 199 |
+
if cls._recursive_debug == 0:
|
| 200 |
+
outcomes.exit("Quitting debugger")
|
| 201 |
+
|
| 202 |
+
return ret
|
| 203 |
+
|
| 204 |
+
do_q = do_quit
|
| 205 |
+
do_exit = do_quit
|
| 206 |
+
|
| 207 |
+
def setup(self, f, tb):
|
| 208 |
+
"""Suspend on setup().
|
| 209 |
+
|
| 210 |
+
Needed after do_continue resumed, and entering another
|
| 211 |
+
breakpoint again.
|
| 212 |
+
"""
|
| 213 |
+
ret = super().setup(f, tb)
|
| 214 |
+
if not ret and self._continued:
|
| 215 |
+
# pdb.setup() returns True if the command wants to exit
|
| 216 |
+
# from the interaction: do not suspend capturing then.
|
| 217 |
+
if self._pytest_capman:
|
| 218 |
+
self._pytest_capman.suspend_global_capture(in_=True)
|
| 219 |
+
return ret
|
| 220 |
+
|
| 221 |
+
def get_stack(self, f, t):
|
| 222 |
+
stack, i = super().get_stack(f, t)
|
| 223 |
+
if f is None:
|
| 224 |
+
# Find last non-hidden frame.
|
| 225 |
+
i = max(0, len(stack) - 1)
|
| 226 |
+
while i and stack[i][0].f_locals.get("__tracebackhide__", False):
|
| 227 |
+
i -= 1
|
| 228 |
+
return stack, i
|
| 229 |
+
|
| 230 |
+
return PytestPdbWrapper
|
| 231 |
+
|
| 232 |
+
@classmethod
|
| 233 |
+
def _init_pdb(cls, method, *args, **kwargs):
|
| 234 |
+
"""Initialize PDB debugging, dropping any IO capturing."""
|
| 235 |
+
import _pytest.config
|
| 236 |
+
|
| 237 |
+
if cls._pluginmanager is None:
|
| 238 |
+
capman: CaptureManager | None = None
|
| 239 |
+
else:
|
| 240 |
+
capman = cls._pluginmanager.getplugin("capturemanager")
|
| 241 |
+
if capman:
|
| 242 |
+
capman.suspend(in_=True)
|
| 243 |
+
|
| 244 |
+
if cls._config:
|
| 245 |
+
tw = _pytest.config.create_terminal_writer(cls._config)
|
| 246 |
+
tw.line()
|
| 247 |
+
|
| 248 |
+
if cls._recursive_debug == 0:
|
| 249 |
+
# Handle header similar to pdb.set_trace in py37+.
|
| 250 |
+
header = kwargs.pop("header", None)
|
| 251 |
+
if header is not None:
|
| 252 |
+
tw.sep(">", header)
|
| 253 |
+
else:
|
| 254 |
+
capturing = cls._is_capturing(capman)
|
| 255 |
+
if capturing == "global":
|
| 256 |
+
tw.sep(">", f"PDB {method} (IO-capturing turned off)")
|
| 257 |
+
elif capturing:
|
| 258 |
+
tw.sep(
|
| 259 |
+
">",
|
| 260 |
+
f"PDB {method} (IO-capturing turned off for {capturing})",
|
| 261 |
+
)
|
| 262 |
+
else:
|
| 263 |
+
tw.sep(">", f"PDB {method}")
|
| 264 |
+
|
| 265 |
+
_pdb = cls._import_pdb_cls(capman)(**kwargs)
|
| 266 |
+
|
| 267 |
+
if cls._pluginmanager:
|
| 268 |
+
cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb)
|
| 269 |
+
return _pdb
|
| 270 |
+
|
| 271 |
+
@classmethod
|
| 272 |
+
def set_trace(cls, *args, **kwargs) -> None:
|
| 273 |
+
"""Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing."""
|
| 274 |
+
frame = sys._getframe().f_back
|
| 275 |
+
_pdb = cls._init_pdb("set_trace", *args, **kwargs)
|
| 276 |
+
_pdb.set_trace(frame)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class PdbInvoke:
|
| 280 |
+
def pytest_exception_interact(
|
| 281 |
+
self, node: Node, call: CallInfo[Any], report: BaseReport
|
| 282 |
+
) -> None:
|
| 283 |
+
capman = node.config.pluginmanager.getplugin("capturemanager")
|
| 284 |
+
if capman:
|
| 285 |
+
capman.suspend_global_capture(in_=True)
|
| 286 |
+
out, err = capman.read_global_capture()
|
| 287 |
+
sys.stdout.write(out)
|
| 288 |
+
sys.stdout.write(err)
|
| 289 |
+
assert call.excinfo is not None
|
| 290 |
+
|
| 291 |
+
if not isinstance(call.excinfo.value, unittest.SkipTest):
|
| 292 |
+
_enter_pdb(node, call.excinfo, report)
|
| 293 |
+
|
| 294 |
+
def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None:
|
| 295 |
+
tb = _postmortem_traceback(excinfo)
|
| 296 |
+
post_mortem(tb)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
class PdbTrace:
|
| 300 |
+
@hookimpl(wrapper=True)
|
| 301 |
+
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]:
|
| 302 |
+
wrap_pytest_function_for_tracing(pyfuncitem)
|
| 303 |
+
return (yield)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
|
| 307 |
+
"""Change the Python function object of the given Function item by a
|
| 308 |
+
wrapper which actually enters pdb before calling the python function
|
| 309 |
+
itself, effectively leaving the user in the pdb prompt in the first
|
| 310 |
+
statement of the function."""
|
| 311 |
+
_pdb = pytestPDB._init_pdb("runcall")
|
| 312 |
+
testfunction = pyfuncitem.obj
|
| 313 |
+
|
| 314 |
+
# we can't just return `partial(pdb.runcall, testfunction)` because (on
|
| 315 |
+
# python < 3.7.4) runcall's first param is `func`, which means we'd get
|
| 316 |
+
# an exception if one of the kwargs to testfunction was called `func`.
|
| 317 |
+
@functools.wraps(testfunction)
|
| 318 |
+
def wrapper(*args, **kwargs) -> None:
|
| 319 |
+
func = functools.partial(testfunction, *args, **kwargs)
|
| 320 |
+
_pdb.runcall(func)
|
| 321 |
+
|
| 322 |
+
pyfuncitem.obj = wrapper
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
|
| 326 |
+
"""Wrap the given pytestfunct item for tracing support if --trace was given in
|
| 327 |
+
the command line."""
|
| 328 |
+
if pyfuncitem.config.getvalue("trace"):
|
| 329 |
+
wrap_pytest_function_for_tracing(pyfuncitem)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _enter_pdb(
|
| 333 |
+
node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport
|
| 334 |
+
) -> BaseReport:
|
| 335 |
+
# XXX we reuse the TerminalReporter's terminalwriter
|
| 336 |
+
# because this seems to avoid some encoding related troubles
|
| 337 |
+
# for not completely clear reasons.
|
| 338 |
+
tw = node.config.pluginmanager.getplugin("terminalreporter")._tw
|
| 339 |
+
tw.line()
|
| 340 |
+
|
| 341 |
+
showcapture = node.config.option.showcapture
|
| 342 |
+
|
| 343 |
+
for sectionname, content in (
|
| 344 |
+
("stdout", rep.capstdout),
|
| 345 |
+
("stderr", rep.capstderr),
|
| 346 |
+
("log", rep.caplog),
|
| 347 |
+
):
|
| 348 |
+
if showcapture in (sectionname, "all") and content:
|
| 349 |
+
tw.sep(">", "captured " + sectionname)
|
| 350 |
+
if content[-1:] == "\n":
|
| 351 |
+
content = content[:-1]
|
| 352 |
+
tw.line(content)
|
| 353 |
+
|
| 354 |
+
tw.sep(">", "traceback")
|
| 355 |
+
rep.toterminal(tw)
|
| 356 |
+
tw.sep(">", "entering PDB")
|
| 357 |
+
tb = _postmortem_traceback(excinfo)
|
| 358 |
+
rep._pdbshown = True # type: ignore[attr-defined]
|
| 359 |
+
post_mortem(tb)
|
| 360 |
+
return rep
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _postmortem_traceback(excinfo: ExceptionInfo[BaseException]) -> types.TracebackType:
|
| 364 |
+
from doctest import UnexpectedException
|
| 365 |
+
|
| 366 |
+
if isinstance(excinfo.value, UnexpectedException):
|
| 367 |
+
# A doctest.UnexpectedException is not useful for post_mortem.
|
| 368 |
+
# Use the underlying exception instead:
|
| 369 |
+
return excinfo.value.exc_info[2]
|
| 370 |
+
elif isinstance(excinfo.value, ConftestImportFailure):
|
| 371 |
+
# A config.ConftestImportFailure is not useful for post_mortem.
|
| 372 |
+
# Use the underlying exception instead:
|
| 373 |
+
assert excinfo.value.cause.__traceback__ is not None
|
| 374 |
+
return excinfo.value.cause.__traceback__
|
| 375 |
+
else:
|
| 376 |
+
assert excinfo._excinfo is not None
|
| 377 |
+
return excinfo._excinfo[2]
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def post_mortem(t: types.TracebackType) -> None:
|
| 381 |
+
p = pytestPDB._init_pdb("post_mortem")
|
| 382 |
+
p.reset()
|
| 383 |
+
p.interaction(None, t)
|
| 384 |
+
if p.quitting:
|
| 385 |
+
outcomes.exit("Quitting debugger")
|
.venv/lib/python3.11/site-packages/_pytest/deprecated.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deprecation messages and bits of code used elsewhere in the codebase that
|
| 2 |
+
is planned to be removed in the next pytest release.
|
| 3 |
+
|
| 4 |
+
Keeping it in a central location makes it easy to track what is deprecated and should
|
| 5 |
+
be removed when the time comes.
|
| 6 |
+
|
| 7 |
+
All constants defined in this module should be either instances of
|
| 8 |
+
:class:`PytestWarning`, or :class:`UnformattedWarning`
|
| 9 |
+
in case of warnings which need to format their messages.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from warnings import warn
|
| 15 |
+
|
| 16 |
+
from _pytest.warning_types import PytestDeprecationWarning
|
| 17 |
+
from _pytest.warning_types import PytestRemovedIn9Warning
|
| 18 |
+
from _pytest.warning_types import UnformattedWarning
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# set of plugins which have been integrated into the core; we use this list to ignore
|
| 22 |
+
# them during registration to avoid conflicts
|
| 23 |
+
DEPRECATED_EXTERNAL_PLUGINS = {
|
| 24 |
+
"pytest_catchlog",
|
| 25 |
+
"pytest_capturelog",
|
| 26 |
+
"pytest_faulthandler",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# This can be* removed pytest 8, but it's harmless and common, so no rush to remove.
|
| 31 |
+
# * If you're in the future: "could have been".
|
| 32 |
+
YIELD_FIXTURE = PytestDeprecationWarning(
|
| 33 |
+
"@pytest.yield_fixture is deprecated.\n"
|
| 34 |
+
"Use @pytest.fixture instead; they are the same."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
# This deprecation is never really meant to be removed.
|
| 38 |
+
PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
HOOK_LEGACY_PATH_ARG = UnformattedWarning(
|
| 42 |
+
PytestRemovedIn9Warning,
|
| 43 |
+
"The ({pylib_path_arg}: py.path.local) argument is deprecated, please use ({pathlib_path_arg}: pathlib.Path)\n"
|
| 44 |
+
"see https://docs.pytest.org/en/latest/deprecations.html"
|
| 45 |
+
"#py-path-local-arguments-for-hooks-replaced-with-pathlib-path",
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
NODE_CTOR_FSPATH_ARG = UnformattedWarning(
|
| 49 |
+
PytestRemovedIn9Warning,
|
| 50 |
+
"The (fspath: py.path.local) argument to {node_type_name} is deprecated. "
|
| 51 |
+
"Please use the (path: pathlib.Path) argument instead.\n"
|
| 52 |
+
"See https://docs.pytest.org/en/latest/deprecations.html"
|
| 53 |
+
"#fspath-argument-for-node-constructors-replaced-with-pathlib-path",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
HOOK_LEGACY_MARKING = UnformattedWarning(
|
| 57 |
+
PytestDeprecationWarning,
|
| 58 |
+
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
|
| 59 |
+
"Please use the pytest.hook{type}({hook_opts}) decorator instead\n"
|
| 60 |
+
" to configure the hooks.\n"
|
| 61 |
+
" See https://docs.pytest.org/en/latest/deprecations.html"
|
| 62 |
+
"#configuring-hook-specs-impls-using-markers",
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
MARKED_FIXTURE = PytestRemovedIn9Warning(
|
| 66 |
+
"Marks applied to fixtures have no effect\n"
|
| 67 |
+
"See docs: https://docs.pytest.org/en/stable/deprecations.html#applying-a-mark-to-a-fixture-function"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# You want to make some `__init__` or function "private".
|
| 71 |
+
#
|
| 72 |
+
# def my_private_function(some, args):
|
| 73 |
+
# ...
|
| 74 |
+
#
|
| 75 |
+
# Do this:
|
| 76 |
+
#
|
| 77 |
+
# def my_private_function(some, args, *, _ispytest: bool = False):
|
| 78 |
+
# check_ispytest(_ispytest)
|
| 79 |
+
# ...
|
| 80 |
+
#
|
| 81 |
+
# Change all internal/allowed calls to
|
| 82 |
+
#
|
| 83 |
+
# my_private_function(some, args, _ispytest=True)
|
| 84 |
+
#
|
| 85 |
+
# All other calls will get the default _ispytest=False and trigger
|
| 86 |
+
# the warning (possibly error in the future).
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def check_ispytest(ispytest: bool) -> None:
|
| 90 |
+
if not ispytest:
|
| 91 |
+
warn(PRIVATE, stacklevel=3)
|
.venv/lib/python3.11/site-packages/_pytest/faulthandler.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from typing import Generator
|
| 6 |
+
|
| 7 |
+
from _pytest.config import Config
|
| 8 |
+
from _pytest.config.argparsing import Parser
|
| 9 |
+
from _pytest.nodes import Item
|
| 10 |
+
from _pytest.stash import StashKey
|
| 11 |
+
import pytest
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
fault_handler_original_stderr_fd_key = StashKey[int]()
|
| 15 |
+
fault_handler_stderr_fd_key = StashKey[int]()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 19 |
+
help = (
|
| 20 |
+
"Dump the traceback of all threads if a test takes "
|
| 21 |
+
"more than TIMEOUT seconds to finish"
|
| 22 |
+
)
|
| 23 |
+
parser.addini("faulthandler_timeout", help, default=0.0)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def pytest_configure(config: Config) -> None:
|
| 27 |
+
import faulthandler
|
| 28 |
+
|
| 29 |
+
# at teardown we want to restore the original faulthandler fileno
|
| 30 |
+
# but faulthandler has no api to return the original fileno
|
| 31 |
+
# so here we stash the stderr fileno to be used at teardown
|
| 32 |
+
# sys.stderr and sys.__stderr__ may be closed or patched during the session
|
| 33 |
+
# so we can't rely on their values being good at that point (#11572).
|
| 34 |
+
stderr_fileno = get_stderr_fileno()
|
| 35 |
+
if faulthandler.is_enabled():
|
| 36 |
+
config.stash[fault_handler_original_stderr_fd_key] = stderr_fileno
|
| 37 |
+
config.stash[fault_handler_stderr_fd_key] = os.dup(stderr_fileno)
|
| 38 |
+
faulthandler.enable(file=config.stash[fault_handler_stderr_fd_key])
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def pytest_unconfigure(config: Config) -> None:
|
| 42 |
+
import faulthandler
|
| 43 |
+
|
| 44 |
+
faulthandler.disable()
|
| 45 |
+
# Close the dup file installed during pytest_configure.
|
| 46 |
+
if fault_handler_stderr_fd_key in config.stash:
|
| 47 |
+
os.close(config.stash[fault_handler_stderr_fd_key])
|
| 48 |
+
del config.stash[fault_handler_stderr_fd_key]
|
| 49 |
+
# Re-enable the faulthandler if it was originally enabled.
|
| 50 |
+
if fault_handler_original_stderr_fd_key in config.stash:
|
| 51 |
+
faulthandler.enable(config.stash[fault_handler_original_stderr_fd_key])
|
| 52 |
+
del config.stash[fault_handler_original_stderr_fd_key]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_stderr_fileno() -> int:
|
| 56 |
+
try:
|
| 57 |
+
fileno = sys.stderr.fileno()
|
| 58 |
+
# The Twisted Logger will return an invalid file descriptor since it is not backed
|
| 59 |
+
# by an FD. So, let's also forward this to the same code path as with pytest-xdist.
|
| 60 |
+
if fileno == -1:
|
| 61 |
+
raise AttributeError()
|
| 62 |
+
return fileno
|
| 63 |
+
except (AttributeError, ValueError):
|
| 64 |
+
# pytest-xdist monkeypatches sys.stderr with an object that is not an actual file.
|
| 65 |
+
# https://docs.python.org/3/library/faulthandler.html#issue-with-file-descriptors
|
| 66 |
+
# This is potentially dangerous, but the best we can do.
|
| 67 |
+
assert sys.__stderr__ is not None
|
| 68 |
+
return sys.__stderr__.fileno()
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_timeout_config_value(config: Config) -> float:
|
| 72 |
+
return float(config.getini("faulthandler_timeout") or 0.0)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.hookimpl(wrapper=True, trylast=True)
|
| 76 |
+
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
|
| 77 |
+
timeout = get_timeout_config_value(item.config)
|
| 78 |
+
if timeout > 0:
|
| 79 |
+
import faulthandler
|
| 80 |
+
|
| 81 |
+
stderr = item.config.stash[fault_handler_stderr_fd_key]
|
| 82 |
+
faulthandler.dump_traceback_later(timeout, file=stderr)
|
| 83 |
+
try:
|
| 84 |
+
return (yield)
|
| 85 |
+
finally:
|
| 86 |
+
faulthandler.cancel_dump_traceback_later()
|
| 87 |
+
else:
|
| 88 |
+
return (yield)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@pytest.hookimpl(tryfirst=True)
|
| 92 |
+
def pytest_enter_pdb() -> None:
|
| 93 |
+
"""Cancel any traceback dumping due to timeout before entering pdb."""
|
| 94 |
+
import faulthandler
|
| 95 |
+
|
| 96 |
+
faulthandler.cancel_dump_traceback_later()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@pytest.hookimpl(tryfirst=True)
|
| 100 |
+
def pytest_exception_interact() -> None:
|
| 101 |
+
"""Cancel any traceback dumping due to an interactive exception being
|
| 102 |
+
raised."""
|
| 103 |
+
import faulthandler
|
| 104 |
+
|
| 105 |
+
faulthandler.cancel_dump_traceback_later()
|
.venv/lib/python3.11/site-packages/_pytest/fixtures.py
ADDED
|
@@ -0,0 +1,1932 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import abc
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from collections import deque
|
| 7 |
+
import dataclasses
|
| 8 |
+
import functools
|
| 9 |
+
import inspect
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
import sys
|
| 13 |
+
import types
|
| 14 |
+
from typing import AbstractSet
|
| 15 |
+
from typing import Any
|
| 16 |
+
from typing import Callable
|
| 17 |
+
from typing import cast
|
| 18 |
+
from typing import Dict
|
| 19 |
+
from typing import Final
|
| 20 |
+
from typing import final
|
| 21 |
+
from typing import Generator
|
| 22 |
+
from typing import Generic
|
| 23 |
+
from typing import Iterable
|
| 24 |
+
from typing import Iterator
|
| 25 |
+
from typing import Mapping
|
| 26 |
+
from typing import MutableMapping
|
| 27 |
+
from typing import NoReturn
|
| 28 |
+
from typing import Optional
|
| 29 |
+
from typing import OrderedDict
|
| 30 |
+
from typing import overload
|
| 31 |
+
from typing import Sequence
|
| 32 |
+
from typing import Tuple
|
| 33 |
+
from typing import TYPE_CHECKING
|
| 34 |
+
from typing import TypeVar
|
| 35 |
+
from typing import Union
|
| 36 |
+
import warnings
|
| 37 |
+
|
| 38 |
+
import _pytest
|
| 39 |
+
from _pytest import nodes
|
| 40 |
+
from _pytest._code import getfslineno
|
| 41 |
+
from _pytest._code import Source
|
| 42 |
+
from _pytest._code.code import FormattedExcinfo
|
| 43 |
+
from _pytest._code.code import TerminalRepr
|
| 44 |
+
from _pytest._io import TerminalWriter
|
| 45 |
+
from _pytest.compat import _PytestWrapper
|
| 46 |
+
from _pytest.compat import assert_never
|
| 47 |
+
from _pytest.compat import get_real_func
|
| 48 |
+
from _pytest.compat import get_real_method
|
| 49 |
+
from _pytest.compat import getfuncargnames
|
| 50 |
+
from _pytest.compat import getimfunc
|
| 51 |
+
from _pytest.compat import getlocation
|
| 52 |
+
from _pytest.compat import is_generator
|
| 53 |
+
from _pytest.compat import NOTSET
|
| 54 |
+
from _pytest.compat import NotSetType
|
| 55 |
+
from _pytest.compat import safe_getattr
|
| 56 |
+
from _pytest.compat import safe_isclass
|
| 57 |
+
from _pytest.config import _PluggyPlugin
|
| 58 |
+
from _pytest.config import Config
|
| 59 |
+
from _pytest.config import ExitCode
|
| 60 |
+
from _pytest.config.argparsing import Parser
|
| 61 |
+
from _pytest.deprecated import check_ispytest
|
| 62 |
+
from _pytest.deprecated import MARKED_FIXTURE
|
| 63 |
+
from _pytest.deprecated import YIELD_FIXTURE
|
| 64 |
+
from _pytest.main import Session
|
| 65 |
+
from _pytest.mark import Mark
|
| 66 |
+
from _pytest.mark import ParameterSet
|
| 67 |
+
from _pytest.mark.structures import MarkDecorator
|
| 68 |
+
from _pytest.outcomes import fail
|
| 69 |
+
from _pytest.outcomes import skip
|
| 70 |
+
from _pytest.outcomes import TEST_OUTCOME
|
| 71 |
+
from _pytest.pathlib import absolutepath
|
| 72 |
+
from _pytest.pathlib import bestrelpath
|
| 73 |
+
from _pytest.scope import _ScopeName
|
| 74 |
+
from _pytest.scope import HIGH_SCOPES
|
| 75 |
+
from _pytest.scope import Scope
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if sys.version_info < (3, 11):
|
| 79 |
+
from exceptiongroup import BaseExceptionGroup
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
if TYPE_CHECKING:
|
| 83 |
+
from _pytest.python import CallSpec2
|
| 84 |
+
from _pytest.python import Function
|
| 85 |
+
from _pytest.python import Metafunc
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# The value of the fixture -- return/yield of the fixture function (type variable).
|
| 89 |
+
FixtureValue = TypeVar("FixtureValue")
|
| 90 |
+
# The type of the fixture function (type variable).
|
| 91 |
+
FixtureFunction = TypeVar("FixtureFunction", bound=Callable[..., object])
|
| 92 |
+
# The type of a fixture function (type alias generic in fixture value).
|
| 93 |
+
_FixtureFunc = Union[
|
| 94 |
+
Callable[..., FixtureValue], Callable[..., Generator[FixtureValue, None, None]]
|
| 95 |
+
]
|
| 96 |
+
# The type of FixtureDef.cached_result (type alias generic in fixture value).
|
| 97 |
+
_FixtureCachedResult = Union[
|
| 98 |
+
Tuple[
|
| 99 |
+
# The result.
|
| 100 |
+
FixtureValue,
|
| 101 |
+
# Cache key.
|
| 102 |
+
object,
|
| 103 |
+
None,
|
| 104 |
+
],
|
| 105 |
+
Tuple[
|
| 106 |
+
None,
|
| 107 |
+
# Cache key.
|
| 108 |
+
object,
|
| 109 |
+
# The exception and the original traceback.
|
| 110 |
+
Tuple[BaseException, Optional[types.TracebackType]],
|
| 111 |
+
],
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@dataclasses.dataclass(frozen=True)
|
| 116 |
+
class PseudoFixtureDef(Generic[FixtureValue]):
|
| 117 |
+
cached_result: _FixtureCachedResult[FixtureValue]
|
| 118 |
+
_scope: Scope
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def pytest_sessionstart(session: Session) -> None:
|
| 122 |
+
session._fixturemanager = FixtureManager(session)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def get_scope_package(
|
| 126 |
+
node: nodes.Item,
|
| 127 |
+
fixturedef: FixtureDef[object],
|
| 128 |
+
) -> nodes.Node | None:
|
| 129 |
+
from _pytest.python import Package
|
| 130 |
+
|
| 131 |
+
for parent in node.iter_parents():
|
| 132 |
+
if isinstance(parent, Package) and parent.nodeid == fixturedef.baseid:
|
| 133 |
+
return parent
|
| 134 |
+
return node.session
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def get_scope_node(node: nodes.Node, scope: Scope) -> nodes.Node | None:
|
| 138 |
+
import _pytest.python
|
| 139 |
+
|
| 140 |
+
if scope is Scope.Function:
|
| 141 |
+
# Type ignored because this is actually safe, see:
|
| 142 |
+
# https://github.com/python/mypy/issues/4717
|
| 143 |
+
return node.getparent(nodes.Item) # type: ignore[type-abstract]
|
| 144 |
+
elif scope is Scope.Class:
|
| 145 |
+
return node.getparent(_pytest.python.Class)
|
| 146 |
+
elif scope is Scope.Module:
|
| 147 |
+
return node.getparent(_pytest.python.Module)
|
| 148 |
+
elif scope is Scope.Package:
|
| 149 |
+
return node.getparent(_pytest.python.Package)
|
| 150 |
+
elif scope is Scope.Session:
|
| 151 |
+
return node.getparent(_pytest.main.Session)
|
| 152 |
+
else:
|
| 153 |
+
assert_never(scope)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def getfixturemarker(obj: object) -> FixtureFunctionMarker | None:
|
| 157 |
+
"""Return fixturemarker or None if it doesn't exist or raised
|
| 158 |
+
exceptions."""
|
| 159 |
+
return cast(
|
| 160 |
+
Optional[FixtureFunctionMarker],
|
| 161 |
+
safe_getattr(obj, "_pytestfixturefunction", None),
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# Algorithm for sorting on a per-parametrized resource setup basis.
|
| 166 |
+
# It is called for Session scope first and performs sorting
|
| 167 |
+
# down to the lower scopes such as to minimize number of "high scope"
|
| 168 |
+
# setups and teardowns.
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@dataclasses.dataclass(frozen=True)
|
| 172 |
+
class FixtureArgKey:
|
| 173 |
+
argname: str
|
| 174 |
+
param_index: int
|
| 175 |
+
scoped_item_path: Path | None
|
| 176 |
+
item_cls: type | None
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
_V = TypeVar("_V")
|
| 180 |
+
OrderedSet = Dict[_V, None]
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def get_parametrized_fixture_argkeys(
|
| 184 |
+
item: nodes.Item, scope: Scope
|
| 185 |
+
) -> Iterator[FixtureArgKey]:
|
| 186 |
+
"""Return list of keys for all parametrized arguments which match
|
| 187 |
+
the specified scope."""
|
| 188 |
+
assert scope is not Scope.Function
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
|
| 192 |
+
except AttributeError:
|
| 193 |
+
return
|
| 194 |
+
|
| 195 |
+
item_cls = None
|
| 196 |
+
if scope is Scope.Session:
|
| 197 |
+
scoped_item_path = None
|
| 198 |
+
elif scope is Scope.Package:
|
| 199 |
+
# Package key = module's directory.
|
| 200 |
+
scoped_item_path = item.path.parent
|
| 201 |
+
elif scope is Scope.Module:
|
| 202 |
+
scoped_item_path = item.path
|
| 203 |
+
elif scope is Scope.Class:
|
| 204 |
+
scoped_item_path = item.path
|
| 205 |
+
item_cls = item.cls # type: ignore[attr-defined]
|
| 206 |
+
else:
|
| 207 |
+
assert_never(scope)
|
| 208 |
+
|
| 209 |
+
for argname in callspec.indices:
|
| 210 |
+
if callspec._arg2scope[argname] != scope:
|
| 211 |
+
continue
|
| 212 |
+
param_index = callspec.indices[argname]
|
| 213 |
+
yield FixtureArgKey(argname, param_index, scoped_item_path, item_cls)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def reorder_items(items: Sequence[nodes.Item]) -> list[nodes.Item]:
|
| 217 |
+
argkeys_by_item: dict[Scope, dict[nodes.Item, OrderedSet[FixtureArgKey]]] = {}
|
| 218 |
+
items_by_argkey: dict[
|
| 219 |
+
Scope, dict[FixtureArgKey, OrderedDict[nodes.Item, None]]
|
| 220 |
+
] = {}
|
| 221 |
+
for scope in HIGH_SCOPES:
|
| 222 |
+
scoped_argkeys_by_item = argkeys_by_item[scope] = {}
|
| 223 |
+
scoped_items_by_argkey = items_by_argkey[scope] = defaultdict(OrderedDict)
|
| 224 |
+
for item in items:
|
| 225 |
+
argkeys = dict.fromkeys(get_parametrized_fixture_argkeys(item, scope))
|
| 226 |
+
if argkeys:
|
| 227 |
+
scoped_argkeys_by_item[item] = argkeys
|
| 228 |
+
for argkey in argkeys:
|
| 229 |
+
scoped_items_by_argkey[argkey][item] = None
|
| 230 |
+
|
| 231 |
+
items_set = dict.fromkeys(items)
|
| 232 |
+
return list(
|
| 233 |
+
reorder_items_atscope(
|
| 234 |
+
items_set, argkeys_by_item, items_by_argkey, Scope.Session
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def reorder_items_atscope(
|
| 240 |
+
items: OrderedSet[nodes.Item],
|
| 241 |
+
argkeys_by_item: Mapping[Scope, Mapping[nodes.Item, OrderedSet[FixtureArgKey]]],
|
| 242 |
+
items_by_argkey: Mapping[
|
| 243 |
+
Scope, Mapping[FixtureArgKey, OrderedDict[nodes.Item, None]]
|
| 244 |
+
],
|
| 245 |
+
scope: Scope,
|
| 246 |
+
) -> OrderedSet[nodes.Item]:
|
| 247 |
+
if scope is Scope.Function or len(items) < 3:
|
| 248 |
+
return items
|
| 249 |
+
|
| 250 |
+
scoped_items_by_argkey = items_by_argkey[scope]
|
| 251 |
+
scoped_argkeys_by_item = argkeys_by_item[scope]
|
| 252 |
+
|
| 253 |
+
ignore: set[FixtureArgKey] = set()
|
| 254 |
+
items_deque = deque(items)
|
| 255 |
+
items_done: OrderedSet[nodes.Item] = {}
|
| 256 |
+
while items_deque:
|
| 257 |
+
no_argkey_items: OrderedSet[nodes.Item] = {}
|
| 258 |
+
slicing_argkey = None
|
| 259 |
+
while items_deque:
|
| 260 |
+
item = items_deque.popleft()
|
| 261 |
+
if item in items_done or item in no_argkey_items:
|
| 262 |
+
continue
|
| 263 |
+
argkeys = dict.fromkeys(
|
| 264 |
+
k for k in scoped_argkeys_by_item.get(item, ()) if k not in ignore
|
| 265 |
+
)
|
| 266 |
+
if not argkeys:
|
| 267 |
+
no_argkey_items[item] = None
|
| 268 |
+
else:
|
| 269 |
+
slicing_argkey, _ = argkeys.popitem()
|
| 270 |
+
# We don't have to remove relevant items from later in the
|
| 271 |
+
# deque because they'll just be ignored.
|
| 272 |
+
matching_items = [
|
| 273 |
+
i for i in scoped_items_by_argkey[slicing_argkey] if i in items
|
| 274 |
+
]
|
| 275 |
+
for i in reversed(matching_items):
|
| 276 |
+
items_deque.appendleft(i)
|
| 277 |
+
# Fix items_by_argkey order.
|
| 278 |
+
for other_scope in HIGH_SCOPES:
|
| 279 |
+
other_scoped_items_by_argkey = items_by_argkey[other_scope]
|
| 280 |
+
for argkey in argkeys_by_item[other_scope].get(i, ()):
|
| 281 |
+
other_scoped_items_by_argkey[argkey][i] = None
|
| 282 |
+
other_scoped_items_by_argkey[argkey].move_to_end(
|
| 283 |
+
i, last=False
|
| 284 |
+
)
|
| 285 |
+
break
|
| 286 |
+
if no_argkey_items:
|
| 287 |
+
reordered_no_argkey_items = reorder_items_atscope(
|
| 288 |
+
no_argkey_items, argkeys_by_item, items_by_argkey, scope.next_lower()
|
| 289 |
+
)
|
| 290 |
+
items_done.update(reordered_no_argkey_items)
|
| 291 |
+
if slicing_argkey is not None:
|
| 292 |
+
ignore.add(slicing_argkey)
|
| 293 |
+
return items_done
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@dataclasses.dataclass(frozen=True)
|
| 297 |
+
class FuncFixtureInfo:
|
| 298 |
+
"""Fixture-related information for a fixture-requesting item (e.g. test
|
| 299 |
+
function).
|
| 300 |
+
|
| 301 |
+
This is used to examine the fixtures which an item requests statically
|
| 302 |
+
(known during collection). This includes autouse fixtures, fixtures
|
| 303 |
+
requested by the `usefixtures` marker, fixtures requested in the function
|
| 304 |
+
parameters, and the transitive closure of these.
|
| 305 |
+
|
| 306 |
+
An item may also request fixtures dynamically (using `request.getfixturevalue`);
|
| 307 |
+
these are not reflected here.
|
| 308 |
+
"""
|
| 309 |
+
|
| 310 |
+
__slots__ = ("argnames", "initialnames", "names_closure", "name2fixturedefs")
|
| 311 |
+
|
| 312 |
+
# Fixture names that the item requests directly by function parameters.
|
| 313 |
+
argnames: tuple[str, ...]
|
| 314 |
+
# Fixture names that the item immediately requires. These include
|
| 315 |
+
# argnames + fixture names specified via usefixtures and via autouse=True in
|
| 316 |
+
# fixture definitions.
|
| 317 |
+
initialnames: tuple[str, ...]
|
| 318 |
+
# The transitive closure of the fixture names that the item requires.
|
| 319 |
+
# Note: can't include dynamic dependencies (`request.getfixturevalue` calls).
|
| 320 |
+
names_closure: list[str]
|
| 321 |
+
# A map from a fixture name in the transitive closure to the FixtureDefs
|
| 322 |
+
# matching the name which are applicable to this function.
|
| 323 |
+
# There may be multiple overriding fixtures with the same name. The
|
| 324 |
+
# sequence is ordered from furthest to closes to the function.
|
| 325 |
+
name2fixturedefs: dict[str, Sequence[FixtureDef[Any]]]
|
| 326 |
+
|
| 327 |
+
def prune_dependency_tree(self) -> None:
|
| 328 |
+
"""Recompute names_closure from initialnames and name2fixturedefs.
|
| 329 |
+
|
| 330 |
+
Can only reduce names_closure, which means that the new closure will
|
| 331 |
+
always be a subset of the old one. The order is preserved.
|
| 332 |
+
|
| 333 |
+
This method is needed because direct parametrization may shadow some
|
| 334 |
+
of the fixtures that were included in the originally built dependency
|
| 335 |
+
tree. In this way the dependency tree can get pruned, and the closure
|
| 336 |
+
of argnames may get reduced.
|
| 337 |
+
"""
|
| 338 |
+
closure: set[str] = set()
|
| 339 |
+
working_set = set(self.initialnames)
|
| 340 |
+
while working_set:
|
| 341 |
+
argname = working_set.pop()
|
| 342 |
+
# Argname may be something not included in the original names_closure,
|
| 343 |
+
# in which case we ignore it. This currently happens with pseudo
|
| 344 |
+
# FixtureDefs which wrap 'get_direct_param_fixture_func(request)'.
|
| 345 |
+
# So they introduce the new dependency 'request' which might have
|
| 346 |
+
# been missing in the original tree (closure).
|
| 347 |
+
if argname not in closure and argname in self.names_closure:
|
| 348 |
+
closure.add(argname)
|
| 349 |
+
if argname in self.name2fixturedefs:
|
| 350 |
+
working_set.update(self.name2fixturedefs[argname][-1].argnames)
|
| 351 |
+
|
| 352 |
+
self.names_closure[:] = sorted(closure, key=self.names_closure.index)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
class FixtureRequest(abc.ABC):
|
| 356 |
+
"""The type of the ``request`` fixture.
|
| 357 |
+
|
| 358 |
+
A request object gives access to the requesting test context and has a
|
| 359 |
+
``param`` attribute in case the fixture is parametrized.
|
| 360 |
+
"""
|
| 361 |
+
|
| 362 |
+
def __init__(
|
| 363 |
+
self,
|
| 364 |
+
pyfuncitem: Function,
|
| 365 |
+
fixturename: str | None,
|
| 366 |
+
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]],
|
| 367 |
+
fixture_defs: dict[str, FixtureDef[Any]],
|
| 368 |
+
*,
|
| 369 |
+
_ispytest: bool = False,
|
| 370 |
+
) -> None:
|
| 371 |
+
check_ispytest(_ispytest)
|
| 372 |
+
#: Fixture for which this request is being performed.
|
| 373 |
+
self.fixturename: Final = fixturename
|
| 374 |
+
self._pyfuncitem: Final = pyfuncitem
|
| 375 |
+
# The FixtureDefs for each fixture name requested by this item.
|
| 376 |
+
# Starts from the statically-known fixturedefs resolved during
|
| 377 |
+
# collection. Dynamically requested fixtures (using
|
| 378 |
+
# `request.getfixturevalue("foo")`) are added dynamically.
|
| 379 |
+
self._arg2fixturedefs: Final = arg2fixturedefs
|
| 380 |
+
# The evaluated argnames so far, mapping to the FixtureDef they resolved
|
| 381 |
+
# to.
|
| 382 |
+
self._fixture_defs: Final = fixture_defs
|
| 383 |
+
# Notes on the type of `param`:
|
| 384 |
+
# -`request.param` is only defined in parametrized fixtures, and will raise
|
| 385 |
+
# AttributeError otherwise. Python typing has no notion of "undefined", so
|
| 386 |
+
# this cannot be reflected in the type.
|
| 387 |
+
# - Technically `param` is only (possibly) defined on SubRequest, not
|
| 388 |
+
# FixtureRequest, but the typing of that is still in flux so this cheats.
|
| 389 |
+
# - In the future we might consider using a generic for the param type, but
|
| 390 |
+
# for now just using Any.
|
| 391 |
+
self.param: Any
|
| 392 |
+
|
| 393 |
+
@property
|
| 394 |
+
def _fixturemanager(self) -> FixtureManager:
|
| 395 |
+
return self._pyfuncitem.session._fixturemanager
|
| 396 |
+
|
| 397 |
+
@property
|
| 398 |
+
@abc.abstractmethod
|
| 399 |
+
def _scope(self) -> Scope:
|
| 400 |
+
raise NotImplementedError()
|
| 401 |
+
|
| 402 |
+
@property
|
| 403 |
+
def scope(self) -> _ScopeName:
|
| 404 |
+
"""Scope string, one of "function", "class", "module", "package", "session"."""
|
| 405 |
+
return self._scope.value
|
| 406 |
+
|
| 407 |
+
@abc.abstractmethod
|
| 408 |
+
def _check_scope(
|
| 409 |
+
self,
|
| 410 |
+
requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object],
|
| 411 |
+
requested_scope: Scope,
|
| 412 |
+
) -> None:
|
| 413 |
+
raise NotImplementedError()
|
| 414 |
+
|
| 415 |
+
@property
|
| 416 |
+
def fixturenames(self) -> list[str]:
|
| 417 |
+
"""Names of all active fixtures in this request."""
|
| 418 |
+
result = list(self._pyfuncitem.fixturenames)
|
| 419 |
+
result.extend(set(self._fixture_defs).difference(result))
|
| 420 |
+
return result
|
| 421 |
+
|
| 422 |
+
@property
|
| 423 |
+
@abc.abstractmethod
|
| 424 |
+
def node(self):
|
| 425 |
+
"""Underlying collection node (depends on current request scope)."""
|
| 426 |
+
raise NotImplementedError()
|
| 427 |
+
|
| 428 |
+
@property
|
| 429 |
+
def config(self) -> Config:
|
| 430 |
+
"""The pytest config object associated with this request."""
|
| 431 |
+
return self._pyfuncitem.config
|
| 432 |
+
|
| 433 |
+
@property
|
| 434 |
+
def function(self):
|
| 435 |
+
"""Test function object if the request has a per-function scope."""
|
| 436 |
+
if self.scope != "function":
|
| 437 |
+
raise AttributeError(
|
| 438 |
+
f"function not available in {self.scope}-scoped context"
|
| 439 |
+
)
|
| 440 |
+
return self._pyfuncitem.obj
|
| 441 |
+
|
| 442 |
+
@property
|
| 443 |
+
def cls(self):
|
| 444 |
+
"""Class (can be None) where the test function was collected."""
|
| 445 |
+
if self.scope not in ("class", "function"):
|
| 446 |
+
raise AttributeError(f"cls not available in {self.scope}-scoped context")
|
| 447 |
+
clscol = self._pyfuncitem.getparent(_pytest.python.Class)
|
| 448 |
+
if clscol:
|
| 449 |
+
return clscol.obj
|
| 450 |
+
|
| 451 |
+
@property
|
| 452 |
+
def instance(self):
|
| 453 |
+
"""Instance (can be None) on which test function was collected."""
|
| 454 |
+
if self.scope != "function":
|
| 455 |
+
return None
|
| 456 |
+
return getattr(self._pyfuncitem, "instance", None)
|
| 457 |
+
|
| 458 |
+
@property
|
| 459 |
+
def module(self):
|
| 460 |
+
"""Python module object where the test function was collected."""
|
| 461 |
+
if self.scope not in ("function", "class", "module"):
|
| 462 |
+
raise AttributeError(f"module not available in {self.scope}-scoped context")
|
| 463 |
+
mod = self._pyfuncitem.getparent(_pytest.python.Module)
|
| 464 |
+
assert mod is not None
|
| 465 |
+
return mod.obj
|
| 466 |
+
|
| 467 |
+
@property
|
| 468 |
+
def path(self) -> Path:
|
| 469 |
+
"""Path where the test function was collected."""
|
| 470 |
+
if self.scope not in ("function", "class", "module", "package"):
|
| 471 |
+
raise AttributeError(f"path not available in {self.scope}-scoped context")
|
| 472 |
+
return self._pyfuncitem.path
|
| 473 |
+
|
| 474 |
+
@property
|
| 475 |
+
def keywords(self) -> MutableMapping[str, Any]:
|
| 476 |
+
"""Keywords/markers dictionary for the underlying node."""
|
| 477 |
+
node: nodes.Node = self.node
|
| 478 |
+
return node.keywords
|
| 479 |
+
|
| 480 |
+
@property
|
| 481 |
+
def session(self) -> Session:
|
| 482 |
+
"""Pytest session object."""
|
| 483 |
+
return self._pyfuncitem.session
|
| 484 |
+
|
| 485 |
+
@abc.abstractmethod
|
| 486 |
+
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
|
| 487 |
+
"""Add finalizer/teardown function to be called without arguments after
|
| 488 |
+
the last test within the requesting test context finished execution."""
|
| 489 |
+
raise NotImplementedError()
|
| 490 |
+
|
| 491 |
+
def applymarker(self, marker: str | MarkDecorator) -> None:
|
| 492 |
+
"""Apply a marker to a single test function invocation.
|
| 493 |
+
|
| 494 |
+
This method is useful if you don't want to have a keyword/marker
|
| 495 |
+
on all function invocations.
|
| 496 |
+
|
| 497 |
+
:param marker:
|
| 498 |
+
An object created by a call to ``pytest.mark.NAME(...)``.
|
| 499 |
+
"""
|
| 500 |
+
self.node.add_marker(marker)
|
| 501 |
+
|
| 502 |
+
def raiseerror(self, msg: str | None) -> NoReturn:
|
| 503 |
+
"""Raise a FixtureLookupError exception.
|
| 504 |
+
|
| 505 |
+
:param msg:
|
| 506 |
+
An optional custom error message.
|
| 507 |
+
"""
|
| 508 |
+
raise FixtureLookupError(None, self, msg)
|
| 509 |
+
|
| 510 |
+
def getfixturevalue(self, argname: str) -> Any:
|
| 511 |
+
"""Dynamically run a named fixture function.
|
| 512 |
+
|
| 513 |
+
Declaring fixtures via function argument is recommended where possible.
|
| 514 |
+
But if you can only decide whether to use another fixture at test
|
| 515 |
+
setup time, you may use this function to retrieve it inside a fixture
|
| 516 |
+
or test function body.
|
| 517 |
+
|
| 518 |
+
This method can be used during the test setup phase or the test run
|
| 519 |
+
phase, but during the test teardown phase a fixture's value may not
|
| 520 |
+
be available.
|
| 521 |
+
|
| 522 |
+
:param argname:
|
| 523 |
+
The fixture name.
|
| 524 |
+
:raises pytest.FixtureLookupError:
|
| 525 |
+
If the given fixture could not be found.
|
| 526 |
+
"""
|
| 527 |
+
# Note that in addition to the use case described in the docstring,
|
| 528 |
+
# getfixturevalue() is also called by pytest itself during item and fixture
|
| 529 |
+
# setup to evaluate the fixtures that are requested statically
|
| 530 |
+
# (using function parameters, autouse, etc).
|
| 531 |
+
|
| 532 |
+
fixturedef = self._get_active_fixturedef(argname)
|
| 533 |
+
assert fixturedef.cached_result is not None, (
|
| 534 |
+
f'The fixture value for "{argname}" is not available. '
|
| 535 |
+
"This can happen when the fixture has already been torn down."
|
| 536 |
+
)
|
| 537 |
+
return fixturedef.cached_result[0]
|
| 538 |
+
|
| 539 |
+
def _iter_chain(self) -> Iterator[SubRequest]:
|
| 540 |
+
"""Yield all SubRequests in the chain, from self up.
|
| 541 |
+
|
| 542 |
+
Note: does *not* yield the TopRequest.
|
| 543 |
+
"""
|
| 544 |
+
current = self
|
| 545 |
+
while isinstance(current, SubRequest):
|
| 546 |
+
yield current
|
| 547 |
+
current = current._parent_request
|
| 548 |
+
|
| 549 |
+
def _get_active_fixturedef(
|
| 550 |
+
self, argname: str
|
| 551 |
+
) -> FixtureDef[object] | PseudoFixtureDef[object]:
|
| 552 |
+
if argname == "request":
|
| 553 |
+
cached_result = (self, [0], None)
|
| 554 |
+
return PseudoFixtureDef(cached_result, Scope.Function)
|
| 555 |
+
|
| 556 |
+
# If we already finished computing a fixture by this name in this item,
|
| 557 |
+
# return it.
|
| 558 |
+
fixturedef = self._fixture_defs.get(argname)
|
| 559 |
+
if fixturedef is not None:
|
| 560 |
+
self._check_scope(fixturedef, fixturedef._scope)
|
| 561 |
+
return fixturedef
|
| 562 |
+
|
| 563 |
+
# Find the appropriate fixturedef.
|
| 564 |
+
fixturedefs = self._arg2fixturedefs.get(argname, None)
|
| 565 |
+
if fixturedefs is None:
|
| 566 |
+
# We arrive here because of a dynamic call to
|
| 567 |
+
# getfixturevalue(argname) which was naturally
|
| 568 |
+
# not known at parsing/collection time.
|
| 569 |
+
fixturedefs = self._fixturemanager.getfixturedefs(argname, self._pyfuncitem)
|
| 570 |
+
if fixturedefs is not None:
|
| 571 |
+
self._arg2fixturedefs[argname] = fixturedefs
|
| 572 |
+
# No fixtures defined with this name.
|
| 573 |
+
if fixturedefs is None:
|
| 574 |
+
raise FixtureLookupError(argname, self)
|
| 575 |
+
# The are no fixtures with this name applicable for the function.
|
| 576 |
+
if not fixturedefs:
|
| 577 |
+
raise FixtureLookupError(argname, self)
|
| 578 |
+
# A fixture may override another fixture with the same name, e.g. a
|
| 579 |
+
# fixture in a module can override a fixture in a conftest, a fixture in
|
| 580 |
+
# a class can override a fixture in the module, and so on.
|
| 581 |
+
# An overriding fixture can request its own name (possibly indirectly);
|
| 582 |
+
# in this case it gets the value of the fixture it overrides, one level
|
| 583 |
+
# up.
|
| 584 |
+
# Check how many `argname`s deep we are, and take the next one.
|
| 585 |
+
# `fixturedefs` is sorted from furthest to closest, so use negative
|
| 586 |
+
# indexing to go in reverse.
|
| 587 |
+
index = -1
|
| 588 |
+
for request in self._iter_chain():
|
| 589 |
+
if request.fixturename == argname:
|
| 590 |
+
index -= 1
|
| 591 |
+
# If already consumed all of the available levels, fail.
|
| 592 |
+
if -index > len(fixturedefs):
|
| 593 |
+
raise FixtureLookupError(argname, self)
|
| 594 |
+
fixturedef = fixturedefs[index]
|
| 595 |
+
|
| 596 |
+
# Prepare a SubRequest object for calling the fixture.
|
| 597 |
+
try:
|
| 598 |
+
callspec = self._pyfuncitem.callspec
|
| 599 |
+
except AttributeError:
|
| 600 |
+
callspec = None
|
| 601 |
+
if callspec is not None and argname in callspec.params:
|
| 602 |
+
param = callspec.params[argname]
|
| 603 |
+
param_index = callspec.indices[argname]
|
| 604 |
+
# The parametrize invocation scope overrides the fixture's scope.
|
| 605 |
+
scope = callspec._arg2scope[argname]
|
| 606 |
+
else:
|
| 607 |
+
param = NOTSET
|
| 608 |
+
param_index = 0
|
| 609 |
+
scope = fixturedef._scope
|
| 610 |
+
self._check_fixturedef_without_param(fixturedef)
|
| 611 |
+
self._check_scope(fixturedef, scope)
|
| 612 |
+
subrequest = SubRequest(
|
| 613 |
+
self, scope, param, param_index, fixturedef, _ispytest=True
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
# Make sure the fixture value is cached, running it if it isn't
|
| 617 |
+
fixturedef.execute(request=subrequest)
|
| 618 |
+
|
| 619 |
+
self._fixture_defs[argname] = fixturedef
|
| 620 |
+
return fixturedef
|
| 621 |
+
|
| 622 |
+
def _check_fixturedef_without_param(self, fixturedef: FixtureDef[object]) -> None:
|
| 623 |
+
"""Check that this request is allowed to execute this fixturedef without
|
| 624 |
+
a param."""
|
| 625 |
+
funcitem = self._pyfuncitem
|
| 626 |
+
has_params = fixturedef.params is not None
|
| 627 |
+
fixtures_not_supported = getattr(funcitem, "nofuncargs", False)
|
| 628 |
+
if has_params and fixtures_not_supported:
|
| 629 |
+
msg = (
|
| 630 |
+
f"{funcitem.name} does not support fixtures, maybe unittest.TestCase subclass?\n"
|
| 631 |
+
f"Node id: {funcitem.nodeid}\n"
|
| 632 |
+
f"Function type: {type(funcitem).__name__}"
|
| 633 |
+
)
|
| 634 |
+
fail(msg, pytrace=False)
|
| 635 |
+
if has_params:
|
| 636 |
+
frame = inspect.stack()[3]
|
| 637 |
+
frameinfo = inspect.getframeinfo(frame[0])
|
| 638 |
+
source_path = absolutepath(frameinfo.filename)
|
| 639 |
+
source_lineno = frameinfo.lineno
|
| 640 |
+
try:
|
| 641 |
+
source_path_str = str(source_path.relative_to(funcitem.config.rootpath))
|
| 642 |
+
except ValueError:
|
| 643 |
+
source_path_str = str(source_path)
|
| 644 |
+
location = getlocation(fixturedef.func, funcitem.config.rootpath)
|
| 645 |
+
msg = (
|
| 646 |
+
"The requested fixture has no parameter defined for test:\n"
|
| 647 |
+
f" {funcitem.nodeid}\n\n"
|
| 648 |
+
f"Requested fixture '{fixturedef.argname}' defined in:\n"
|
| 649 |
+
f"{location}\n\n"
|
| 650 |
+
f"Requested here:\n"
|
| 651 |
+
f"{source_path_str}:{source_lineno}"
|
| 652 |
+
)
|
| 653 |
+
fail(msg, pytrace=False)
|
| 654 |
+
|
| 655 |
+
def _get_fixturestack(self) -> list[FixtureDef[Any]]:
|
| 656 |
+
values = [request._fixturedef for request in self._iter_chain()]
|
| 657 |
+
values.reverse()
|
| 658 |
+
return values
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
@final
|
| 662 |
+
class TopRequest(FixtureRequest):
|
| 663 |
+
"""The type of the ``request`` fixture in a test function."""
|
| 664 |
+
|
| 665 |
+
def __init__(self, pyfuncitem: Function, *, _ispytest: bool = False) -> None:
|
| 666 |
+
super().__init__(
|
| 667 |
+
fixturename=None,
|
| 668 |
+
pyfuncitem=pyfuncitem,
|
| 669 |
+
arg2fixturedefs=pyfuncitem._fixtureinfo.name2fixturedefs.copy(),
|
| 670 |
+
fixture_defs={},
|
| 671 |
+
_ispytest=_ispytest,
|
| 672 |
+
)
|
| 673 |
+
|
| 674 |
+
@property
|
| 675 |
+
def _scope(self) -> Scope:
|
| 676 |
+
return Scope.Function
|
| 677 |
+
|
| 678 |
+
def _check_scope(
|
| 679 |
+
self,
|
| 680 |
+
requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object],
|
| 681 |
+
requested_scope: Scope,
|
| 682 |
+
) -> None:
|
| 683 |
+
# TopRequest always has function scope so always valid.
|
| 684 |
+
pass
|
| 685 |
+
|
| 686 |
+
@property
|
| 687 |
+
def node(self):
|
| 688 |
+
return self._pyfuncitem
|
| 689 |
+
|
| 690 |
+
def __repr__(self) -> str:
|
| 691 |
+
return f"<FixtureRequest for {self.node!r}>"
|
| 692 |
+
|
| 693 |
+
def _fillfixtures(self) -> None:
|
| 694 |
+
item = self._pyfuncitem
|
| 695 |
+
for argname in item.fixturenames:
|
| 696 |
+
if argname not in item.funcargs:
|
| 697 |
+
item.funcargs[argname] = self.getfixturevalue(argname)
|
| 698 |
+
|
| 699 |
+
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
|
| 700 |
+
self.node.addfinalizer(finalizer)
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
@final
|
| 704 |
+
class SubRequest(FixtureRequest):
|
| 705 |
+
"""The type of the ``request`` fixture in a fixture function requested
|
| 706 |
+
(transitively) by a test function."""
|
| 707 |
+
|
| 708 |
+
def __init__(
|
| 709 |
+
self,
|
| 710 |
+
request: FixtureRequest,
|
| 711 |
+
scope: Scope,
|
| 712 |
+
param: Any,
|
| 713 |
+
param_index: int,
|
| 714 |
+
fixturedef: FixtureDef[object],
|
| 715 |
+
*,
|
| 716 |
+
_ispytest: bool = False,
|
| 717 |
+
) -> None:
|
| 718 |
+
super().__init__(
|
| 719 |
+
pyfuncitem=request._pyfuncitem,
|
| 720 |
+
fixturename=fixturedef.argname,
|
| 721 |
+
fixture_defs=request._fixture_defs,
|
| 722 |
+
arg2fixturedefs=request._arg2fixturedefs,
|
| 723 |
+
_ispytest=_ispytest,
|
| 724 |
+
)
|
| 725 |
+
self._parent_request: Final[FixtureRequest] = request
|
| 726 |
+
self._scope_field: Final = scope
|
| 727 |
+
self._fixturedef: Final[FixtureDef[object]] = fixturedef
|
| 728 |
+
if param is not NOTSET:
|
| 729 |
+
self.param = param
|
| 730 |
+
self.param_index: Final = param_index
|
| 731 |
+
|
| 732 |
+
def __repr__(self) -> str:
|
| 733 |
+
return f"<SubRequest {self.fixturename!r} for {self._pyfuncitem!r}>"
|
| 734 |
+
|
| 735 |
+
@property
|
| 736 |
+
def _scope(self) -> Scope:
|
| 737 |
+
return self._scope_field
|
| 738 |
+
|
| 739 |
+
@property
|
| 740 |
+
def node(self):
|
| 741 |
+
scope = self._scope
|
| 742 |
+
if scope is Scope.Function:
|
| 743 |
+
# This might also be a non-function Item despite its attribute name.
|
| 744 |
+
node: nodes.Node | None = self._pyfuncitem
|
| 745 |
+
elif scope is Scope.Package:
|
| 746 |
+
node = get_scope_package(self._pyfuncitem, self._fixturedef)
|
| 747 |
+
else:
|
| 748 |
+
node = get_scope_node(self._pyfuncitem, scope)
|
| 749 |
+
if node is None and scope is Scope.Class:
|
| 750 |
+
# Fallback to function item itself.
|
| 751 |
+
node = self._pyfuncitem
|
| 752 |
+
assert node, f'Could not obtain a node for scope "{scope}" for function {self._pyfuncitem!r}'
|
| 753 |
+
return node
|
| 754 |
+
|
| 755 |
+
def _check_scope(
|
| 756 |
+
self,
|
| 757 |
+
requested_fixturedef: FixtureDef[object] | PseudoFixtureDef[object],
|
| 758 |
+
requested_scope: Scope,
|
| 759 |
+
) -> None:
|
| 760 |
+
if isinstance(requested_fixturedef, PseudoFixtureDef):
|
| 761 |
+
return
|
| 762 |
+
if self._scope > requested_scope:
|
| 763 |
+
# Try to report something helpful.
|
| 764 |
+
argname = requested_fixturedef.argname
|
| 765 |
+
fixture_stack = "\n".join(
|
| 766 |
+
self._format_fixturedef_line(fixturedef)
|
| 767 |
+
for fixturedef in self._get_fixturestack()
|
| 768 |
+
)
|
| 769 |
+
requested_fixture = self._format_fixturedef_line(requested_fixturedef)
|
| 770 |
+
fail(
|
| 771 |
+
f"ScopeMismatch: You tried to access the {requested_scope.value} scoped "
|
| 772 |
+
f"fixture {argname} with a {self._scope.value} scoped request object. "
|
| 773 |
+
f"Requesting fixture stack:\n{fixture_stack}\n"
|
| 774 |
+
f"Requested fixture:\n{requested_fixture}",
|
| 775 |
+
pytrace=False,
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
def _format_fixturedef_line(self, fixturedef: FixtureDef[object]) -> str:
|
| 779 |
+
factory = fixturedef.func
|
| 780 |
+
path, lineno = getfslineno(factory)
|
| 781 |
+
if isinstance(path, Path):
|
| 782 |
+
path = bestrelpath(self._pyfuncitem.session.path, path)
|
| 783 |
+
signature = inspect.signature(factory)
|
| 784 |
+
return f"{path}:{lineno + 1}: def {factory.__name__}{signature}"
|
| 785 |
+
|
| 786 |
+
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
|
| 787 |
+
self._fixturedef.addfinalizer(finalizer)
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
@final
|
| 791 |
+
class FixtureLookupError(LookupError):
|
| 792 |
+
"""Could not return a requested fixture (missing or invalid)."""
|
| 793 |
+
|
| 794 |
+
def __init__(
|
| 795 |
+
self, argname: str | None, request: FixtureRequest, msg: str | None = None
|
| 796 |
+
) -> None:
|
| 797 |
+
self.argname = argname
|
| 798 |
+
self.request = request
|
| 799 |
+
self.fixturestack = request._get_fixturestack()
|
| 800 |
+
self.msg = msg
|
| 801 |
+
|
| 802 |
+
def formatrepr(self) -> FixtureLookupErrorRepr:
|
| 803 |
+
tblines: list[str] = []
|
| 804 |
+
addline = tblines.append
|
| 805 |
+
stack = [self.request._pyfuncitem.obj]
|
| 806 |
+
stack.extend(map(lambda x: x.func, self.fixturestack))
|
| 807 |
+
msg = self.msg
|
| 808 |
+
if msg is not None:
|
| 809 |
+
# The last fixture raise an error, let's present
|
| 810 |
+
# it at the requesting side.
|
| 811 |
+
stack = stack[:-1]
|
| 812 |
+
for function in stack:
|
| 813 |
+
fspath, lineno = getfslineno(function)
|
| 814 |
+
try:
|
| 815 |
+
lines, _ = inspect.getsourcelines(get_real_func(function))
|
| 816 |
+
except (OSError, IndexError, TypeError):
|
| 817 |
+
error_msg = "file %s, line %s: source code not available"
|
| 818 |
+
addline(error_msg % (fspath, lineno + 1))
|
| 819 |
+
else:
|
| 820 |
+
addline(f"file {fspath}, line {lineno + 1}")
|
| 821 |
+
for i, line in enumerate(lines):
|
| 822 |
+
line = line.rstrip()
|
| 823 |
+
addline(" " + line)
|
| 824 |
+
if line.lstrip().startswith("def"):
|
| 825 |
+
break
|
| 826 |
+
|
| 827 |
+
if msg is None:
|
| 828 |
+
fm = self.request._fixturemanager
|
| 829 |
+
available = set()
|
| 830 |
+
parent = self.request._pyfuncitem.parent
|
| 831 |
+
assert parent is not None
|
| 832 |
+
for name, fixturedefs in fm._arg2fixturedefs.items():
|
| 833 |
+
faclist = list(fm._matchfactories(fixturedefs, parent))
|
| 834 |
+
if faclist:
|
| 835 |
+
available.add(name)
|
| 836 |
+
if self.argname in available:
|
| 837 |
+
msg = (
|
| 838 |
+
f" recursive dependency involving fixture '{self.argname}' detected"
|
| 839 |
+
)
|
| 840 |
+
else:
|
| 841 |
+
msg = f"fixture '{self.argname}' not found"
|
| 842 |
+
msg += "\n available fixtures: {}".format(", ".join(sorted(available)))
|
| 843 |
+
msg += "\n use 'pytest --fixtures [testpath]' for help on them."
|
| 844 |
+
|
| 845 |
+
return FixtureLookupErrorRepr(fspath, lineno, tblines, msg, self.argname)
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
class FixtureLookupErrorRepr(TerminalRepr):
|
| 849 |
+
def __init__(
|
| 850 |
+
self,
|
| 851 |
+
filename: str | os.PathLike[str],
|
| 852 |
+
firstlineno: int,
|
| 853 |
+
tblines: Sequence[str],
|
| 854 |
+
errorstring: str,
|
| 855 |
+
argname: str | None,
|
| 856 |
+
) -> None:
|
| 857 |
+
self.tblines = tblines
|
| 858 |
+
self.errorstring = errorstring
|
| 859 |
+
self.filename = filename
|
| 860 |
+
self.firstlineno = firstlineno
|
| 861 |
+
self.argname = argname
|
| 862 |
+
|
| 863 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 864 |
+
# tw.line("FixtureLookupError: %s" %(self.argname), red=True)
|
| 865 |
+
for tbline in self.tblines:
|
| 866 |
+
tw.line(tbline.rstrip())
|
| 867 |
+
lines = self.errorstring.split("\n")
|
| 868 |
+
if lines:
|
| 869 |
+
tw.line(
|
| 870 |
+
f"{FormattedExcinfo.fail_marker} {lines[0].strip()}",
|
| 871 |
+
red=True,
|
| 872 |
+
)
|
| 873 |
+
for line in lines[1:]:
|
| 874 |
+
tw.line(
|
| 875 |
+
f"{FormattedExcinfo.flow_marker} {line.strip()}",
|
| 876 |
+
red=True,
|
| 877 |
+
)
|
| 878 |
+
tw.line()
|
| 879 |
+
tw.line("%s:%d" % (os.fspath(self.filename), self.firstlineno + 1))
|
| 880 |
+
|
| 881 |
+
|
| 882 |
+
def call_fixture_func(
|
| 883 |
+
fixturefunc: _FixtureFunc[FixtureValue], request: FixtureRequest, kwargs
|
| 884 |
+
) -> FixtureValue:
|
| 885 |
+
if is_generator(fixturefunc):
|
| 886 |
+
fixturefunc = cast(
|
| 887 |
+
Callable[..., Generator[FixtureValue, None, None]], fixturefunc
|
| 888 |
+
)
|
| 889 |
+
generator = fixturefunc(**kwargs)
|
| 890 |
+
try:
|
| 891 |
+
fixture_result = next(generator)
|
| 892 |
+
except StopIteration:
|
| 893 |
+
raise ValueError(f"{request.fixturename} did not yield a value") from None
|
| 894 |
+
finalizer = functools.partial(_teardown_yield_fixture, fixturefunc, generator)
|
| 895 |
+
request.addfinalizer(finalizer)
|
| 896 |
+
else:
|
| 897 |
+
fixturefunc = cast(Callable[..., FixtureValue], fixturefunc)
|
| 898 |
+
fixture_result = fixturefunc(**kwargs)
|
| 899 |
+
return fixture_result
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def _teardown_yield_fixture(fixturefunc, it) -> None:
|
| 903 |
+
"""Execute the teardown of a fixture function by advancing the iterator
|
| 904 |
+
after the yield and ensure the iteration ends (if not it means there is
|
| 905 |
+
more than one yield in the function)."""
|
| 906 |
+
try:
|
| 907 |
+
next(it)
|
| 908 |
+
except StopIteration:
|
| 909 |
+
pass
|
| 910 |
+
else:
|
| 911 |
+
fs, lineno = getfslineno(fixturefunc)
|
| 912 |
+
fail(
|
| 913 |
+
f"fixture function has more than one 'yield':\n\n"
|
| 914 |
+
f"{Source(fixturefunc).indent()}\n"
|
| 915 |
+
f"{fs}:{lineno + 1}",
|
| 916 |
+
pytrace=False,
|
| 917 |
+
)
|
| 918 |
+
|
| 919 |
+
|
| 920 |
+
def _eval_scope_callable(
|
| 921 |
+
scope_callable: Callable[[str, Config], _ScopeName],
|
| 922 |
+
fixture_name: str,
|
| 923 |
+
config: Config,
|
| 924 |
+
) -> _ScopeName:
|
| 925 |
+
try:
|
| 926 |
+
# Type ignored because there is no typing mechanism to specify
|
| 927 |
+
# keyword arguments, currently.
|
| 928 |
+
result = scope_callable(fixture_name=fixture_name, config=config) # type: ignore[call-arg]
|
| 929 |
+
except Exception as e:
|
| 930 |
+
raise TypeError(
|
| 931 |
+
f"Error evaluating {scope_callable} while defining fixture '{fixture_name}'.\n"
|
| 932 |
+
"Expected a function with the signature (*, fixture_name, config)"
|
| 933 |
+
) from e
|
| 934 |
+
if not isinstance(result, str):
|
| 935 |
+
fail(
|
| 936 |
+
f"Expected {scope_callable} to return a 'str' while defining fixture '{fixture_name}', but it returned:\n"
|
| 937 |
+
f"{result!r}",
|
| 938 |
+
pytrace=False,
|
| 939 |
+
)
|
| 940 |
+
return result
|
| 941 |
+
|
| 942 |
+
|
| 943 |
+
@final
|
| 944 |
+
class FixtureDef(Generic[FixtureValue]):
|
| 945 |
+
"""A container for a fixture definition.
|
| 946 |
+
|
| 947 |
+
Note: At this time, only explicitly documented fields and methods are
|
| 948 |
+
considered public stable API.
|
| 949 |
+
"""
|
| 950 |
+
|
| 951 |
+
def __init__(
|
| 952 |
+
self,
|
| 953 |
+
config: Config,
|
| 954 |
+
baseid: str | None,
|
| 955 |
+
argname: str,
|
| 956 |
+
func: _FixtureFunc[FixtureValue],
|
| 957 |
+
scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] | None,
|
| 958 |
+
params: Sequence[object] | None,
|
| 959 |
+
ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None,
|
| 960 |
+
*,
|
| 961 |
+
_ispytest: bool = False,
|
| 962 |
+
) -> None:
|
| 963 |
+
check_ispytest(_ispytest)
|
| 964 |
+
# The "base" node ID for the fixture.
|
| 965 |
+
#
|
| 966 |
+
# This is a node ID prefix. A fixture is only available to a node (e.g.
|
| 967 |
+
# a `Function` item) if the fixture's baseid is a nodeid of a parent of
|
| 968 |
+
# node.
|
| 969 |
+
#
|
| 970 |
+
# For a fixture found in a Collector's object (e.g. a `Module`s module,
|
| 971 |
+
# a `Class`'s class), the baseid is the Collector's nodeid.
|
| 972 |
+
#
|
| 973 |
+
# For a fixture found in a conftest plugin, the baseid is the conftest's
|
| 974 |
+
# directory path relative to the rootdir.
|
| 975 |
+
#
|
| 976 |
+
# For other plugins, the baseid is the empty string (always matches).
|
| 977 |
+
self.baseid: Final = baseid or ""
|
| 978 |
+
# Whether the fixture was found from a node or a conftest in the
|
| 979 |
+
# collection tree. Will be false for fixtures defined in non-conftest
|
| 980 |
+
# plugins.
|
| 981 |
+
self.has_location: Final = baseid is not None
|
| 982 |
+
# The fixture factory function.
|
| 983 |
+
self.func: Final = func
|
| 984 |
+
# The name by which the fixture may be requested.
|
| 985 |
+
self.argname: Final = argname
|
| 986 |
+
if scope is None:
|
| 987 |
+
scope = Scope.Function
|
| 988 |
+
elif callable(scope):
|
| 989 |
+
scope = _eval_scope_callable(scope, argname, config)
|
| 990 |
+
if isinstance(scope, str):
|
| 991 |
+
scope = Scope.from_user(
|
| 992 |
+
scope, descr=f"Fixture '{func.__name__}'", where=baseid
|
| 993 |
+
)
|
| 994 |
+
self._scope: Final = scope
|
| 995 |
+
# If the fixture is directly parametrized, the parameter values.
|
| 996 |
+
self.params: Final = params
|
| 997 |
+
# If the fixture is directly parametrized, a tuple of explicit IDs to
|
| 998 |
+
# assign to the parameter values, or a callable to generate an ID given
|
| 999 |
+
# a parameter value.
|
| 1000 |
+
self.ids: Final = ids
|
| 1001 |
+
# The names requested by the fixtures.
|
| 1002 |
+
self.argnames: Final = getfuncargnames(func, name=argname)
|
| 1003 |
+
# If the fixture was executed, the current value of the fixture.
|
| 1004 |
+
# Can change if the fixture is executed with different parameters.
|
| 1005 |
+
self.cached_result: _FixtureCachedResult[FixtureValue] | None = None
|
| 1006 |
+
self._finalizers: Final[list[Callable[[], object]]] = []
|
| 1007 |
+
|
| 1008 |
+
@property
|
| 1009 |
+
def scope(self) -> _ScopeName:
|
| 1010 |
+
"""Scope string, one of "function", "class", "module", "package", "session"."""
|
| 1011 |
+
return self._scope.value
|
| 1012 |
+
|
| 1013 |
+
def addfinalizer(self, finalizer: Callable[[], object]) -> None:
|
| 1014 |
+
self._finalizers.append(finalizer)
|
| 1015 |
+
|
| 1016 |
+
def finish(self, request: SubRequest) -> None:
|
| 1017 |
+
exceptions: list[BaseException] = []
|
| 1018 |
+
while self._finalizers:
|
| 1019 |
+
fin = self._finalizers.pop()
|
| 1020 |
+
try:
|
| 1021 |
+
fin()
|
| 1022 |
+
except BaseException as e:
|
| 1023 |
+
exceptions.append(e)
|
| 1024 |
+
node = request.node
|
| 1025 |
+
node.ihook.pytest_fixture_post_finalizer(fixturedef=self, request=request)
|
| 1026 |
+
# Even if finalization fails, we invalidate the cached fixture
|
| 1027 |
+
# value and remove all finalizers because they may be bound methods
|
| 1028 |
+
# which will keep instances alive.
|
| 1029 |
+
self.cached_result = None
|
| 1030 |
+
self._finalizers.clear()
|
| 1031 |
+
if len(exceptions) == 1:
|
| 1032 |
+
raise exceptions[0]
|
| 1033 |
+
elif len(exceptions) > 1:
|
| 1034 |
+
msg = f'errors while tearing down fixture "{self.argname}" of {node}'
|
| 1035 |
+
raise BaseExceptionGroup(msg, exceptions[::-1])
|
| 1036 |
+
|
| 1037 |
+
def execute(self, request: SubRequest) -> FixtureValue:
|
| 1038 |
+
"""Return the value of this fixture, executing it if not cached."""
|
| 1039 |
+
# Ensure that the dependent fixtures requested by this fixture are loaded.
|
| 1040 |
+
# This needs to be done before checking if we have a cached value, since
|
| 1041 |
+
# if a dependent fixture has their cache invalidated, e.g. due to
|
| 1042 |
+
# parametrization, they finalize themselves and fixtures depending on it
|
| 1043 |
+
# (which will likely include this fixture) setting `self.cached_result = None`.
|
| 1044 |
+
# See #4871
|
| 1045 |
+
requested_fixtures_that_should_finalize_us = []
|
| 1046 |
+
for argname in self.argnames:
|
| 1047 |
+
fixturedef = request._get_active_fixturedef(argname)
|
| 1048 |
+
# Saves requested fixtures in a list so we later can add our finalizer
|
| 1049 |
+
# to them, ensuring that if a requested fixture gets torn down we get torn
|
| 1050 |
+
# down first. This is generally handled by SetupState, but still currently
|
| 1051 |
+
# needed when this fixture is not parametrized but depends on a parametrized
|
| 1052 |
+
# fixture.
|
| 1053 |
+
if not isinstance(fixturedef, PseudoFixtureDef):
|
| 1054 |
+
requested_fixtures_that_should_finalize_us.append(fixturedef)
|
| 1055 |
+
|
| 1056 |
+
# Check for (and return) cached value/exception.
|
| 1057 |
+
if self.cached_result is not None:
|
| 1058 |
+
request_cache_key = self.cache_key(request)
|
| 1059 |
+
cache_key = self.cached_result[1]
|
| 1060 |
+
try:
|
| 1061 |
+
# Attempt to make a normal == check: this might fail for objects
|
| 1062 |
+
# which do not implement the standard comparison (like numpy arrays -- #6497).
|
| 1063 |
+
cache_hit = bool(request_cache_key == cache_key)
|
| 1064 |
+
except (ValueError, RuntimeError):
|
| 1065 |
+
# If the comparison raises, use 'is' as fallback.
|
| 1066 |
+
cache_hit = request_cache_key is cache_key
|
| 1067 |
+
|
| 1068 |
+
if cache_hit:
|
| 1069 |
+
if self.cached_result[2] is not None:
|
| 1070 |
+
exc, exc_tb = self.cached_result[2]
|
| 1071 |
+
raise exc.with_traceback(exc_tb)
|
| 1072 |
+
else:
|
| 1073 |
+
result = self.cached_result[0]
|
| 1074 |
+
return result
|
| 1075 |
+
# We have a previous but differently parametrized fixture instance
|
| 1076 |
+
# so we need to tear it down before creating a new one.
|
| 1077 |
+
self.finish(request)
|
| 1078 |
+
assert self.cached_result is None
|
| 1079 |
+
|
| 1080 |
+
# Add finalizer to requested fixtures we saved previously.
|
| 1081 |
+
# We make sure to do this after checking for cached value to avoid
|
| 1082 |
+
# adding our finalizer multiple times. (#12135)
|
| 1083 |
+
finalizer = functools.partial(self.finish, request=request)
|
| 1084 |
+
for parent_fixture in requested_fixtures_that_should_finalize_us:
|
| 1085 |
+
parent_fixture.addfinalizer(finalizer)
|
| 1086 |
+
|
| 1087 |
+
ihook = request.node.ihook
|
| 1088 |
+
try:
|
| 1089 |
+
# Setup the fixture, run the code in it, and cache the value
|
| 1090 |
+
# in self.cached_result
|
| 1091 |
+
result = ihook.pytest_fixture_setup(fixturedef=self, request=request)
|
| 1092 |
+
finally:
|
| 1093 |
+
# schedule our finalizer, even if the setup failed
|
| 1094 |
+
request.node.addfinalizer(finalizer)
|
| 1095 |
+
|
| 1096 |
+
return result
|
| 1097 |
+
|
| 1098 |
+
def cache_key(self, request: SubRequest) -> object:
|
| 1099 |
+
return getattr(request, "param", None)
|
| 1100 |
+
|
| 1101 |
+
def __repr__(self) -> str:
|
| 1102 |
+
return f"<FixtureDef argname={self.argname!r} scope={self.scope!r} baseid={self.baseid!r}>"
|
| 1103 |
+
|
| 1104 |
+
|
| 1105 |
+
def resolve_fixture_function(
|
| 1106 |
+
fixturedef: FixtureDef[FixtureValue], request: FixtureRequest
|
| 1107 |
+
) -> _FixtureFunc[FixtureValue]:
|
| 1108 |
+
"""Get the actual callable that can be called to obtain the fixture
|
| 1109 |
+
value."""
|
| 1110 |
+
fixturefunc = fixturedef.func
|
| 1111 |
+
# The fixture function needs to be bound to the actual
|
| 1112 |
+
# request.instance so that code working with "fixturedef" behaves
|
| 1113 |
+
# as expected.
|
| 1114 |
+
instance = request.instance
|
| 1115 |
+
if instance is not None:
|
| 1116 |
+
# Handle the case where fixture is defined not in a test class, but some other class
|
| 1117 |
+
# (for example a plugin class with a fixture), see #2270.
|
| 1118 |
+
if hasattr(fixturefunc, "__self__") and not isinstance(
|
| 1119 |
+
instance,
|
| 1120 |
+
fixturefunc.__self__.__class__,
|
| 1121 |
+
):
|
| 1122 |
+
return fixturefunc
|
| 1123 |
+
fixturefunc = getimfunc(fixturedef.func)
|
| 1124 |
+
if fixturefunc != fixturedef.func:
|
| 1125 |
+
fixturefunc = fixturefunc.__get__(instance)
|
| 1126 |
+
return fixturefunc
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
def pytest_fixture_setup(
|
| 1130 |
+
fixturedef: FixtureDef[FixtureValue], request: SubRequest
|
| 1131 |
+
) -> FixtureValue:
|
| 1132 |
+
"""Execution of fixture setup."""
|
| 1133 |
+
kwargs = {}
|
| 1134 |
+
for argname in fixturedef.argnames:
|
| 1135 |
+
kwargs[argname] = request.getfixturevalue(argname)
|
| 1136 |
+
|
| 1137 |
+
fixturefunc = resolve_fixture_function(fixturedef, request)
|
| 1138 |
+
my_cache_key = fixturedef.cache_key(request)
|
| 1139 |
+
try:
|
| 1140 |
+
result = call_fixture_func(fixturefunc, request, kwargs)
|
| 1141 |
+
except TEST_OUTCOME as e:
|
| 1142 |
+
if isinstance(e, skip.Exception):
|
| 1143 |
+
# The test requested a fixture which caused a skip.
|
| 1144 |
+
# Don't show the fixture as the skip location, as then the user
|
| 1145 |
+
# wouldn't know which test skipped.
|
| 1146 |
+
e._use_item_location = True
|
| 1147 |
+
fixturedef.cached_result = (None, my_cache_key, (e, e.__traceback__))
|
| 1148 |
+
raise
|
| 1149 |
+
fixturedef.cached_result = (result, my_cache_key, None)
|
| 1150 |
+
return result
|
| 1151 |
+
|
| 1152 |
+
|
| 1153 |
+
def wrap_function_to_error_out_if_called_directly(
|
| 1154 |
+
function: FixtureFunction,
|
| 1155 |
+
fixture_marker: FixtureFunctionMarker,
|
| 1156 |
+
) -> FixtureFunction:
|
| 1157 |
+
"""Wrap the given fixture function so we can raise an error about it being called directly,
|
| 1158 |
+
instead of used as an argument in a test function."""
|
| 1159 |
+
name = fixture_marker.name or function.__name__
|
| 1160 |
+
message = (
|
| 1161 |
+
f'Fixture "{name}" called directly. Fixtures are not meant to be called directly,\n'
|
| 1162 |
+
"but are created automatically when test functions request them as parameters.\n"
|
| 1163 |
+
"See https://docs.pytest.org/en/stable/explanation/fixtures.html for more information about fixtures, and\n"
|
| 1164 |
+
"https://docs.pytest.org/en/stable/deprecations.html#calling-fixtures-directly about how to update your code."
|
| 1165 |
+
)
|
| 1166 |
+
|
| 1167 |
+
@functools.wraps(function)
|
| 1168 |
+
def result(*args, **kwargs):
|
| 1169 |
+
fail(message, pytrace=False)
|
| 1170 |
+
|
| 1171 |
+
# Keep reference to the original function in our own custom attribute so we don't unwrap
|
| 1172 |
+
# further than this point and lose useful wrappings like @mock.patch (#3774).
|
| 1173 |
+
result.__pytest_wrapped__ = _PytestWrapper(function) # type: ignore[attr-defined]
|
| 1174 |
+
|
| 1175 |
+
return cast(FixtureFunction, result)
|
| 1176 |
+
|
| 1177 |
+
|
| 1178 |
+
@final
|
| 1179 |
+
@dataclasses.dataclass(frozen=True)
|
| 1180 |
+
class FixtureFunctionMarker:
|
| 1181 |
+
scope: _ScopeName | Callable[[str, Config], _ScopeName]
|
| 1182 |
+
params: tuple[object, ...] | None
|
| 1183 |
+
autouse: bool = False
|
| 1184 |
+
ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None
|
| 1185 |
+
name: str | None = None
|
| 1186 |
+
|
| 1187 |
+
_ispytest: dataclasses.InitVar[bool] = False
|
| 1188 |
+
|
| 1189 |
+
def __post_init__(self, _ispytest: bool) -> None:
|
| 1190 |
+
check_ispytest(_ispytest)
|
| 1191 |
+
|
| 1192 |
+
def __call__(self, function: FixtureFunction) -> FixtureFunction:
|
| 1193 |
+
if inspect.isclass(function):
|
| 1194 |
+
raise ValueError("class fixtures not supported (maybe in the future)")
|
| 1195 |
+
|
| 1196 |
+
if getattr(function, "_pytestfixturefunction", False):
|
| 1197 |
+
raise ValueError(
|
| 1198 |
+
f"@pytest.fixture is being applied more than once to the same function {function.__name__!r}"
|
| 1199 |
+
)
|
| 1200 |
+
|
| 1201 |
+
if hasattr(function, "pytestmark"):
|
| 1202 |
+
warnings.warn(MARKED_FIXTURE, stacklevel=2)
|
| 1203 |
+
|
| 1204 |
+
function = wrap_function_to_error_out_if_called_directly(function, self)
|
| 1205 |
+
|
| 1206 |
+
name = self.name or function.__name__
|
| 1207 |
+
if name == "request":
|
| 1208 |
+
location = getlocation(function)
|
| 1209 |
+
fail(
|
| 1210 |
+
f"'request' is a reserved word for fixtures, use another name:\n {location}",
|
| 1211 |
+
pytrace=False,
|
| 1212 |
+
)
|
| 1213 |
+
|
| 1214 |
+
# Type ignored because https://github.com/python/mypy/issues/2087.
|
| 1215 |
+
function._pytestfixturefunction = self # type: ignore[attr-defined]
|
| 1216 |
+
return function
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
@overload
|
| 1220 |
+
def fixture(
|
| 1221 |
+
fixture_function: FixtureFunction,
|
| 1222 |
+
*,
|
| 1223 |
+
scope: _ScopeName | Callable[[str, Config], _ScopeName] = ...,
|
| 1224 |
+
params: Iterable[object] | None = ...,
|
| 1225 |
+
autouse: bool = ...,
|
| 1226 |
+
ids: Sequence[object | None] | Callable[[Any], object | None] | None = ...,
|
| 1227 |
+
name: str | None = ...,
|
| 1228 |
+
) -> FixtureFunction: ...
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
+
@overload
|
| 1232 |
+
def fixture(
|
| 1233 |
+
fixture_function: None = ...,
|
| 1234 |
+
*,
|
| 1235 |
+
scope: _ScopeName | Callable[[str, Config], _ScopeName] = ...,
|
| 1236 |
+
params: Iterable[object] | None = ...,
|
| 1237 |
+
autouse: bool = ...,
|
| 1238 |
+
ids: Sequence[object | None] | Callable[[Any], object | None] | None = ...,
|
| 1239 |
+
name: str | None = None,
|
| 1240 |
+
) -> FixtureFunctionMarker: ...
|
| 1241 |
+
|
| 1242 |
+
|
| 1243 |
+
def fixture(
|
| 1244 |
+
fixture_function: FixtureFunction | None = None,
|
| 1245 |
+
*,
|
| 1246 |
+
scope: _ScopeName | Callable[[str, Config], _ScopeName] = "function",
|
| 1247 |
+
params: Iterable[object] | None = None,
|
| 1248 |
+
autouse: bool = False,
|
| 1249 |
+
ids: Sequence[object | None] | Callable[[Any], object | None] | None = None,
|
| 1250 |
+
name: str | None = None,
|
| 1251 |
+
) -> FixtureFunctionMarker | FixtureFunction:
|
| 1252 |
+
"""Decorator to mark a fixture factory function.
|
| 1253 |
+
|
| 1254 |
+
This decorator can be used, with or without parameters, to define a
|
| 1255 |
+
fixture function.
|
| 1256 |
+
|
| 1257 |
+
The name of the fixture function can later be referenced to cause its
|
| 1258 |
+
invocation ahead of running tests: test modules or classes can use the
|
| 1259 |
+
``pytest.mark.usefixtures(fixturename)`` marker.
|
| 1260 |
+
|
| 1261 |
+
Test functions can directly use fixture names as input arguments in which
|
| 1262 |
+
case the fixture instance returned from the fixture function will be
|
| 1263 |
+
injected.
|
| 1264 |
+
|
| 1265 |
+
Fixtures can provide their values to test functions using ``return`` or
|
| 1266 |
+
``yield`` statements. When using ``yield`` the code block after the
|
| 1267 |
+
``yield`` statement is executed as teardown code regardless of the test
|
| 1268 |
+
outcome, and must yield exactly once.
|
| 1269 |
+
|
| 1270 |
+
:param scope:
|
| 1271 |
+
The scope for which this fixture is shared; one of ``"function"``
|
| 1272 |
+
(default), ``"class"``, ``"module"``, ``"package"`` or ``"session"``.
|
| 1273 |
+
|
| 1274 |
+
This parameter may also be a callable which receives ``(fixture_name, config)``
|
| 1275 |
+
as parameters, and must return a ``str`` with one of the values mentioned above.
|
| 1276 |
+
|
| 1277 |
+
See :ref:`dynamic scope` in the docs for more information.
|
| 1278 |
+
|
| 1279 |
+
:param params:
|
| 1280 |
+
An optional list of parameters which will cause multiple invocations
|
| 1281 |
+
of the fixture function and all of the tests using it. The current
|
| 1282 |
+
parameter is available in ``request.param``.
|
| 1283 |
+
|
| 1284 |
+
:param autouse:
|
| 1285 |
+
If True, the fixture func is activated for all tests that can see it.
|
| 1286 |
+
If False (the default), an explicit reference is needed to activate
|
| 1287 |
+
the fixture.
|
| 1288 |
+
|
| 1289 |
+
:param ids:
|
| 1290 |
+
Sequence of ids each corresponding to the params so that they are
|
| 1291 |
+
part of the test id. If no ids are provided they will be generated
|
| 1292 |
+
automatically from the params.
|
| 1293 |
+
|
| 1294 |
+
:param name:
|
| 1295 |
+
The name of the fixture. This defaults to the name of the decorated
|
| 1296 |
+
function. If a fixture is used in the same module in which it is
|
| 1297 |
+
defined, the function name of the fixture will be shadowed by the
|
| 1298 |
+
function arg that requests the fixture; one way to resolve this is to
|
| 1299 |
+
name the decorated function ``fixture_<fixturename>`` and then use
|
| 1300 |
+
``@pytest.fixture(name='<fixturename>')``.
|
| 1301 |
+
"""
|
| 1302 |
+
fixture_marker = FixtureFunctionMarker(
|
| 1303 |
+
scope=scope,
|
| 1304 |
+
params=tuple(params) if params is not None else None,
|
| 1305 |
+
autouse=autouse,
|
| 1306 |
+
ids=None if ids is None else ids if callable(ids) else tuple(ids),
|
| 1307 |
+
name=name,
|
| 1308 |
+
_ispytest=True,
|
| 1309 |
+
)
|
| 1310 |
+
|
| 1311 |
+
# Direct decoration.
|
| 1312 |
+
if fixture_function:
|
| 1313 |
+
return fixture_marker(fixture_function)
|
| 1314 |
+
|
| 1315 |
+
return fixture_marker
|
| 1316 |
+
|
| 1317 |
+
|
| 1318 |
+
def yield_fixture(
|
| 1319 |
+
fixture_function=None,
|
| 1320 |
+
*args,
|
| 1321 |
+
scope="function",
|
| 1322 |
+
params=None,
|
| 1323 |
+
autouse=False,
|
| 1324 |
+
ids=None,
|
| 1325 |
+
name=None,
|
| 1326 |
+
):
|
| 1327 |
+
"""(Return a) decorator to mark a yield-fixture factory function.
|
| 1328 |
+
|
| 1329 |
+
.. deprecated:: 3.0
|
| 1330 |
+
Use :py:func:`pytest.fixture` directly instead.
|
| 1331 |
+
"""
|
| 1332 |
+
warnings.warn(YIELD_FIXTURE, stacklevel=2)
|
| 1333 |
+
return fixture(
|
| 1334 |
+
fixture_function,
|
| 1335 |
+
*args,
|
| 1336 |
+
scope=scope,
|
| 1337 |
+
params=params,
|
| 1338 |
+
autouse=autouse,
|
| 1339 |
+
ids=ids,
|
| 1340 |
+
name=name,
|
| 1341 |
+
)
|
| 1342 |
+
|
| 1343 |
+
|
| 1344 |
+
@fixture(scope="session")
|
| 1345 |
+
def pytestconfig(request: FixtureRequest) -> Config:
|
| 1346 |
+
"""Session-scoped fixture that returns the session's :class:`pytest.Config`
|
| 1347 |
+
object.
|
| 1348 |
+
|
| 1349 |
+
Example::
|
| 1350 |
+
|
| 1351 |
+
def test_foo(pytestconfig):
|
| 1352 |
+
if pytestconfig.get_verbosity() > 0:
|
| 1353 |
+
...
|
| 1354 |
+
|
| 1355 |
+
"""
|
| 1356 |
+
return request.config
|
| 1357 |
+
|
| 1358 |
+
|
| 1359 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 1360 |
+
parser.addini(
|
| 1361 |
+
"usefixtures",
|
| 1362 |
+
type="args",
|
| 1363 |
+
default=[],
|
| 1364 |
+
help="List of default fixtures to be used with this project",
|
| 1365 |
+
)
|
| 1366 |
+
group = parser.getgroup("general")
|
| 1367 |
+
group.addoption(
|
| 1368 |
+
"--fixtures",
|
| 1369 |
+
"--funcargs",
|
| 1370 |
+
action="store_true",
|
| 1371 |
+
dest="showfixtures",
|
| 1372 |
+
default=False,
|
| 1373 |
+
help="Show available fixtures, sorted by plugin appearance "
|
| 1374 |
+
"(fixtures with leading '_' are only shown with '-v')",
|
| 1375 |
+
)
|
| 1376 |
+
group.addoption(
|
| 1377 |
+
"--fixtures-per-test",
|
| 1378 |
+
action="store_true",
|
| 1379 |
+
dest="show_fixtures_per_test",
|
| 1380 |
+
default=False,
|
| 1381 |
+
help="Show fixtures per test",
|
| 1382 |
+
)
|
| 1383 |
+
|
| 1384 |
+
|
| 1385 |
+
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
|
| 1386 |
+
if config.option.showfixtures:
|
| 1387 |
+
showfixtures(config)
|
| 1388 |
+
return 0
|
| 1389 |
+
if config.option.show_fixtures_per_test:
|
| 1390 |
+
show_fixtures_per_test(config)
|
| 1391 |
+
return 0
|
| 1392 |
+
return None
|
| 1393 |
+
|
| 1394 |
+
|
| 1395 |
+
def _get_direct_parametrize_args(node: nodes.Node) -> set[str]:
|
| 1396 |
+
"""Return all direct parametrization arguments of a node, so we don't
|
| 1397 |
+
mistake them for fixtures.
|
| 1398 |
+
|
| 1399 |
+
Check https://github.com/pytest-dev/pytest/issues/5036.
|
| 1400 |
+
|
| 1401 |
+
These things are done later as well when dealing with parametrization
|
| 1402 |
+
so this could be improved.
|
| 1403 |
+
"""
|
| 1404 |
+
parametrize_argnames: set[str] = set()
|
| 1405 |
+
for marker in node.iter_markers(name="parametrize"):
|
| 1406 |
+
if not marker.kwargs.get("indirect", False):
|
| 1407 |
+
p_argnames, _ = ParameterSet._parse_parametrize_args(
|
| 1408 |
+
*marker.args, **marker.kwargs
|
| 1409 |
+
)
|
| 1410 |
+
parametrize_argnames.update(p_argnames)
|
| 1411 |
+
return parametrize_argnames
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
def deduplicate_names(*seqs: Iterable[str]) -> tuple[str, ...]:
|
| 1415 |
+
"""De-duplicate the sequence of names while keeping the original order."""
|
| 1416 |
+
# Ideally we would use a set, but it does not preserve insertion order.
|
| 1417 |
+
return tuple(dict.fromkeys(name for seq in seqs for name in seq))
|
| 1418 |
+
|
| 1419 |
+
|
| 1420 |
+
class FixtureManager:
|
| 1421 |
+
"""pytest fixture definitions and information is stored and managed
|
| 1422 |
+
from this class.
|
| 1423 |
+
|
| 1424 |
+
During collection fm.parsefactories() is called multiple times to parse
|
| 1425 |
+
fixture function definitions into FixtureDef objects and internal
|
| 1426 |
+
data structures.
|
| 1427 |
+
|
| 1428 |
+
During collection of test functions, metafunc-mechanics instantiate
|
| 1429 |
+
a FuncFixtureInfo object which is cached per node/func-name.
|
| 1430 |
+
This FuncFixtureInfo object is later retrieved by Function nodes
|
| 1431 |
+
which themselves offer a fixturenames attribute.
|
| 1432 |
+
|
| 1433 |
+
The FuncFixtureInfo object holds information about fixtures and FixtureDefs
|
| 1434 |
+
relevant for a particular function. An initial list of fixtures is
|
| 1435 |
+
assembled like this:
|
| 1436 |
+
|
| 1437 |
+
- ini-defined usefixtures
|
| 1438 |
+
- autouse-marked fixtures along the collection chain up from the function
|
| 1439 |
+
- usefixtures markers at module/class/function level
|
| 1440 |
+
- test function funcargs
|
| 1441 |
+
|
| 1442 |
+
Subsequently the funcfixtureinfo.fixturenames attribute is computed
|
| 1443 |
+
as the closure of the fixtures needed to setup the initial fixtures,
|
| 1444 |
+
i.e. fixtures needed by fixture functions themselves are appended
|
| 1445 |
+
to the fixturenames list.
|
| 1446 |
+
|
| 1447 |
+
Upon the test-setup phases all fixturenames are instantiated, retrieved
|
| 1448 |
+
by a lookup of their FuncFixtureInfo.
|
| 1449 |
+
"""
|
| 1450 |
+
|
| 1451 |
+
def __init__(self, session: Session) -> None:
|
| 1452 |
+
self.session = session
|
| 1453 |
+
self.config: Config = session.config
|
| 1454 |
+
# Maps a fixture name (argname) to all of the FixtureDefs in the test
|
| 1455 |
+
# suite/plugins defined with this name. Populated by parsefactories().
|
| 1456 |
+
# TODO: The order of the FixtureDefs list of each arg is significant,
|
| 1457 |
+
# explain.
|
| 1458 |
+
self._arg2fixturedefs: Final[dict[str, list[FixtureDef[Any]]]] = {}
|
| 1459 |
+
self._holderobjseen: Final[set[object]] = set()
|
| 1460 |
+
# A mapping from a nodeid to a list of autouse fixtures it defines.
|
| 1461 |
+
self._nodeid_autousenames: Final[dict[str, list[str]]] = {
|
| 1462 |
+
"": self.config.getini("usefixtures"),
|
| 1463 |
+
}
|
| 1464 |
+
session.config.pluginmanager.register(self, "funcmanage")
|
| 1465 |
+
|
| 1466 |
+
def getfixtureinfo(
|
| 1467 |
+
self,
|
| 1468 |
+
node: nodes.Item,
|
| 1469 |
+
func: Callable[..., object] | None,
|
| 1470 |
+
cls: type | None,
|
| 1471 |
+
) -> FuncFixtureInfo:
|
| 1472 |
+
"""Calculate the :class:`FuncFixtureInfo` for an item.
|
| 1473 |
+
|
| 1474 |
+
If ``func`` is None, or if the item sets an attribute
|
| 1475 |
+
``nofuncargs = True``, then ``func`` is not examined at all.
|
| 1476 |
+
|
| 1477 |
+
:param node:
|
| 1478 |
+
The item requesting the fixtures.
|
| 1479 |
+
:param func:
|
| 1480 |
+
The item's function.
|
| 1481 |
+
:param cls:
|
| 1482 |
+
If the function is a method, the method's class.
|
| 1483 |
+
"""
|
| 1484 |
+
if func is not None and not getattr(node, "nofuncargs", False):
|
| 1485 |
+
argnames = getfuncargnames(func, name=node.name, cls=cls)
|
| 1486 |
+
else:
|
| 1487 |
+
argnames = ()
|
| 1488 |
+
usefixturesnames = self._getusefixturesnames(node)
|
| 1489 |
+
autousenames = self._getautousenames(node)
|
| 1490 |
+
initialnames = deduplicate_names(autousenames, usefixturesnames, argnames)
|
| 1491 |
+
|
| 1492 |
+
direct_parametrize_args = _get_direct_parametrize_args(node)
|
| 1493 |
+
|
| 1494 |
+
names_closure, arg2fixturedefs = self.getfixtureclosure(
|
| 1495 |
+
parentnode=node,
|
| 1496 |
+
initialnames=initialnames,
|
| 1497 |
+
ignore_args=direct_parametrize_args,
|
| 1498 |
+
)
|
| 1499 |
+
|
| 1500 |
+
return FuncFixtureInfo(argnames, initialnames, names_closure, arg2fixturedefs)
|
| 1501 |
+
|
| 1502 |
+
def pytest_plugin_registered(self, plugin: _PluggyPlugin, plugin_name: str) -> None:
|
| 1503 |
+
# Fixtures defined in conftest plugins are only visible to within the
|
| 1504 |
+
# conftest's directory. This is unlike fixtures in non-conftest plugins
|
| 1505 |
+
# which have global visibility. So for conftests, construct the base
|
| 1506 |
+
# nodeid from the plugin name (which is the conftest path).
|
| 1507 |
+
if plugin_name and plugin_name.endswith("conftest.py"):
|
| 1508 |
+
# Note: we explicitly do *not* use `plugin.__file__` here -- The
|
| 1509 |
+
# difference is that plugin_name has the correct capitalization on
|
| 1510 |
+
# case-insensitive systems (Windows) and other normalization issues
|
| 1511 |
+
# (issue #11816).
|
| 1512 |
+
conftestpath = absolutepath(plugin_name)
|
| 1513 |
+
try:
|
| 1514 |
+
nodeid = str(conftestpath.parent.relative_to(self.config.rootpath))
|
| 1515 |
+
except ValueError:
|
| 1516 |
+
nodeid = ""
|
| 1517 |
+
if nodeid == ".":
|
| 1518 |
+
nodeid = ""
|
| 1519 |
+
if os.sep != nodes.SEP:
|
| 1520 |
+
nodeid = nodeid.replace(os.sep, nodes.SEP)
|
| 1521 |
+
else:
|
| 1522 |
+
nodeid = None
|
| 1523 |
+
|
| 1524 |
+
self.parsefactories(plugin, nodeid)
|
| 1525 |
+
|
| 1526 |
+
def _getautousenames(self, node: nodes.Node) -> Iterator[str]:
|
| 1527 |
+
"""Return the names of autouse fixtures applicable to node."""
|
| 1528 |
+
for parentnode in node.listchain():
|
| 1529 |
+
basenames = self._nodeid_autousenames.get(parentnode.nodeid)
|
| 1530 |
+
if basenames:
|
| 1531 |
+
yield from basenames
|
| 1532 |
+
|
| 1533 |
+
def _getusefixturesnames(self, node: nodes.Item) -> Iterator[str]:
|
| 1534 |
+
"""Return the names of usefixtures fixtures applicable to node."""
|
| 1535 |
+
for mark in node.iter_markers(name="usefixtures"):
|
| 1536 |
+
yield from mark.args
|
| 1537 |
+
|
| 1538 |
+
def getfixtureclosure(
|
| 1539 |
+
self,
|
| 1540 |
+
parentnode: nodes.Node,
|
| 1541 |
+
initialnames: tuple[str, ...],
|
| 1542 |
+
ignore_args: AbstractSet[str],
|
| 1543 |
+
) -> tuple[list[str], dict[str, Sequence[FixtureDef[Any]]]]:
|
| 1544 |
+
# Collect the closure of all fixtures, starting with the given
|
| 1545 |
+
# fixturenames as the initial set. As we have to visit all
|
| 1546 |
+
# factory definitions anyway, we also return an arg2fixturedefs
|
| 1547 |
+
# mapping so that the caller can reuse it and does not have
|
| 1548 |
+
# to re-discover fixturedefs again for each fixturename
|
| 1549 |
+
# (discovering matching fixtures for a given name/node is expensive).
|
| 1550 |
+
|
| 1551 |
+
fixturenames_closure = list(initialnames)
|
| 1552 |
+
|
| 1553 |
+
arg2fixturedefs: dict[str, Sequence[FixtureDef[Any]]] = {}
|
| 1554 |
+
lastlen = -1
|
| 1555 |
+
while lastlen != len(fixturenames_closure):
|
| 1556 |
+
lastlen = len(fixturenames_closure)
|
| 1557 |
+
for argname in fixturenames_closure:
|
| 1558 |
+
if argname in ignore_args:
|
| 1559 |
+
continue
|
| 1560 |
+
if argname in arg2fixturedefs:
|
| 1561 |
+
continue
|
| 1562 |
+
fixturedefs = self.getfixturedefs(argname, parentnode)
|
| 1563 |
+
if fixturedefs:
|
| 1564 |
+
arg2fixturedefs[argname] = fixturedefs
|
| 1565 |
+
for arg in fixturedefs[-1].argnames:
|
| 1566 |
+
if arg not in fixturenames_closure:
|
| 1567 |
+
fixturenames_closure.append(arg)
|
| 1568 |
+
|
| 1569 |
+
def sort_by_scope(arg_name: str) -> Scope:
|
| 1570 |
+
try:
|
| 1571 |
+
fixturedefs = arg2fixturedefs[arg_name]
|
| 1572 |
+
except KeyError:
|
| 1573 |
+
return Scope.Function
|
| 1574 |
+
else:
|
| 1575 |
+
return fixturedefs[-1]._scope
|
| 1576 |
+
|
| 1577 |
+
fixturenames_closure.sort(key=sort_by_scope, reverse=True)
|
| 1578 |
+
return fixturenames_closure, arg2fixturedefs
|
| 1579 |
+
|
| 1580 |
+
def pytest_generate_tests(self, metafunc: Metafunc) -> None:
|
| 1581 |
+
"""Generate new tests based on parametrized fixtures used by the given metafunc"""
|
| 1582 |
+
|
| 1583 |
+
def get_parametrize_mark_argnames(mark: Mark) -> Sequence[str]:
|
| 1584 |
+
args, _ = ParameterSet._parse_parametrize_args(*mark.args, **mark.kwargs)
|
| 1585 |
+
return args
|
| 1586 |
+
|
| 1587 |
+
for argname in metafunc.fixturenames:
|
| 1588 |
+
# Get the FixtureDefs for the argname.
|
| 1589 |
+
fixture_defs = metafunc._arg2fixturedefs.get(argname)
|
| 1590 |
+
if not fixture_defs:
|
| 1591 |
+
# Will raise FixtureLookupError at setup time if not parametrized somewhere
|
| 1592 |
+
# else (e.g @pytest.mark.parametrize)
|
| 1593 |
+
continue
|
| 1594 |
+
|
| 1595 |
+
# If the test itself parametrizes using this argname, give it
|
| 1596 |
+
# precedence.
|
| 1597 |
+
if any(
|
| 1598 |
+
argname in get_parametrize_mark_argnames(mark)
|
| 1599 |
+
for mark in metafunc.definition.iter_markers("parametrize")
|
| 1600 |
+
):
|
| 1601 |
+
continue
|
| 1602 |
+
|
| 1603 |
+
# In the common case we only look at the fixture def with the
|
| 1604 |
+
# closest scope (last in the list). But if the fixture overrides
|
| 1605 |
+
# another fixture, while requesting the super fixture, keep going
|
| 1606 |
+
# in case the super fixture is parametrized (#1953).
|
| 1607 |
+
for fixturedef in reversed(fixture_defs):
|
| 1608 |
+
# Fixture is parametrized, apply it and stop.
|
| 1609 |
+
if fixturedef.params is not None:
|
| 1610 |
+
metafunc.parametrize(
|
| 1611 |
+
argname,
|
| 1612 |
+
fixturedef.params,
|
| 1613 |
+
indirect=True,
|
| 1614 |
+
scope=fixturedef.scope,
|
| 1615 |
+
ids=fixturedef.ids,
|
| 1616 |
+
)
|
| 1617 |
+
break
|
| 1618 |
+
|
| 1619 |
+
# Not requesting the overridden super fixture, stop.
|
| 1620 |
+
if argname not in fixturedef.argnames:
|
| 1621 |
+
break
|
| 1622 |
+
|
| 1623 |
+
# Try next super fixture, if any.
|
| 1624 |
+
|
| 1625 |
+
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> None:
|
| 1626 |
+
# Separate parametrized setups.
|
| 1627 |
+
items[:] = reorder_items(items)
|
| 1628 |
+
|
| 1629 |
+
def _register_fixture(
|
| 1630 |
+
self,
|
| 1631 |
+
*,
|
| 1632 |
+
name: str,
|
| 1633 |
+
func: _FixtureFunc[object],
|
| 1634 |
+
nodeid: str | None,
|
| 1635 |
+
scope: Scope | _ScopeName | Callable[[str, Config], _ScopeName] = "function",
|
| 1636 |
+
params: Sequence[object] | None = None,
|
| 1637 |
+
ids: tuple[object | None, ...] | Callable[[Any], object | None] | None = None,
|
| 1638 |
+
autouse: bool = False,
|
| 1639 |
+
) -> None:
|
| 1640 |
+
"""Register a fixture
|
| 1641 |
+
|
| 1642 |
+
:param name:
|
| 1643 |
+
The fixture's name.
|
| 1644 |
+
:param func:
|
| 1645 |
+
The fixture's implementation function.
|
| 1646 |
+
:param nodeid:
|
| 1647 |
+
The visibility of the fixture. The fixture will be available to the
|
| 1648 |
+
node with this nodeid and its children in the collection tree.
|
| 1649 |
+
None means that the fixture is visible to the entire collection tree,
|
| 1650 |
+
e.g. a fixture defined for general use in a plugin.
|
| 1651 |
+
:param scope:
|
| 1652 |
+
The fixture's scope.
|
| 1653 |
+
:param params:
|
| 1654 |
+
The fixture's parametrization params.
|
| 1655 |
+
:param ids:
|
| 1656 |
+
The fixture's IDs.
|
| 1657 |
+
:param autouse:
|
| 1658 |
+
Whether this is an autouse fixture.
|
| 1659 |
+
"""
|
| 1660 |
+
fixture_def = FixtureDef(
|
| 1661 |
+
config=self.config,
|
| 1662 |
+
baseid=nodeid,
|
| 1663 |
+
argname=name,
|
| 1664 |
+
func=func,
|
| 1665 |
+
scope=scope,
|
| 1666 |
+
params=params,
|
| 1667 |
+
ids=ids,
|
| 1668 |
+
_ispytest=True,
|
| 1669 |
+
)
|
| 1670 |
+
|
| 1671 |
+
faclist = self._arg2fixturedefs.setdefault(name, [])
|
| 1672 |
+
if fixture_def.has_location:
|
| 1673 |
+
faclist.append(fixture_def)
|
| 1674 |
+
else:
|
| 1675 |
+
# fixturedefs with no location are at the front
|
| 1676 |
+
# so this inserts the current fixturedef after the
|
| 1677 |
+
# existing fixturedefs from external plugins but
|
| 1678 |
+
# before the fixturedefs provided in conftests.
|
| 1679 |
+
i = len([f for f in faclist if not f.has_location])
|
| 1680 |
+
faclist.insert(i, fixture_def)
|
| 1681 |
+
if autouse:
|
| 1682 |
+
self._nodeid_autousenames.setdefault(nodeid or "", []).append(name)
|
| 1683 |
+
|
| 1684 |
+
@overload
|
| 1685 |
+
def parsefactories(
|
| 1686 |
+
self,
|
| 1687 |
+
node_or_obj: nodes.Node,
|
| 1688 |
+
) -> None:
|
| 1689 |
+
raise NotImplementedError()
|
| 1690 |
+
|
| 1691 |
+
@overload
|
| 1692 |
+
def parsefactories(
|
| 1693 |
+
self,
|
| 1694 |
+
node_or_obj: object,
|
| 1695 |
+
nodeid: str | None,
|
| 1696 |
+
) -> None:
|
| 1697 |
+
raise NotImplementedError()
|
| 1698 |
+
|
| 1699 |
+
def parsefactories(
|
| 1700 |
+
self,
|
| 1701 |
+
node_or_obj: nodes.Node | object,
|
| 1702 |
+
nodeid: str | NotSetType | None = NOTSET,
|
| 1703 |
+
) -> None:
|
| 1704 |
+
"""Collect fixtures from a collection node or object.
|
| 1705 |
+
|
| 1706 |
+
Found fixtures are parsed into `FixtureDef`s and saved.
|
| 1707 |
+
|
| 1708 |
+
If `node_or_object` is a collection node (with an underlying Python
|
| 1709 |
+
object), the node's object is traversed and the node's nodeid is used to
|
| 1710 |
+
determine the fixtures' visibility. `nodeid` must not be specified in
|
| 1711 |
+
this case.
|
| 1712 |
+
|
| 1713 |
+
If `node_or_object` is an object (e.g. a plugin), the object is
|
| 1714 |
+
traversed and the given `nodeid` is used to determine the fixtures'
|
| 1715 |
+
visibility. `nodeid` must be specified in this case; None and "" mean
|
| 1716 |
+
total visibility.
|
| 1717 |
+
"""
|
| 1718 |
+
if nodeid is not NOTSET:
|
| 1719 |
+
holderobj = node_or_obj
|
| 1720 |
+
else:
|
| 1721 |
+
assert isinstance(node_or_obj, nodes.Node)
|
| 1722 |
+
holderobj = cast(object, node_or_obj.obj) # type: ignore[attr-defined]
|
| 1723 |
+
assert isinstance(node_or_obj.nodeid, str)
|
| 1724 |
+
nodeid = node_or_obj.nodeid
|
| 1725 |
+
if holderobj in self._holderobjseen:
|
| 1726 |
+
return
|
| 1727 |
+
|
| 1728 |
+
# Avoid accessing `@property` (and other descriptors) when iterating fixtures.
|
| 1729 |
+
if not safe_isclass(holderobj) and not isinstance(holderobj, types.ModuleType):
|
| 1730 |
+
holderobj_tp: object = type(holderobj)
|
| 1731 |
+
else:
|
| 1732 |
+
holderobj_tp = holderobj
|
| 1733 |
+
|
| 1734 |
+
self._holderobjseen.add(holderobj)
|
| 1735 |
+
for name in dir(holderobj):
|
| 1736 |
+
# The attribute can be an arbitrary descriptor, so the attribute
|
| 1737 |
+
# access below can raise. safe_getattr() ignores such exceptions.
|
| 1738 |
+
obj_ub = safe_getattr(holderobj_tp, name, None)
|
| 1739 |
+
marker = getfixturemarker(obj_ub)
|
| 1740 |
+
if not isinstance(marker, FixtureFunctionMarker):
|
| 1741 |
+
# Magic globals with __getattr__ might have got us a wrong
|
| 1742 |
+
# fixture attribute.
|
| 1743 |
+
continue
|
| 1744 |
+
|
| 1745 |
+
# OK we know it is a fixture -- now safe to look up on the _instance_.
|
| 1746 |
+
obj = getattr(holderobj, name)
|
| 1747 |
+
|
| 1748 |
+
if marker.name:
|
| 1749 |
+
name = marker.name
|
| 1750 |
+
|
| 1751 |
+
# During fixture definition we wrap the original fixture function
|
| 1752 |
+
# to issue a warning if called directly, so here we unwrap it in
|
| 1753 |
+
# order to not emit the warning when pytest itself calls the
|
| 1754 |
+
# fixture function.
|
| 1755 |
+
func = get_real_method(obj, holderobj)
|
| 1756 |
+
|
| 1757 |
+
self._register_fixture(
|
| 1758 |
+
name=name,
|
| 1759 |
+
nodeid=nodeid,
|
| 1760 |
+
func=func,
|
| 1761 |
+
scope=marker.scope,
|
| 1762 |
+
params=marker.params,
|
| 1763 |
+
ids=marker.ids,
|
| 1764 |
+
autouse=marker.autouse,
|
| 1765 |
+
)
|
| 1766 |
+
|
| 1767 |
+
def getfixturedefs(
|
| 1768 |
+
self, argname: str, node: nodes.Node
|
| 1769 |
+
) -> Sequence[FixtureDef[Any]] | None:
|
| 1770 |
+
"""Get FixtureDefs for a fixture name which are applicable
|
| 1771 |
+
to a given node.
|
| 1772 |
+
|
| 1773 |
+
Returns None if there are no fixtures at all defined with the given
|
| 1774 |
+
name. (This is different from the case in which there are fixtures
|
| 1775 |
+
with the given name, but none applicable to the node. In this case,
|
| 1776 |
+
an empty result is returned).
|
| 1777 |
+
|
| 1778 |
+
:param argname: Name of the fixture to search for.
|
| 1779 |
+
:param node: The requesting Node.
|
| 1780 |
+
"""
|
| 1781 |
+
try:
|
| 1782 |
+
fixturedefs = self._arg2fixturedefs[argname]
|
| 1783 |
+
except KeyError:
|
| 1784 |
+
return None
|
| 1785 |
+
return tuple(self._matchfactories(fixturedefs, node))
|
| 1786 |
+
|
| 1787 |
+
def _matchfactories(
|
| 1788 |
+
self, fixturedefs: Iterable[FixtureDef[Any]], node: nodes.Node
|
| 1789 |
+
) -> Iterator[FixtureDef[Any]]:
|
| 1790 |
+
parentnodeids = {n.nodeid for n in node.iter_parents()}
|
| 1791 |
+
for fixturedef in fixturedefs:
|
| 1792 |
+
if fixturedef.baseid in parentnodeids:
|
| 1793 |
+
yield fixturedef
|
| 1794 |
+
|
| 1795 |
+
|
| 1796 |
+
def show_fixtures_per_test(config: Config) -> int | ExitCode:
|
| 1797 |
+
from _pytest.main import wrap_session
|
| 1798 |
+
|
| 1799 |
+
return wrap_session(config, _show_fixtures_per_test)
|
| 1800 |
+
|
| 1801 |
+
|
| 1802 |
+
_PYTEST_DIR = Path(_pytest.__file__).parent
|
| 1803 |
+
|
| 1804 |
+
|
| 1805 |
+
def _pretty_fixture_path(invocation_dir: Path, func) -> str:
|
| 1806 |
+
loc = Path(getlocation(func, invocation_dir))
|
| 1807 |
+
prefix = Path("...", "_pytest")
|
| 1808 |
+
try:
|
| 1809 |
+
return str(prefix / loc.relative_to(_PYTEST_DIR))
|
| 1810 |
+
except ValueError:
|
| 1811 |
+
return bestrelpath(invocation_dir, loc)
|
| 1812 |
+
|
| 1813 |
+
|
| 1814 |
+
def _show_fixtures_per_test(config: Config, session: Session) -> None:
|
| 1815 |
+
import _pytest.config
|
| 1816 |
+
|
| 1817 |
+
session.perform_collect()
|
| 1818 |
+
invocation_dir = config.invocation_params.dir
|
| 1819 |
+
tw = _pytest.config.create_terminal_writer(config)
|
| 1820 |
+
verbose = config.get_verbosity()
|
| 1821 |
+
|
| 1822 |
+
def get_best_relpath(func) -> str:
|
| 1823 |
+
loc = getlocation(func, invocation_dir)
|
| 1824 |
+
return bestrelpath(invocation_dir, Path(loc))
|
| 1825 |
+
|
| 1826 |
+
def write_fixture(fixture_def: FixtureDef[object]) -> None:
|
| 1827 |
+
argname = fixture_def.argname
|
| 1828 |
+
if verbose <= 0 and argname.startswith("_"):
|
| 1829 |
+
return
|
| 1830 |
+
prettypath = _pretty_fixture_path(invocation_dir, fixture_def.func)
|
| 1831 |
+
tw.write(f"{argname}", green=True)
|
| 1832 |
+
tw.write(f" -- {prettypath}", yellow=True)
|
| 1833 |
+
tw.write("\n")
|
| 1834 |
+
fixture_doc = inspect.getdoc(fixture_def.func)
|
| 1835 |
+
if fixture_doc:
|
| 1836 |
+
write_docstring(
|
| 1837 |
+
tw,
|
| 1838 |
+
fixture_doc.split("\n\n", maxsplit=1)[0]
|
| 1839 |
+
if verbose <= 0
|
| 1840 |
+
else fixture_doc,
|
| 1841 |
+
)
|
| 1842 |
+
else:
|
| 1843 |
+
tw.line(" no docstring available", red=True)
|
| 1844 |
+
|
| 1845 |
+
def write_item(item: nodes.Item) -> None:
|
| 1846 |
+
# Not all items have _fixtureinfo attribute.
|
| 1847 |
+
info: FuncFixtureInfo | None = getattr(item, "_fixtureinfo", None)
|
| 1848 |
+
if info is None or not info.name2fixturedefs:
|
| 1849 |
+
# This test item does not use any fixtures.
|
| 1850 |
+
return
|
| 1851 |
+
tw.line()
|
| 1852 |
+
tw.sep("-", f"fixtures used by {item.name}")
|
| 1853 |
+
# TODO: Fix this type ignore.
|
| 1854 |
+
tw.sep("-", f"({get_best_relpath(item.function)})") # type: ignore[attr-defined]
|
| 1855 |
+
# dict key not used in loop but needed for sorting.
|
| 1856 |
+
for _, fixturedefs in sorted(info.name2fixturedefs.items()):
|
| 1857 |
+
assert fixturedefs is not None
|
| 1858 |
+
if not fixturedefs:
|
| 1859 |
+
continue
|
| 1860 |
+
# Last item is expected to be the one used by the test item.
|
| 1861 |
+
write_fixture(fixturedefs[-1])
|
| 1862 |
+
|
| 1863 |
+
for session_item in session.items:
|
| 1864 |
+
write_item(session_item)
|
| 1865 |
+
|
| 1866 |
+
|
| 1867 |
+
def showfixtures(config: Config) -> int | ExitCode:
|
| 1868 |
+
from _pytest.main import wrap_session
|
| 1869 |
+
|
| 1870 |
+
return wrap_session(config, _showfixtures_main)
|
| 1871 |
+
|
| 1872 |
+
|
| 1873 |
+
def _showfixtures_main(config: Config, session: Session) -> None:
|
| 1874 |
+
import _pytest.config
|
| 1875 |
+
|
| 1876 |
+
session.perform_collect()
|
| 1877 |
+
invocation_dir = config.invocation_params.dir
|
| 1878 |
+
tw = _pytest.config.create_terminal_writer(config)
|
| 1879 |
+
verbose = config.get_verbosity()
|
| 1880 |
+
|
| 1881 |
+
fm = session._fixturemanager
|
| 1882 |
+
|
| 1883 |
+
available = []
|
| 1884 |
+
seen: set[tuple[str, str]] = set()
|
| 1885 |
+
|
| 1886 |
+
for argname, fixturedefs in fm._arg2fixturedefs.items():
|
| 1887 |
+
assert fixturedefs is not None
|
| 1888 |
+
if not fixturedefs:
|
| 1889 |
+
continue
|
| 1890 |
+
for fixturedef in fixturedefs:
|
| 1891 |
+
loc = getlocation(fixturedef.func, invocation_dir)
|
| 1892 |
+
if (fixturedef.argname, loc) in seen:
|
| 1893 |
+
continue
|
| 1894 |
+
seen.add((fixturedef.argname, loc))
|
| 1895 |
+
available.append(
|
| 1896 |
+
(
|
| 1897 |
+
len(fixturedef.baseid),
|
| 1898 |
+
fixturedef.func.__module__,
|
| 1899 |
+
_pretty_fixture_path(invocation_dir, fixturedef.func),
|
| 1900 |
+
fixturedef.argname,
|
| 1901 |
+
fixturedef,
|
| 1902 |
+
)
|
| 1903 |
+
)
|
| 1904 |
+
|
| 1905 |
+
available.sort()
|
| 1906 |
+
currentmodule = None
|
| 1907 |
+
for baseid, module, prettypath, argname, fixturedef in available:
|
| 1908 |
+
if currentmodule != module:
|
| 1909 |
+
if not module.startswith("_pytest."):
|
| 1910 |
+
tw.line()
|
| 1911 |
+
tw.sep("-", f"fixtures defined from {module}")
|
| 1912 |
+
currentmodule = module
|
| 1913 |
+
if verbose <= 0 and argname.startswith("_"):
|
| 1914 |
+
continue
|
| 1915 |
+
tw.write(f"{argname}", green=True)
|
| 1916 |
+
if fixturedef.scope != "function":
|
| 1917 |
+
tw.write(f" [{fixturedef.scope} scope]", cyan=True)
|
| 1918 |
+
tw.write(f" -- {prettypath}", yellow=True)
|
| 1919 |
+
tw.write("\n")
|
| 1920 |
+
doc = inspect.getdoc(fixturedef.func)
|
| 1921 |
+
if doc:
|
| 1922 |
+
write_docstring(
|
| 1923 |
+
tw, doc.split("\n\n", maxsplit=1)[0] if verbose <= 0 else doc
|
| 1924 |
+
)
|
| 1925 |
+
else:
|
| 1926 |
+
tw.line(" no docstring available", red=True)
|
| 1927 |
+
tw.line()
|
| 1928 |
+
|
| 1929 |
+
|
| 1930 |
+
def write_docstring(tw: TerminalWriter, doc: str, indent: str = " ") -> None:
|
| 1931 |
+
for line in doc.split("\n"):
|
| 1932 |
+
tw.line(indent + line)
|
.venv/lib/python3.11/site-packages/_pytest/freeze_support.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provides a function to report all internal modules for using freezing
|
| 2 |
+
tools."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import types
|
| 7 |
+
from typing import Iterator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def freeze_includes() -> list[str]:
|
| 11 |
+
"""Return a list of module names used by pytest that should be
|
| 12 |
+
included by cx_freeze."""
|
| 13 |
+
import _pytest
|
| 14 |
+
|
| 15 |
+
result = list(_iter_all_modules(_pytest))
|
| 16 |
+
return result
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _iter_all_modules(
|
| 20 |
+
package: str | types.ModuleType,
|
| 21 |
+
prefix: str = "",
|
| 22 |
+
) -> Iterator[str]:
|
| 23 |
+
"""Iterate over the names of all modules that can be found in the given
|
| 24 |
+
package, recursively.
|
| 25 |
+
|
| 26 |
+
>>> import _pytest
|
| 27 |
+
>>> list(_iter_all_modules(_pytest))
|
| 28 |
+
['_pytest._argcomplete', '_pytest._code.code', ...]
|
| 29 |
+
"""
|
| 30 |
+
import os
|
| 31 |
+
import pkgutil
|
| 32 |
+
|
| 33 |
+
if isinstance(package, str):
|
| 34 |
+
path = package
|
| 35 |
+
else:
|
| 36 |
+
# Type ignored because typeshed doesn't define ModuleType.__path__
|
| 37 |
+
# (only defined on packages).
|
| 38 |
+
package_path = package.__path__
|
| 39 |
+
path, prefix = package_path[0], package.__name__ + "."
|
| 40 |
+
for _, name, is_package in pkgutil.iter_modules([path]):
|
| 41 |
+
if is_package:
|
| 42 |
+
for m in _iter_all_modules(os.path.join(path, name), prefix=name + "."):
|
| 43 |
+
yield prefix + m
|
| 44 |
+
else:
|
| 45 |
+
yield prefix + name
|
.venv/lib/python3.11/site-packages/_pytest/junitxml.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Report test results in JUnit-XML format, for use with Jenkins and build
|
| 3 |
+
integration servers.
|
| 4 |
+
|
| 5 |
+
Based on initial code from Ross Lawley.
|
| 6 |
+
|
| 7 |
+
Output conforms to
|
| 8 |
+
https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from datetime import timezone
|
| 15 |
+
import functools
|
| 16 |
+
import os
|
| 17 |
+
import platform
|
| 18 |
+
import re
|
| 19 |
+
from typing import Callable
|
| 20 |
+
from typing import Match
|
| 21 |
+
import xml.etree.ElementTree as ET
|
| 22 |
+
|
| 23 |
+
from _pytest import nodes
|
| 24 |
+
from _pytest import timing
|
| 25 |
+
from _pytest._code.code import ExceptionRepr
|
| 26 |
+
from _pytest._code.code import ReprFileLocation
|
| 27 |
+
from _pytest.config import Config
|
| 28 |
+
from _pytest.config import filename_arg
|
| 29 |
+
from _pytest.config.argparsing import Parser
|
| 30 |
+
from _pytest.fixtures import FixtureRequest
|
| 31 |
+
from _pytest.reports import TestReport
|
| 32 |
+
from _pytest.stash import StashKey
|
| 33 |
+
from _pytest.terminal import TerminalReporter
|
| 34 |
+
import pytest
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
xml_key = StashKey["LogXML"]()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def bin_xml_escape(arg: object) -> str:
|
| 41 |
+
r"""Visually escape invalid XML characters.
|
| 42 |
+
|
| 43 |
+
For example, transforms
|
| 44 |
+
'hello\aworld\b'
|
| 45 |
+
into
|
| 46 |
+
'hello#x07world#x08'
|
| 47 |
+
Note that the #xABs are *not* XML escapes - missing the ampersand «.
|
| 48 |
+
The idea is to escape visually for the user rather than for XML itself.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def repl(matchobj: Match[str]) -> str:
|
| 52 |
+
i = ord(matchobj.group())
|
| 53 |
+
if i <= 0xFF:
|
| 54 |
+
return f"#x{i:02X}"
|
| 55 |
+
else:
|
| 56 |
+
return f"#x{i:04X}"
|
| 57 |
+
|
| 58 |
+
# The spec range of valid chars is:
|
| 59 |
+
# Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
|
| 60 |
+
# For an unknown(?) reason, we disallow #x7F (DEL) as well.
|
| 61 |
+
illegal_xml_re = (
|
| 62 |
+
"[^\u0009\u000a\u000d\u0020-\u007e\u0080-\ud7ff\ue000-\ufffd\u10000-\u10ffff]"
|
| 63 |
+
)
|
| 64 |
+
return re.sub(illegal_xml_re, repl, str(arg))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def merge_family(left, right) -> None:
|
| 68 |
+
result = {}
|
| 69 |
+
for kl, vl in left.items():
|
| 70 |
+
for kr, vr in right.items():
|
| 71 |
+
if not isinstance(vl, list):
|
| 72 |
+
raise TypeError(type(vl))
|
| 73 |
+
result[kl] = vl + vr
|
| 74 |
+
left.update(result)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
families = {}
|
| 78 |
+
families["_base"] = {"testcase": ["classname", "name"]}
|
| 79 |
+
families["_base_legacy"] = {"testcase": ["file", "line", "url"]}
|
| 80 |
+
|
| 81 |
+
# xUnit 1.x inherits legacy attributes.
|
| 82 |
+
families["xunit1"] = families["_base"].copy()
|
| 83 |
+
merge_family(families["xunit1"], families["_base_legacy"])
|
| 84 |
+
|
| 85 |
+
# xUnit 2.x uses strict base attributes.
|
| 86 |
+
families["xunit2"] = families["_base"]
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class _NodeReporter:
|
| 90 |
+
def __init__(self, nodeid: str | TestReport, xml: LogXML) -> None:
|
| 91 |
+
self.id = nodeid
|
| 92 |
+
self.xml = xml
|
| 93 |
+
self.add_stats = self.xml.add_stats
|
| 94 |
+
self.family = self.xml.family
|
| 95 |
+
self.duration = 0.0
|
| 96 |
+
self.properties: list[tuple[str, str]] = []
|
| 97 |
+
self.nodes: list[ET.Element] = []
|
| 98 |
+
self.attrs: dict[str, str] = {}
|
| 99 |
+
|
| 100 |
+
def append(self, node: ET.Element) -> None:
|
| 101 |
+
self.xml.add_stats(node.tag)
|
| 102 |
+
self.nodes.append(node)
|
| 103 |
+
|
| 104 |
+
def add_property(self, name: str, value: object) -> None:
|
| 105 |
+
self.properties.append((str(name), bin_xml_escape(value)))
|
| 106 |
+
|
| 107 |
+
def add_attribute(self, name: str, value: object) -> None:
|
| 108 |
+
self.attrs[str(name)] = bin_xml_escape(value)
|
| 109 |
+
|
| 110 |
+
def make_properties_node(self) -> ET.Element | None:
|
| 111 |
+
"""Return a Junit node containing custom properties, if any."""
|
| 112 |
+
if self.properties:
|
| 113 |
+
properties = ET.Element("properties")
|
| 114 |
+
for name, value in self.properties:
|
| 115 |
+
properties.append(ET.Element("property", name=name, value=value))
|
| 116 |
+
return properties
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
def record_testreport(self, testreport: TestReport) -> None:
|
| 120 |
+
names = mangle_test_address(testreport.nodeid)
|
| 121 |
+
existing_attrs = self.attrs
|
| 122 |
+
classnames = names[:-1]
|
| 123 |
+
if self.xml.prefix:
|
| 124 |
+
classnames.insert(0, self.xml.prefix)
|
| 125 |
+
attrs: dict[str, str] = {
|
| 126 |
+
"classname": ".".join(classnames),
|
| 127 |
+
"name": bin_xml_escape(names[-1]),
|
| 128 |
+
"file": testreport.location[0],
|
| 129 |
+
}
|
| 130 |
+
if testreport.location[1] is not None:
|
| 131 |
+
attrs["line"] = str(testreport.location[1])
|
| 132 |
+
if hasattr(testreport, "url"):
|
| 133 |
+
attrs["url"] = testreport.url
|
| 134 |
+
self.attrs = attrs
|
| 135 |
+
self.attrs.update(existing_attrs) # Restore any user-defined attributes.
|
| 136 |
+
|
| 137 |
+
# Preserve legacy testcase behavior.
|
| 138 |
+
if self.family == "xunit1":
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
# Filter out attributes not permitted by this test family.
|
| 142 |
+
# Including custom attributes because they are not valid here.
|
| 143 |
+
temp_attrs = {}
|
| 144 |
+
for key in self.attrs:
|
| 145 |
+
if key in families[self.family]["testcase"]:
|
| 146 |
+
temp_attrs[key] = self.attrs[key]
|
| 147 |
+
self.attrs = temp_attrs
|
| 148 |
+
|
| 149 |
+
def to_xml(self) -> ET.Element:
|
| 150 |
+
testcase = ET.Element("testcase", self.attrs, time=f"{self.duration:.3f}")
|
| 151 |
+
properties = self.make_properties_node()
|
| 152 |
+
if properties is not None:
|
| 153 |
+
testcase.append(properties)
|
| 154 |
+
testcase.extend(self.nodes)
|
| 155 |
+
return testcase
|
| 156 |
+
|
| 157 |
+
def _add_simple(self, tag: str, message: str, data: str | None = None) -> None:
|
| 158 |
+
node = ET.Element(tag, message=message)
|
| 159 |
+
node.text = bin_xml_escape(data)
|
| 160 |
+
self.append(node)
|
| 161 |
+
|
| 162 |
+
def write_captured_output(self, report: TestReport) -> None:
|
| 163 |
+
if not self.xml.log_passing_tests and report.passed:
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
content_out = report.capstdout
|
| 167 |
+
content_log = report.caplog
|
| 168 |
+
content_err = report.capstderr
|
| 169 |
+
if self.xml.logging == "no":
|
| 170 |
+
return
|
| 171 |
+
content_all = ""
|
| 172 |
+
if self.xml.logging in ["log", "all"]:
|
| 173 |
+
content_all = self._prepare_content(content_log, " Captured Log ")
|
| 174 |
+
if self.xml.logging in ["system-out", "out-err", "all"]:
|
| 175 |
+
content_all += self._prepare_content(content_out, " Captured Out ")
|
| 176 |
+
self._write_content(report, content_all, "system-out")
|
| 177 |
+
content_all = ""
|
| 178 |
+
if self.xml.logging in ["system-err", "out-err", "all"]:
|
| 179 |
+
content_all += self._prepare_content(content_err, " Captured Err ")
|
| 180 |
+
self._write_content(report, content_all, "system-err")
|
| 181 |
+
content_all = ""
|
| 182 |
+
if content_all:
|
| 183 |
+
self._write_content(report, content_all, "system-out")
|
| 184 |
+
|
| 185 |
+
def _prepare_content(self, content: str, header: str) -> str:
|
| 186 |
+
return "\n".join([header.center(80, "-"), content, ""])
|
| 187 |
+
|
| 188 |
+
def _write_content(self, report: TestReport, content: str, jheader: str) -> None:
|
| 189 |
+
tag = ET.Element(jheader)
|
| 190 |
+
tag.text = bin_xml_escape(content)
|
| 191 |
+
self.append(tag)
|
| 192 |
+
|
| 193 |
+
def append_pass(self, report: TestReport) -> None:
|
| 194 |
+
self.add_stats("passed")
|
| 195 |
+
|
| 196 |
+
def append_failure(self, report: TestReport) -> None:
|
| 197 |
+
# msg = str(report.longrepr.reprtraceback.extraline)
|
| 198 |
+
if hasattr(report, "wasxfail"):
|
| 199 |
+
self._add_simple("skipped", "xfail-marked test passes unexpectedly")
|
| 200 |
+
else:
|
| 201 |
+
assert report.longrepr is not None
|
| 202 |
+
reprcrash: ReprFileLocation | None = getattr(
|
| 203 |
+
report.longrepr, "reprcrash", None
|
| 204 |
+
)
|
| 205 |
+
if reprcrash is not None:
|
| 206 |
+
message = reprcrash.message
|
| 207 |
+
else:
|
| 208 |
+
message = str(report.longrepr)
|
| 209 |
+
message = bin_xml_escape(message)
|
| 210 |
+
self._add_simple("failure", message, str(report.longrepr))
|
| 211 |
+
|
| 212 |
+
def append_collect_error(self, report: TestReport) -> None:
|
| 213 |
+
# msg = str(report.longrepr.reprtraceback.extraline)
|
| 214 |
+
assert report.longrepr is not None
|
| 215 |
+
self._add_simple("error", "collection failure", str(report.longrepr))
|
| 216 |
+
|
| 217 |
+
def append_collect_skipped(self, report: TestReport) -> None:
|
| 218 |
+
self._add_simple("skipped", "collection skipped", str(report.longrepr))
|
| 219 |
+
|
| 220 |
+
def append_error(self, report: TestReport) -> None:
|
| 221 |
+
assert report.longrepr is not None
|
| 222 |
+
reprcrash: ReprFileLocation | None = getattr(report.longrepr, "reprcrash", None)
|
| 223 |
+
if reprcrash is not None:
|
| 224 |
+
reason = reprcrash.message
|
| 225 |
+
else:
|
| 226 |
+
reason = str(report.longrepr)
|
| 227 |
+
|
| 228 |
+
if report.when == "teardown":
|
| 229 |
+
msg = f'failed on teardown with "{reason}"'
|
| 230 |
+
else:
|
| 231 |
+
msg = f'failed on setup with "{reason}"'
|
| 232 |
+
self._add_simple("error", bin_xml_escape(msg), str(report.longrepr))
|
| 233 |
+
|
| 234 |
+
def append_skipped(self, report: TestReport) -> None:
|
| 235 |
+
if hasattr(report, "wasxfail"):
|
| 236 |
+
xfailreason = report.wasxfail
|
| 237 |
+
if xfailreason.startswith("reason: "):
|
| 238 |
+
xfailreason = xfailreason[8:]
|
| 239 |
+
xfailreason = bin_xml_escape(xfailreason)
|
| 240 |
+
skipped = ET.Element("skipped", type="pytest.xfail", message=xfailreason)
|
| 241 |
+
self.append(skipped)
|
| 242 |
+
else:
|
| 243 |
+
assert isinstance(report.longrepr, tuple)
|
| 244 |
+
filename, lineno, skipreason = report.longrepr
|
| 245 |
+
if skipreason.startswith("Skipped: "):
|
| 246 |
+
skipreason = skipreason[9:]
|
| 247 |
+
details = f"{filename}:{lineno}: {skipreason}"
|
| 248 |
+
|
| 249 |
+
skipped = ET.Element(
|
| 250 |
+
"skipped", type="pytest.skip", message=bin_xml_escape(skipreason)
|
| 251 |
+
)
|
| 252 |
+
skipped.text = bin_xml_escape(details)
|
| 253 |
+
self.append(skipped)
|
| 254 |
+
self.write_captured_output(report)
|
| 255 |
+
|
| 256 |
+
def finalize(self) -> None:
|
| 257 |
+
data = self.to_xml()
|
| 258 |
+
self.__dict__.clear()
|
| 259 |
+
# Type ignored because mypy doesn't like overriding a method.
|
| 260 |
+
# Also the return value doesn't match...
|
| 261 |
+
self.to_xml = lambda: data # type: ignore[method-assign]
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _warn_incompatibility_with_xunit2(
|
| 265 |
+
request: FixtureRequest, fixture_name: str
|
| 266 |
+
) -> None:
|
| 267 |
+
"""Emit a PytestWarning about the given fixture being incompatible with newer xunit revisions."""
|
| 268 |
+
from _pytest.warning_types import PytestWarning
|
| 269 |
+
|
| 270 |
+
xml = request.config.stash.get(xml_key, None)
|
| 271 |
+
if xml is not None and xml.family not in ("xunit1", "legacy"):
|
| 272 |
+
request.node.warn(
|
| 273 |
+
PytestWarning(
|
| 274 |
+
f"{fixture_name} is incompatible with junit_family '{xml.family}' (use 'legacy' or 'xunit1')"
|
| 275 |
+
)
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
@pytest.fixture
|
| 280 |
+
def record_property(request: FixtureRequest) -> Callable[[str, object], None]:
|
| 281 |
+
"""Add extra properties to the calling test.
|
| 282 |
+
|
| 283 |
+
User properties become part of the test report and are available to the
|
| 284 |
+
configured reporters, like JUnit XML.
|
| 285 |
+
|
| 286 |
+
The fixture is callable with ``name, value``. The value is automatically
|
| 287 |
+
XML-encoded.
|
| 288 |
+
|
| 289 |
+
Example::
|
| 290 |
+
|
| 291 |
+
def test_function(record_property):
|
| 292 |
+
record_property("example_key", 1)
|
| 293 |
+
"""
|
| 294 |
+
_warn_incompatibility_with_xunit2(request, "record_property")
|
| 295 |
+
|
| 296 |
+
def append_property(name: str, value: object) -> None:
|
| 297 |
+
request.node.user_properties.append((name, value))
|
| 298 |
+
|
| 299 |
+
return append_property
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
@pytest.fixture
|
| 303 |
+
def record_xml_attribute(request: FixtureRequest) -> Callable[[str, object], None]:
|
| 304 |
+
"""Add extra xml attributes to the tag for the calling test.
|
| 305 |
+
|
| 306 |
+
The fixture is callable with ``name, value``. The value is
|
| 307 |
+
automatically XML-encoded.
|
| 308 |
+
"""
|
| 309 |
+
from _pytest.warning_types import PytestExperimentalApiWarning
|
| 310 |
+
|
| 311 |
+
request.node.warn(
|
| 312 |
+
PytestExperimentalApiWarning("record_xml_attribute is an experimental feature")
|
| 313 |
+
)
|
| 314 |
+
|
| 315 |
+
_warn_incompatibility_with_xunit2(request, "record_xml_attribute")
|
| 316 |
+
|
| 317 |
+
# Declare noop
|
| 318 |
+
def add_attr_noop(name: str, value: object) -> None:
|
| 319 |
+
pass
|
| 320 |
+
|
| 321 |
+
attr_func = add_attr_noop
|
| 322 |
+
|
| 323 |
+
xml = request.config.stash.get(xml_key, None)
|
| 324 |
+
if xml is not None:
|
| 325 |
+
node_reporter = xml.node_reporter(request.node.nodeid)
|
| 326 |
+
attr_func = node_reporter.add_attribute
|
| 327 |
+
|
| 328 |
+
return attr_func
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def _check_record_param_type(param: str, v: str) -> None:
|
| 332 |
+
"""Used by record_testsuite_property to check that the given parameter name is of the proper
|
| 333 |
+
type."""
|
| 334 |
+
__tracebackhide__ = True
|
| 335 |
+
if not isinstance(v, str):
|
| 336 |
+
msg = "{param} parameter needs to be a string, but {g} given" # type: ignore[unreachable]
|
| 337 |
+
raise TypeError(msg.format(param=param, g=type(v).__name__))
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
@pytest.fixture(scope="session")
|
| 341 |
+
def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object], None]:
|
| 342 |
+
"""Record a new ``<property>`` tag as child of the root ``<testsuite>``.
|
| 343 |
+
|
| 344 |
+
This is suitable to writing global information regarding the entire test
|
| 345 |
+
suite, and is compatible with ``xunit2`` JUnit family.
|
| 346 |
+
|
| 347 |
+
This is a ``session``-scoped fixture which is called with ``(name, value)``. Example:
|
| 348 |
+
|
| 349 |
+
.. code-block:: python
|
| 350 |
+
|
| 351 |
+
def test_foo(record_testsuite_property):
|
| 352 |
+
record_testsuite_property("ARCH", "PPC")
|
| 353 |
+
record_testsuite_property("STORAGE_TYPE", "CEPH")
|
| 354 |
+
|
| 355 |
+
:param name:
|
| 356 |
+
The property name.
|
| 357 |
+
:param value:
|
| 358 |
+
The property value. Will be converted to a string.
|
| 359 |
+
|
| 360 |
+
.. warning::
|
| 361 |
+
|
| 362 |
+
Currently this fixture **does not work** with the
|
| 363 |
+
`pytest-xdist <https://github.com/pytest-dev/pytest-xdist>`__ plugin. See
|
| 364 |
+
:issue:`7767` for details.
|
| 365 |
+
"""
|
| 366 |
+
__tracebackhide__ = True
|
| 367 |
+
|
| 368 |
+
def record_func(name: str, value: object) -> None:
|
| 369 |
+
"""No-op function in case --junit-xml was not passed in the command-line."""
|
| 370 |
+
__tracebackhide__ = True
|
| 371 |
+
_check_record_param_type("name", name)
|
| 372 |
+
|
| 373 |
+
xml = request.config.stash.get(xml_key, None)
|
| 374 |
+
if xml is not None:
|
| 375 |
+
record_func = xml.add_global_property
|
| 376 |
+
return record_func
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 380 |
+
group = parser.getgroup("terminal reporting")
|
| 381 |
+
group.addoption(
|
| 382 |
+
"--junitxml",
|
| 383 |
+
"--junit-xml",
|
| 384 |
+
action="store",
|
| 385 |
+
dest="xmlpath",
|
| 386 |
+
metavar="path",
|
| 387 |
+
type=functools.partial(filename_arg, optname="--junitxml"),
|
| 388 |
+
default=None,
|
| 389 |
+
help="Create junit-xml style report file at given path",
|
| 390 |
+
)
|
| 391 |
+
group.addoption(
|
| 392 |
+
"--junitprefix",
|
| 393 |
+
"--junit-prefix",
|
| 394 |
+
action="store",
|
| 395 |
+
metavar="str",
|
| 396 |
+
default=None,
|
| 397 |
+
help="Prepend prefix to classnames in junit-xml output",
|
| 398 |
+
)
|
| 399 |
+
parser.addini(
|
| 400 |
+
"junit_suite_name", "Test suite name for JUnit report", default="pytest"
|
| 401 |
+
)
|
| 402 |
+
parser.addini(
|
| 403 |
+
"junit_logging",
|
| 404 |
+
"Write captured log messages to JUnit report: "
|
| 405 |
+
"one of no|log|system-out|system-err|out-err|all",
|
| 406 |
+
default="no",
|
| 407 |
+
)
|
| 408 |
+
parser.addini(
|
| 409 |
+
"junit_log_passing_tests",
|
| 410 |
+
"Capture log information for passing tests to JUnit report: ",
|
| 411 |
+
type="bool",
|
| 412 |
+
default=True,
|
| 413 |
+
)
|
| 414 |
+
parser.addini(
|
| 415 |
+
"junit_duration_report",
|
| 416 |
+
"Duration time to report: one of total|call",
|
| 417 |
+
default="total",
|
| 418 |
+
) # choices=['total', 'call'])
|
| 419 |
+
parser.addini(
|
| 420 |
+
"junit_family",
|
| 421 |
+
"Emit XML for schema: one of legacy|xunit1|xunit2",
|
| 422 |
+
default="xunit2",
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def pytest_configure(config: Config) -> None:
|
| 427 |
+
xmlpath = config.option.xmlpath
|
| 428 |
+
# Prevent opening xmllog on worker nodes (xdist).
|
| 429 |
+
if xmlpath and not hasattr(config, "workerinput"):
|
| 430 |
+
junit_family = config.getini("junit_family")
|
| 431 |
+
config.stash[xml_key] = LogXML(
|
| 432 |
+
xmlpath,
|
| 433 |
+
config.option.junitprefix,
|
| 434 |
+
config.getini("junit_suite_name"),
|
| 435 |
+
config.getini("junit_logging"),
|
| 436 |
+
config.getini("junit_duration_report"),
|
| 437 |
+
junit_family,
|
| 438 |
+
config.getini("junit_log_passing_tests"),
|
| 439 |
+
)
|
| 440 |
+
config.pluginmanager.register(config.stash[xml_key])
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def pytest_unconfigure(config: Config) -> None:
|
| 444 |
+
xml = config.stash.get(xml_key, None)
|
| 445 |
+
if xml:
|
| 446 |
+
del config.stash[xml_key]
|
| 447 |
+
config.pluginmanager.unregister(xml)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def mangle_test_address(address: str) -> list[str]:
|
| 451 |
+
path, possible_open_bracket, params = address.partition("[")
|
| 452 |
+
names = path.split("::")
|
| 453 |
+
# Convert file path to dotted path.
|
| 454 |
+
names[0] = names[0].replace(nodes.SEP, ".")
|
| 455 |
+
names[0] = re.sub(r"\.py$", "", names[0])
|
| 456 |
+
# Put any params back.
|
| 457 |
+
names[-1] += possible_open_bracket + params
|
| 458 |
+
return names
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
class LogXML:
|
| 462 |
+
def __init__(
|
| 463 |
+
self,
|
| 464 |
+
logfile,
|
| 465 |
+
prefix: str | None,
|
| 466 |
+
suite_name: str = "pytest",
|
| 467 |
+
logging: str = "no",
|
| 468 |
+
report_duration: str = "total",
|
| 469 |
+
family="xunit1",
|
| 470 |
+
log_passing_tests: bool = True,
|
| 471 |
+
) -> None:
|
| 472 |
+
logfile = os.path.expanduser(os.path.expandvars(logfile))
|
| 473 |
+
self.logfile = os.path.normpath(os.path.abspath(logfile))
|
| 474 |
+
self.prefix = prefix
|
| 475 |
+
self.suite_name = suite_name
|
| 476 |
+
self.logging = logging
|
| 477 |
+
self.log_passing_tests = log_passing_tests
|
| 478 |
+
self.report_duration = report_duration
|
| 479 |
+
self.family = family
|
| 480 |
+
self.stats: dict[str, int] = dict.fromkeys(
|
| 481 |
+
["error", "passed", "failure", "skipped"], 0
|
| 482 |
+
)
|
| 483 |
+
self.node_reporters: dict[tuple[str | TestReport, object], _NodeReporter] = {}
|
| 484 |
+
self.node_reporters_ordered: list[_NodeReporter] = []
|
| 485 |
+
self.global_properties: list[tuple[str, str]] = []
|
| 486 |
+
|
| 487 |
+
# List of reports that failed on call but teardown is pending.
|
| 488 |
+
self.open_reports: list[TestReport] = []
|
| 489 |
+
self.cnt_double_fail_tests = 0
|
| 490 |
+
|
| 491 |
+
# Replaces convenience family with real family.
|
| 492 |
+
if self.family == "legacy":
|
| 493 |
+
self.family = "xunit1"
|
| 494 |
+
|
| 495 |
+
def finalize(self, report: TestReport) -> None:
|
| 496 |
+
nodeid = getattr(report, "nodeid", report)
|
| 497 |
+
# Local hack to handle xdist report order.
|
| 498 |
+
workernode = getattr(report, "node", None)
|
| 499 |
+
reporter = self.node_reporters.pop((nodeid, workernode))
|
| 500 |
+
|
| 501 |
+
for propname, propvalue in report.user_properties:
|
| 502 |
+
reporter.add_property(propname, str(propvalue))
|
| 503 |
+
|
| 504 |
+
if reporter is not None:
|
| 505 |
+
reporter.finalize()
|
| 506 |
+
|
| 507 |
+
def node_reporter(self, report: TestReport | str) -> _NodeReporter:
|
| 508 |
+
nodeid: str | TestReport = getattr(report, "nodeid", report)
|
| 509 |
+
# Local hack to handle xdist report order.
|
| 510 |
+
workernode = getattr(report, "node", None)
|
| 511 |
+
|
| 512 |
+
key = nodeid, workernode
|
| 513 |
+
|
| 514 |
+
if key in self.node_reporters:
|
| 515 |
+
# TODO: breaks for --dist=each
|
| 516 |
+
return self.node_reporters[key]
|
| 517 |
+
|
| 518 |
+
reporter = _NodeReporter(nodeid, self)
|
| 519 |
+
|
| 520 |
+
self.node_reporters[key] = reporter
|
| 521 |
+
self.node_reporters_ordered.append(reporter)
|
| 522 |
+
|
| 523 |
+
return reporter
|
| 524 |
+
|
| 525 |
+
def add_stats(self, key: str) -> None:
|
| 526 |
+
if key in self.stats:
|
| 527 |
+
self.stats[key] += 1
|
| 528 |
+
|
| 529 |
+
def _opentestcase(self, report: TestReport) -> _NodeReporter:
|
| 530 |
+
reporter = self.node_reporter(report)
|
| 531 |
+
reporter.record_testreport(report)
|
| 532 |
+
return reporter
|
| 533 |
+
|
| 534 |
+
def pytest_runtest_logreport(self, report: TestReport) -> None:
|
| 535 |
+
"""Handle a setup/call/teardown report, generating the appropriate
|
| 536 |
+
XML tags as necessary.
|
| 537 |
+
|
| 538 |
+
Note: due to plugins like xdist, this hook may be called in interlaced
|
| 539 |
+
order with reports from other nodes. For example:
|
| 540 |
+
|
| 541 |
+
Usual call order:
|
| 542 |
+
-> setup node1
|
| 543 |
+
-> call node1
|
| 544 |
+
-> teardown node1
|
| 545 |
+
-> setup node2
|
| 546 |
+
-> call node2
|
| 547 |
+
-> teardown node2
|
| 548 |
+
|
| 549 |
+
Possible call order in xdist:
|
| 550 |
+
-> setup node1
|
| 551 |
+
-> call node1
|
| 552 |
+
-> setup node2
|
| 553 |
+
-> call node2
|
| 554 |
+
-> teardown node2
|
| 555 |
+
-> teardown node1
|
| 556 |
+
"""
|
| 557 |
+
close_report = None
|
| 558 |
+
if report.passed:
|
| 559 |
+
if report.when == "call": # ignore setup/teardown
|
| 560 |
+
reporter = self._opentestcase(report)
|
| 561 |
+
reporter.append_pass(report)
|
| 562 |
+
elif report.failed:
|
| 563 |
+
if report.when == "teardown":
|
| 564 |
+
# The following vars are needed when xdist plugin is used.
|
| 565 |
+
report_wid = getattr(report, "worker_id", None)
|
| 566 |
+
report_ii = getattr(report, "item_index", None)
|
| 567 |
+
close_report = next(
|
| 568 |
+
(
|
| 569 |
+
rep
|
| 570 |
+
for rep in self.open_reports
|
| 571 |
+
if (
|
| 572 |
+
rep.nodeid == report.nodeid
|
| 573 |
+
and getattr(rep, "item_index", None) == report_ii
|
| 574 |
+
and getattr(rep, "worker_id", None) == report_wid
|
| 575 |
+
)
|
| 576 |
+
),
|
| 577 |
+
None,
|
| 578 |
+
)
|
| 579 |
+
if close_report:
|
| 580 |
+
# We need to open new testcase in case we have failure in
|
| 581 |
+
# call and error in teardown in order to follow junit
|
| 582 |
+
# schema.
|
| 583 |
+
self.finalize(close_report)
|
| 584 |
+
self.cnt_double_fail_tests += 1
|
| 585 |
+
reporter = self._opentestcase(report)
|
| 586 |
+
if report.when == "call":
|
| 587 |
+
reporter.append_failure(report)
|
| 588 |
+
self.open_reports.append(report)
|
| 589 |
+
if not self.log_passing_tests:
|
| 590 |
+
reporter.write_captured_output(report)
|
| 591 |
+
else:
|
| 592 |
+
reporter.append_error(report)
|
| 593 |
+
elif report.skipped:
|
| 594 |
+
reporter = self._opentestcase(report)
|
| 595 |
+
reporter.append_skipped(report)
|
| 596 |
+
self.update_testcase_duration(report)
|
| 597 |
+
if report.when == "teardown":
|
| 598 |
+
reporter = self._opentestcase(report)
|
| 599 |
+
reporter.write_captured_output(report)
|
| 600 |
+
|
| 601 |
+
self.finalize(report)
|
| 602 |
+
report_wid = getattr(report, "worker_id", None)
|
| 603 |
+
report_ii = getattr(report, "item_index", None)
|
| 604 |
+
close_report = next(
|
| 605 |
+
(
|
| 606 |
+
rep
|
| 607 |
+
for rep in self.open_reports
|
| 608 |
+
if (
|
| 609 |
+
rep.nodeid == report.nodeid
|
| 610 |
+
and getattr(rep, "item_index", None) == report_ii
|
| 611 |
+
and getattr(rep, "worker_id", None) == report_wid
|
| 612 |
+
)
|
| 613 |
+
),
|
| 614 |
+
None,
|
| 615 |
+
)
|
| 616 |
+
if close_report:
|
| 617 |
+
self.open_reports.remove(close_report)
|
| 618 |
+
|
| 619 |
+
def update_testcase_duration(self, report: TestReport) -> None:
|
| 620 |
+
"""Accumulate total duration for nodeid from given report and update
|
| 621 |
+
the Junit.testcase with the new total if already created."""
|
| 622 |
+
if self.report_duration in {"total", report.when}:
|
| 623 |
+
reporter = self.node_reporter(report)
|
| 624 |
+
reporter.duration += getattr(report, "duration", 0.0)
|
| 625 |
+
|
| 626 |
+
def pytest_collectreport(self, report: TestReport) -> None:
|
| 627 |
+
if not report.passed:
|
| 628 |
+
reporter = self._opentestcase(report)
|
| 629 |
+
if report.failed:
|
| 630 |
+
reporter.append_collect_error(report)
|
| 631 |
+
else:
|
| 632 |
+
reporter.append_collect_skipped(report)
|
| 633 |
+
|
| 634 |
+
def pytest_internalerror(self, excrepr: ExceptionRepr) -> None:
|
| 635 |
+
reporter = self.node_reporter("internal")
|
| 636 |
+
reporter.attrs.update(classname="pytest", name="internal")
|
| 637 |
+
reporter._add_simple("error", "internal error", str(excrepr))
|
| 638 |
+
|
| 639 |
+
def pytest_sessionstart(self) -> None:
|
| 640 |
+
self.suite_start_time = timing.time()
|
| 641 |
+
|
| 642 |
+
def pytest_sessionfinish(self) -> None:
|
| 643 |
+
dirname = os.path.dirname(os.path.abspath(self.logfile))
|
| 644 |
+
# exist_ok avoids filesystem race conditions between checking path existence and requesting creation
|
| 645 |
+
os.makedirs(dirname, exist_ok=True)
|
| 646 |
+
|
| 647 |
+
with open(self.logfile, "w", encoding="utf-8") as logfile:
|
| 648 |
+
suite_stop_time = timing.time()
|
| 649 |
+
suite_time_delta = suite_stop_time - self.suite_start_time
|
| 650 |
+
|
| 651 |
+
numtests = (
|
| 652 |
+
self.stats["passed"]
|
| 653 |
+
+ self.stats["failure"]
|
| 654 |
+
+ self.stats["skipped"]
|
| 655 |
+
+ self.stats["error"]
|
| 656 |
+
- self.cnt_double_fail_tests
|
| 657 |
+
)
|
| 658 |
+
logfile.write('<?xml version="1.0" encoding="utf-8"?>')
|
| 659 |
+
|
| 660 |
+
suite_node = ET.Element(
|
| 661 |
+
"testsuite",
|
| 662 |
+
name=self.suite_name,
|
| 663 |
+
errors=str(self.stats["error"]),
|
| 664 |
+
failures=str(self.stats["failure"]),
|
| 665 |
+
skipped=str(self.stats["skipped"]),
|
| 666 |
+
tests=str(numtests),
|
| 667 |
+
time=f"{suite_time_delta:.3f}",
|
| 668 |
+
timestamp=datetime.fromtimestamp(self.suite_start_time, timezone.utc)
|
| 669 |
+
.astimezone()
|
| 670 |
+
.isoformat(),
|
| 671 |
+
hostname=platform.node(),
|
| 672 |
+
)
|
| 673 |
+
global_properties = self._get_global_properties_node()
|
| 674 |
+
if global_properties is not None:
|
| 675 |
+
suite_node.append(global_properties)
|
| 676 |
+
for node_reporter in self.node_reporters_ordered:
|
| 677 |
+
suite_node.append(node_reporter.to_xml())
|
| 678 |
+
testsuites = ET.Element("testsuites")
|
| 679 |
+
testsuites.append(suite_node)
|
| 680 |
+
logfile.write(ET.tostring(testsuites, encoding="unicode"))
|
| 681 |
+
|
| 682 |
+
def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
|
| 683 |
+
terminalreporter.write_sep("-", f"generated xml file: {self.logfile}")
|
| 684 |
+
|
| 685 |
+
def add_global_property(self, name: str, value: object) -> None:
|
| 686 |
+
__tracebackhide__ = True
|
| 687 |
+
_check_record_param_type("name", name)
|
| 688 |
+
self.global_properties.append((name, bin_xml_escape(value)))
|
| 689 |
+
|
| 690 |
+
def _get_global_properties_node(self) -> ET.Element | None:
|
| 691 |
+
"""Return a Junit node containing custom properties, if any."""
|
| 692 |
+
if self.global_properties:
|
| 693 |
+
properties = ET.Element("properties")
|
| 694 |
+
for name, value in self.global_properties:
|
| 695 |
+
properties.append(ET.Element("property", name=name, value=value))
|
| 696 |
+
return properties
|
| 697 |
+
return None
|
.venv/lib/python3.11/site-packages/_pytest/main.py
ADDED
|
@@ -0,0 +1,1072 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Core implementation of the testing process: init, session, runtest loop."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import dataclasses
|
| 7 |
+
import fnmatch
|
| 8 |
+
import functools
|
| 9 |
+
import importlib
|
| 10 |
+
import importlib.util
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
import sys
|
| 14 |
+
from typing import AbstractSet
|
| 15 |
+
from typing import Callable
|
| 16 |
+
from typing import Dict
|
| 17 |
+
from typing import final
|
| 18 |
+
from typing import Iterable
|
| 19 |
+
from typing import Iterator
|
| 20 |
+
from typing import Literal
|
| 21 |
+
from typing import overload
|
| 22 |
+
from typing import Sequence
|
| 23 |
+
from typing import TYPE_CHECKING
|
| 24 |
+
import warnings
|
| 25 |
+
|
| 26 |
+
import pluggy
|
| 27 |
+
|
| 28 |
+
from _pytest import nodes
|
| 29 |
+
import _pytest._code
|
| 30 |
+
from _pytest.config import Config
|
| 31 |
+
from _pytest.config import directory_arg
|
| 32 |
+
from _pytest.config import ExitCode
|
| 33 |
+
from _pytest.config import hookimpl
|
| 34 |
+
from _pytest.config import PytestPluginManager
|
| 35 |
+
from _pytest.config import UsageError
|
| 36 |
+
from _pytest.config.argparsing import Parser
|
| 37 |
+
from _pytest.config.compat import PathAwareHookProxy
|
| 38 |
+
from _pytest.outcomes import exit
|
| 39 |
+
from _pytest.pathlib import absolutepath
|
| 40 |
+
from _pytest.pathlib import bestrelpath
|
| 41 |
+
from _pytest.pathlib import fnmatch_ex
|
| 42 |
+
from _pytest.pathlib import safe_exists
|
| 43 |
+
from _pytest.pathlib import scandir
|
| 44 |
+
from _pytest.reports import CollectReport
|
| 45 |
+
from _pytest.reports import TestReport
|
| 46 |
+
from _pytest.runner import collect_one_node
|
| 47 |
+
from _pytest.runner import SetupState
|
| 48 |
+
from _pytest.warning_types import PytestWarning
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if TYPE_CHECKING:
|
| 52 |
+
from typing_extensions import Self
|
| 53 |
+
|
| 54 |
+
from _pytest.fixtures import FixtureManager
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 58 |
+
parser.addini(
|
| 59 |
+
"norecursedirs",
|
| 60 |
+
"Directory patterns to avoid for recursion",
|
| 61 |
+
type="args",
|
| 62 |
+
default=[
|
| 63 |
+
"*.egg",
|
| 64 |
+
".*",
|
| 65 |
+
"_darcs",
|
| 66 |
+
"build",
|
| 67 |
+
"CVS",
|
| 68 |
+
"dist",
|
| 69 |
+
"node_modules",
|
| 70 |
+
"venv",
|
| 71 |
+
"{arch}",
|
| 72 |
+
],
|
| 73 |
+
)
|
| 74 |
+
parser.addini(
|
| 75 |
+
"testpaths",
|
| 76 |
+
"Directories to search for tests when no files or directories are given on the "
|
| 77 |
+
"command line",
|
| 78 |
+
type="args",
|
| 79 |
+
default=[],
|
| 80 |
+
)
|
| 81 |
+
group = parser.getgroup("general", "Running and selection options")
|
| 82 |
+
group._addoption(
|
| 83 |
+
"-x",
|
| 84 |
+
"--exitfirst",
|
| 85 |
+
action="store_const",
|
| 86 |
+
dest="maxfail",
|
| 87 |
+
const=1,
|
| 88 |
+
help="Exit instantly on first error or failed test",
|
| 89 |
+
)
|
| 90 |
+
group = parser.getgroup("pytest-warnings")
|
| 91 |
+
group.addoption(
|
| 92 |
+
"-W",
|
| 93 |
+
"--pythonwarnings",
|
| 94 |
+
action="append",
|
| 95 |
+
help="Set which warnings to report, see -W option of Python itself",
|
| 96 |
+
)
|
| 97 |
+
parser.addini(
|
| 98 |
+
"filterwarnings",
|
| 99 |
+
type="linelist",
|
| 100 |
+
help="Each line specifies a pattern for "
|
| 101 |
+
"warnings.filterwarnings. "
|
| 102 |
+
"Processed after -W/--pythonwarnings.",
|
| 103 |
+
)
|
| 104 |
+
group._addoption(
|
| 105 |
+
"--maxfail",
|
| 106 |
+
metavar="num",
|
| 107 |
+
action="store",
|
| 108 |
+
type=int,
|
| 109 |
+
dest="maxfail",
|
| 110 |
+
default=0,
|
| 111 |
+
help="Exit after first num failures or errors",
|
| 112 |
+
)
|
| 113 |
+
group._addoption(
|
| 114 |
+
"--strict-config",
|
| 115 |
+
action="store_true",
|
| 116 |
+
help="Any warnings encountered while parsing the `pytest` section of the "
|
| 117 |
+
"configuration file raise errors",
|
| 118 |
+
)
|
| 119 |
+
group._addoption(
|
| 120 |
+
"--strict-markers",
|
| 121 |
+
action="store_true",
|
| 122 |
+
help="Markers not registered in the `markers` section of the configuration "
|
| 123 |
+
"file raise errors",
|
| 124 |
+
)
|
| 125 |
+
group._addoption(
|
| 126 |
+
"--strict",
|
| 127 |
+
action="store_true",
|
| 128 |
+
help="(Deprecated) alias to --strict-markers",
|
| 129 |
+
)
|
| 130 |
+
group._addoption(
|
| 131 |
+
"-c",
|
| 132 |
+
"--config-file",
|
| 133 |
+
metavar="FILE",
|
| 134 |
+
type=str,
|
| 135 |
+
dest="inifilename",
|
| 136 |
+
help="Load configuration from `FILE` instead of trying to locate one of the "
|
| 137 |
+
"implicit configuration files.",
|
| 138 |
+
)
|
| 139 |
+
group._addoption(
|
| 140 |
+
"--continue-on-collection-errors",
|
| 141 |
+
action="store_true",
|
| 142 |
+
default=False,
|
| 143 |
+
dest="continue_on_collection_errors",
|
| 144 |
+
help="Force test execution even if collection errors occur",
|
| 145 |
+
)
|
| 146 |
+
group._addoption(
|
| 147 |
+
"--rootdir",
|
| 148 |
+
action="store",
|
| 149 |
+
dest="rootdir",
|
| 150 |
+
help="Define root directory for tests. Can be relative path: 'root_dir', './root_dir', "
|
| 151 |
+
"'root_dir/another_dir/'; absolute path: '/home/user/root_dir'; path with variables: "
|
| 152 |
+
"'$HOME/root_dir'.",
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
group = parser.getgroup("collect", "collection")
|
| 156 |
+
group.addoption(
|
| 157 |
+
"--collectonly",
|
| 158 |
+
"--collect-only",
|
| 159 |
+
"--co",
|
| 160 |
+
action="store_true",
|
| 161 |
+
help="Only collect tests, don't execute them",
|
| 162 |
+
)
|
| 163 |
+
group.addoption(
|
| 164 |
+
"--pyargs",
|
| 165 |
+
action="store_true",
|
| 166 |
+
help="Try to interpret all arguments as Python packages",
|
| 167 |
+
)
|
| 168 |
+
group.addoption(
|
| 169 |
+
"--ignore",
|
| 170 |
+
action="append",
|
| 171 |
+
metavar="path",
|
| 172 |
+
help="Ignore path during collection (multi-allowed)",
|
| 173 |
+
)
|
| 174 |
+
group.addoption(
|
| 175 |
+
"--ignore-glob",
|
| 176 |
+
action="append",
|
| 177 |
+
metavar="path",
|
| 178 |
+
help="Ignore path pattern during collection (multi-allowed)",
|
| 179 |
+
)
|
| 180 |
+
group.addoption(
|
| 181 |
+
"--deselect",
|
| 182 |
+
action="append",
|
| 183 |
+
metavar="nodeid_prefix",
|
| 184 |
+
help="Deselect item (via node id prefix) during collection (multi-allowed)",
|
| 185 |
+
)
|
| 186 |
+
group.addoption(
|
| 187 |
+
"--confcutdir",
|
| 188 |
+
dest="confcutdir",
|
| 189 |
+
default=None,
|
| 190 |
+
metavar="dir",
|
| 191 |
+
type=functools.partial(directory_arg, optname="--confcutdir"),
|
| 192 |
+
help="Only load conftest.py's relative to specified dir",
|
| 193 |
+
)
|
| 194 |
+
group.addoption(
|
| 195 |
+
"--noconftest",
|
| 196 |
+
action="store_true",
|
| 197 |
+
dest="noconftest",
|
| 198 |
+
default=False,
|
| 199 |
+
help="Don't load any conftest.py files",
|
| 200 |
+
)
|
| 201 |
+
group.addoption(
|
| 202 |
+
"--keepduplicates",
|
| 203 |
+
"--keep-duplicates",
|
| 204 |
+
action="store_true",
|
| 205 |
+
dest="keepduplicates",
|
| 206 |
+
default=False,
|
| 207 |
+
help="Keep duplicate tests",
|
| 208 |
+
)
|
| 209 |
+
group.addoption(
|
| 210 |
+
"--collect-in-virtualenv",
|
| 211 |
+
action="store_true",
|
| 212 |
+
dest="collect_in_virtualenv",
|
| 213 |
+
default=False,
|
| 214 |
+
help="Don't ignore tests in a local virtualenv directory",
|
| 215 |
+
)
|
| 216 |
+
group.addoption(
|
| 217 |
+
"--import-mode",
|
| 218 |
+
default="prepend",
|
| 219 |
+
choices=["prepend", "append", "importlib"],
|
| 220 |
+
dest="importmode",
|
| 221 |
+
help="Prepend/append to sys.path when importing test modules and conftest "
|
| 222 |
+
"files. Default: prepend.",
|
| 223 |
+
)
|
| 224 |
+
parser.addini(
|
| 225 |
+
"consider_namespace_packages",
|
| 226 |
+
type="bool",
|
| 227 |
+
default=False,
|
| 228 |
+
help="Consider namespace packages when resolving module names during import",
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
group = parser.getgroup("debugconfig", "test session debugging and configuration")
|
| 232 |
+
group.addoption(
|
| 233 |
+
"--basetemp",
|
| 234 |
+
dest="basetemp",
|
| 235 |
+
default=None,
|
| 236 |
+
type=validate_basetemp,
|
| 237 |
+
metavar="dir",
|
| 238 |
+
help=(
|
| 239 |
+
"Base temporary directory for this test run. "
|
| 240 |
+
"(Warning: this directory is removed if it exists.)"
|
| 241 |
+
),
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def validate_basetemp(path: str) -> str:
|
| 246 |
+
# GH 7119
|
| 247 |
+
msg = "basetemp must not be empty, the current working directory or any parent directory of it"
|
| 248 |
+
|
| 249 |
+
# empty path
|
| 250 |
+
if not path:
|
| 251 |
+
raise argparse.ArgumentTypeError(msg)
|
| 252 |
+
|
| 253 |
+
def is_ancestor(base: Path, query: Path) -> bool:
|
| 254 |
+
"""Return whether query is an ancestor of base."""
|
| 255 |
+
if base == query:
|
| 256 |
+
return True
|
| 257 |
+
return query in base.parents
|
| 258 |
+
|
| 259 |
+
# check if path is an ancestor of cwd
|
| 260 |
+
if is_ancestor(Path.cwd(), Path(path).absolute()):
|
| 261 |
+
raise argparse.ArgumentTypeError(msg)
|
| 262 |
+
|
| 263 |
+
# check symlinks for ancestors
|
| 264 |
+
if is_ancestor(Path.cwd().resolve(), Path(path).resolve()):
|
| 265 |
+
raise argparse.ArgumentTypeError(msg)
|
| 266 |
+
|
| 267 |
+
return path
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def wrap_session(
|
| 271 |
+
config: Config, doit: Callable[[Config, Session], int | ExitCode | None]
|
| 272 |
+
) -> int | ExitCode:
|
| 273 |
+
"""Skeleton command line program."""
|
| 274 |
+
session = Session.from_config(config)
|
| 275 |
+
session.exitstatus = ExitCode.OK
|
| 276 |
+
initstate = 0
|
| 277 |
+
try:
|
| 278 |
+
try:
|
| 279 |
+
config._do_configure()
|
| 280 |
+
initstate = 1
|
| 281 |
+
config.hook.pytest_sessionstart(session=session)
|
| 282 |
+
initstate = 2
|
| 283 |
+
session.exitstatus = doit(config, session) or 0
|
| 284 |
+
except UsageError:
|
| 285 |
+
session.exitstatus = ExitCode.USAGE_ERROR
|
| 286 |
+
raise
|
| 287 |
+
except Failed:
|
| 288 |
+
session.exitstatus = ExitCode.TESTS_FAILED
|
| 289 |
+
except (KeyboardInterrupt, exit.Exception):
|
| 290 |
+
excinfo = _pytest._code.ExceptionInfo.from_current()
|
| 291 |
+
exitstatus: int | ExitCode = ExitCode.INTERRUPTED
|
| 292 |
+
if isinstance(excinfo.value, exit.Exception):
|
| 293 |
+
if excinfo.value.returncode is not None:
|
| 294 |
+
exitstatus = excinfo.value.returncode
|
| 295 |
+
if initstate < 2:
|
| 296 |
+
sys.stderr.write(f"{excinfo.typename}: {excinfo.value.msg}\n")
|
| 297 |
+
config.hook.pytest_keyboard_interrupt(excinfo=excinfo)
|
| 298 |
+
session.exitstatus = exitstatus
|
| 299 |
+
except BaseException:
|
| 300 |
+
session.exitstatus = ExitCode.INTERNAL_ERROR
|
| 301 |
+
excinfo = _pytest._code.ExceptionInfo.from_current()
|
| 302 |
+
try:
|
| 303 |
+
config.notify_exception(excinfo, config.option)
|
| 304 |
+
except exit.Exception as exc:
|
| 305 |
+
if exc.returncode is not None:
|
| 306 |
+
session.exitstatus = exc.returncode
|
| 307 |
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
| 308 |
+
else:
|
| 309 |
+
if isinstance(excinfo.value, SystemExit):
|
| 310 |
+
sys.stderr.write("mainloop: caught unexpected SystemExit!\n")
|
| 311 |
+
|
| 312 |
+
finally:
|
| 313 |
+
# Explicitly break reference cycle.
|
| 314 |
+
excinfo = None # type: ignore
|
| 315 |
+
os.chdir(session.startpath)
|
| 316 |
+
if initstate >= 2:
|
| 317 |
+
try:
|
| 318 |
+
config.hook.pytest_sessionfinish(
|
| 319 |
+
session=session, exitstatus=session.exitstatus
|
| 320 |
+
)
|
| 321 |
+
except exit.Exception as exc:
|
| 322 |
+
if exc.returncode is not None:
|
| 323 |
+
session.exitstatus = exc.returncode
|
| 324 |
+
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
| 325 |
+
config._ensure_unconfigure()
|
| 326 |
+
return session.exitstatus
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def pytest_cmdline_main(config: Config) -> int | ExitCode:
|
| 330 |
+
return wrap_session(config, _main)
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def _main(config: Config, session: Session) -> int | ExitCode | None:
|
| 334 |
+
"""Default command line protocol for initialization, session,
|
| 335 |
+
running tests and reporting."""
|
| 336 |
+
config.hook.pytest_collection(session=session)
|
| 337 |
+
config.hook.pytest_runtestloop(session=session)
|
| 338 |
+
|
| 339 |
+
if session.testsfailed:
|
| 340 |
+
return ExitCode.TESTS_FAILED
|
| 341 |
+
elif session.testscollected == 0:
|
| 342 |
+
return ExitCode.NO_TESTS_COLLECTED
|
| 343 |
+
return None
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def pytest_collection(session: Session) -> None:
|
| 347 |
+
session.perform_collect()
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def pytest_runtestloop(session: Session) -> bool:
|
| 351 |
+
if session.testsfailed and not session.config.option.continue_on_collection_errors:
|
| 352 |
+
raise session.Interrupted(
|
| 353 |
+
"%d error%s during collection"
|
| 354 |
+
% (session.testsfailed, "s" if session.testsfailed != 1 else "")
|
| 355 |
+
)
|
| 356 |
+
|
| 357 |
+
if session.config.option.collectonly:
|
| 358 |
+
return True
|
| 359 |
+
|
| 360 |
+
for i, item in enumerate(session.items):
|
| 361 |
+
nextitem = session.items[i + 1] if i + 1 < len(session.items) else None
|
| 362 |
+
item.config.hook.pytest_runtest_protocol(item=item, nextitem=nextitem)
|
| 363 |
+
if session.shouldfail:
|
| 364 |
+
raise session.Failed(session.shouldfail)
|
| 365 |
+
if session.shouldstop:
|
| 366 |
+
raise session.Interrupted(session.shouldstop)
|
| 367 |
+
return True
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def _in_venv(path: Path) -> bool:
|
| 371 |
+
"""Attempt to detect if ``path`` is the root of a Virtual Environment by
|
| 372 |
+
checking for the existence of the pyvenv.cfg file.
|
| 373 |
+
|
| 374 |
+
[https://peps.python.org/pep-0405/]
|
| 375 |
+
|
| 376 |
+
For regression protection we also check for conda environments that do not include pyenv.cfg yet --
|
| 377 |
+
https://github.com/conda/conda/issues/13337 is the conda issue tracking adding pyenv.cfg.
|
| 378 |
+
|
| 379 |
+
Checking for the `conda-meta/history` file per https://github.com/pytest-dev/pytest/issues/12652#issuecomment-2246336902.
|
| 380 |
+
|
| 381 |
+
"""
|
| 382 |
+
try:
|
| 383 |
+
return (
|
| 384 |
+
path.joinpath("pyvenv.cfg").is_file()
|
| 385 |
+
or path.joinpath("conda-meta", "history").is_file()
|
| 386 |
+
)
|
| 387 |
+
except OSError:
|
| 388 |
+
return False
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
def pytest_ignore_collect(collection_path: Path, config: Config) -> bool | None:
|
| 392 |
+
if collection_path.name == "__pycache__":
|
| 393 |
+
return True
|
| 394 |
+
|
| 395 |
+
ignore_paths = config._getconftest_pathlist(
|
| 396 |
+
"collect_ignore", path=collection_path.parent
|
| 397 |
+
)
|
| 398 |
+
ignore_paths = ignore_paths or []
|
| 399 |
+
excludeopt = config.getoption("ignore")
|
| 400 |
+
if excludeopt:
|
| 401 |
+
ignore_paths.extend(absolutepath(x) for x in excludeopt)
|
| 402 |
+
|
| 403 |
+
if collection_path in ignore_paths:
|
| 404 |
+
return True
|
| 405 |
+
|
| 406 |
+
ignore_globs = config._getconftest_pathlist(
|
| 407 |
+
"collect_ignore_glob", path=collection_path.parent
|
| 408 |
+
)
|
| 409 |
+
ignore_globs = ignore_globs or []
|
| 410 |
+
excludeglobopt = config.getoption("ignore_glob")
|
| 411 |
+
if excludeglobopt:
|
| 412 |
+
ignore_globs.extend(absolutepath(x) for x in excludeglobopt)
|
| 413 |
+
|
| 414 |
+
if any(fnmatch.fnmatch(str(collection_path), str(glob)) for glob in ignore_globs):
|
| 415 |
+
return True
|
| 416 |
+
|
| 417 |
+
allow_in_venv = config.getoption("collect_in_virtualenv")
|
| 418 |
+
if not allow_in_venv and _in_venv(collection_path):
|
| 419 |
+
return True
|
| 420 |
+
|
| 421 |
+
if collection_path.is_dir():
|
| 422 |
+
norecursepatterns = config.getini("norecursedirs")
|
| 423 |
+
if any(fnmatch_ex(pat, collection_path) for pat in norecursepatterns):
|
| 424 |
+
return True
|
| 425 |
+
|
| 426 |
+
return None
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def pytest_collect_directory(
|
| 430 |
+
path: Path, parent: nodes.Collector
|
| 431 |
+
) -> nodes.Collector | None:
|
| 432 |
+
return Dir.from_parent(parent, path=path)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def pytest_collection_modifyitems(items: list[nodes.Item], config: Config) -> None:
|
| 436 |
+
deselect_prefixes = tuple(config.getoption("deselect") or [])
|
| 437 |
+
if not deselect_prefixes:
|
| 438 |
+
return
|
| 439 |
+
|
| 440 |
+
remaining = []
|
| 441 |
+
deselected = []
|
| 442 |
+
for colitem in items:
|
| 443 |
+
if colitem.nodeid.startswith(deselect_prefixes):
|
| 444 |
+
deselected.append(colitem)
|
| 445 |
+
else:
|
| 446 |
+
remaining.append(colitem)
|
| 447 |
+
|
| 448 |
+
if deselected:
|
| 449 |
+
config.hook.pytest_deselected(items=deselected)
|
| 450 |
+
items[:] = remaining
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
class FSHookProxy:
|
| 454 |
+
def __init__(
|
| 455 |
+
self,
|
| 456 |
+
pm: PytestPluginManager,
|
| 457 |
+
remove_mods: AbstractSet[object],
|
| 458 |
+
) -> None:
|
| 459 |
+
self.pm = pm
|
| 460 |
+
self.remove_mods = remove_mods
|
| 461 |
+
|
| 462 |
+
def __getattr__(self, name: str) -> pluggy.HookCaller:
|
| 463 |
+
x = self.pm.subset_hook_caller(name, remove_plugins=self.remove_mods)
|
| 464 |
+
self.__dict__[name] = x
|
| 465 |
+
return x
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
class Interrupted(KeyboardInterrupt):
|
| 469 |
+
"""Signals that the test run was interrupted."""
|
| 470 |
+
|
| 471 |
+
__module__ = "builtins" # For py3.
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
class Failed(Exception):
|
| 475 |
+
"""Signals a stop as failed test run."""
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
@dataclasses.dataclass
|
| 479 |
+
class _bestrelpath_cache(Dict[Path, str]):
|
| 480 |
+
__slots__ = ("path",)
|
| 481 |
+
|
| 482 |
+
path: Path
|
| 483 |
+
|
| 484 |
+
def __missing__(self, path: Path) -> str:
|
| 485 |
+
r = bestrelpath(self.path, path)
|
| 486 |
+
self[path] = r
|
| 487 |
+
return r
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
@final
|
| 491 |
+
class Dir(nodes.Directory):
|
| 492 |
+
"""Collector of files in a file system directory.
|
| 493 |
+
|
| 494 |
+
.. versionadded:: 8.0
|
| 495 |
+
|
| 496 |
+
.. note::
|
| 497 |
+
|
| 498 |
+
Python directories with an `__init__.py` file are instead collected by
|
| 499 |
+
:class:`~pytest.Package` by default. Both are :class:`~pytest.Directory`
|
| 500 |
+
collectors.
|
| 501 |
+
"""
|
| 502 |
+
|
| 503 |
+
@classmethod
|
| 504 |
+
def from_parent( # type: ignore[override]
|
| 505 |
+
cls,
|
| 506 |
+
parent: nodes.Collector,
|
| 507 |
+
*,
|
| 508 |
+
path: Path,
|
| 509 |
+
) -> Self:
|
| 510 |
+
"""The public constructor.
|
| 511 |
+
|
| 512 |
+
:param parent: The parent collector of this Dir.
|
| 513 |
+
:param path: The directory's path.
|
| 514 |
+
:type path: pathlib.Path
|
| 515 |
+
"""
|
| 516 |
+
return super().from_parent(parent=parent, path=path)
|
| 517 |
+
|
| 518 |
+
def collect(self) -> Iterable[nodes.Item | nodes.Collector]:
|
| 519 |
+
config = self.config
|
| 520 |
+
col: nodes.Collector | None
|
| 521 |
+
cols: Sequence[nodes.Collector]
|
| 522 |
+
ihook = self.ihook
|
| 523 |
+
for direntry in scandir(self.path):
|
| 524 |
+
if direntry.is_dir():
|
| 525 |
+
path = Path(direntry.path)
|
| 526 |
+
if not self.session.isinitpath(path, with_parents=True):
|
| 527 |
+
if ihook.pytest_ignore_collect(collection_path=path, config=config):
|
| 528 |
+
continue
|
| 529 |
+
col = ihook.pytest_collect_directory(path=path, parent=self)
|
| 530 |
+
if col is not None:
|
| 531 |
+
yield col
|
| 532 |
+
|
| 533 |
+
elif direntry.is_file():
|
| 534 |
+
path = Path(direntry.path)
|
| 535 |
+
if not self.session.isinitpath(path):
|
| 536 |
+
if ihook.pytest_ignore_collect(collection_path=path, config=config):
|
| 537 |
+
continue
|
| 538 |
+
cols = ihook.pytest_collect_file(file_path=path, parent=self)
|
| 539 |
+
yield from cols
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
@final
|
| 543 |
+
class Session(nodes.Collector):
|
| 544 |
+
"""The root of the collection tree.
|
| 545 |
+
|
| 546 |
+
``Session`` collects the initial paths given as arguments to pytest.
|
| 547 |
+
"""
|
| 548 |
+
|
| 549 |
+
Interrupted = Interrupted
|
| 550 |
+
Failed = Failed
|
| 551 |
+
# Set on the session by runner.pytest_sessionstart.
|
| 552 |
+
_setupstate: SetupState
|
| 553 |
+
# Set on the session by fixtures.pytest_sessionstart.
|
| 554 |
+
_fixturemanager: FixtureManager
|
| 555 |
+
exitstatus: int | ExitCode
|
| 556 |
+
|
| 557 |
+
def __init__(self, config: Config) -> None:
|
| 558 |
+
super().__init__(
|
| 559 |
+
name="",
|
| 560 |
+
path=config.rootpath,
|
| 561 |
+
fspath=None,
|
| 562 |
+
parent=None,
|
| 563 |
+
config=config,
|
| 564 |
+
session=self,
|
| 565 |
+
nodeid="",
|
| 566 |
+
)
|
| 567 |
+
self.testsfailed = 0
|
| 568 |
+
self.testscollected = 0
|
| 569 |
+
self._shouldstop: bool | str = False
|
| 570 |
+
self._shouldfail: bool | str = False
|
| 571 |
+
self.trace = config.trace.root.get("collection")
|
| 572 |
+
self._initialpaths: frozenset[Path] = frozenset()
|
| 573 |
+
self._initialpaths_with_parents: frozenset[Path] = frozenset()
|
| 574 |
+
self._notfound: list[tuple[str, Sequence[nodes.Collector]]] = []
|
| 575 |
+
self._initial_parts: list[CollectionArgument] = []
|
| 576 |
+
self._collection_cache: dict[nodes.Collector, CollectReport] = {}
|
| 577 |
+
self.items: list[nodes.Item] = []
|
| 578 |
+
|
| 579 |
+
self._bestrelpathcache: dict[Path, str] = _bestrelpath_cache(config.rootpath)
|
| 580 |
+
|
| 581 |
+
self.config.pluginmanager.register(self, name="session")
|
| 582 |
+
|
| 583 |
+
@classmethod
|
| 584 |
+
def from_config(cls, config: Config) -> Session:
|
| 585 |
+
session: Session = cls._create(config=config)
|
| 586 |
+
return session
|
| 587 |
+
|
| 588 |
+
def __repr__(self) -> str:
|
| 589 |
+
return "<%s %s exitstatus=%r testsfailed=%d testscollected=%d>" % (
|
| 590 |
+
self.__class__.__name__,
|
| 591 |
+
self.name,
|
| 592 |
+
getattr(self, "exitstatus", "<UNSET>"),
|
| 593 |
+
self.testsfailed,
|
| 594 |
+
self.testscollected,
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
@property
|
| 598 |
+
def shouldstop(self) -> bool | str:
|
| 599 |
+
return self._shouldstop
|
| 600 |
+
|
| 601 |
+
@shouldstop.setter
|
| 602 |
+
def shouldstop(self, value: bool | str) -> None:
|
| 603 |
+
# The runner checks shouldfail and assumes that if it is set we are
|
| 604 |
+
# definitely stopping, so prevent unsetting it.
|
| 605 |
+
if value is False and self._shouldstop:
|
| 606 |
+
warnings.warn(
|
| 607 |
+
PytestWarning(
|
| 608 |
+
"session.shouldstop cannot be unset after it has been set; ignoring."
|
| 609 |
+
),
|
| 610 |
+
stacklevel=2,
|
| 611 |
+
)
|
| 612 |
+
return
|
| 613 |
+
self._shouldstop = value
|
| 614 |
+
|
| 615 |
+
@property
|
| 616 |
+
def shouldfail(self) -> bool | str:
|
| 617 |
+
return self._shouldfail
|
| 618 |
+
|
| 619 |
+
@shouldfail.setter
|
| 620 |
+
def shouldfail(self, value: bool | str) -> None:
|
| 621 |
+
# The runner checks shouldfail and assumes that if it is set we are
|
| 622 |
+
# definitely stopping, so prevent unsetting it.
|
| 623 |
+
if value is False and self._shouldfail:
|
| 624 |
+
warnings.warn(
|
| 625 |
+
PytestWarning(
|
| 626 |
+
"session.shouldfail cannot be unset after it has been set; ignoring."
|
| 627 |
+
),
|
| 628 |
+
stacklevel=2,
|
| 629 |
+
)
|
| 630 |
+
return
|
| 631 |
+
self._shouldfail = value
|
| 632 |
+
|
| 633 |
+
@property
|
| 634 |
+
def startpath(self) -> Path:
|
| 635 |
+
"""The path from which pytest was invoked.
|
| 636 |
+
|
| 637 |
+
.. versionadded:: 7.0.0
|
| 638 |
+
"""
|
| 639 |
+
return self.config.invocation_params.dir
|
| 640 |
+
|
| 641 |
+
def _node_location_to_relpath(self, node_path: Path) -> str:
|
| 642 |
+
# bestrelpath is a quite slow function.
|
| 643 |
+
return self._bestrelpathcache[node_path]
|
| 644 |
+
|
| 645 |
+
@hookimpl(tryfirst=True)
|
| 646 |
+
def pytest_collectstart(self) -> None:
|
| 647 |
+
if self.shouldfail:
|
| 648 |
+
raise self.Failed(self.shouldfail)
|
| 649 |
+
if self.shouldstop:
|
| 650 |
+
raise self.Interrupted(self.shouldstop)
|
| 651 |
+
|
| 652 |
+
@hookimpl(tryfirst=True)
|
| 653 |
+
def pytest_runtest_logreport(self, report: TestReport | CollectReport) -> None:
|
| 654 |
+
if report.failed and not hasattr(report, "wasxfail"):
|
| 655 |
+
self.testsfailed += 1
|
| 656 |
+
maxfail = self.config.getvalue("maxfail")
|
| 657 |
+
if maxfail and self.testsfailed >= maxfail:
|
| 658 |
+
self.shouldfail = "stopping after %d failures" % (self.testsfailed)
|
| 659 |
+
|
| 660 |
+
pytest_collectreport = pytest_runtest_logreport
|
| 661 |
+
|
| 662 |
+
def isinitpath(
|
| 663 |
+
self,
|
| 664 |
+
path: str | os.PathLike[str],
|
| 665 |
+
*,
|
| 666 |
+
with_parents: bool = False,
|
| 667 |
+
) -> bool:
|
| 668 |
+
"""Is path an initial path?
|
| 669 |
+
|
| 670 |
+
An initial path is a path explicitly given to pytest on the command
|
| 671 |
+
line.
|
| 672 |
+
|
| 673 |
+
:param with_parents:
|
| 674 |
+
If set, also return True if the path is a parent of an initial path.
|
| 675 |
+
|
| 676 |
+
.. versionchanged:: 8.0
|
| 677 |
+
Added the ``with_parents`` parameter.
|
| 678 |
+
"""
|
| 679 |
+
# Optimization: Path(Path(...)) is much slower than isinstance.
|
| 680 |
+
path_ = path if isinstance(path, Path) else Path(path)
|
| 681 |
+
if with_parents:
|
| 682 |
+
return path_ in self._initialpaths_with_parents
|
| 683 |
+
else:
|
| 684 |
+
return path_ in self._initialpaths
|
| 685 |
+
|
| 686 |
+
def gethookproxy(self, fspath: os.PathLike[str]) -> pluggy.HookRelay:
|
| 687 |
+
# Optimization: Path(Path(...)) is much slower than isinstance.
|
| 688 |
+
path = fspath if isinstance(fspath, Path) else Path(fspath)
|
| 689 |
+
pm = self.config.pluginmanager
|
| 690 |
+
# Check if we have the common case of running
|
| 691 |
+
# hooks with all conftest.py files.
|
| 692 |
+
my_conftestmodules = pm._getconftestmodules(path)
|
| 693 |
+
remove_mods = pm._conftest_plugins.difference(my_conftestmodules)
|
| 694 |
+
proxy: pluggy.HookRelay
|
| 695 |
+
if remove_mods:
|
| 696 |
+
# One or more conftests are not in use at this path.
|
| 697 |
+
proxy = PathAwareHookProxy(FSHookProxy(pm, remove_mods)) # type: ignore[arg-type,assignment]
|
| 698 |
+
else:
|
| 699 |
+
# All plugins are active for this fspath.
|
| 700 |
+
proxy = self.config.hook
|
| 701 |
+
return proxy
|
| 702 |
+
|
| 703 |
+
def _collect_path(
|
| 704 |
+
self,
|
| 705 |
+
path: Path,
|
| 706 |
+
path_cache: dict[Path, Sequence[nodes.Collector]],
|
| 707 |
+
) -> Sequence[nodes.Collector]:
|
| 708 |
+
"""Create a Collector for the given path.
|
| 709 |
+
|
| 710 |
+
`path_cache` makes it so the same Collectors are returned for the same
|
| 711 |
+
path.
|
| 712 |
+
"""
|
| 713 |
+
if path in path_cache:
|
| 714 |
+
return path_cache[path]
|
| 715 |
+
|
| 716 |
+
if path.is_dir():
|
| 717 |
+
ihook = self.gethookproxy(path.parent)
|
| 718 |
+
col: nodes.Collector | None = ihook.pytest_collect_directory(
|
| 719 |
+
path=path, parent=self
|
| 720 |
+
)
|
| 721 |
+
cols: Sequence[nodes.Collector] = (col,) if col is not None else ()
|
| 722 |
+
|
| 723 |
+
elif path.is_file():
|
| 724 |
+
ihook = self.gethookproxy(path)
|
| 725 |
+
cols = ihook.pytest_collect_file(file_path=path, parent=self)
|
| 726 |
+
|
| 727 |
+
else:
|
| 728 |
+
# Broken symlink or invalid/missing file.
|
| 729 |
+
cols = ()
|
| 730 |
+
|
| 731 |
+
path_cache[path] = cols
|
| 732 |
+
return cols
|
| 733 |
+
|
| 734 |
+
@overload
|
| 735 |
+
def perform_collect(
|
| 736 |
+
self, args: Sequence[str] | None = ..., genitems: Literal[True] = ...
|
| 737 |
+
) -> Sequence[nodes.Item]: ...
|
| 738 |
+
|
| 739 |
+
@overload
|
| 740 |
+
def perform_collect(
|
| 741 |
+
self, args: Sequence[str] | None = ..., genitems: bool = ...
|
| 742 |
+
) -> Sequence[nodes.Item | nodes.Collector]: ...
|
| 743 |
+
|
| 744 |
+
def perform_collect(
|
| 745 |
+
self, args: Sequence[str] | None = None, genitems: bool = True
|
| 746 |
+
) -> Sequence[nodes.Item | nodes.Collector]:
|
| 747 |
+
"""Perform the collection phase for this session.
|
| 748 |
+
|
| 749 |
+
This is called by the default :hook:`pytest_collection` hook
|
| 750 |
+
implementation; see the documentation of this hook for more details.
|
| 751 |
+
For testing purposes, it may also be called directly on a fresh
|
| 752 |
+
``Session``.
|
| 753 |
+
|
| 754 |
+
This function normally recursively expands any collectors collected
|
| 755 |
+
from the session to their items, and only items are returned. For
|
| 756 |
+
testing purposes, this may be suppressed by passing ``genitems=False``,
|
| 757 |
+
in which case the return value contains these collectors unexpanded,
|
| 758 |
+
and ``session.items`` is empty.
|
| 759 |
+
"""
|
| 760 |
+
if args is None:
|
| 761 |
+
args = self.config.args
|
| 762 |
+
|
| 763 |
+
self.trace("perform_collect", self, args)
|
| 764 |
+
self.trace.root.indent += 1
|
| 765 |
+
|
| 766 |
+
hook = self.config.hook
|
| 767 |
+
|
| 768 |
+
self._notfound = []
|
| 769 |
+
self._initial_parts = []
|
| 770 |
+
self._collection_cache = {}
|
| 771 |
+
self.items = []
|
| 772 |
+
items: Sequence[nodes.Item | nodes.Collector] = self.items
|
| 773 |
+
try:
|
| 774 |
+
initialpaths: list[Path] = []
|
| 775 |
+
initialpaths_with_parents: list[Path] = []
|
| 776 |
+
for arg in args:
|
| 777 |
+
collection_argument = resolve_collection_argument(
|
| 778 |
+
self.config.invocation_params.dir,
|
| 779 |
+
arg,
|
| 780 |
+
as_pypath=self.config.option.pyargs,
|
| 781 |
+
)
|
| 782 |
+
self._initial_parts.append(collection_argument)
|
| 783 |
+
initialpaths.append(collection_argument.path)
|
| 784 |
+
initialpaths_with_parents.append(collection_argument.path)
|
| 785 |
+
initialpaths_with_parents.extend(collection_argument.path.parents)
|
| 786 |
+
self._initialpaths = frozenset(initialpaths)
|
| 787 |
+
self._initialpaths_with_parents = frozenset(initialpaths_with_parents)
|
| 788 |
+
|
| 789 |
+
rep = collect_one_node(self)
|
| 790 |
+
self.ihook.pytest_collectreport(report=rep)
|
| 791 |
+
self.trace.root.indent -= 1
|
| 792 |
+
if self._notfound:
|
| 793 |
+
errors = []
|
| 794 |
+
for arg, collectors in self._notfound:
|
| 795 |
+
if collectors:
|
| 796 |
+
errors.append(
|
| 797 |
+
f"not found: {arg}\n(no match in any of {collectors!r})"
|
| 798 |
+
)
|
| 799 |
+
else:
|
| 800 |
+
errors.append(f"found no collectors for {arg}")
|
| 801 |
+
|
| 802 |
+
raise UsageError(*errors)
|
| 803 |
+
|
| 804 |
+
if not genitems:
|
| 805 |
+
items = rep.result
|
| 806 |
+
else:
|
| 807 |
+
if rep.passed:
|
| 808 |
+
for node in rep.result:
|
| 809 |
+
self.items.extend(self.genitems(node))
|
| 810 |
+
|
| 811 |
+
self.config.pluginmanager.check_pending()
|
| 812 |
+
hook.pytest_collection_modifyitems(
|
| 813 |
+
session=self, config=self.config, items=items
|
| 814 |
+
)
|
| 815 |
+
finally:
|
| 816 |
+
self._notfound = []
|
| 817 |
+
self._initial_parts = []
|
| 818 |
+
self._collection_cache = {}
|
| 819 |
+
hook.pytest_collection_finish(session=self)
|
| 820 |
+
|
| 821 |
+
if genitems:
|
| 822 |
+
self.testscollected = len(items)
|
| 823 |
+
|
| 824 |
+
return items
|
| 825 |
+
|
| 826 |
+
def _collect_one_node(
|
| 827 |
+
self,
|
| 828 |
+
node: nodes.Collector,
|
| 829 |
+
handle_dupes: bool = True,
|
| 830 |
+
) -> tuple[CollectReport, bool]:
|
| 831 |
+
if node in self._collection_cache and handle_dupes:
|
| 832 |
+
rep = self._collection_cache[node]
|
| 833 |
+
return rep, True
|
| 834 |
+
else:
|
| 835 |
+
rep = collect_one_node(node)
|
| 836 |
+
self._collection_cache[node] = rep
|
| 837 |
+
return rep, False
|
| 838 |
+
|
| 839 |
+
def collect(self) -> Iterator[nodes.Item | nodes.Collector]:
|
| 840 |
+
# This is a cache for the root directories of the initial paths.
|
| 841 |
+
# We can't use collection_cache for Session because of its special
|
| 842 |
+
# role as the bootstrapping collector.
|
| 843 |
+
path_cache: dict[Path, Sequence[nodes.Collector]] = {}
|
| 844 |
+
|
| 845 |
+
pm = self.config.pluginmanager
|
| 846 |
+
|
| 847 |
+
for collection_argument in self._initial_parts:
|
| 848 |
+
self.trace("processing argument", collection_argument)
|
| 849 |
+
self.trace.root.indent += 1
|
| 850 |
+
|
| 851 |
+
argpath = collection_argument.path
|
| 852 |
+
names = collection_argument.parts
|
| 853 |
+
module_name = collection_argument.module_name
|
| 854 |
+
|
| 855 |
+
# resolve_collection_argument() ensures this.
|
| 856 |
+
if argpath.is_dir():
|
| 857 |
+
assert not names, f"invalid arg {(argpath, names)!r}"
|
| 858 |
+
|
| 859 |
+
paths = [argpath]
|
| 860 |
+
# Add relevant parents of the path, from the root, e.g.
|
| 861 |
+
# /a/b/c.py -> [/, /a, /a/b, /a/b/c.py]
|
| 862 |
+
if module_name is None:
|
| 863 |
+
# Paths outside of the confcutdir should not be considered.
|
| 864 |
+
for path in argpath.parents:
|
| 865 |
+
if not pm._is_in_confcutdir(path):
|
| 866 |
+
break
|
| 867 |
+
paths.insert(0, path)
|
| 868 |
+
else:
|
| 869 |
+
# For --pyargs arguments, only consider paths matching the module
|
| 870 |
+
# name. Paths beyond the package hierarchy are not included.
|
| 871 |
+
module_name_parts = module_name.split(".")
|
| 872 |
+
for i, path in enumerate(argpath.parents, 2):
|
| 873 |
+
if i > len(module_name_parts) or path.stem != module_name_parts[-i]:
|
| 874 |
+
break
|
| 875 |
+
paths.insert(0, path)
|
| 876 |
+
|
| 877 |
+
# Start going over the parts from the root, collecting each level
|
| 878 |
+
# and discarding all nodes which don't match the level's part.
|
| 879 |
+
any_matched_in_initial_part = False
|
| 880 |
+
notfound_collectors = []
|
| 881 |
+
work: list[tuple[nodes.Collector | nodes.Item, list[Path | str]]] = [
|
| 882 |
+
(self, [*paths, *names])
|
| 883 |
+
]
|
| 884 |
+
while work:
|
| 885 |
+
matchnode, matchparts = work.pop()
|
| 886 |
+
|
| 887 |
+
# Pop'd all of the parts, this is a match.
|
| 888 |
+
if not matchparts:
|
| 889 |
+
yield matchnode
|
| 890 |
+
any_matched_in_initial_part = True
|
| 891 |
+
continue
|
| 892 |
+
|
| 893 |
+
# Should have been matched by now, discard.
|
| 894 |
+
if not isinstance(matchnode, nodes.Collector):
|
| 895 |
+
continue
|
| 896 |
+
|
| 897 |
+
# Collect this level of matching.
|
| 898 |
+
# Collecting Session (self) is done directly to avoid endless
|
| 899 |
+
# recursion to this function.
|
| 900 |
+
subnodes: Sequence[nodes.Collector | nodes.Item]
|
| 901 |
+
if isinstance(matchnode, Session):
|
| 902 |
+
assert isinstance(matchparts[0], Path)
|
| 903 |
+
subnodes = matchnode._collect_path(matchparts[0], path_cache)
|
| 904 |
+
else:
|
| 905 |
+
# For backward compat, files given directly multiple
|
| 906 |
+
# times on the command line should not be deduplicated.
|
| 907 |
+
handle_dupes = not (
|
| 908 |
+
len(matchparts) == 1
|
| 909 |
+
and isinstance(matchparts[0], Path)
|
| 910 |
+
and matchparts[0].is_file()
|
| 911 |
+
)
|
| 912 |
+
rep, duplicate = self._collect_one_node(matchnode, handle_dupes)
|
| 913 |
+
if not duplicate and not rep.passed:
|
| 914 |
+
# Report collection failures here to avoid failing to
|
| 915 |
+
# run some test specified in the command line because
|
| 916 |
+
# the module could not be imported (#134).
|
| 917 |
+
matchnode.ihook.pytest_collectreport(report=rep)
|
| 918 |
+
if not rep.passed:
|
| 919 |
+
continue
|
| 920 |
+
subnodes = rep.result
|
| 921 |
+
|
| 922 |
+
# Prune this level.
|
| 923 |
+
any_matched_in_collector = False
|
| 924 |
+
for node in reversed(subnodes):
|
| 925 |
+
# Path part e.g. `/a/b/` in `/a/b/test_file.py::TestIt::test_it`.
|
| 926 |
+
if isinstance(matchparts[0], Path):
|
| 927 |
+
is_match = node.path == matchparts[0]
|
| 928 |
+
if sys.platform == "win32" and not is_match:
|
| 929 |
+
# In case the file paths do not match, fallback to samefile() to
|
| 930 |
+
# account for short-paths on Windows (#11895).
|
| 931 |
+
same_file = os.path.samefile(node.path, matchparts[0])
|
| 932 |
+
# We don't want to match links to the current node,
|
| 933 |
+
# otherwise we would match the same file more than once (#12039).
|
| 934 |
+
is_match = same_file and (
|
| 935 |
+
os.path.islink(node.path)
|
| 936 |
+
== os.path.islink(matchparts[0])
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
# Name part e.g. `TestIt` in `/a/b/test_file.py::TestIt::test_it`.
|
| 940 |
+
else:
|
| 941 |
+
# TODO: Remove parametrized workaround once collection structure contains
|
| 942 |
+
# parametrization.
|
| 943 |
+
is_match = (
|
| 944 |
+
node.name == matchparts[0]
|
| 945 |
+
or node.name.split("[")[0] == matchparts[0]
|
| 946 |
+
)
|
| 947 |
+
if is_match:
|
| 948 |
+
work.append((node, matchparts[1:]))
|
| 949 |
+
any_matched_in_collector = True
|
| 950 |
+
|
| 951 |
+
if not any_matched_in_collector:
|
| 952 |
+
notfound_collectors.append(matchnode)
|
| 953 |
+
|
| 954 |
+
if not any_matched_in_initial_part:
|
| 955 |
+
report_arg = "::".join((str(argpath), *names))
|
| 956 |
+
self._notfound.append((report_arg, notfound_collectors))
|
| 957 |
+
|
| 958 |
+
self.trace.root.indent -= 1
|
| 959 |
+
|
| 960 |
+
def genitems(self, node: nodes.Item | nodes.Collector) -> Iterator[nodes.Item]:
|
| 961 |
+
self.trace("genitems", node)
|
| 962 |
+
if isinstance(node, nodes.Item):
|
| 963 |
+
node.ihook.pytest_itemcollected(item=node)
|
| 964 |
+
yield node
|
| 965 |
+
else:
|
| 966 |
+
assert isinstance(node, nodes.Collector)
|
| 967 |
+
keepduplicates = self.config.getoption("keepduplicates")
|
| 968 |
+
# For backward compat, dedup only applies to files.
|
| 969 |
+
handle_dupes = not (keepduplicates and isinstance(node, nodes.File))
|
| 970 |
+
rep, duplicate = self._collect_one_node(node, handle_dupes)
|
| 971 |
+
if duplicate and not keepduplicates:
|
| 972 |
+
return
|
| 973 |
+
if rep.passed:
|
| 974 |
+
for subnode in rep.result:
|
| 975 |
+
yield from self.genitems(subnode)
|
| 976 |
+
if not duplicate:
|
| 977 |
+
node.ihook.pytest_collectreport(report=rep)
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
def search_pypath(module_name: str) -> str | None:
|
| 981 |
+
"""Search sys.path for the given a dotted module name, and return its file
|
| 982 |
+
system path if found."""
|
| 983 |
+
try:
|
| 984 |
+
spec = importlib.util.find_spec(module_name)
|
| 985 |
+
# AttributeError: looks like package module, but actually filename
|
| 986 |
+
# ImportError: module does not exist
|
| 987 |
+
# ValueError: not a module name
|
| 988 |
+
except (AttributeError, ImportError, ValueError):
|
| 989 |
+
return None
|
| 990 |
+
if spec is None or spec.origin is None or spec.origin == "namespace":
|
| 991 |
+
return None
|
| 992 |
+
elif spec.submodule_search_locations:
|
| 993 |
+
return os.path.dirname(spec.origin)
|
| 994 |
+
else:
|
| 995 |
+
return spec.origin
|
| 996 |
+
|
| 997 |
+
|
| 998 |
+
@dataclasses.dataclass(frozen=True)
|
| 999 |
+
class CollectionArgument:
|
| 1000 |
+
"""A resolved collection argument."""
|
| 1001 |
+
|
| 1002 |
+
path: Path
|
| 1003 |
+
parts: Sequence[str]
|
| 1004 |
+
module_name: str | None
|
| 1005 |
+
|
| 1006 |
+
|
| 1007 |
+
def resolve_collection_argument(
|
| 1008 |
+
invocation_path: Path, arg: str, *, as_pypath: bool = False
|
| 1009 |
+
) -> CollectionArgument:
|
| 1010 |
+
"""Parse path arguments optionally containing selection parts and return (fspath, names).
|
| 1011 |
+
|
| 1012 |
+
Command-line arguments can point to files and/or directories, and optionally contain
|
| 1013 |
+
parts for specific tests selection, for example:
|
| 1014 |
+
|
| 1015 |
+
"pkg/tests/test_foo.py::TestClass::test_foo"
|
| 1016 |
+
|
| 1017 |
+
This function ensures the path exists, and returns a resolved `CollectionArgument`:
|
| 1018 |
+
|
| 1019 |
+
CollectionArgument(
|
| 1020 |
+
path=Path("/full/path/to/pkg/tests/test_foo.py"),
|
| 1021 |
+
parts=["TestClass", "test_foo"],
|
| 1022 |
+
module_name=None,
|
| 1023 |
+
)
|
| 1024 |
+
|
| 1025 |
+
When as_pypath is True, expects that the command-line argument actually contains
|
| 1026 |
+
module paths instead of file-system paths:
|
| 1027 |
+
|
| 1028 |
+
"pkg.tests.test_foo::TestClass::test_foo"
|
| 1029 |
+
|
| 1030 |
+
In which case we search sys.path for a matching module, and then return the *path* to the
|
| 1031 |
+
found module, which may look like this:
|
| 1032 |
+
|
| 1033 |
+
CollectionArgument(
|
| 1034 |
+
path=Path("/home/u/myvenv/lib/site-packages/pkg/tests/test_foo.py"),
|
| 1035 |
+
parts=["TestClass", "test_foo"],
|
| 1036 |
+
module_name="pkg.tests.test_foo",
|
| 1037 |
+
)
|
| 1038 |
+
|
| 1039 |
+
If the path doesn't exist, raise UsageError.
|
| 1040 |
+
If the path is a directory and selection parts are present, raise UsageError.
|
| 1041 |
+
"""
|
| 1042 |
+
base, squacket, rest = str(arg).partition("[")
|
| 1043 |
+
strpath, *parts = base.split("::")
|
| 1044 |
+
if parts:
|
| 1045 |
+
parts[-1] = f"{parts[-1]}{squacket}{rest}"
|
| 1046 |
+
module_name = None
|
| 1047 |
+
if as_pypath:
|
| 1048 |
+
pyarg_strpath = search_pypath(strpath)
|
| 1049 |
+
if pyarg_strpath is not None:
|
| 1050 |
+
module_name = strpath
|
| 1051 |
+
strpath = pyarg_strpath
|
| 1052 |
+
fspath = invocation_path / strpath
|
| 1053 |
+
fspath = absolutepath(fspath)
|
| 1054 |
+
if not safe_exists(fspath):
|
| 1055 |
+
msg = (
|
| 1056 |
+
"module or package not found: {arg} (missing __init__.py?)"
|
| 1057 |
+
if as_pypath
|
| 1058 |
+
else "file or directory not found: {arg}"
|
| 1059 |
+
)
|
| 1060 |
+
raise UsageError(msg.format(arg=arg))
|
| 1061 |
+
if parts and fspath.is_dir():
|
| 1062 |
+
msg = (
|
| 1063 |
+
"package argument cannot contain :: selection parts: {arg}"
|
| 1064 |
+
if as_pypath
|
| 1065 |
+
else "directory argument cannot contain :: selection parts: {arg}"
|
| 1066 |
+
)
|
| 1067 |
+
raise UsageError(msg.format(arg=arg))
|
| 1068 |
+
return CollectionArgument(
|
| 1069 |
+
path=fspath,
|
| 1070 |
+
parts=parts,
|
| 1071 |
+
module_name=module_name,
|
| 1072 |
+
)
|
.venv/lib/python3.11/site-packages/_pytest/mark/__init__.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generic mechanism for marking and selecting python functions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import collections
|
| 6 |
+
import dataclasses
|
| 7 |
+
from typing import AbstractSet
|
| 8 |
+
from typing import Collection
|
| 9 |
+
from typing import Iterable
|
| 10 |
+
from typing import Optional
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
|
| 13 |
+
from .expression import Expression
|
| 14 |
+
from .expression import ParseError
|
| 15 |
+
from .structures import EMPTY_PARAMETERSET_OPTION
|
| 16 |
+
from .structures import get_empty_parameterset_mark
|
| 17 |
+
from .structures import Mark
|
| 18 |
+
from .structures import MARK_GEN
|
| 19 |
+
from .structures import MarkDecorator
|
| 20 |
+
from .structures import MarkGenerator
|
| 21 |
+
from .structures import ParameterSet
|
| 22 |
+
from _pytest.config import Config
|
| 23 |
+
from _pytest.config import ExitCode
|
| 24 |
+
from _pytest.config import hookimpl
|
| 25 |
+
from _pytest.config import UsageError
|
| 26 |
+
from _pytest.config.argparsing import NOT_SET
|
| 27 |
+
from _pytest.config.argparsing import Parser
|
| 28 |
+
from _pytest.stash import StashKey
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
if TYPE_CHECKING:
|
| 32 |
+
from _pytest.nodes import Item
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
__all__ = [
|
| 36 |
+
"MARK_GEN",
|
| 37 |
+
"Mark",
|
| 38 |
+
"MarkDecorator",
|
| 39 |
+
"MarkGenerator",
|
| 40 |
+
"ParameterSet",
|
| 41 |
+
"get_empty_parameterset_mark",
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
old_mark_config_key = StashKey[Optional[Config]]()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def param(
|
| 49 |
+
*values: object,
|
| 50 |
+
marks: MarkDecorator | Collection[MarkDecorator | Mark] = (),
|
| 51 |
+
id: str | None = None,
|
| 52 |
+
) -> ParameterSet:
|
| 53 |
+
"""Specify a parameter in `pytest.mark.parametrize`_ calls or
|
| 54 |
+
:ref:`parametrized fixtures <fixture-parametrize-marks>`.
|
| 55 |
+
|
| 56 |
+
.. code-block:: python
|
| 57 |
+
|
| 58 |
+
@pytest.mark.parametrize(
|
| 59 |
+
"test_input,expected",
|
| 60 |
+
[
|
| 61 |
+
("3+5", 8),
|
| 62 |
+
pytest.param("6*9", 42, marks=pytest.mark.xfail),
|
| 63 |
+
],
|
| 64 |
+
)
|
| 65 |
+
def test_eval(test_input, expected):
|
| 66 |
+
assert eval(test_input) == expected
|
| 67 |
+
|
| 68 |
+
:param values: Variable args of the values of the parameter set, in order.
|
| 69 |
+
:param marks: A single mark or a list of marks to be applied to this parameter set.
|
| 70 |
+
:param id: The id to attribute to this parameter set.
|
| 71 |
+
"""
|
| 72 |
+
return ParameterSet.param(*values, marks=marks, id=id)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 76 |
+
group = parser.getgroup("general")
|
| 77 |
+
group._addoption(
|
| 78 |
+
"-k",
|
| 79 |
+
action="store",
|
| 80 |
+
dest="keyword",
|
| 81 |
+
default="",
|
| 82 |
+
metavar="EXPRESSION",
|
| 83 |
+
help="Only run tests which match the given substring expression. "
|
| 84 |
+
"An expression is a Python evaluable expression "
|
| 85 |
+
"where all names are substring-matched against test names "
|
| 86 |
+
"and their parent classes. Example: -k 'test_method or test_"
|
| 87 |
+
"other' matches all test functions and classes whose name "
|
| 88 |
+
"contains 'test_method' or 'test_other', while -k 'not test_method' "
|
| 89 |
+
"matches those that don't contain 'test_method' in their names. "
|
| 90 |
+
"-k 'not test_method and not test_other' will eliminate the matches. "
|
| 91 |
+
"Additionally keywords are matched to classes and functions "
|
| 92 |
+
"containing extra names in their 'extra_keyword_matches' set, "
|
| 93 |
+
"as well as functions which have names assigned directly to them. "
|
| 94 |
+
"The matching is case-insensitive.",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
group._addoption(
|
| 98 |
+
"-m",
|
| 99 |
+
action="store",
|
| 100 |
+
dest="markexpr",
|
| 101 |
+
default="",
|
| 102 |
+
metavar="MARKEXPR",
|
| 103 |
+
help="Only run tests matching given mark expression. "
|
| 104 |
+
"For example: -m 'mark1 and not mark2'.",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
group.addoption(
|
| 108 |
+
"--markers",
|
| 109 |
+
action="store_true",
|
| 110 |
+
help="show markers (builtin, plugin and per-project ones).",
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
parser.addini("markers", "Register new markers for test functions", "linelist")
|
| 114 |
+
parser.addini(EMPTY_PARAMETERSET_OPTION, "Default marker for empty parametersets")
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
@hookimpl(tryfirst=True)
|
| 118 |
+
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
|
| 119 |
+
import _pytest.config
|
| 120 |
+
|
| 121 |
+
if config.option.markers:
|
| 122 |
+
config._do_configure()
|
| 123 |
+
tw = _pytest.config.create_terminal_writer(config)
|
| 124 |
+
for line in config.getini("markers"):
|
| 125 |
+
parts = line.split(":", 1)
|
| 126 |
+
name = parts[0]
|
| 127 |
+
rest = parts[1] if len(parts) == 2 else ""
|
| 128 |
+
tw.write(f"@pytest.mark.{name}:", bold=True)
|
| 129 |
+
tw.line(rest)
|
| 130 |
+
tw.line()
|
| 131 |
+
config._ensure_unconfigure()
|
| 132 |
+
return 0
|
| 133 |
+
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@dataclasses.dataclass
|
| 138 |
+
class KeywordMatcher:
|
| 139 |
+
"""A matcher for keywords.
|
| 140 |
+
|
| 141 |
+
Given a list of names, matches any substring of one of these names. The
|
| 142 |
+
string inclusion check is case-insensitive.
|
| 143 |
+
|
| 144 |
+
Will match on the name of colitem, including the names of its parents.
|
| 145 |
+
Only matches names of items which are either a :class:`Class` or a
|
| 146 |
+
:class:`Function`.
|
| 147 |
+
|
| 148 |
+
Additionally, matches on names in the 'extra_keyword_matches' set of
|
| 149 |
+
any item, as well as names directly assigned to test functions.
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
__slots__ = ("_names",)
|
| 153 |
+
|
| 154 |
+
_names: AbstractSet[str]
|
| 155 |
+
|
| 156 |
+
@classmethod
|
| 157 |
+
def from_item(cls, item: Item) -> KeywordMatcher:
|
| 158 |
+
mapped_names = set()
|
| 159 |
+
|
| 160 |
+
# Add the names of the current item and any parent items,
|
| 161 |
+
# except the Session and root Directory's which are not
|
| 162 |
+
# interesting for matching.
|
| 163 |
+
import pytest
|
| 164 |
+
|
| 165 |
+
for node in item.listchain():
|
| 166 |
+
if isinstance(node, pytest.Session):
|
| 167 |
+
continue
|
| 168 |
+
if isinstance(node, pytest.Directory) and isinstance(
|
| 169 |
+
node.parent, pytest.Session
|
| 170 |
+
):
|
| 171 |
+
continue
|
| 172 |
+
mapped_names.add(node.name)
|
| 173 |
+
|
| 174 |
+
# Add the names added as extra keywords to current or parent items.
|
| 175 |
+
mapped_names.update(item.listextrakeywords())
|
| 176 |
+
|
| 177 |
+
# Add the names attached to the current function through direct assignment.
|
| 178 |
+
function_obj = getattr(item, "function", None)
|
| 179 |
+
if function_obj:
|
| 180 |
+
mapped_names.update(function_obj.__dict__)
|
| 181 |
+
|
| 182 |
+
# Add the markers to the keywords as we no longer handle them correctly.
|
| 183 |
+
mapped_names.update(mark.name for mark in item.iter_markers())
|
| 184 |
+
|
| 185 |
+
return cls(mapped_names)
|
| 186 |
+
|
| 187 |
+
def __call__(self, subname: str, /, **kwargs: str | int | bool | None) -> bool:
|
| 188 |
+
if kwargs:
|
| 189 |
+
raise UsageError("Keyword expressions do not support call parameters.")
|
| 190 |
+
subname = subname.lower()
|
| 191 |
+
names = (name.lower() for name in self._names)
|
| 192 |
+
|
| 193 |
+
for name in names:
|
| 194 |
+
if subname in name:
|
| 195 |
+
return True
|
| 196 |
+
return False
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def deselect_by_keyword(items: list[Item], config: Config) -> None:
|
| 200 |
+
keywordexpr = config.option.keyword.lstrip()
|
| 201 |
+
if not keywordexpr:
|
| 202 |
+
return
|
| 203 |
+
|
| 204 |
+
expr = _parse_expression(keywordexpr, "Wrong expression passed to '-k'")
|
| 205 |
+
|
| 206 |
+
remaining = []
|
| 207 |
+
deselected = []
|
| 208 |
+
for colitem in items:
|
| 209 |
+
if not expr.evaluate(KeywordMatcher.from_item(colitem)):
|
| 210 |
+
deselected.append(colitem)
|
| 211 |
+
else:
|
| 212 |
+
remaining.append(colitem)
|
| 213 |
+
|
| 214 |
+
if deselected:
|
| 215 |
+
config.hook.pytest_deselected(items=deselected)
|
| 216 |
+
items[:] = remaining
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@dataclasses.dataclass
|
| 220 |
+
class MarkMatcher:
|
| 221 |
+
"""A matcher for markers which are present.
|
| 222 |
+
|
| 223 |
+
Tries to match on any marker names, attached to the given colitem.
|
| 224 |
+
"""
|
| 225 |
+
|
| 226 |
+
__slots__ = ("own_mark_name_mapping",)
|
| 227 |
+
|
| 228 |
+
own_mark_name_mapping: dict[str, list[Mark]]
|
| 229 |
+
|
| 230 |
+
@classmethod
|
| 231 |
+
def from_markers(cls, markers: Iterable[Mark]) -> MarkMatcher:
|
| 232 |
+
mark_name_mapping = collections.defaultdict(list)
|
| 233 |
+
for mark in markers:
|
| 234 |
+
mark_name_mapping[mark.name].append(mark)
|
| 235 |
+
return cls(mark_name_mapping)
|
| 236 |
+
|
| 237 |
+
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool:
|
| 238 |
+
if not (matches := self.own_mark_name_mapping.get(name, [])):
|
| 239 |
+
return False
|
| 240 |
+
|
| 241 |
+
for mark in matches:
|
| 242 |
+
if all(mark.kwargs.get(k, NOT_SET) == v for k, v in kwargs.items()):
|
| 243 |
+
return True
|
| 244 |
+
|
| 245 |
+
return False
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def deselect_by_mark(items: list[Item], config: Config) -> None:
|
| 249 |
+
matchexpr = config.option.markexpr
|
| 250 |
+
if not matchexpr:
|
| 251 |
+
return
|
| 252 |
+
|
| 253 |
+
expr = _parse_expression(matchexpr, "Wrong expression passed to '-m'")
|
| 254 |
+
remaining: list[Item] = []
|
| 255 |
+
deselected: list[Item] = []
|
| 256 |
+
for item in items:
|
| 257 |
+
if expr.evaluate(MarkMatcher.from_markers(item.iter_markers())):
|
| 258 |
+
remaining.append(item)
|
| 259 |
+
else:
|
| 260 |
+
deselected.append(item)
|
| 261 |
+
if deselected:
|
| 262 |
+
config.hook.pytest_deselected(items=deselected)
|
| 263 |
+
items[:] = remaining
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def _parse_expression(expr: str, exc_message: str) -> Expression:
|
| 267 |
+
try:
|
| 268 |
+
return Expression.compile(expr)
|
| 269 |
+
except ParseError as e:
|
| 270 |
+
raise UsageError(f"{exc_message}: {expr}: {e}") from None
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def pytest_collection_modifyitems(items: list[Item], config: Config) -> None:
|
| 274 |
+
deselect_by_keyword(items, config)
|
| 275 |
+
deselect_by_mark(items, config)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def pytest_configure(config: Config) -> None:
|
| 279 |
+
config.stash[old_mark_config_key] = MARK_GEN._config
|
| 280 |
+
MARK_GEN._config = config
|
| 281 |
+
|
| 282 |
+
empty_parameterset = config.getini(EMPTY_PARAMETERSET_OPTION)
|
| 283 |
+
|
| 284 |
+
if empty_parameterset not in ("skip", "xfail", "fail_at_collect", None, ""):
|
| 285 |
+
raise UsageError(
|
| 286 |
+
f"{EMPTY_PARAMETERSET_OPTION!s} must be one of skip, xfail or fail_at_collect"
|
| 287 |
+
f" but it is {empty_parameterset!r}"
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def pytest_unconfigure(config: Config) -> None:
|
| 292 |
+
MARK_GEN._config = config.stash.get(old_mark_config_key, None)
|
.venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/expression.cpython-311.pyc
ADDED
|
Binary file (18.3 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/mark/__pycache__/structures.cpython-311.pyc
ADDED
|
Binary file (28.8 kB). View file
|
|
|
.venv/lib/python3.11/site-packages/_pytest/mark/expression.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
r"""Evaluate match expressions, as used by `-k` and `-m`.
|
| 2 |
+
|
| 3 |
+
The grammar is:
|
| 4 |
+
|
| 5 |
+
expression: expr? EOF
|
| 6 |
+
expr: and_expr ('or' and_expr)*
|
| 7 |
+
and_expr: not_expr ('and' not_expr)*
|
| 8 |
+
not_expr: 'not' not_expr | '(' expr ')' | ident kwargs?
|
| 9 |
+
|
| 10 |
+
ident: (\w|:|\+|-|\.|\[|\]|\\|/)+
|
| 11 |
+
kwargs: ('(' name '=' value ( ', ' name '=' value )* ')')
|
| 12 |
+
name: a valid ident, but not a reserved keyword
|
| 13 |
+
value: (unescaped) string literal | (-)?[0-9]+ | 'False' | 'True' | 'None'
|
| 14 |
+
|
| 15 |
+
The semantics are:
|
| 16 |
+
|
| 17 |
+
- Empty expression evaluates to False.
|
| 18 |
+
- ident evaluates to True or False according to a provided matcher function.
|
| 19 |
+
- or/and/not evaluate according to the usual boolean semantics.
|
| 20 |
+
- ident with parentheses and keyword arguments evaluates to True or False according to a provided matcher function.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import ast
|
| 26 |
+
import dataclasses
|
| 27 |
+
import enum
|
| 28 |
+
import keyword
|
| 29 |
+
import re
|
| 30 |
+
import types
|
| 31 |
+
from typing import Iterator
|
| 32 |
+
from typing import Literal
|
| 33 |
+
from typing import Mapping
|
| 34 |
+
from typing import NoReturn
|
| 35 |
+
from typing import overload
|
| 36 |
+
from typing import Protocol
|
| 37 |
+
from typing import Sequence
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
__all__ = [
|
| 41 |
+
"Expression",
|
| 42 |
+
"ParseError",
|
| 43 |
+
]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class TokenType(enum.Enum):
|
| 47 |
+
LPAREN = "left parenthesis"
|
| 48 |
+
RPAREN = "right parenthesis"
|
| 49 |
+
OR = "or"
|
| 50 |
+
AND = "and"
|
| 51 |
+
NOT = "not"
|
| 52 |
+
IDENT = "identifier"
|
| 53 |
+
EOF = "end of input"
|
| 54 |
+
EQUAL = "="
|
| 55 |
+
STRING = "string literal"
|
| 56 |
+
COMMA = ","
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclasses.dataclass(frozen=True)
|
| 60 |
+
class Token:
|
| 61 |
+
__slots__ = ("type", "value", "pos")
|
| 62 |
+
type: TokenType
|
| 63 |
+
value: str
|
| 64 |
+
pos: int
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class ParseError(Exception):
|
| 68 |
+
"""The expression contains invalid syntax.
|
| 69 |
+
|
| 70 |
+
:param column: The column in the line where the error occurred (1-based).
|
| 71 |
+
:param message: A description of the error.
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
def __init__(self, column: int, message: str) -> None:
|
| 75 |
+
self.column = column
|
| 76 |
+
self.message = message
|
| 77 |
+
|
| 78 |
+
def __str__(self) -> str:
|
| 79 |
+
return f"at column {self.column}: {self.message}"
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class Scanner:
|
| 83 |
+
__slots__ = ("tokens", "current")
|
| 84 |
+
|
| 85 |
+
def __init__(self, input: str) -> None:
|
| 86 |
+
self.tokens = self.lex(input)
|
| 87 |
+
self.current = next(self.tokens)
|
| 88 |
+
|
| 89 |
+
def lex(self, input: str) -> Iterator[Token]:
|
| 90 |
+
pos = 0
|
| 91 |
+
while pos < len(input):
|
| 92 |
+
if input[pos] in (" ", "\t"):
|
| 93 |
+
pos += 1
|
| 94 |
+
elif input[pos] == "(":
|
| 95 |
+
yield Token(TokenType.LPAREN, "(", pos)
|
| 96 |
+
pos += 1
|
| 97 |
+
elif input[pos] == ")":
|
| 98 |
+
yield Token(TokenType.RPAREN, ")", pos)
|
| 99 |
+
pos += 1
|
| 100 |
+
elif input[pos] == "=":
|
| 101 |
+
yield Token(TokenType.EQUAL, "=", pos)
|
| 102 |
+
pos += 1
|
| 103 |
+
elif input[pos] == ",":
|
| 104 |
+
yield Token(TokenType.COMMA, ",", pos)
|
| 105 |
+
pos += 1
|
| 106 |
+
elif (quote_char := input[pos]) in ("'", '"'):
|
| 107 |
+
end_quote_pos = input.find(quote_char, pos + 1)
|
| 108 |
+
if end_quote_pos == -1:
|
| 109 |
+
raise ParseError(
|
| 110 |
+
pos + 1,
|
| 111 |
+
f'closing quote "{quote_char}" is missing',
|
| 112 |
+
)
|
| 113 |
+
value = input[pos : end_quote_pos + 1]
|
| 114 |
+
if (backslash_pos := input.find("\\")) != -1:
|
| 115 |
+
raise ParseError(
|
| 116 |
+
backslash_pos + 1,
|
| 117 |
+
r'escaping with "\" not supported in marker expression',
|
| 118 |
+
)
|
| 119 |
+
yield Token(TokenType.STRING, value, pos)
|
| 120 |
+
pos += len(value)
|
| 121 |
+
else:
|
| 122 |
+
match = re.match(r"(:?\w|:|\+|-|\.|\[|\]|\\|/)+", input[pos:])
|
| 123 |
+
if match:
|
| 124 |
+
value = match.group(0)
|
| 125 |
+
if value == "or":
|
| 126 |
+
yield Token(TokenType.OR, value, pos)
|
| 127 |
+
elif value == "and":
|
| 128 |
+
yield Token(TokenType.AND, value, pos)
|
| 129 |
+
elif value == "not":
|
| 130 |
+
yield Token(TokenType.NOT, value, pos)
|
| 131 |
+
else:
|
| 132 |
+
yield Token(TokenType.IDENT, value, pos)
|
| 133 |
+
pos += len(value)
|
| 134 |
+
else:
|
| 135 |
+
raise ParseError(
|
| 136 |
+
pos + 1,
|
| 137 |
+
f'unexpected character "{input[pos]}"',
|
| 138 |
+
)
|
| 139 |
+
yield Token(TokenType.EOF, "", pos)
|
| 140 |
+
|
| 141 |
+
@overload
|
| 142 |
+
def accept(self, type: TokenType, *, reject: Literal[True]) -> Token: ...
|
| 143 |
+
|
| 144 |
+
@overload
|
| 145 |
+
def accept(
|
| 146 |
+
self, type: TokenType, *, reject: Literal[False] = False
|
| 147 |
+
) -> Token | None: ...
|
| 148 |
+
|
| 149 |
+
def accept(self, type: TokenType, *, reject: bool = False) -> Token | None:
|
| 150 |
+
if self.current.type is type:
|
| 151 |
+
token = self.current
|
| 152 |
+
if token.type is not TokenType.EOF:
|
| 153 |
+
self.current = next(self.tokens)
|
| 154 |
+
return token
|
| 155 |
+
if reject:
|
| 156 |
+
self.reject((type,))
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
def reject(self, expected: Sequence[TokenType]) -> NoReturn:
|
| 160 |
+
raise ParseError(
|
| 161 |
+
self.current.pos + 1,
|
| 162 |
+
"expected {}; got {}".format(
|
| 163 |
+
" OR ".join(type.value for type in expected),
|
| 164 |
+
self.current.type.value,
|
| 165 |
+
),
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# True, False and None are legal match expression identifiers,
|
| 170 |
+
# but illegal as Python identifiers. To fix this, this prefix
|
| 171 |
+
# is added to identifiers in the conversion to Python AST.
|
| 172 |
+
IDENT_PREFIX = "$"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def expression(s: Scanner) -> ast.Expression:
|
| 176 |
+
if s.accept(TokenType.EOF):
|
| 177 |
+
ret: ast.expr = ast.Constant(False)
|
| 178 |
+
else:
|
| 179 |
+
ret = expr(s)
|
| 180 |
+
s.accept(TokenType.EOF, reject=True)
|
| 181 |
+
return ast.fix_missing_locations(ast.Expression(ret))
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def expr(s: Scanner) -> ast.expr:
|
| 185 |
+
ret = and_expr(s)
|
| 186 |
+
while s.accept(TokenType.OR):
|
| 187 |
+
rhs = and_expr(s)
|
| 188 |
+
ret = ast.BoolOp(ast.Or(), [ret, rhs])
|
| 189 |
+
return ret
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def and_expr(s: Scanner) -> ast.expr:
|
| 193 |
+
ret = not_expr(s)
|
| 194 |
+
while s.accept(TokenType.AND):
|
| 195 |
+
rhs = not_expr(s)
|
| 196 |
+
ret = ast.BoolOp(ast.And(), [ret, rhs])
|
| 197 |
+
return ret
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def not_expr(s: Scanner) -> ast.expr:
|
| 201 |
+
if s.accept(TokenType.NOT):
|
| 202 |
+
return ast.UnaryOp(ast.Not(), not_expr(s))
|
| 203 |
+
if s.accept(TokenType.LPAREN):
|
| 204 |
+
ret = expr(s)
|
| 205 |
+
s.accept(TokenType.RPAREN, reject=True)
|
| 206 |
+
return ret
|
| 207 |
+
ident = s.accept(TokenType.IDENT)
|
| 208 |
+
if ident:
|
| 209 |
+
name = ast.Name(IDENT_PREFIX + ident.value, ast.Load())
|
| 210 |
+
if s.accept(TokenType.LPAREN):
|
| 211 |
+
ret = ast.Call(func=name, args=[], keywords=all_kwargs(s))
|
| 212 |
+
s.accept(TokenType.RPAREN, reject=True)
|
| 213 |
+
else:
|
| 214 |
+
ret = name
|
| 215 |
+
return ret
|
| 216 |
+
|
| 217 |
+
s.reject((TokenType.NOT, TokenType.LPAREN, TokenType.IDENT))
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
BUILTIN_MATCHERS = {"True": True, "False": False, "None": None}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def single_kwarg(s: Scanner) -> ast.keyword:
|
| 224 |
+
keyword_name = s.accept(TokenType.IDENT, reject=True)
|
| 225 |
+
if not keyword_name.value.isidentifier():
|
| 226 |
+
raise ParseError(
|
| 227 |
+
keyword_name.pos + 1,
|
| 228 |
+
f"not a valid python identifier {keyword_name.value}",
|
| 229 |
+
)
|
| 230 |
+
if keyword.iskeyword(keyword_name.value):
|
| 231 |
+
raise ParseError(
|
| 232 |
+
keyword_name.pos + 1,
|
| 233 |
+
f"unexpected reserved python keyword `{keyword_name.value}`",
|
| 234 |
+
)
|
| 235 |
+
s.accept(TokenType.EQUAL, reject=True)
|
| 236 |
+
|
| 237 |
+
if value_token := s.accept(TokenType.STRING):
|
| 238 |
+
value: str | int | bool | None = value_token.value[1:-1] # strip quotes
|
| 239 |
+
else:
|
| 240 |
+
value_token = s.accept(TokenType.IDENT, reject=True)
|
| 241 |
+
if (
|
| 242 |
+
(number := value_token.value).isdigit()
|
| 243 |
+
or number.startswith("-")
|
| 244 |
+
and number[1:].isdigit()
|
| 245 |
+
):
|
| 246 |
+
value = int(number)
|
| 247 |
+
elif value_token.value in BUILTIN_MATCHERS:
|
| 248 |
+
value = BUILTIN_MATCHERS[value_token.value]
|
| 249 |
+
else:
|
| 250 |
+
raise ParseError(
|
| 251 |
+
value_token.pos + 1,
|
| 252 |
+
f'unexpected character/s "{value_token.value}"',
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
ret = ast.keyword(keyword_name.value, ast.Constant(value))
|
| 256 |
+
return ret
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def all_kwargs(s: Scanner) -> list[ast.keyword]:
|
| 260 |
+
ret = [single_kwarg(s)]
|
| 261 |
+
while s.accept(TokenType.COMMA):
|
| 262 |
+
ret.append(single_kwarg(s))
|
| 263 |
+
return ret
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
class MatcherCall(Protocol):
|
| 267 |
+
def __call__(self, name: str, /, **kwargs: str | int | bool | None) -> bool: ...
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
@dataclasses.dataclass
|
| 271 |
+
class MatcherNameAdapter:
|
| 272 |
+
matcher: MatcherCall
|
| 273 |
+
name: str
|
| 274 |
+
|
| 275 |
+
def __bool__(self) -> bool:
|
| 276 |
+
return self.matcher(self.name)
|
| 277 |
+
|
| 278 |
+
def __call__(self, **kwargs: str | int | bool | None) -> bool:
|
| 279 |
+
return self.matcher(self.name, **kwargs)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
class MatcherAdapter(Mapping[str, MatcherNameAdapter]):
|
| 283 |
+
"""Adapts a matcher function to a locals mapping as required by eval()."""
|
| 284 |
+
|
| 285 |
+
def __init__(self, matcher: MatcherCall) -> None:
|
| 286 |
+
self.matcher = matcher
|
| 287 |
+
|
| 288 |
+
def __getitem__(self, key: str) -> MatcherNameAdapter:
|
| 289 |
+
return MatcherNameAdapter(matcher=self.matcher, name=key[len(IDENT_PREFIX) :])
|
| 290 |
+
|
| 291 |
+
def __iter__(self) -> Iterator[str]:
|
| 292 |
+
raise NotImplementedError()
|
| 293 |
+
|
| 294 |
+
def __len__(self) -> int:
|
| 295 |
+
raise NotImplementedError()
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
class Expression:
|
| 299 |
+
"""A compiled match expression as used by -k and -m.
|
| 300 |
+
|
| 301 |
+
The expression can be evaluated against different matchers.
|
| 302 |
+
"""
|
| 303 |
+
|
| 304 |
+
__slots__ = ("code",)
|
| 305 |
+
|
| 306 |
+
def __init__(self, code: types.CodeType) -> None:
|
| 307 |
+
self.code = code
|
| 308 |
+
|
| 309 |
+
@classmethod
|
| 310 |
+
def compile(self, input: str) -> Expression:
|
| 311 |
+
"""Compile a match expression.
|
| 312 |
+
|
| 313 |
+
:param input: The input expression - one line.
|
| 314 |
+
"""
|
| 315 |
+
astexpr = expression(Scanner(input))
|
| 316 |
+
code: types.CodeType = compile(
|
| 317 |
+
astexpr,
|
| 318 |
+
filename="<pytest match expression>",
|
| 319 |
+
mode="eval",
|
| 320 |
+
)
|
| 321 |
+
return Expression(code)
|
| 322 |
+
|
| 323 |
+
def evaluate(self, matcher: MatcherCall) -> bool:
|
| 324 |
+
"""Evaluate the match expression.
|
| 325 |
+
|
| 326 |
+
:param matcher:
|
| 327 |
+
Given an identifier, should return whether it matches or not.
|
| 328 |
+
Should be prepared to handle arbitrary strings as input.
|
| 329 |
+
|
| 330 |
+
:returns: Whether the expression matches or not.
|
| 331 |
+
"""
|
| 332 |
+
ret: bool = bool(eval(self.code, {"__builtins__": {}}, MatcherAdapter(matcher)))
|
| 333 |
+
return ret
|
.venv/lib/python3.11/site-packages/_pytest/mark/structures.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import collections.abc
|
| 5 |
+
import dataclasses
|
| 6 |
+
import inspect
|
| 7 |
+
from typing import Any
|
| 8 |
+
from typing import Callable
|
| 9 |
+
from typing import Collection
|
| 10 |
+
from typing import final
|
| 11 |
+
from typing import Iterable
|
| 12 |
+
from typing import Iterator
|
| 13 |
+
from typing import Mapping
|
| 14 |
+
from typing import MutableMapping
|
| 15 |
+
from typing import NamedTuple
|
| 16 |
+
from typing import overload
|
| 17 |
+
from typing import Sequence
|
| 18 |
+
from typing import TYPE_CHECKING
|
| 19 |
+
from typing import TypeVar
|
| 20 |
+
from typing import Union
|
| 21 |
+
import warnings
|
| 22 |
+
|
| 23 |
+
from .._code import getfslineno
|
| 24 |
+
from ..compat import ascii_escaped
|
| 25 |
+
from ..compat import NOTSET
|
| 26 |
+
from ..compat import NotSetType
|
| 27 |
+
from _pytest.config import Config
|
| 28 |
+
from _pytest.deprecated import check_ispytest
|
| 29 |
+
from _pytest.deprecated import MARKED_FIXTURE
|
| 30 |
+
from _pytest.outcomes import fail
|
| 31 |
+
from _pytest.scope import _ScopeName
|
| 32 |
+
from _pytest.warning_types import PytestUnknownMarkWarning
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
if TYPE_CHECKING:
|
| 36 |
+
from ..nodes import Node
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
EMPTY_PARAMETERSET_OPTION = "empty_parameter_set_mark"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def istestfunc(func) -> bool:
|
| 43 |
+
return callable(func) and getattr(func, "__name__", "<lambda>") != "<lambda>"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def get_empty_parameterset_mark(
|
| 47 |
+
config: Config, argnames: Sequence[str], func
|
| 48 |
+
) -> MarkDecorator:
|
| 49 |
+
from ..nodes import Collector
|
| 50 |
+
|
| 51 |
+
fs, lineno = getfslineno(func)
|
| 52 |
+
reason = "got empty parameter set %r, function %s at %s:%d" % (
|
| 53 |
+
argnames,
|
| 54 |
+
func.__name__,
|
| 55 |
+
fs,
|
| 56 |
+
lineno,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
requested_mark = config.getini(EMPTY_PARAMETERSET_OPTION)
|
| 60 |
+
if requested_mark in ("", None, "skip"):
|
| 61 |
+
mark = MARK_GEN.skip(reason=reason)
|
| 62 |
+
elif requested_mark == "xfail":
|
| 63 |
+
mark = MARK_GEN.xfail(reason=reason, run=False)
|
| 64 |
+
elif requested_mark == "fail_at_collect":
|
| 65 |
+
f_name = func.__name__
|
| 66 |
+
_, lineno = getfslineno(func)
|
| 67 |
+
raise Collector.CollectError(
|
| 68 |
+
"Empty parameter set in '%s' at line %d" % (f_name, lineno + 1)
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
raise LookupError(requested_mark)
|
| 72 |
+
return mark
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ParameterSet(NamedTuple):
|
| 76 |
+
values: Sequence[object | NotSetType]
|
| 77 |
+
marks: Collection[MarkDecorator | Mark]
|
| 78 |
+
id: str | None
|
| 79 |
+
|
| 80 |
+
@classmethod
|
| 81 |
+
def param(
|
| 82 |
+
cls,
|
| 83 |
+
*values: object,
|
| 84 |
+
marks: MarkDecorator | Collection[MarkDecorator | Mark] = (),
|
| 85 |
+
id: str | None = None,
|
| 86 |
+
) -> ParameterSet:
|
| 87 |
+
if isinstance(marks, MarkDecorator):
|
| 88 |
+
marks = (marks,)
|
| 89 |
+
else:
|
| 90 |
+
assert isinstance(marks, collections.abc.Collection)
|
| 91 |
+
|
| 92 |
+
if id is not None:
|
| 93 |
+
if not isinstance(id, str):
|
| 94 |
+
raise TypeError(f"Expected id to be a string, got {type(id)}: {id!r}")
|
| 95 |
+
id = ascii_escaped(id)
|
| 96 |
+
return cls(values, marks, id)
|
| 97 |
+
|
| 98 |
+
@classmethod
|
| 99 |
+
def extract_from(
|
| 100 |
+
cls,
|
| 101 |
+
parameterset: ParameterSet | Sequence[object] | object,
|
| 102 |
+
force_tuple: bool = False,
|
| 103 |
+
) -> ParameterSet:
|
| 104 |
+
"""Extract from an object or objects.
|
| 105 |
+
|
| 106 |
+
:param parameterset:
|
| 107 |
+
A legacy style parameterset that may or may not be a tuple,
|
| 108 |
+
and may or may not be wrapped into a mess of mark objects.
|
| 109 |
+
|
| 110 |
+
:param force_tuple:
|
| 111 |
+
Enforce tuple wrapping so single argument tuple values
|
| 112 |
+
don't get decomposed and break tests.
|
| 113 |
+
"""
|
| 114 |
+
if isinstance(parameterset, cls):
|
| 115 |
+
return parameterset
|
| 116 |
+
if force_tuple:
|
| 117 |
+
return cls.param(parameterset)
|
| 118 |
+
else:
|
| 119 |
+
# TODO: Refactor to fix this type-ignore. Currently the following
|
| 120 |
+
# passes type-checking but crashes:
|
| 121 |
+
#
|
| 122 |
+
# @pytest.mark.parametrize(('x', 'y'), [1, 2])
|
| 123 |
+
# def test_foo(x, y): pass
|
| 124 |
+
return cls(parameterset, marks=[], id=None) # type: ignore[arg-type]
|
| 125 |
+
|
| 126 |
+
@staticmethod
|
| 127 |
+
def _parse_parametrize_args(
|
| 128 |
+
argnames: str | Sequence[str],
|
| 129 |
+
argvalues: Iterable[ParameterSet | Sequence[object] | object],
|
| 130 |
+
*args,
|
| 131 |
+
**kwargs,
|
| 132 |
+
) -> tuple[Sequence[str], bool]:
|
| 133 |
+
if isinstance(argnames, str):
|
| 134 |
+
argnames = [x.strip() for x in argnames.split(",") if x.strip()]
|
| 135 |
+
force_tuple = len(argnames) == 1
|
| 136 |
+
else:
|
| 137 |
+
force_tuple = False
|
| 138 |
+
return argnames, force_tuple
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
def _parse_parametrize_parameters(
|
| 142 |
+
argvalues: Iterable[ParameterSet | Sequence[object] | object],
|
| 143 |
+
force_tuple: bool,
|
| 144 |
+
) -> list[ParameterSet]:
|
| 145 |
+
return [
|
| 146 |
+
ParameterSet.extract_from(x, force_tuple=force_tuple) for x in argvalues
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
@classmethod
|
| 150 |
+
def _for_parametrize(
|
| 151 |
+
cls,
|
| 152 |
+
argnames: str | Sequence[str],
|
| 153 |
+
argvalues: Iterable[ParameterSet | Sequence[object] | object],
|
| 154 |
+
func,
|
| 155 |
+
config: Config,
|
| 156 |
+
nodeid: str,
|
| 157 |
+
) -> tuple[Sequence[str], list[ParameterSet]]:
|
| 158 |
+
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
|
| 159 |
+
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
|
| 160 |
+
del argvalues
|
| 161 |
+
|
| 162 |
+
if parameters:
|
| 163 |
+
# Check all parameter sets have the correct number of values.
|
| 164 |
+
for param in parameters:
|
| 165 |
+
if len(param.values) != len(argnames):
|
| 166 |
+
msg = (
|
| 167 |
+
'{nodeid}: in "parametrize" the number of names ({names_len}):\n'
|
| 168 |
+
" {names}\n"
|
| 169 |
+
"must be equal to the number of values ({values_len}):\n"
|
| 170 |
+
" {values}"
|
| 171 |
+
)
|
| 172 |
+
fail(
|
| 173 |
+
msg.format(
|
| 174 |
+
nodeid=nodeid,
|
| 175 |
+
values=param.values,
|
| 176 |
+
names=argnames,
|
| 177 |
+
names_len=len(argnames),
|
| 178 |
+
values_len=len(param.values),
|
| 179 |
+
),
|
| 180 |
+
pytrace=False,
|
| 181 |
+
)
|
| 182 |
+
else:
|
| 183 |
+
# Empty parameter set (likely computed at runtime): create a single
|
| 184 |
+
# parameter set with NOTSET values, with the "empty parameter set" mark applied to it.
|
| 185 |
+
mark = get_empty_parameterset_mark(config, argnames, func)
|
| 186 |
+
parameters.append(
|
| 187 |
+
ParameterSet(values=(NOTSET,) * len(argnames), marks=[mark], id=None)
|
| 188 |
+
)
|
| 189 |
+
return argnames, parameters
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@final
|
| 193 |
+
@dataclasses.dataclass(frozen=True)
|
| 194 |
+
class Mark:
|
| 195 |
+
"""A pytest mark."""
|
| 196 |
+
|
| 197 |
+
#: Name of the mark.
|
| 198 |
+
name: str
|
| 199 |
+
#: Positional arguments of the mark decorator.
|
| 200 |
+
args: tuple[Any, ...]
|
| 201 |
+
#: Keyword arguments of the mark decorator.
|
| 202 |
+
kwargs: Mapping[str, Any]
|
| 203 |
+
|
| 204 |
+
#: Source Mark for ids with parametrize Marks.
|
| 205 |
+
_param_ids_from: Mark | None = dataclasses.field(default=None, repr=False)
|
| 206 |
+
#: Resolved/generated ids with parametrize Marks.
|
| 207 |
+
_param_ids_generated: Sequence[str] | None = dataclasses.field(
|
| 208 |
+
default=None, repr=False
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
def __init__(
|
| 212 |
+
self,
|
| 213 |
+
name: str,
|
| 214 |
+
args: tuple[Any, ...],
|
| 215 |
+
kwargs: Mapping[str, Any],
|
| 216 |
+
param_ids_from: Mark | None = None,
|
| 217 |
+
param_ids_generated: Sequence[str] | None = None,
|
| 218 |
+
*,
|
| 219 |
+
_ispytest: bool = False,
|
| 220 |
+
) -> None:
|
| 221 |
+
""":meta private:"""
|
| 222 |
+
check_ispytest(_ispytest)
|
| 223 |
+
# Weirdness to bypass frozen=True.
|
| 224 |
+
object.__setattr__(self, "name", name)
|
| 225 |
+
object.__setattr__(self, "args", args)
|
| 226 |
+
object.__setattr__(self, "kwargs", kwargs)
|
| 227 |
+
object.__setattr__(self, "_param_ids_from", param_ids_from)
|
| 228 |
+
object.__setattr__(self, "_param_ids_generated", param_ids_generated)
|
| 229 |
+
|
| 230 |
+
def _has_param_ids(self) -> bool:
|
| 231 |
+
return "ids" in self.kwargs or len(self.args) >= 4
|
| 232 |
+
|
| 233 |
+
def combined_with(self, other: Mark) -> Mark:
|
| 234 |
+
"""Return a new Mark which is a combination of this
|
| 235 |
+
Mark and another Mark.
|
| 236 |
+
|
| 237 |
+
Combines by appending args and merging kwargs.
|
| 238 |
+
|
| 239 |
+
:param Mark other: The mark to combine with.
|
| 240 |
+
:rtype: Mark
|
| 241 |
+
"""
|
| 242 |
+
assert self.name == other.name
|
| 243 |
+
|
| 244 |
+
# Remember source of ids with parametrize Marks.
|
| 245 |
+
param_ids_from: Mark | None = None
|
| 246 |
+
if self.name == "parametrize":
|
| 247 |
+
if other._has_param_ids():
|
| 248 |
+
param_ids_from = other
|
| 249 |
+
elif self._has_param_ids():
|
| 250 |
+
param_ids_from = self
|
| 251 |
+
|
| 252 |
+
return Mark(
|
| 253 |
+
self.name,
|
| 254 |
+
self.args + other.args,
|
| 255 |
+
dict(self.kwargs, **other.kwargs),
|
| 256 |
+
param_ids_from=param_ids_from,
|
| 257 |
+
_ispytest=True,
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# A generic parameter designating an object to which a Mark may
|
| 262 |
+
# be applied -- a test function (callable) or class.
|
| 263 |
+
# Note: a lambda is not allowed, but this can't be represented.
|
| 264 |
+
Markable = TypeVar("Markable", bound=Union[Callable[..., object], type])
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
@dataclasses.dataclass
|
| 268 |
+
class MarkDecorator:
|
| 269 |
+
"""A decorator for applying a mark on test functions and classes.
|
| 270 |
+
|
| 271 |
+
``MarkDecorators`` are created with ``pytest.mark``::
|
| 272 |
+
|
| 273 |
+
mark1 = pytest.mark.NAME # Simple MarkDecorator
|
| 274 |
+
mark2 = pytest.mark.NAME(name1=value) # Parametrized MarkDecorator
|
| 275 |
+
|
| 276 |
+
and can then be applied as decorators to test functions::
|
| 277 |
+
|
| 278 |
+
@mark2
|
| 279 |
+
def test_function():
|
| 280 |
+
pass
|
| 281 |
+
|
| 282 |
+
When a ``MarkDecorator`` is called, it does the following:
|
| 283 |
+
|
| 284 |
+
1. If called with a single class as its only positional argument and no
|
| 285 |
+
additional keyword arguments, it attaches the mark to the class so it
|
| 286 |
+
gets applied automatically to all test cases found in that class.
|
| 287 |
+
|
| 288 |
+
2. If called with a single function as its only positional argument and
|
| 289 |
+
no additional keyword arguments, it attaches the mark to the function,
|
| 290 |
+
containing all the arguments already stored internally in the
|
| 291 |
+
``MarkDecorator``.
|
| 292 |
+
|
| 293 |
+
3. When called in any other case, it returns a new ``MarkDecorator``
|
| 294 |
+
instance with the original ``MarkDecorator``'s content updated with
|
| 295 |
+
the arguments passed to this call.
|
| 296 |
+
|
| 297 |
+
Note: The rules above prevent a ``MarkDecorator`` from storing only a
|
| 298 |
+
single function or class reference as its positional argument with no
|
| 299 |
+
additional keyword or positional arguments. You can work around this by
|
| 300 |
+
using `with_args()`.
|
| 301 |
+
"""
|
| 302 |
+
|
| 303 |
+
mark: Mark
|
| 304 |
+
|
| 305 |
+
def __init__(self, mark: Mark, *, _ispytest: bool = False) -> None:
|
| 306 |
+
""":meta private:"""
|
| 307 |
+
check_ispytest(_ispytest)
|
| 308 |
+
self.mark = mark
|
| 309 |
+
|
| 310 |
+
@property
|
| 311 |
+
def name(self) -> str:
|
| 312 |
+
"""Alias for mark.name."""
|
| 313 |
+
return self.mark.name
|
| 314 |
+
|
| 315 |
+
@property
|
| 316 |
+
def args(self) -> tuple[Any, ...]:
|
| 317 |
+
"""Alias for mark.args."""
|
| 318 |
+
return self.mark.args
|
| 319 |
+
|
| 320 |
+
@property
|
| 321 |
+
def kwargs(self) -> Mapping[str, Any]:
|
| 322 |
+
"""Alias for mark.kwargs."""
|
| 323 |
+
return self.mark.kwargs
|
| 324 |
+
|
| 325 |
+
@property
|
| 326 |
+
def markname(self) -> str:
|
| 327 |
+
""":meta private:"""
|
| 328 |
+
return self.name # for backward-compat (2.4.1 had this attr)
|
| 329 |
+
|
| 330 |
+
def with_args(self, *args: object, **kwargs: object) -> MarkDecorator:
|
| 331 |
+
"""Return a MarkDecorator with extra arguments added.
|
| 332 |
+
|
| 333 |
+
Unlike calling the MarkDecorator, with_args() can be used even
|
| 334 |
+
if the sole argument is a callable/class.
|
| 335 |
+
"""
|
| 336 |
+
mark = Mark(self.name, args, kwargs, _ispytest=True)
|
| 337 |
+
return MarkDecorator(self.mark.combined_with(mark), _ispytest=True)
|
| 338 |
+
|
| 339 |
+
# Type ignored because the overloads overlap with an incompatible
|
| 340 |
+
# return type. Not much we can do about that. Thankfully mypy picks
|
| 341 |
+
# the first match so it works out even if we break the rules.
|
| 342 |
+
@overload
|
| 343 |
+
def __call__(self, arg: Markable) -> Markable: # type: ignore[overload-overlap]
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
@overload
|
| 347 |
+
def __call__(self, *args: object, **kwargs: object) -> MarkDecorator:
|
| 348 |
+
pass
|
| 349 |
+
|
| 350 |
+
def __call__(self, *args: object, **kwargs: object):
|
| 351 |
+
"""Call the MarkDecorator."""
|
| 352 |
+
if args and not kwargs:
|
| 353 |
+
func = args[0]
|
| 354 |
+
is_class = inspect.isclass(func)
|
| 355 |
+
if len(args) == 1 and (istestfunc(func) or is_class):
|
| 356 |
+
store_mark(func, self.mark, stacklevel=3)
|
| 357 |
+
return func
|
| 358 |
+
return self.with_args(*args, **kwargs)
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def get_unpacked_marks(
|
| 362 |
+
obj: object | type,
|
| 363 |
+
*,
|
| 364 |
+
consider_mro: bool = True,
|
| 365 |
+
) -> list[Mark]:
|
| 366 |
+
"""Obtain the unpacked marks that are stored on an object.
|
| 367 |
+
|
| 368 |
+
If obj is a class and consider_mro is true, return marks applied to
|
| 369 |
+
this class and all of its super-classes in MRO order. If consider_mro
|
| 370 |
+
is false, only return marks applied directly to this class.
|
| 371 |
+
"""
|
| 372 |
+
if isinstance(obj, type):
|
| 373 |
+
if not consider_mro:
|
| 374 |
+
mark_lists = [obj.__dict__.get("pytestmark", [])]
|
| 375 |
+
else:
|
| 376 |
+
mark_lists = [
|
| 377 |
+
x.__dict__.get("pytestmark", []) for x in reversed(obj.__mro__)
|
| 378 |
+
]
|
| 379 |
+
mark_list = []
|
| 380 |
+
for item in mark_lists:
|
| 381 |
+
if isinstance(item, list):
|
| 382 |
+
mark_list.extend(item)
|
| 383 |
+
else:
|
| 384 |
+
mark_list.append(item)
|
| 385 |
+
else:
|
| 386 |
+
mark_attribute = getattr(obj, "pytestmark", [])
|
| 387 |
+
if isinstance(mark_attribute, list):
|
| 388 |
+
mark_list = mark_attribute
|
| 389 |
+
else:
|
| 390 |
+
mark_list = [mark_attribute]
|
| 391 |
+
return list(normalize_mark_list(mark_list))
|
| 392 |
+
|
| 393 |
+
|
| 394 |
+
def normalize_mark_list(
|
| 395 |
+
mark_list: Iterable[Mark | MarkDecorator],
|
| 396 |
+
) -> Iterable[Mark]:
|
| 397 |
+
"""
|
| 398 |
+
Normalize an iterable of Mark or MarkDecorator objects into a list of marks
|
| 399 |
+
by retrieving the `mark` attribute on MarkDecorator instances.
|
| 400 |
+
|
| 401 |
+
:param mark_list: marks to normalize
|
| 402 |
+
:returns: A new list of the extracted Mark objects
|
| 403 |
+
"""
|
| 404 |
+
for mark in mark_list:
|
| 405 |
+
mark_obj = getattr(mark, "mark", mark)
|
| 406 |
+
if not isinstance(mark_obj, Mark):
|
| 407 |
+
raise TypeError(f"got {mark_obj!r} instead of Mark")
|
| 408 |
+
yield mark_obj
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def store_mark(obj, mark: Mark, *, stacklevel: int = 2) -> None:
|
| 412 |
+
"""Store a Mark on an object.
|
| 413 |
+
|
| 414 |
+
This is used to implement the Mark declarations/decorators correctly.
|
| 415 |
+
"""
|
| 416 |
+
assert isinstance(mark, Mark), mark
|
| 417 |
+
|
| 418 |
+
from ..fixtures import getfixturemarker
|
| 419 |
+
|
| 420 |
+
if getfixturemarker(obj) is not None:
|
| 421 |
+
warnings.warn(MARKED_FIXTURE, stacklevel=stacklevel)
|
| 422 |
+
|
| 423 |
+
# Always reassign name to avoid updating pytestmark in a reference that
|
| 424 |
+
# was only borrowed.
|
| 425 |
+
obj.pytestmark = [*get_unpacked_marks(obj, consider_mro=False), mark]
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# Typing for builtin pytest marks. This is cheating; it gives builtin marks
|
| 429 |
+
# special privilege, and breaks modularity. But practicality beats purity...
|
| 430 |
+
if TYPE_CHECKING:
|
| 431 |
+
|
| 432 |
+
class _SkipMarkDecorator(MarkDecorator):
|
| 433 |
+
@overload # type: ignore[override,no-overload-impl]
|
| 434 |
+
def __call__(self, arg: Markable) -> Markable: ...
|
| 435 |
+
|
| 436 |
+
@overload
|
| 437 |
+
def __call__(self, reason: str = ...) -> MarkDecorator: ...
|
| 438 |
+
|
| 439 |
+
class _SkipifMarkDecorator(MarkDecorator):
|
| 440 |
+
def __call__( # type: ignore[override]
|
| 441 |
+
self,
|
| 442 |
+
condition: str | bool = ...,
|
| 443 |
+
*conditions: str | bool,
|
| 444 |
+
reason: str = ...,
|
| 445 |
+
) -> MarkDecorator: ...
|
| 446 |
+
|
| 447 |
+
class _XfailMarkDecorator(MarkDecorator):
|
| 448 |
+
@overload # type: ignore[override,no-overload-impl]
|
| 449 |
+
def __call__(self, arg: Markable) -> Markable: ...
|
| 450 |
+
|
| 451 |
+
@overload
|
| 452 |
+
def __call__(
|
| 453 |
+
self,
|
| 454 |
+
condition: str | bool = False,
|
| 455 |
+
*conditions: str | bool,
|
| 456 |
+
reason: str = ...,
|
| 457 |
+
run: bool = ...,
|
| 458 |
+
raises: None | type[BaseException] | tuple[type[BaseException], ...] = ...,
|
| 459 |
+
strict: bool = ...,
|
| 460 |
+
) -> MarkDecorator: ...
|
| 461 |
+
|
| 462 |
+
class _ParametrizeMarkDecorator(MarkDecorator):
|
| 463 |
+
def __call__( # type: ignore[override]
|
| 464 |
+
self,
|
| 465 |
+
argnames: str | Sequence[str],
|
| 466 |
+
argvalues: Iterable[ParameterSet | Sequence[object] | object],
|
| 467 |
+
*,
|
| 468 |
+
indirect: bool | Sequence[str] = ...,
|
| 469 |
+
ids: Iterable[None | str | float | int | bool]
|
| 470 |
+
| Callable[[Any], object | None]
|
| 471 |
+
| None = ...,
|
| 472 |
+
scope: _ScopeName | None = ...,
|
| 473 |
+
) -> MarkDecorator: ...
|
| 474 |
+
|
| 475 |
+
class _UsefixturesMarkDecorator(MarkDecorator):
|
| 476 |
+
def __call__(self, *fixtures: str) -> MarkDecorator: # type: ignore[override]
|
| 477 |
+
...
|
| 478 |
+
|
| 479 |
+
class _FilterwarningsMarkDecorator(MarkDecorator):
|
| 480 |
+
def __call__(self, *filters: str) -> MarkDecorator: # type: ignore[override]
|
| 481 |
+
...
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
@final
|
| 485 |
+
class MarkGenerator:
|
| 486 |
+
"""Factory for :class:`MarkDecorator` objects - exposed as
|
| 487 |
+
a ``pytest.mark`` singleton instance.
|
| 488 |
+
|
| 489 |
+
Example::
|
| 490 |
+
|
| 491 |
+
import pytest
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
@pytest.mark.slowtest
|
| 495 |
+
def test_function():
|
| 496 |
+
pass
|
| 497 |
+
|
| 498 |
+
applies a 'slowtest' :class:`Mark` on ``test_function``.
|
| 499 |
+
"""
|
| 500 |
+
|
| 501 |
+
# See TYPE_CHECKING above.
|
| 502 |
+
if TYPE_CHECKING:
|
| 503 |
+
skip: _SkipMarkDecorator
|
| 504 |
+
skipif: _SkipifMarkDecorator
|
| 505 |
+
xfail: _XfailMarkDecorator
|
| 506 |
+
parametrize: _ParametrizeMarkDecorator
|
| 507 |
+
usefixtures: _UsefixturesMarkDecorator
|
| 508 |
+
filterwarnings: _FilterwarningsMarkDecorator
|
| 509 |
+
|
| 510 |
+
def __init__(self, *, _ispytest: bool = False) -> None:
|
| 511 |
+
check_ispytest(_ispytest)
|
| 512 |
+
self._config: Config | None = None
|
| 513 |
+
self._markers: set[str] = set()
|
| 514 |
+
|
| 515 |
+
def __getattr__(self, name: str) -> MarkDecorator:
|
| 516 |
+
"""Generate a new :class:`MarkDecorator` with the given name."""
|
| 517 |
+
if name[0] == "_":
|
| 518 |
+
raise AttributeError("Marker name must NOT start with underscore")
|
| 519 |
+
|
| 520 |
+
if self._config is not None:
|
| 521 |
+
# We store a set of markers as a performance optimisation - if a mark
|
| 522 |
+
# name is in the set we definitely know it, but a mark may be known and
|
| 523 |
+
# not in the set. We therefore start by updating the set!
|
| 524 |
+
if name not in self._markers:
|
| 525 |
+
for line in self._config.getini("markers"):
|
| 526 |
+
# example lines: "skipif(condition): skip the given test if..."
|
| 527 |
+
# or "hypothesis: tests which use Hypothesis", so to get the
|
| 528 |
+
# marker name we split on both `:` and `(`.
|
| 529 |
+
marker = line.split(":")[0].split("(")[0].strip()
|
| 530 |
+
self._markers.add(marker)
|
| 531 |
+
|
| 532 |
+
# If the name is not in the set of known marks after updating,
|
| 533 |
+
# then it really is time to issue a warning or an error.
|
| 534 |
+
if name not in self._markers:
|
| 535 |
+
if self._config.option.strict_markers or self._config.option.strict:
|
| 536 |
+
fail(
|
| 537 |
+
f"{name!r} not found in `markers` configuration option",
|
| 538 |
+
pytrace=False,
|
| 539 |
+
)
|
| 540 |
+
|
| 541 |
+
# Raise a specific error for common misspellings of "parametrize".
|
| 542 |
+
if name in ["parameterize", "parametrise", "parameterise"]:
|
| 543 |
+
__tracebackhide__ = True
|
| 544 |
+
fail(f"Unknown '{name}' mark, did you mean 'parametrize'?")
|
| 545 |
+
|
| 546 |
+
warnings.warn(
|
| 547 |
+
f"Unknown pytest.mark.{name} - is this a typo? You can register "
|
| 548 |
+
"custom marks to avoid this warning - for details, see "
|
| 549 |
+
"https://docs.pytest.org/en/stable/how-to/mark.html",
|
| 550 |
+
PytestUnknownMarkWarning,
|
| 551 |
+
2,
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
return MarkDecorator(Mark(name, (), {}, _ispytest=True), _ispytest=True)
|
| 555 |
+
|
| 556 |
+
|
| 557 |
+
MARK_GEN = MarkGenerator(_ispytest=True)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
@final
|
| 561 |
+
class NodeKeywords(MutableMapping[str, Any]):
|
| 562 |
+
__slots__ = ("node", "parent", "_markers")
|
| 563 |
+
|
| 564 |
+
def __init__(self, node: Node) -> None:
|
| 565 |
+
self.node = node
|
| 566 |
+
self.parent = node.parent
|
| 567 |
+
self._markers = {node.name: True}
|
| 568 |
+
|
| 569 |
+
def __getitem__(self, key: str) -> Any:
|
| 570 |
+
try:
|
| 571 |
+
return self._markers[key]
|
| 572 |
+
except KeyError:
|
| 573 |
+
if self.parent is None:
|
| 574 |
+
raise
|
| 575 |
+
return self.parent.keywords[key]
|
| 576 |
+
|
| 577 |
+
def __setitem__(self, key: str, value: Any) -> None:
|
| 578 |
+
self._markers[key] = value
|
| 579 |
+
|
| 580 |
+
# Note: we could've avoided explicitly implementing some of the methods
|
| 581 |
+
# below and use the collections.abc fallback, but that would be slow.
|
| 582 |
+
|
| 583 |
+
def __contains__(self, key: object) -> bool:
|
| 584 |
+
return (
|
| 585 |
+
key in self._markers
|
| 586 |
+
or self.parent is not None
|
| 587 |
+
and key in self.parent.keywords
|
| 588 |
+
)
|
| 589 |
+
|
| 590 |
+
def update( # type: ignore[override]
|
| 591 |
+
self,
|
| 592 |
+
other: Mapping[str, Any] | Iterable[tuple[str, Any]] = (),
|
| 593 |
+
**kwds: Any,
|
| 594 |
+
) -> None:
|
| 595 |
+
self._markers.update(other)
|
| 596 |
+
self._markers.update(kwds)
|
| 597 |
+
|
| 598 |
+
def __delitem__(self, key: str) -> None:
|
| 599 |
+
raise ValueError("cannot delete key in keywords dict")
|
| 600 |
+
|
| 601 |
+
def __iter__(self) -> Iterator[str]:
|
| 602 |
+
# Doesn't need to be fast.
|
| 603 |
+
yield from self._markers
|
| 604 |
+
if self.parent is not None:
|
| 605 |
+
for keyword in self.parent.keywords:
|
| 606 |
+
# self._marks and self.parent.keywords can have duplicates.
|
| 607 |
+
if keyword not in self._markers:
|
| 608 |
+
yield keyword
|
| 609 |
+
|
| 610 |
+
def __len__(self) -> int:
|
| 611 |
+
# Doesn't need to be fast.
|
| 612 |
+
return sum(1 for keyword in self)
|
| 613 |
+
|
| 614 |
+
def __repr__(self) -> str:
|
| 615 |
+
return f"<NodeKeywords for node {self.node}>"
|
.venv/lib/python3.11/site-packages/_pytest/monkeypatch.py
ADDED
|
@@ -0,0 +1,415 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Monkeypatching and mocking functionality."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from contextlib import contextmanager
|
| 7 |
+
import os
|
| 8 |
+
import re
|
| 9 |
+
import sys
|
| 10 |
+
from typing import Any
|
| 11 |
+
from typing import final
|
| 12 |
+
from typing import Generator
|
| 13 |
+
from typing import Mapping
|
| 14 |
+
from typing import MutableMapping
|
| 15 |
+
from typing import overload
|
| 16 |
+
from typing import TypeVar
|
| 17 |
+
import warnings
|
| 18 |
+
|
| 19 |
+
from _pytest.fixtures import fixture
|
| 20 |
+
from _pytest.warning_types import PytestWarning
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
RE_IMPORT_ERROR_NAME = re.compile(r"^No module named (.*)$")
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
K = TypeVar("K")
|
| 27 |
+
V = TypeVar("V")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@fixture
|
| 31 |
+
def monkeypatch() -> Generator[MonkeyPatch]:
|
| 32 |
+
"""A convenient fixture for monkey-patching.
|
| 33 |
+
|
| 34 |
+
The fixture provides these methods to modify objects, dictionaries, or
|
| 35 |
+
:data:`os.environ`:
|
| 36 |
+
|
| 37 |
+
* :meth:`monkeypatch.setattr(obj, name, value, raising=True) <pytest.MonkeyPatch.setattr>`
|
| 38 |
+
* :meth:`monkeypatch.delattr(obj, name, raising=True) <pytest.MonkeyPatch.delattr>`
|
| 39 |
+
* :meth:`monkeypatch.setitem(mapping, name, value) <pytest.MonkeyPatch.setitem>`
|
| 40 |
+
* :meth:`monkeypatch.delitem(obj, name, raising=True) <pytest.MonkeyPatch.delitem>`
|
| 41 |
+
* :meth:`monkeypatch.setenv(name, value, prepend=None) <pytest.MonkeyPatch.setenv>`
|
| 42 |
+
* :meth:`monkeypatch.delenv(name, raising=True) <pytest.MonkeyPatch.delenv>`
|
| 43 |
+
* :meth:`monkeypatch.syspath_prepend(path) <pytest.MonkeyPatch.syspath_prepend>`
|
| 44 |
+
* :meth:`monkeypatch.chdir(path) <pytest.MonkeyPatch.chdir>`
|
| 45 |
+
* :meth:`monkeypatch.context() <pytest.MonkeyPatch.context>`
|
| 46 |
+
|
| 47 |
+
All modifications will be undone after the requesting test function or
|
| 48 |
+
fixture has finished. The ``raising`` parameter determines if a :class:`KeyError`
|
| 49 |
+
or :class:`AttributeError` will be raised if the set/deletion operation does not have the
|
| 50 |
+
specified target.
|
| 51 |
+
|
| 52 |
+
To undo modifications done by the fixture in a contained scope,
|
| 53 |
+
use :meth:`context() <pytest.MonkeyPatch.context>`.
|
| 54 |
+
"""
|
| 55 |
+
mpatch = MonkeyPatch()
|
| 56 |
+
yield mpatch
|
| 57 |
+
mpatch.undo()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def resolve(name: str) -> object:
|
| 61 |
+
# Simplified from zope.dottedname.
|
| 62 |
+
parts = name.split(".")
|
| 63 |
+
|
| 64 |
+
used = parts.pop(0)
|
| 65 |
+
found: object = __import__(used)
|
| 66 |
+
for part in parts:
|
| 67 |
+
used += "." + part
|
| 68 |
+
try:
|
| 69 |
+
found = getattr(found, part)
|
| 70 |
+
except AttributeError:
|
| 71 |
+
pass
|
| 72 |
+
else:
|
| 73 |
+
continue
|
| 74 |
+
# We use explicit un-nesting of the handling block in order
|
| 75 |
+
# to avoid nested exceptions.
|
| 76 |
+
try:
|
| 77 |
+
__import__(used)
|
| 78 |
+
except ImportError as ex:
|
| 79 |
+
expected = str(ex).split()[-1]
|
| 80 |
+
if expected == used:
|
| 81 |
+
raise
|
| 82 |
+
else:
|
| 83 |
+
raise ImportError(f"import error in {used}: {ex}") from ex
|
| 84 |
+
found = annotated_getattr(found, part, used)
|
| 85 |
+
return found
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def annotated_getattr(obj: object, name: str, ann: str) -> object:
|
| 89 |
+
try:
|
| 90 |
+
obj = getattr(obj, name)
|
| 91 |
+
except AttributeError as e:
|
| 92 |
+
raise AttributeError(
|
| 93 |
+
f"{type(obj).__name__!r} object at {ann} has no attribute {name!r}"
|
| 94 |
+
) from e
|
| 95 |
+
return obj
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def derive_importpath(import_path: str, raising: bool) -> tuple[str, object]:
|
| 99 |
+
if not isinstance(import_path, str) or "." not in import_path:
|
| 100 |
+
raise TypeError(f"must be absolute import path string, not {import_path!r}")
|
| 101 |
+
module, attr = import_path.rsplit(".", 1)
|
| 102 |
+
target = resolve(module)
|
| 103 |
+
if raising:
|
| 104 |
+
annotated_getattr(target, attr, ann=module)
|
| 105 |
+
return attr, target
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class Notset:
|
| 109 |
+
def __repr__(self) -> str:
|
| 110 |
+
return "<notset>"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
notset = Notset()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@final
|
| 117 |
+
class MonkeyPatch:
|
| 118 |
+
"""Helper to conveniently monkeypatch attributes/items/environment
|
| 119 |
+
variables/syspath.
|
| 120 |
+
|
| 121 |
+
Returned by the :fixture:`monkeypatch` fixture.
|
| 122 |
+
|
| 123 |
+
.. versionchanged:: 6.2
|
| 124 |
+
Can now also be used directly as `pytest.MonkeyPatch()`, for when
|
| 125 |
+
the fixture is not available. In this case, use
|
| 126 |
+
:meth:`with MonkeyPatch.context() as mp: <context>` or remember to call
|
| 127 |
+
:meth:`undo` explicitly.
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
def __init__(self) -> None:
|
| 131 |
+
self._setattr: list[tuple[object, str, object]] = []
|
| 132 |
+
self._setitem: list[tuple[Mapping[Any, Any], object, object]] = []
|
| 133 |
+
self._cwd: str | None = None
|
| 134 |
+
self._savesyspath: list[str] | None = None
|
| 135 |
+
|
| 136 |
+
@classmethod
|
| 137 |
+
@contextmanager
|
| 138 |
+
def context(cls) -> Generator[MonkeyPatch]:
|
| 139 |
+
"""Context manager that returns a new :class:`MonkeyPatch` object
|
| 140 |
+
which undoes any patching done inside the ``with`` block upon exit.
|
| 141 |
+
|
| 142 |
+
Example:
|
| 143 |
+
|
| 144 |
+
.. code-block:: python
|
| 145 |
+
|
| 146 |
+
import functools
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def test_partial(monkeypatch):
|
| 150 |
+
with monkeypatch.context() as m:
|
| 151 |
+
m.setattr(functools, "partial", 3)
|
| 152 |
+
|
| 153 |
+
Useful in situations where it is desired to undo some patches before the test ends,
|
| 154 |
+
such as mocking ``stdlib`` functions that might break pytest itself if mocked (for examples
|
| 155 |
+
of this see :issue:`3290`).
|
| 156 |
+
"""
|
| 157 |
+
m = cls()
|
| 158 |
+
try:
|
| 159 |
+
yield m
|
| 160 |
+
finally:
|
| 161 |
+
m.undo()
|
| 162 |
+
|
| 163 |
+
@overload
|
| 164 |
+
def setattr(
|
| 165 |
+
self,
|
| 166 |
+
target: str,
|
| 167 |
+
name: object,
|
| 168 |
+
value: Notset = ...,
|
| 169 |
+
raising: bool = ...,
|
| 170 |
+
) -> None: ...
|
| 171 |
+
|
| 172 |
+
@overload
|
| 173 |
+
def setattr(
|
| 174 |
+
self,
|
| 175 |
+
target: object,
|
| 176 |
+
name: str,
|
| 177 |
+
value: object,
|
| 178 |
+
raising: bool = ...,
|
| 179 |
+
) -> None: ...
|
| 180 |
+
|
| 181 |
+
def setattr(
|
| 182 |
+
self,
|
| 183 |
+
target: str | object,
|
| 184 |
+
name: object | str,
|
| 185 |
+
value: object = notset,
|
| 186 |
+
raising: bool = True,
|
| 187 |
+
) -> None:
|
| 188 |
+
"""
|
| 189 |
+
Set attribute value on target, memorizing the old value.
|
| 190 |
+
|
| 191 |
+
For example:
|
| 192 |
+
|
| 193 |
+
.. code-block:: python
|
| 194 |
+
|
| 195 |
+
import os
|
| 196 |
+
|
| 197 |
+
monkeypatch.setattr(os, "getcwd", lambda: "/")
|
| 198 |
+
|
| 199 |
+
The code above replaces the :func:`os.getcwd` function by a ``lambda`` which
|
| 200 |
+
always returns ``"/"``.
|
| 201 |
+
|
| 202 |
+
For convenience, you can specify a string as ``target`` which
|
| 203 |
+
will be interpreted as a dotted import path, with the last part
|
| 204 |
+
being the attribute name:
|
| 205 |
+
|
| 206 |
+
.. code-block:: python
|
| 207 |
+
|
| 208 |
+
monkeypatch.setattr("os.getcwd", lambda: "/")
|
| 209 |
+
|
| 210 |
+
Raises :class:`AttributeError` if the attribute does not exist, unless
|
| 211 |
+
``raising`` is set to False.
|
| 212 |
+
|
| 213 |
+
**Where to patch**
|
| 214 |
+
|
| 215 |
+
``monkeypatch.setattr`` works by (temporarily) changing the object that a name points to with another one.
|
| 216 |
+
There can be many names pointing to any individual object, so for patching to work you must ensure
|
| 217 |
+
that you patch the name used by the system under test.
|
| 218 |
+
|
| 219 |
+
See the section :ref:`Where to patch <python:where-to-patch>` in the :mod:`unittest.mock`
|
| 220 |
+
docs for a complete explanation, which is meant for :func:`unittest.mock.patch` but
|
| 221 |
+
applies to ``monkeypatch.setattr`` as well.
|
| 222 |
+
"""
|
| 223 |
+
__tracebackhide__ = True
|
| 224 |
+
import inspect
|
| 225 |
+
|
| 226 |
+
if isinstance(value, Notset):
|
| 227 |
+
if not isinstance(target, str):
|
| 228 |
+
raise TypeError(
|
| 229 |
+
"use setattr(target, name, value) or "
|
| 230 |
+
"setattr(target, value) with target being a dotted "
|
| 231 |
+
"import string"
|
| 232 |
+
)
|
| 233 |
+
value = name
|
| 234 |
+
name, target = derive_importpath(target, raising)
|
| 235 |
+
else:
|
| 236 |
+
if not isinstance(name, str):
|
| 237 |
+
raise TypeError(
|
| 238 |
+
"use setattr(target, name, value) with name being a string or "
|
| 239 |
+
"setattr(target, value) with target being a dotted "
|
| 240 |
+
"import string"
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
oldval = getattr(target, name, notset)
|
| 244 |
+
if raising and oldval is notset:
|
| 245 |
+
raise AttributeError(f"{target!r} has no attribute {name!r}")
|
| 246 |
+
|
| 247 |
+
# avoid class descriptors like staticmethod/classmethod
|
| 248 |
+
if inspect.isclass(target):
|
| 249 |
+
oldval = target.__dict__.get(name, notset)
|
| 250 |
+
self._setattr.append((target, name, oldval))
|
| 251 |
+
setattr(target, name, value)
|
| 252 |
+
|
| 253 |
+
def delattr(
|
| 254 |
+
self,
|
| 255 |
+
target: object | str,
|
| 256 |
+
name: str | Notset = notset,
|
| 257 |
+
raising: bool = True,
|
| 258 |
+
) -> None:
|
| 259 |
+
"""Delete attribute ``name`` from ``target``.
|
| 260 |
+
|
| 261 |
+
If no ``name`` is specified and ``target`` is a string
|
| 262 |
+
it will be interpreted as a dotted import path with the
|
| 263 |
+
last part being the attribute name.
|
| 264 |
+
|
| 265 |
+
Raises AttributeError it the attribute does not exist, unless
|
| 266 |
+
``raising`` is set to False.
|
| 267 |
+
"""
|
| 268 |
+
__tracebackhide__ = True
|
| 269 |
+
import inspect
|
| 270 |
+
|
| 271 |
+
if isinstance(name, Notset):
|
| 272 |
+
if not isinstance(target, str):
|
| 273 |
+
raise TypeError(
|
| 274 |
+
"use delattr(target, name) or "
|
| 275 |
+
"delattr(target) with target being a dotted "
|
| 276 |
+
"import string"
|
| 277 |
+
)
|
| 278 |
+
name, target = derive_importpath(target, raising)
|
| 279 |
+
|
| 280 |
+
if not hasattr(target, name):
|
| 281 |
+
if raising:
|
| 282 |
+
raise AttributeError(name)
|
| 283 |
+
else:
|
| 284 |
+
oldval = getattr(target, name, notset)
|
| 285 |
+
# Avoid class descriptors like staticmethod/classmethod.
|
| 286 |
+
if inspect.isclass(target):
|
| 287 |
+
oldval = target.__dict__.get(name, notset)
|
| 288 |
+
self._setattr.append((target, name, oldval))
|
| 289 |
+
delattr(target, name)
|
| 290 |
+
|
| 291 |
+
def setitem(self, dic: Mapping[K, V], name: K, value: V) -> None:
|
| 292 |
+
"""Set dictionary entry ``name`` to value."""
|
| 293 |
+
self._setitem.append((dic, name, dic.get(name, notset)))
|
| 294 |
+
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
| 295 |
+
dic[name] = value # type: ignore[index]
|
| 296 |
+
|
| 297 |
+
def delitem(self, dic: Mapping[K, V], name: K, raising: bool = True) -> None:
|
| 298 |
+
"""Delete ``name`` from dict.
|
| 299 |
+
|
| 300 |
+
Raises ``KeyError`` if it doesn't exist, unless ``raising`` is set to
|
| 301 |
+
False.
|
| 302 |
+
"""
|
| 303 |
+
if name not in dic:
|
| 304 |
+
if raising:
|
| 305 |
+
raise KeyError(name)
|
| 306 |
+
else:
|
| 307 |
+
self._setitem.append((dic, name, dic.get(name, notset)))
|
| 308 |
+
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
| 309 |
+
del dic[name] # type: ignore[attr-defined]
|
| 310 |
+
|
| 311 |
+
def setenv(self, name: str, value: str, prepend: str | None = None) -> None:
|
| 312 |
+
"""Set environment variable ``name`` to ``value``.
|
| 313 |
+
|
| 314 |
+
If ``prepend`` is a character, read the current environment variable
|
| 315 |
+
value and prepend the ``value`` adjoined with the ``prepend``
|
| 316 |
+
character.
|
| 317 |
+
"""
|
| 318 |
+
if not isinstance(value, str):
|
| 319 |
+
warnings.warn( # type: ignore[unreachable]
|
| 320 |
+
PytestWarning(
|
| 321 |
+
f"Value of environment variable {name} type should be str, but got "
|
| 322 |
+
f"{value!r} (type: {type(value).__name__}); converted to str implicitly"
|
| 323 |
+
),
|
| 324 |
+
stacklevel=2,
|
| 325 |
+
)
|
| 326 |
+
value = str(value)
|
| 327 |
+
if prepend and name in os.environ:
|
| 328 |
+
value = value + prepend + os.environ[name]
|
| 329 |
+
self.setitem(os.environ, name, value)
|
| 330 |
+
|
| 331 |
+
def delenv(self, name: str, raising: bool = True) -> None:
|
| 332 |
+
"""Delete ``name`` from the environment.
|
| 333 |
+
|
| 334 |
+
Raises ``KeyError`` if it does not exist, unless ``raising`` is set to
|
| 335 |
+
False.
|
| 336 |
+
"""
|
| 337 |
+
environ: MutableMapping[str, str] = os.environ
|
| 338 |
+
self.delitem(environ, name, raising=raising)
|
| 339 |
+
|
| 340 |
+
def syspath_prepend(self, path) -> None:
|
| 341 |
+
"""Prepend ``path`` to ``sys.path`` list of import locations."""
|
| 342 |
+
if self._savesyspath is None:
|
| 343 |
+
self._savesyspath = sys.path[:]
|
| 344 |
+
sys.path.insert(0, str(path))
|
| 345 |
+
|
| 346 |
+
# https://github.com/pypa/setuptools/blob/d8b901bc/docs/pkg_resources.txt#L162-L171
|
| 347 |
+
# this is only needed when pkg_resources was already loaded by the namespace package
|
| 348 |
+
if "pkg_resources" in sys.modules:
|
| 349 |
+
from pkg_resources import fixup_namespace_packages
|
| 350 |
+
|
| 351 |
+
fixup_namespace_packages(str(path))
|
| 352 |
+
|
| 353 |
+
# A call to syspathinsert() usually means that the caller wants to
|
| 354 |
+
# import some dynamically created files, thus with python3 we
|
| 355 |
+
# invalidate its import caches.
|
| 356 |
+
# This is especially important when any namespace package is in use,
|
| 357 |
+
# since then the mtime based FileFinder cache (that gets created in
|
| 358 |
+
# this case already) gets not invalidated when writing the new files
|
| 359 |
+
# quickly afterwards.
|
| 360 |
+
from importlib import invalidate_caches
|
| 361 |
+
|
| 362 |
+
invalidate_caches()
|
| 363 |
+
|
| 364 |
+
def chdir(self, path: str | os.PathLike[str]) -> None:
|
| 365 |
+
"""Change the current working directory to the specified path.
|
| 366 |
+
|
| 367 |
+
:param path:
|
| 368 |
+
The path to change into.
|
| 369 |
+
"""
|
| 370 |
+
if self._cwd is None:
|
| 371 |
+
self._cwd = os.getcwd()
|
| 372 |
+
os.chdir(path)
|
| 373 |
+
|
| 374 |
+
def undo(self) -> None:
|
| 375 |
+
"""Undo previous changes.
|
| 376 |
+
|
| 377 |
+
This call consumes the undo stack. Calling it a second time has no
|
| 378 |
+
effect unless you do more monkeypatching after the undo call.
|
| 379 |
+
|
| 380 |
+
There is generally no need to call `undo()`, since it is
|
| 381 |
+
called automatically during tear-down.
|
| 382 |
+
|
| 383 |
+
.. note::
|
| 384 |
+
The same `monkeypatch` fixture is used across a
|
| 385 |
+
single test function invocation. If `monkeypatch` is used both by
|
| 386 |
+
the test function itself and one of the test fixtures,
|
| 387 |
+
calling `undo()` will undo all of the changes made in
|
| 388 |
+
both functions.
|
| 389 |
+
|
| 390 |
+
Prefer to use :meth:`context() <pytest.MonkeyPatch.context>` instead.
|
| 391 |
+
"""
|
| 392 |
+
for obj, name, value in reversed(self._setattr):
|
| 393 |
+
if value is not notset:
|
| 394 |
+
setattr(obj, name, value)
|
| 395 |
+
else:
|
| 396 |
+
delattr(obj, name)
|
| 397 |
+
self._setattr[:] = []
|
| 398 |
+
for dictionary, key, value in reversed(self._setitem):
|
| 399 |
+
if value is notset:
|
| 400 |
+
try:
|
| 401 |
+
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
| 402 |
+
del dictionary[key] # type: ignore[attr-defined]
|
| 403 |
+
except KeyError:
|
| 404 |
+
pass # Was already deleted, so we have the desired state.
|
| 405 |
+
else:
|
| 406 |
+
# Not all Mapping types support indexing, but MutableMapping doesn't support TypedDict
|
| 407 |
+
dictionary[key] = value # type: ignore[index]
|
| 408 |
+
self._setitem[:] = []
|
| 409 |
+
if self._savesyspath is not None:
|
| 410 |
+
sys.path[:] = self._savesyspath
|
| 411 |
+
self._savesyspath = None
|
| 412 |
+
|
| 413 |
+
if self._cwd is not None:
|
| 414 |
+
os.chdir(self._cwd)
|
| 415 |
+
self._cwd = None
|
.venv/lib/python3.11/site-packages/_pytest/outcomes.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exception classes and constants handling test outcomes as well as
|
| 2 |
+
functions creating them."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from typing import Any
|
| 8 |
+
from typing import Callable
|
| 9 |
+
from typing import cast
|
| 10 |
+
from typing import NoReturn
|
| 11 |
+
from typing import Protocol
|
| 12 |
+
from typing import Type
|
| 13 |
+
from typing import TypeVar
|
| 14 |
+
|
| 15 |
+
from .warning_types import PytestDeprecationWarning
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class OutcomeException(BaseException):
|
| 19 |
+
"""OutcomeException and its subclass instances indicate and contain info
|
| 20 |
+
about test and collection outcomes."""
|
| 21 |
+
|
| 22 |
+
def __init__(self, msg: str | None = None, pytrace: bool = True) -> None:
|
| 23 |
+
if msg is not None and not isinstance(msg, str):
|
| 24 |
+
error_msg = ( # type: ignore[unreachable]
|
| 25 |
+
"{} expected string as 'msg' parameter, got '{}' instead.\n"
|
| 26 |
+
"Perhaps you meant to use a mark?"
|
| 27 |
+
)
|
| 28 |
+
raise TypeError(error_msg.format(type(self).__name__, type(msg).__name__))
|
| 29 |
+
super().__init__(msg)
|
| 30 |
+
self.msg = msg
|
| 31 |
+
self.pytrace = pytrace
|
| 32 |
+
|
| 33 |
+
def __repr__(self) -> str:
|
| 34 |
+
if self.msg is not None:
|
| 35 |
+
return self.msg
|
| 36 |
+
return f"<{self.__class__.__name__} instance>"
|
| 37 |
+
|
| 38 |
+
__str__ = __repr__
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
TEST_OUTCOME = (OutcomeException, Exception)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class Skipped(OutcomeException):
|
| 45 |
+
# XXX hackish: on 3k we fake to live in the builtins
|
| 46 |
+
# in order to have Skipped exception printing shorter/nicer
|
| 47 |
+
__module__ = "builtins"
|
| 48 |
+
|
| 49 |
+
def __init__(
|
| 50 |
+
self,
|
| 51 |
+
msg: str | None = None,
|
| 52 |
+
pytrace: bool = True,
|
| 53 |
+
allow_module_level: bool = False,
|
| 54 |
+
*,
|
| 55 |
+
_use_item_location: bool = False,
|
| 56 |
+
) -> None:
|
| 57 |
+
super().__init__(msg=msg, pytrace=pytrace)
|
| 58 |
+
self.allow_module_level = allow_module_level
|
| 59 |
+
# If true, the skip location is reported as the item's location,
|
| 60 |
+
# instead of the place that raises the exception/calls skip().
|
| 61 |
+
self._use_item_location = _use_item_location
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class Failed(OutcomeException):
|
| 65 |
+
"""Raised from an explicit call to pytest.fail()."""
|
| 66 |
+
|
| 67 |
+
__module__ = "builtins"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class Exit(Exception):
|
| 71 |
+
"""Raised for immediate program exits (no tracebacks/summaries)."""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self, msg: str = "unknown reason", returncode: int | None = None
|
| 75 |
+
) -> None:
|
| 76 |
+
self.msg = msg
|
| 77 |
+
self.returncode = returncode
|
| 78 |
+
super().__init__(msg)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# Elaborate hack to work around https://github.com/python/mypy/issues/2087.
|
| 82 |
+
# Ideally would just be `exit.Exception = Exit` etc.
|
| 83 |
+
|
| 84 |
+
_F = TypeVar("_F", bound=Callable[..., object])
|
| 85 |
+
_ET = TypeVar("_ET", bound=Type[BaseException])
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class _WithException(Protocol[_F, _ET]):
|
| 89 |
+
Exception: _ET
|
| 90 |
+
__call__: _F
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _with_exception(exception_type: _ET) -> Callable[[_F], _WithException[_F, _ET]]:
|
| 94 |
+
def decorate(func: _F) -> _WithException[_F, _ET]:
|
| 95 |
+
func_with_exception = cast(_WithException[_F, _ET], func)
|
| 96 |
+
func_with_exception.Exception = exception_type
|
| 97 |
+
return func_with_exception
|
| 98 |
+
|
| 99 |
+
return decorate
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Exposed helper methods.
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@_with_exception(Exit)
|
| 106 |
+
def exit(
|
| 107 |
+
reason: str = "",
|
| 108 |
+
returncode: int | None = None,
|
| 109 |
+
) -> NoReturn:
|
| 110 |
+
"""Exit testing process.
|
| 111 |
+
|
| 112 |
+
:param reason:
|
| 113 |
+
The message to show as the reason for exiting pytest. reason has a default value
|
| 114 |
+
only because `msg` is deprecated.
|
| 115 |
+
|
| 116 |
+
:param returncode:
|
| 117 |
+
Return code to be used when exiting pytest. None means the same as ``0`` (no error), same as :func:`sys.exit`.
|
| 118 |
+
|
| 119 |
+
:raises pytest.exit.Exception:
|
| 120 |
+
The exception that is raised.
|
| 121 |
+
"""
|
| 122 |
+
__tracebackhide__ = True
|
| 123 |
+
raise Exit(reason, returncode)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@_with_exception(Skipped)
|
| 127 |
+
def skip(
|
| 128 |
+
reason: str = "",
|
| 129 |
+
*,
|
| 130 |
+
allow_module_level: bool = False,
|
| 131 |
+
) -> NoReturn:
|
| 132 |
+
"""Skip an executing test with the given message.
|
| 133 |
+
|
| 134 |
+
This function should be called only during testing (setup, call or teardown) or
|
| 135 |
+
during collection by using the ``allow_module_level`` flag. This function can
|
| 136 |
+
be called in doctests as well.
|
| 137 |
+
|
| 138 |
+
:param reason:
|
| 139 |
+
The message to show the user as reason for the skip.
|
| 140 |
+
|
| 141 |
+
:param allow_module_level:
|
| 142 |
+
Allows this function to be called at module level.
|
| 143 |
+
Raising the skip exception at module level will stop
|
| 144 |
+
the execution of the module and prevent the collection of all tests in the module,
|
| 145 |
+
even those defined before the `skip` call.
|
| 146 |
+
|
| 147 |
+
Defaults to False.
|
| 148 |
+
|
| 149 |
+
:raises pytest.skip.Exception:
|
| 150 |
+
The exception that is raised.
|
| 151 |
+
|
| 152 |
+
.. note::
|
| 153 |
+
It is better to use the :ref:`pytest.mark.skipif ref` marker when
|
| 154 |
+
possible to declare a test to be skipped under certain conditions
|
| 155 |
+
like mismatching platforms or dependencies.
|
| 156 |
+
Similarly, use the ``# doctest: +SKIP`` directive (see :py:data:`doctest.SKIP`)
|
| 157 |
+
to skip a doctest statically.
|
| 158 |
+
"""
|
| 159 |
+
__tracebackhide__ = True
|
| 160 |
+
raise Skipped(msg=reason, allow_module_level=allow_module_level)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@_with_exception(Failed)
|
| 164 |
+
def fail(reason: str = "", pytrace: bool = True) -> NoReturn:
|
| 165 |
+
"""Explicitly fail an executing test with the given message.
|
| 166 |
+
|
| 167 |
+
:param reason:
|
| 168 |
+
The message to show the user as reason for the failure.
|
| 169 |
+
|
| 170 |
+
:param pytrace:
|
| 171 |
+
If False, msg represents the full failure information and no
|
| 172 |
+
python traceback will be reported.
|
| 173 |
+
|
| 174 |
+
:raises pytest.fail.Exception:
|
| 175 |
+
The exception that is raised.
|
| 176 |
+
"""
|
| 177 |
+
__tracebackhide__ = True
|
| 178 |
+
raise Failed(msg=reason, pytrace=pytrace)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class XFailed(Failed):
|
| 182 |
+
"""Raised from an explicit call to pytest.xfail()."""
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@_with_exception(XFailed)
|
| 186 |
+
def xfail(reason: str = "") -> NoReturn:
|
| 187 |
+
"""Imperatively xfail an executing test or setup function with the given reason.
|
| 188 |
+
|
| 189 |
+
This function should be called only during testing (setup, call or teardown).
|
| 190 |
+
|
| 191 |
+
No other code is executed after using ``xfail()`` (it is implemented
|
| 192 |
+
internally by raising an exception).
|
| 193 |
+
|
| 194 |
+
:param reason:
|
| 195 |
+
The message to show the user as reason for the xfail.
|
| 196 |
+
|
| 197 |
+
.. note::
|
| 198 |
+
It is better to use the :ref:`pytest.mark.xfail ref` marker when
|
| 199 |
+
possible to declare a test to be xfailed under certain conditions
|
| 200 |
+
like known bugs or missing features.
|
| 201 |
+
|
| 202 |
+
:raises pytest.xfail.Exception:
|
| 203 |
+
The exception that is raised.
|
| 204 |
+
"""
|
| 205 |
+
__tracebackhide__ = True
|
| 206 |
+
raise XFailed(reason)
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def importorskip(
|
| 210 |
+
modname: str,
|
| 211 |
+
minversion: str | None = None,
|
| 212 |
+
reason: str | None = None,
|
| 213 |
+
*,
|
| 214 |
+
exc_type: type[ImportError] | None = None,
|
| 215 |
+
) -> Any:
|
| 216 |
+
"""Import and return the requested module ``modname``, or skip the
|
| 217 |
+
current test if the module cannot be imported.
|
| 218 |
+
|
| 219 |
+
:param modname:
|
| 220 |
+
The name of the module to import.
|
| 221 |
+
:param minversion:
|
| 222 |
+
If given, the imported module's ``__version__`` attribute must be at
|
| 223 |
+
least this minimal version, otherwise the test is still skipped.
|
| 224 |
+
:param reason:
|
| 225 |
+
If given, this reason is shown as the message when the module cannot
|
| 226 |
+
be imported.
|
| 227 |
+
:param exc_type:
|
| 228 |
+
The exception that should be captured in order to skip modules.
|
| 229 |
+
Must be :py:class:`ImportError` or a subclass.
|
| 230 |
+
|
| 231 |
+
If the module can be imported but raises :class:`ImportError`, pytest will
|
| 232 |
+
issue a warning to the user, as often users expect the module not to be
|
| 233 |
+
found (which would raise :class:`ModuleNotFoundError` instead).
|
| 234 |
+
|
| 235 |
+
This warning can be suppressed by passing ``exc_type=ImportError`` explicitly.
|
| 236 |
+
|
| 237 |
+
See :ref:`import-or-skip-import-error` for details.
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
:returns:
|
| 241 |
+
The imported module. This should be assigned to its canonical name.
|
| 242 |
+
|
| 243 |
+
:raises pytest.skip.Exception:
|
| 244 |
+
If the module cannot be imported.
|
| 245 |
+
|
| 246 |
+
Example::
|
| 247 |
+
|
| 248 |
+
docutils = pytest.importorskip("docutils")
|
| 249 |
+
|
| 250 |
+
.. versionadded:: 8.2
|
| 251 |
+
|
| 252 |
+
The ``exc_type`` parameter.
|
| 253 |
+
"""
|
| 254 |
+
import warnings
|
| 255 |
+
|
| 256 |
+
__tracebackhide__ = True
|
| 257 |
+
compile(modname, "", "eval") # to catch syntaxerrors
|
| 258 |
+
|
| 259 |
+
# Until pytest 9.1, we will warn the user if we catch ImportError (instead of ModuleNotFoundError),
|
| 260 |
+
# as this might be hiding an installation/environment problem, which is not usually what is intended
|
| 261 |
+
# when using importorskip() (#11523).
|
| 262 |
+
# In 9.1, to keep the function signature compatible, we just change the code below to:
|
| 263 |
+
# 1. Use `exc_type = ModuleNotFoundError` if `exc_type` is not given.
|
| 264 |
+
# 2. Remove `warn_on_import` and the warning handling.
|
| 265 |
+
if exc_type is None:
|
| 266 |
+
exc_type = ImportError
|
| 267 |
+
warn_on_import_error = True
|
| 268 |
+
else:
|
| 269 |
+
warn_on_import_error = False
|
| 270 |
+
|
| 271 |
+
skipped: Skipped | None = None
|
| 272 |
+
warning: Warning | None = None
|
| 273 |
+
|
| 274 |
+
with warnings.catch_warnings():
|
| 275 |
+
# Make sure to ignore ImportWarnings that might happen because
|
| 276 |
+
# of existing directories with the same name we're trying to
|
| 277 |
+
# import but without a __init__.py file.
|
| 278 |
+
warnings.simplefilter("ignore")
|
| 279 |
+
|
| 280 |
+
try:
|
| 281 |
+
__import__(modname)
|
| 282 |
+
except exc_type as exc:
|
| 283 |
+
# Do not raise or issue warnings inside the catch_warnings() block.
|
| 284 |
+
if reason is None:
|
| 285 |
+
reason = f"could not import {modname!r}: {exc}"
|
| 286 |
+
skipped = Skipped(reason, allow_module_level=True)
|
| 287 |
+
|
| 288 |
+
if warn_on_import_error and not isinstance(exc, ModuleNotFoundError):
|
| 289 |
+
lines = [
|
| 290 |
+
"",
|
| 291 |
+
f"Module '{modname}' was found, but when imported by pytest it raised:",
|
| 292 |
+
f" {exc!r}",
|
| 293 |
+
"In pytest 9.1 this warning will become an error by default.",
|
| 294 |
+
"You can fix the underlying problem, or alternatively overwrite this behavior and silence this "
|
| 295 |
+
"warning by passing exc_type=ImportError explicitly.",
|
| 296 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#pytest-importorskip-default-behavior-regarding-importerror",
|
| 297 |
+
]
|
| 298 |
+
warning = PytestDeprecationWarning("\n".join(lines))
|
| 299 |
+
|
| 300 |
+
if warning:
|
| 301 |
+
warnings.warn(warning, stacklevel=2)
|
| 302 |
+
if skipped:
|
| 303 |
+
raise skipped
|
| 304 |
+
|
| 305 |
+
mod = sys.modules[modname]
|
| 306 |
+
if minversion is None:
|
| 307 |
+
return mod
|
| 308 |
+
verattr = getattr(mod, "__version__", None)
|
| 309 |
+
if minversion is not None:
|
| 310 |
+
# Imported lazily to improve start-up time.
|
| 311 |
+
from packaging.version import Version
|
| 312 |
+
|
| 313 |
+
if verattr is None or Version(verattr) < Version(minversion):
|
| 314 |
+
raise Skipped(
|
| 315 |
+
f"module {modname!r} has __version__ {verattr!r}, required is: {minversion!r}",
|
| 316 |
+
allow_module_level=True,
|
| 317 |
+
)
|
| 318 |
+
return mod
|
.venv/lib/python3.11/site-packages/_pytest/pastebin.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Submit failure or test session information to a pastebin service."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from io import StringIO
|
| 7 |
+
import tempfile
|
| 8 |
+
from typing import IO
|
| 9 |
+
|
| 10 |
+
from _pytest.config import Config
|
| 11 |
+
from _pytest.config import create_terminal_writer
|
| 12 |
+
from _pytest.config.argparsing import Parser
|
| 13 |
+
from _pytest.stash import StashKey
|
| 14 |
+
from _pytest.terminal import TerminalReporter
|
| 15 |
+
import pytest
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
pastebinfile_key = StashKey[IO[bytes]]()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 22 |
+
group = parser.getgroup("terminal reporting")
|
| 23 |
+
group._addoption(
|
| 24 |
+
"--pastebin",
|
| 25 |
+
metavar="mode",
|
| 26 |
+
action="store",
|
| 27 |
+
dest="pastebin",
|
| 28 |
+
default=None,
|
| 29 |
+
choices=["failed", "all"],
|
| 30 |
+
help="Send failed|all info to bpaste.net pastebin service",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@pytest.hookimpl(trylast=True)
|
| 35 |
+
def pytest_configure(config: Config) -> None:
|
| 36 |
+
if config.option.pastebin == "all":
|
| 37 |
+
tr = config.pluginmanager.getplugin("terminalreporter")
|
| 38 |
+
# If no terminal reporter plugin is present, nothing we can do here;
|
| 39 |
+
# this can happen when this function executes in a worker node
|
| 40 |
+
# when using pytest-xdist, for example.
|
| 41 |
+
if tr is not None:
|
| 42 |
+
# pastebin file will be UTF-8 encoded binary file.
|
| 43 |
+
config.stash[pastebinfile_key] = tempfile.TemporaryFile("w+b")
|
| 44 |
+
oldwrite = tr._tw.write
|
| 45 |
+
|
| 46 |
+
def tee_write(s, **kwargs):
|
| 47 |
+
oldwrite(s, **kwargs)
|
| 48 |
+
if isinstance(s, str):
|
| 49 |
+
s = s.encode("utf-8")
|
| 50 |
+
config.stash[pastebinfile_key].write(s)
|
| 51 |
+
|
| 52 |
+
tr._tw.write = tee_write
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def pytest_unconfigure(config: Config) -> None:
|
| 56 |
+
if pastebinfile_key in config.stash:
|
| 57 |
+
pastebinfile = config.stash[pastebinfile_key]
|
| 58 |
+
# Get terminal contents and delete file.
|
| 59 |
+
pastebinfile.seek(0)
|
| 60 |
+
sessionlog = pastebinfile.read()
|
| 61 |
+
pastebinfile.close()
|
| 62 |
+
del config.stash[pastebinfile_key]
|
| 63 |
+
# Undo our patching in the terminal reporter.
|
| 64 |
+
tr = config.pluginmanager.getplugin("terminalreporter")
|
| 65 |
+
del tr._tw.__dict__["write"]
|
| 66 |
+
# Write summary.
|
| 67 |
+
tr.write_sep("=", "Sending information to Paste Service")
|
| 68 |
+
pastebinurl = create_new_paste(sessionlog)
|
| 69 |
+
tr.write_line(f"pastebin session-log: {pastebinurl}\n")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def create_new_paste(contents: str | bytes) -> str:
|
| 73 |
+
"""Create a new paste using the bpaste.net service.
|
| 74 |
+
|
| 75 |
+
:contents: Paste contents string.
|
| 76 |
+
:returns: URL to the pasted contents, or an error message.
|
| 77 |
+
"""
|
| 78 |
+
import re
|
| 79 |
+
from urllib.parse import urlencode
|
| 80 |
+
from urllib.request import urlopen
|
| 81 |
+
|
| 82 |
+
params = {"code": contents, "lexer": "text", "expiry": "1week"}
|
| 83 |
+
url = "https://bpa.st"
|
| 84 |
+
try:
|
| 85 |
+
response: str = (
|
| 86 |
+
urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8")
|
| 87 |
+
)
|
| 88 |
+
except OSError as exc_info: # urllib errors
|
| 89 |
+
return f"bad response: {exc_info}"
|
| 90 |
+
m = re.search(r'href="/raw/(\w+)"', response)
|
| 91 |
+
if m:
|
| 92 |
+
return f"{url}/show/{m.group(1)}"
|
| 93 |
+
else:
|
| 94 |
+
return "bad response: invalid format ('" + response + "')"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def pytest_terminal_summary(terminalreporter: TerminalReporter) -> None:
|
| 98 |
+
if terminalreporter.config.option.pastebin != "failed":
|
| 99 |
+
return
|
| 100 |
+
if "failed" in terminalreporter.stats:
|
| 101 |
+
terminalreporter.write_sep("=", "Sending information to Paste Service")
|
| 102 |
+
for rep in terminalreporter.stats["failed"]:
|
| 103 |
+
try:
|
| 104 |
+
msg = rep.longrepr.reprtraceback.reprentries[-1].reprfileloc
|
| 105 |
+
except AttributeError:
|
| 106 |
+
msg = terminalreporter._getfailureheadline(rep)
|
| 107 |
+
file = StringIO()
|
| 108 |
+
tw = create_terminal_writer(terminalreporter.config, file)
|
| 109 |
+
rep.toterminal(tw)
|
| 110 |
+
s = file.getvalue()
|
| 111 |
+
assert len(s)
|
| 112 |
+
pastebinurl = create_new_paste(s)
|
| 113 |
+
terminalreporter.write_line(f"{msg} --> {pastebinurl}")
|
.venv/lib/python3.11/site-packages/_pytest/pathlib.py
ADDED
|
@@ -0,0 +1,1038 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import atexit
|
| 4 |
+
import contextlib
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from errno import EBADF
|
| 7 |
+
from errno import ELOOP
|
| 8 |
+
from errno import ENOENT
|
| 9 |
+
from errno import ENOTDIR
|
| 10 |
+
import fnmatch
|
| 11 |
+
from functools import partial
|
| 12 |
+
from importlib.machinery import ModuleSpec
|
| 13 |
+
from importlib.machinery import PathFinder
|
| 14 |
+
import importlib.util
|
| 15 |
+
import itertools
|
| 16 |
+
import os
|
| 17 |
+
from os.path import expanduser
|
| 18 |
+
from os.path import expandvars
|
| 19 |
+
from os.path import isabs
|
| 20 |
+
from os.path import sep
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from pathlib import PurePath
|
| 23 |
+
from posixpath import sep as posix_sep
|
| 24 |
+
import shutil
|
| 25 |
+
import sys
|
| 26 |
+
import types
|
| 27 |
+
from types import ModuleType
|
| 28 |
+
from typing import Any
|
| 29 |
+
from typing import Callable
|
| 30 |
+
from typing import Iterable
|
| 31 |
+
from typing import Iterator
|
| 32 |
+
from typing import TypeVar
|
| 33 |
+
import uuid
|
| 34 |
+
import warnings
|
| 35 |
+
|
| 36 |
+
from _pytest.compat import assert_never
|
| 37 |
+
from _pytest.outcomes import skip
|
| 38 |
+
from _pytest.warning_types import PytestWarning
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
if sys.version_info < (3, 11):
|
| 42 |
+
from importlib._bootstrap_external import _NamespaceLoader as NamespaceLoader
|
| 43 |
+
else:
|
| 44 |
+
from importlib.machinery import NamespaceLoader
|
| 45 |
+
|
| 46 |
+
LOCK_TIMEOUT = 60 * 60 * 24 * 3
|
| 47 |
+
|
| 48 |
+
_AnyPurePath = TypeVar("_AnyPurePath", bound=PurePath)
|
| 49 |
+
|
| 50 |
+
# The following function, variables and comments were
|
| 51 |
+
# copied from cpython 3.9 Lib/pathlib.py file.
|
| 52 |
+
|
| 53 |
+
# EBADF - guard against macOS `stat` throwing EBADF
|
| 54 |
+
_IGNORED_ERRORS = (ENOENT, ENOTDIR, EBADF, ELOOP)
|
| 55 |
+
|
| 56 |
+
_IGNORED_WINERRORS = (
|
| 57 |
+
21, # ERROR_NOT_READY - drive exists but is not accessible
|
| 58 |
+
1921, # ERROR_CANT_RESOLVE_FILENAME - fix for broken symlink pointing to itself
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _ignore_error(exception: Exception) -> bool:
|
| 63 |
+
return (
|
| 64 |
+
getattr(exception, "errno", None) in _IGNORED_ERRORS
|
| 65 |
+
or getattr(exception, "winerror", None) in _IGNORED_WINERRORS
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
|
| 70 |
+
return path.joinpath(".lock")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def on_rm_rf_error(
|
| 74 |
+
func: Callable[..., Any] | None,
|
| 75 |
+
path: str,
|
| 76 |
+
excinfo: BaseException
|
| 77 |
+
| tuple[type[BaseException], BaseException, types.TracebackType | None],
|
| 78 |
+
*,
|
| 79 |
+
start_path: Path,
|
| 80 |
+
) -> bool:
|
| 81 |
+
"""Handle known read-only errors during rmtree.
|
| 82 |
+
|
| 83 |
+
The returned value is used only by our own tests.
|
| 84 |
+
"""
|
| 85 |
+
if isinstance(excinfo, BaseException):
|
| 86 |
+
exc = excinfo
|
| 87 |
+
else:
|
| 88 |
+
exc = excinfo[1]
|
| 89 |
+
|
| 90 |
+
# Another process removed the file in the middle of the "rm_rf" (xdist for example).
|
| 91 |
+
# More context: https://github.com/pytest-dev/pytest/issues/5974#issuecomment-543799018
|
| 92 |
+
if isinstance(exc, FileNotFoundError):
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
if not isinstance(exc, PermissionError):
|
| 96 |
+
warnings.warn(
|
| 97 |
+
PytestWarning(f"(rm_rf) error removing {path}\n{type(exc)}: {exc}")
|
| 98 |
+
)
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
if func not in (os.rmdir, os.remove, os.unlink):
|
| 102 |
+
if func not in (os.open,):
|
| 103 |
+
warnings.warn(
|
| 104 |
+
PytestWarning(
|
| 105 |
+
f"(rm_rf) unknown function {func} when removing {path}:\n{type(exc)}: {exc}"
|
| 106 |
+
)
|
| 107 |
+
)
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
# Chmod + retry.
|
| 111 |
+
import stat
|
| 112 |
+
|
| 113 |
+
def chmod_rw(p: str) -> None:
|
| 114 |
+
mode = os.stat(p).st_mode
|
| 115 |
+
os.chmod(p, mode | stat.S_IRUSR | stat.S_IWUSR)
|
| 116 |
+
|
| 117 |
+
# For files, we need to recursively go upwards in the directories to
|
| 118 |
+
# ensure they all are also writable.
|
| 119 |
+
p = Path(path)
|
| 120 |
+
if p.is_file():
|
| 121 |
+
for parent in p.parents:
|
| 122 |
+
chmod_rw(str(parent))
|
| 123 |
+
# Stop when we reach the original path passed to rm_rf.
|
| 124 |
+
if parent == start_path:
|
| 125 |
+
break
|
| 126 |
+
chmod_rw(str(path))
|
| 127 |
+
|
| 128 |
+
func(path)
|
| 129 |
+
return True
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def ensure_extended_length_path(path: Path) -> Path:
|
| 133 |
+
"""Get the extended-length version of a path (Windows).
|
| 134 |
+
|
| 135 |
+
On Windows, by default, the maximum length of a path (MAX_PATH) is 260
|
| 136 |
+
characters, and operations on paths longer than that fail. But it is possible
|
| 137 |
+
to overcome this by converting the path to "extended-length" form before
|
| 138 |
+
performing the operation:
|
| 139 |
+
https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file#maximum-path-length-limitation
|
| 140 |
+
|
| 141 |
+
On Windows, this function returns the extended-length absolute version of path.
|
| 142 |
+
On other platforms it returns path unchanged.
|
| 143 |
+
"""
|
| 144 |
+
if sys.platform.startswith("win32"):
|
| 145 |
+
path = path.resolve()
|
| 146 |
+
path = Path(get_extended_length_path_str(str(path)))
|
| 147 |
+
return path
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def get_extended_length_path_str(path: str) -> str:
|
| 151 |
+
"""Convert a path to a Windows extended length path."""
|
| 152 |
+
long_path_prefix = "\\\\?\\"
|
| 153 |
+
unc_long_path_prefix = "\\\\?\\UNC\\"
|
| 154 |
+
if path.startswith((long_path_prefix, unc_long_path_prefix)):
|
| 155 |
+
return path
|
| 156 |
+
# UNC
|
| 157 |
+
if path.startswith("\\\\"):
|
| 158 |
+
return unc_long_path_prefix + path[2:]
|
| 159 |
+
return long_path_prefix + path
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def rm_rf(path: Path) -> None:
|
| 163 |
+
"""Remove the path contents recursively, even if some elements
|
| 164 |
+
are read-only."""
|
| 165 |
+
path = ensure_extended_length_path(path)
|
| 166 |
+
onerror = partial(on_rm_rf_error, start_path=path)
|
| 167 |
+
if sys.version_info >= (3, 12):
|
| 168 |
+
shutil.rmtree(str(path), onexc=onerror)
|
| 169 |
+
else:
|
| 170 |
+
shutil.rmtree(str(path), onerror=onerror)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def find_prefixed(root: Path, prefix: str) -> Iterator[os.DirEntry[str]]:
|
| 174 |
+
"""Find all elements in root that begin with the prefix, case-insensitive."""
|
| 175 |
+
l_prefix = prefix.lower()
|
| 176 |
+
for x in os.scandir(root):
|
| 177 |
+
if x.name.lower().startswith(l_prefix):
|
| 178 |
+
yield x
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def extract_suffixes(iter: Iterable[os.DirEntry[str]], prefix: str) -> Iterator[str]:
|
| 182 |
+
"""Return the parts of the paths following the prefix.
|
| 183 |
+
|
| 184 |
+
:param iter: Iterator over path names.
|
| 185 |
+
:param prefix: Expected prefix of the path names.
|
| 186 |
+
"""
|
| 187 |
+
p_len = len(prefix)
|
| 188 |
+
for entry in iter:
|
| 189 |
+
yield entry.name[p_len:]
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def find_suffixes(root: Path, prefix: str) -> Iterator[str]:
|
| 193 |
+
"""Combine find_prefixes and extract_suffixes."""
|
| 194 |
+
return extract_suffixes(find_prefixed(root, prefix), prefix)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def parse_num(maybe_num: str) -> int:
|
| 198 |
+
"""Parse number path suffixes, returns -1 on error."""
|
| 199 |
+
try:
|
| 200 |
+
return int(maybe_num)
|
| 201 |
+
except ValueError:
|
| 202 |
+
return -1
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _force_symlink(root: Path, target: str | PurePath, link_to: str | Path) -> None:
|
| 206 |
+
"""Helper to create the current symlink.
|
| 207 |
+
|
| 208 |
+
It's full of race conditions that are reasonably OK to ignore
|
| 209 |
+
for the context of best effort linking to the latest test run.
|
| 210 |
+
|
| 211 |
+
The presumption being that in case of much parallelism
|
| 212 |
+
the inaccuracy is going to be acceptable.
|
| 213 |
+
"""
|
| 214 |
+
current_symlink = root.joinpath(target)
|
| 215 |
+
try:
|
| 216 |
+
current_symlink.unlink()
|
| 217 |
+
except OSError:
|
| 218 |
+
pass
|
| 219 |
+
try:
|
| 220 |
+
current_symlink.symlink_to(link_to)
|
| 221 |
+
except Exception:
|
| 222 |
+
pass
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def make_numbered_dir(root: Path, prefix: str, mode: int = 0o700) -> Path:
|
| 226 |
+
"""Create a directory with an increased number as suffix for the given prefix."""
|
| 227 |
+
for i in range(10):
|
| 228 |
+
# try up to 10 times to create the folder
|
| 229 |
+
max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1)
|
| 230 |
+
new_number = max_existing + 1
|
| 231 |
+
new_path = root.joinpath(f"{prefix}{new_number}")
|
| 232 |
+
try:
|
| 233 |
+
new_path.mkdir(mode=mode)
|
| 234 |
+
except Exception:
|
| 235 |
+
pass
|
| 236 |
+
else:
|
| 237 |
+
_force_symlink(root, prefix + "current", new_path)
|
| 238 |
+
return new_path
|
| 239 |
+
else:
|
| 240 |
+
raise OSError(
|
| 241 |
+
"could not create numbered dir with prefix "
|
| 242 |
+
f"{prefix} in {root} after 10 tries"
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def create_cleanup_lock(p: Path) -> Path:
|
| 247 |
+
"""Create a lock to prevent premature folder cleanup."""
|
| 248 |
+
lock_path = get_lock_path(p)
|
| 249 |
+
try:
|
| 250 |
+
fd = os.open(str(lock_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
|
| 251 |
+
except FileExistsError as e:
|
| 252 |
+
raise OSError(f"cannot create lockfile in {p}") from e
|
| 253 |
+
else:
|
| 254 |
+
pid = os.getpid()
|
| 255 |
+
spid = str(pid).encode()
|
| 256 |
+
os.write(fd, spid)
|
| 257 |
+
os.close(fd)
|
| 258 |
+
if not lock_path.is_file():
|
| 259 |
+
raise OSError("lock path got renamed after successful creation")
|
| 260 |
+
return lock_path
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def register_cleanup_lock_removal(
|
| 264 |
+
lock_path: Path, register: Any = atexit.register
|
| 265 |
+
) -> Any:
|
| 266 |
+
"""Register a cleanup function for removing a lock, by default on atexit."""
|
| 267 |
+
pid = os.getpid()
|
| 268 |
+
|
| 269 |
+
def cleanup_on_exit(lock_path: Path = lock_path, original_pid: int = pid) -> None:
|
| 270 |
+
current_pid = os.getpid()
|
| 271 |
+
if current_pid != original_pid:
|
| 272 |
+
# fork
|
| 273 |
+
return
|
| 274 |
+
try:
|
| 275 |
+
lock_path.unlink()
|
| 276 |
+
except OSError:
|
| 277 |
+
pass
|
| 278 |
+
|
| 279 |
+
return register(cleanup_on_exit)
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def maybe_delete_a_numbered_dir(path: Path) -> None:
|
| 283 |
+
"""Remove a numbered directory if its lock can be obtained and it does
|
| 284 |
+
not seem to be in use."""
|
| 285 |
+
path = ensure_extended_length_path(path)
|
| 286 |
+
lock_path = None
|
| 287 |
+
try:
|
| 288 |
+
lock_path = create_cleanup_lock(path)
|
| 289 |
+
parent = path.parent
|
| 290 |
+
|
| 291 |
+
garbage = parent.joinpath(f"garbage-{uuid.uuid4()}")
|
| 292 |
+
path.rename(garbage)
|
| 293 |
+
rm_rf(garbage)
|
| 294 |
+
except OSError:
|
| 295 |
+
# known races:
|
| 296 |
+
# * other process did a cleanup at the same time
|
| 297 |
+
# * deletable folder was found
|
| 298 |
+
# * process cwd (Windows)
|
| 299 |
+
return
|
| 300 |
+
finally:
|
| 301 |
+
# If we created the lock, ensure we remove it even if we failed
|
| 302 |
+
# to properly remove the numbered dir.
|
| 303 |
+
if lock_path is not None:
|
| 304 |
+
try:
|
| 305 |
+
lock_path.unlink()
|
| 306 |
+
except OSError:
|
| 307 |
+
pass
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def ensure_deletable(path: Path, consider_lock_dead_if_created_before: float) -> bool:
|
| 311 |
+
"""Check if `path` is deletable based on whether the lock file is expired."""
|
| 312 |
+
if path.is_symlink():
|
| 313 |
+
return False
|
| 314 |
+
lock = get_lock_path(path)
|
| 315 |
+
try:
|
| 316 |
+
if not lock.is_file():
|
| 317 |
+
return True
|
| 318 |
+
except OSError:
|
| 319 |
+
# we might not have access to the lock file at all, in this case assume
|
| 320 |
+
# we don't have access to the entire directory (#7491).
|
| 321 |
+
return False
|
| 322 |
+
try:
|
| 323 |
+
lock_time = lock.stat().st_mtime
|
| 324 |
+
except Exception:
|
| 325 |
+
return False
|
| 326 |
+
else:
|
| 327 |
+
if lock_time < consider_lock_dead_if_created_before:
|
| 328 |
+
# We want to ignore any errors while trying to remove the lock such as:
|
| 329 |
+
# - PermissionDenied, like the file permissions have changed since the lock creation;
|
| 330 |
+
# - FileNotFoundError, in case another pytest process got here first;
|
| 331 |
+
# and any other cause of failure.
|
| 332 |
+
with contextlib.suppress(OSError):
|
| 333 |
+
lock.unlink()
|
| 334 |
+
return True
|
| 335 |
+
return False
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def try_cleanup(path: Path, consider_lock_dead_if_created_before: float) -> None:
|
| 339 |
+
"""Try to cleanup a folder if we can ensure it's deletable."""
|
| 340 |
+
if ensure_deletable(path, consider_lock_dead_if_created_before):
|
| 341 |
+
maybe_delete_a_numbered_dir(path)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def cleanup_candidates(root: Path, prefix: str, keep: int) -> Iterator[Path]:
|
| 345 |
+
"""List candidates for numbered directories to be removed - follows py.path."""
|
| 346 |
+
max_existing = max(map(parse_num, find_suffixes(root, prefix)), default=-1)
|
| 347 |
+
max_delete = max_existing - keep
|
| 348 |
+
entries = find_prefixed(root, prefix)
|
| 349 |
+
entries, entries2 = itertools.tee(entries)
|
| 350 |
+
numbers = map(parse_num, extract_suffixes(entries2, prefix))
|
| 351 |
+
for entry, number in zip(entries, numbers):
|
| 352 |
+
if number <= max_delete:
|
| 353 |
+
yield Path(entry)
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def cleanup_dead_symlinks(root: Path) -> None:
|
| 357 |
+
for left_dir in root.iterdir():
|
| 358 |
+
if left_dir.is_symlink():
|
| 359 |
+
if not left_dir.resolve().exists():
|
| 360 |
+
left_dir.unlink()
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def cleanup_numbered_dir(
|
| 364 |
+
root: Path, prefix: str, keep: int, consider_lock_dead_if_created_before: float
|
| 365 |
+
) -> None:
|
| 366 |
+
"""Cleanup for lock driven numbered directories."""
|
| 367 |
+
if not root.exists():
|
| 368 |
+
return
|
| 369 |
+
for path in cleanup_candidates(root, prefix, keep):
|
| 370 |
+
try_cleanup(path, consider_lock_dead_if_created_before)
|
| 371 |
+
for path in root.glob("garbage-*"):
|
| 372 |
+
try_cleanup(path, consider_lock_dead_if_created_before)
|
| 373 |
+
|
| 374 |
+
cleanup_dead_symlinks(root)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def make_numbered_dir_with_cleanup(
|
| 378 |
+
root: Path,
|
| 379 |
+
prefix: str,
|
| 380 |
+
keep: int,
|
| 381 |
+
lock_timeout: float,
|
| 382 |
+
mode: int,
|
| 383 |
+
) -> Path:
|
| 384 |
+
"""Create a numbered dir with a cleanup lock and remove old ones."""
|
| 385 |
+
e = None
|
| 386 |
+
for i in range(10):
|
| 387 |
+
try:
|
| 388 |
+
p = make_numbered_dir(root, prefix, mode)
|
| 389 |
+
# Only lock the current dir when keep is not 0
|
| 390 |
+
if keep != 0:
|
| 391 |
+
lock_path = create_cleanup_lock(p)
|
| 392 |
+
register_cleanup_lock_removal(lock_path)
|
| 393 |
+
except Exception as exc:
|
| 394 |
+
e = exc
|
| 395 |
+
else:
|
| 396 |
+
consider_lock_dead_if_created_before = p.stat().st_mtime - lock_timeout
|
| 397 |
+
# Register a cleanup for program exit
|
| 398 |
+
atexit.register(
|
| 399 |
+
cleanup_numbered_dir,
|
| 400 |
+
root,
|
| 401 |
+
prefix,
|
| 402 |
+
keep,
|
| 403 |
+
consider_lock_dead_if_created_before,
|
| 404 |
+
)
|
| 405 |
+
return p
|
| 406 |
+
assert e is not None
|
| 407 |
+
raise e
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def resolve_from_str(input: str, rootpath: Path) -> Path:
|
| 411 |
+
input = expanduser(input)
|
| 412 |
+
input = expandvars(input)
|
| 413 |
+
if isabs(input):
|
| 414 |
+
return Path(input)
|
| 415 |
+
else:
|
| 416 |
+
return rootpath.joinpath(input)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def fnmatch_ex(pattern: str, path: str | os.PathLike[str]) -> bool:
|
| 420 |
+
"""A port of FNMatcher from py.path.common which works with PurePath() instances.
|
| 421 |
+
|
| 422 |
+
The difference between this algorithm and PurePath.match() is that the
|
| 423 |
+
latter matches "**" glob expressions for each part of the path, while
|
| 424 |
+
this algorithm uses the whole path instead.
|
| 425 |
+
|
| 426 |
+
For example:
|
| 427 |
+
"tests/foo/bar/doc/test_foo.py" matches pattern "tests/**/doc/test*.py"
|
| 428 |
+
with this algorithm, but not with PurePath.match().
|
| 429 |
+
|
| 430 |
+
This algorithm was ported to keep backward-compatibility with existing
|
| 431 |
+
settings which assume paths match according this logic.
|
| 432 |
+
|
| 433 |
+
References:
|
| 434 |
+
* https://bugs.python.org/issue29249
|
| 435 |
+
* https://bugs.python.org/issue34731
|
| 436 |
+
"""
|
| 437 |
+
path = PurePath(path)
|
| 438 |
+
iswin32 = sys.platform.startswith("win")
|
| 439 |
+
|
| 440 |
+
if iswin32 and sep not in pattern and posix_sep in pattern:
|
| 441 |
+
# Running on Windows, the pattern has no Windows path separators,
|
| 442 |
+
# and the pattern has one or more Posix path separators. Replace
|
| 443 |
+
# the Posix path separators with the Windows path separator.
|
| 444 |
+
pattern = pattern.replace(posix_sep, sep)
|
| 445 |
+
|
| 446 |
+
if sep not in pattern:
|
| 447 |
+
name = path.name
|
| 448 |
+
else:
|
| 449 |
+
name = str(path)
|
| 450 |
+
if path.is_absolute() and not os.path.isabs(pattern):
|
| 451 |
+
pattern = f"*{os.sep}{pattern}"
|
| 452 |
+
return fnmatch.fnmatch(name, pattern)
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
def parts(s: str) -> set[str]:
|
| 456 |
+
parts = s.split(sep)
|
| 457 |
+
return {sep.join(parts[: i + 1]) or sep for i in range(len(parts))}
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
def symlink_or_skip(
|
| 461 |
+
src: os.PathLike[str] | str,
|
| 462 |
+
dst: os.PathLike[str] | str,
|
| 463 |
+
**kwargs: Any,
|
| 464 |
+
) -> None:
|
| 465 |
+
"""Make a symlink, or skip the test in case symlinks are not supported."""
|
| 466 |
+
try:
|
| 467 |
+
os.symlink(src, dst, **kwargs)
|
| 468 |
+
except OSError as e:
|
| 469 |
+
skip(f"symlinks not supported: {e}")
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
class ImportMode(Enum):
|
| 473 |
+
"""Possible values for `mode` parameter of `import_path`."""
|
| 474 |
+
|
| 475 |
+
prepend = "prepend"
|
| 476 |
+
append = "append"
|
| 477 |
+
importlib = "importlib"
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
class ImportPathMismatchError(ImportError):
|
| 481 |
+
"""Raised on import_path() if there is a mismatch of __file__'s.
|
| 482 |
+
|
| 483 |
+
This can happen when `import_path` is called multiple times with different filenames that has
|
| 484 |
+
the same basename but reside in packages
|
| 485 |
+
(for example "/tests1/test_foo.py" and "/tests2/test_foo.py").
|
| 486 |
+
"""
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def import_path(
|
| 490 |
+
path: str | os.PathLike[str],
|
| 491 |
+
*,
|
| 492 |
+
mode: str | ImportMode = ImportMode.prepend,
|
| 493 |
+
root: Path,
|
| 494 |
+
consider_namespace_packages: bool,
|
| 495 |
+
) -> ModuleType:
|
| 496 |
+
"""
|
| 497 |
+
Import and return a module from the given path, which can be a file (a module) or
|
| 498 |
+
a directory (a package).
|
| 499 |
+
|
| 500 |
+
:param path:
|
| 501 |
+
Path to the file to import.
|
| 502 |
+
|
| 503 |
+
:param mode:
|
| 504 |
+
Controls the underlying import mechanism that will be used:
|
| 505 |
+
|
| 506 |
+
* ImportMode.prepend: the directory containing the module (or package, taking
|
| 507 |
+
`__init__.py` files into account) will be put at the *start* of `sys.path` before
|
| 508 |
+
being imported with `importlib.import_module`.
|
| 509 |
+
|
| 510 |
+
* ImportMode.append: same as `prepend`, but the directory will be appended
|
| 511 |
+
to the end of `sys.path`, if not already in `sys.path`.
|
| 512 |
+
|
| 513 |
+
* ImportMode.importlib: uses more fine control mechanisms provided by `importlib`
|
| 514 |
+
to import the module, which avoids having to muck with `sys.path` at all. It effectively
|
| 515 |
+
allows having same-named test modules in different places.
|
| 516 |
+
|
| 517 |
+
:param root:
|
| 518 |
+
Used as an anchor when mode == ImportMode.importlib to obtain
|
| 519 |
+
a unique name for the module being imported so it can safely be stored
|
| 520 |
+
into ``sys.modules``.
|
| 521 |
+
|
| 522 |
+
:param consider_namespace_packages:
|
| 523 |
+
If True, consider namespace packages when resolving module names.
|
| 524 |
+
|
| 525 |
+
:raises ImportPathMismatchError:
|
| 526 |
+
If after importing the given `path` and the module `__file__`
|
| 527 |
+
are different. Only raised in `prepend` and `append` modes.
|
| 528 |
+
"""
|
| 529 |
+
path = Path(path)
|
| 530 |
+
mode = ImportMode(mode)
|
| 531 |
+
|
| 532 |
+
if not path.exists():
|
| 533 |
+
raise ImportError(path)
|
| 534 |
+
|
| 535 |
+
if mode is ImportMode.importlib:
|
| 536 |
+
# Try to import this module using the standard import mechanisms, but
|
| 537 |
+
# without touching sys.path.
|
| 538 |
+
try:
|
| 539 |
+
pkg_root, module_name = resolve_pkg_root_and_module_name(
|
| 540 |
+
path, consider_namespace_packages=consider_namespace_packages
|
| 541 |
+
)
|
| 542 |
+
except CouldNotResolvePathError:
|
| 543 |
+
pass
|
| 544 |
+
else:
|
| 545 |
+
# If the given module name is already in sys.modules, do not import it again.
|
| 546 |
+
with contextlib.suppress(KeyError):
|
| 547 |
+
return sys.modules[module_name]
|
| 548 |
+
|
| 549 |
+
mod = _import_module_using_spec(
|
| 550 |
+
module_name, path, pkg_root, insert_modules=False
|
| 551 |
+
)
|
| 552 |
+
if mod is not None:
|
| 553 |
+
return mod
|
| 554 |
+
|
| 555 |
+
# Could not import the module with the current sys.path, so we fall back
|
| 556 |
+
# to importing the file as a single module, not being a part of a package.
|
| 557 |
+
module_name = module_name_from_path(path, root)
|
| 558 |
+
with contextlib.suppress(KeyError):
|
| 559 |
+
return sys.modules[module_name]
|
| 560 |
+
|
| 561 |
+
mod = _import_module_using_spec(
|
| 562 |
+
module_name, path, path.parent, insert_modules=True
|
| 563 |
+
)
|
| 564 |
+
if mod is None:
|
| 565 |
+
raise ImportError(f"Can't find module {module_name} at location {path}")
|
| 566 |
+
return mod
|
| 567 |
+
|
| 568 |
+
try:
|
| 569 |
+
pkg_root, module_name = resolve_pkg_root_and_module_name(
|
| 570 |
+
path, consider_namespace_packages=consider_namespace_packages
|
| 571 |
+
)
|
| 572 |
+
except CouldNotResolvePathError:
|
| 573 |
+
pkg_root, module_name = path.parent, path.stem
|
| 574 |
+
|
| 575 |
+
# Change sys.path permanently: restoring it at the end of this function would cause surprising
|
| 576 |
+
# problems because of delayed imports: for example, a conftest.py file imported by this function
|
| 577 |
+
# might have local imports, which would fail at runtime if we restored sys.path.
|
| 578 |
+
if mode is ImportMode.append:
|
| 579 |
+
if str(pkg_root) not in sys.path:
|
| 580 |
+
sys.path.append(str(pkg_root))
|
| 581 |
+
elif mode is ImportMode.prepend:
|
| 582 |
+
if str(pkg_root) != sys.path[0]:
|
| 583 |
+
sys.path.insert(0, str(pkg_root))
|
| 584 |
+
else:
|
| 585 |
+
assert_never(mode)
|
| 586 |
+
|
| 587 |
+
importlib.import_module(module_name)
|
| 588 |
+
|
| 589 |
+
mod = sys.modules[module_name]
|
| 590 |
+
if path.name == "__init__.py":
|
| 591 |
+
return mod
|
| 592 |
+
|
| 593 |
+
ignore = os.environ.get("PY_IGNORE_IMPORTMISMATCH", "")
|
| 594 |
+
if ignore != "1":
|
| 595 |
+
module_file = mod.__file__
|
| 596 |
+
if module_file is None:
|
| 597 |
+
raise ImportPathMismatchError(module_name, module_file, path)
|
| 598 |
+
|
| 599 |
+
if module_file.endswith((".pyc", ".pyo")):
|
| 600 |
+
module_file = module_file[:-1]
|
| 601 |
+
if module_file.endswith(os.sep + "__init__.py"):
|
| 602 |
+
module_file = module_file[: -(len(os.sep + "__init__.py"))]
|
| 603 |
+
|
| 604 |
+
try:
|
| 605 |
+
is_same = _is_same(str(path), module_file)
|
| 606 |
+
except FileNotFoundError:
|
| 607 |
+
is_same = False
|
| 608 |
+
|
| 609 |
+
if not is_same:
|
| 610 |
+
raise ImportPathMismatchError(module_name, module_file, path)
|
| 611 |
+
|
| 612 |
+
return mod
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _import_module_using_spec(
|
| 616 |
+
module_name: str, module_path: Path, module_location: Path, *, insert_modules: bool
|
| 617 |
+
) -> ModuleType | None:
|
| 618 |
+
"""
|
| 619 |
+
Tries to import a module by its canonical name, path, and its parent location.
|
| 620 |
+
|
| 621 |
+
:param module_name:
|
| 622 |
+
The expected module name, will become the key of `sys.modules`.
|
| 623 |
+
|
| 624 |
+
:param module_path:
|
| 625 |
+
The file path of the module, for example `/foo/bar/test_demo.py`.
|
| 626 |
+
If module is a package, pass the path to the `__init__.py` of the package.
|
| 627 |
+
If module is a namespace package, pass directory path.
|
| 628 |
+
|
| 629 |
+
:param module_location:
|
| 630 |
+
The parent location of the module.
|
| 631 |
+
If module is a package, pass the directory containing the `__init__.py` file.
|
| 632 |
+
|
| 633 |
+
:param insert_modules:
|
| 634 |
+
If True, will call `insert_missing_modules` to create empty intermediate modules
|
| 635 |
+
with made-up module names (when importing test files not reachable from `sys.path`).
|
| 636 |
+
|
| 637 |
+
Example 1 of parent_module_*:
|
| 638 |
+
|
| 639 |
+
module_name: "a.b.c.demo"
|
| 640 |
+
module_path: Path("a/b/c/demo.py")
|
| 641 |
+
module_location: Path("a/b/c/")
|
| 642 |
+
if "a.b.c" is package ("a/b/c/__init__.py" exists), then
|
| 643 |
+
parent_module_name: "a.b.c"
|
| 644 |
+
parent_module_path: Path("a/b/c/__init__.py")
|
| 645 |
+
parent_module_location: Path("a/b/c/")
|
| 646 |
+
else:
|
| 647 |
+
parent_module_name: "a.b.c"
|
| 648 |
+
parent_module_path: Path("a/b/c")
|
| 649 |
+
parent_module_location: Path("a/b/")
|
| 650 |
+
|
| 651 |
+
Example 2 of parent_module_*:
|
| 652 |
+
|
| 653 |
+
module_name: "a.b.c"
|
| 654 |
+
module_path: Path("a/b/c/__init__.py")
|
| 655 |
+
module_location: Path("a/b/c/")
|
| 656 |
+
if "a.b" is package ("a/b/__init__.py" exists), then
|
| 657 |
+
parent_module_name: "a.b"
|
| 658 |
+
parent_module_path: Path("a/b/__init__.py")
|
| 659 |
+
parent_module_location: Path("a/b/")
|
| 660 |
+
else:
|
| 661 |
+
parent_module_name: "a.b"
|
| 662 |
+
parent_module_path: Path("a/b/")
|
| 663 |
+
parent_module_location: Path("a/")
|
| 664 |
+
"""
|
| 665 |
+
# Attempt to import the parent module, seems is our responsibility:
|
| 666 |
+
# https://github.com/python/cpython/blob/73906d5c908c1e0b73c5436faeff7d93698fc074/Lib/importlib/_bootstrap.py#L1308-L1311
|
| 667 |
+
parent_module_name, _, name = module_name.rpartition(".")
|
| 668 |
+
parent_module: ModuleType | None = None
|
| 669 |
+
if parent_module_name:
|
| 670 |
+
parent_module = sys.modules.get(parent_module_name)
|
| 671 |
+
if parent_module is None:
|
| 672 |
+
# Get parent_location based on location, get parent_path based on path.
|
| 673 |
+
if module_path.name == "__init__.py":
|
| 674 |
+
# If the current module is in a package,
|
| 675 |
+
# need to leave the package first and then enter the parent module.
|
| 676 |
+
parent_module_path = module_path.parent.parent
|
| 677 |
+
else:
|
| 678 |
+
parent_module_path = module_path.parent
|
| 679 |
+
|
| 680 |
+
if (parent_module_path / "__init__.py").is_file():
|
| 681 |
+
# If the parent module is a package, loading by __init__.py file.
|
| 682 |
+
parent_module_path = parent_module_path / "__init__.py"
|
| 683 |
+
|
| 684 |
+
parent_module = _import_module_using_spec(
|
| 685 |
+
parent_module_name,
|
| 686 |
+
parent_module_path,
|
| 687 |
+
parent_module_path.parent,
|
| 688 |
+
insert_modules=insert_modules,
|
| 689 |
+
)
|
| 690 |
+
|
| 691 |
+
# Checking with sys.meta_path first in case one of its hooks can import this module,
|
| 692 |
+
# such as our own assertion-rewrite hook.
|
| 693 |
+
for meta_importer in sys.meta_path:
|
| 694 |
+
spec = meta_importer.find_spec(
|
| 695 |
+
module_name, [str(module_location), str(module_path)]
|
| 696 |
+
)
|
| 697 |
+
if spec_matches_module_path(spec, module_path):
|
| 698 |
+
break
|
| 699 |
+
else:
|
| 700 |
+
loader = None
|
| 701 |
+
if module_path.is_dir():
|
| 702 |
+
# The `spec_from_file_location` matches a loader based on the file extension by default.
|
| 703 |
+
# For a namespace package, need to manually specify a loader.
|
| 704 |
+
loader = NamespaceLoader(name, module_path, PathFinder())
|
| 705 |
+
|
| 706 |
+
spec = importlib.util.spec_from_file_location(
|
| 707 |
+
module_name, str(module_path), loader=loader
|
| 708 |
+
)
|
| 709 |
+
|
| 710 |
+
if spec_matches_module_path(spec, module_path):
|
| 711 |
+
assert spec is not None
|
| 712 |
+
# Find spec and import this module.
|
| 713 |
+
mod = importlib.util.module_from_spec(spec)
|
| 714 |
+
sys.modules[module_name] = mod
|
| 715 |
+
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
| 716 |
+
|
| 717 |
+
# Set this module as an attribute of the parent module (#12194).
|
| 718 |
+
if parent_module is not None:
|
| 719 |
+
setattr(parent_module, name, mod)
|
| 720 |
+
|
| 721 |
+
if insert_modules:
|
| 722 |
+
insert_missing_modules(sys.modules, module_name)
|
| 723 |
+
return mod
|
| 724 |
+
|
| 725 |
+
return None
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def spec_matches_module_path(module_spec: ModuleSpec | None, module_path: Path) -> bool:
|
| 729 |
+
"""Return true if the given ModuleSpec can be used to import the given module path."""
|
| 730 |
+
if module_spec is None:
|
| 731 |
+
return False
|
| 732 |
+
|
| 733 |
+
if module_spec.origin:
|
| 734 |
+
return Path(module_spec.origin) == module_path
|
| 735 |
+
|
| 736 |
+
# Compare the path with the `module_spec.submodule_Search_Locations` in case
|
| 737 |
+
# the module is part of a namespace package.
|
| 738 |
+
# https://docs.python.org/3/library/importlib.html#importlib.machinery.ModuleSpec.submodule_search_locations
|
| 739 |
+
if module_spec.submodule_search_locations: # can be None.
|
| 740 |
+
for path in module_spec.submodule_search_locations:
|
| 741 |
+
if Path(path) == module_path:
|
| 742 |
+
return True
|
| 743 |
+
|
| 744 |
+
return False
|
| 745 |
+
|
| 746 |
+
|
| 747 |
+
# Implement a special _is_same function on Windows which returns True if the two filenames
|
| 748 |
+
# compare equal, to circumvent os.path.samefile returning False for mounts in UNC (#7678).
|
| 749 |
+
if sys.platform.startswith("win"):
|
| 750 |
+
|
| 751 |
+
def _is_same(f1: str, f2: str) -> bool:
|
| 752 |
+
return Path(f1) == Path(f2) or os.path.samefile(f1, f2)
|
| 753 |
+
|
| 754 |
+
else:
|
| 755 |
+
|
| 756 |
+
def _is_same(f1: str, f2: str) -> bool:
|
| 757 |
+
return os.path.samefile(f1, f2)
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
def module_name_from_path(path: Path, root: Path) -> str:
|
| 761 |
+
"""
|
| 762 |
+
Return a dotted module name based on the given path, anchored on root.
|
| 763 |
+
|
| 764 |
+
For example: path="projects/src/tests/test_foo.py" and root="/projects", the
|
| 765 |
+
resulting module name will be "src.tests.test_foo".
|
| 766 |
+
"""
|
| 767 |
+
path = path.with_suffix("")
|
| 768 |
+
try:
|
| 769 |
+
relative_path = path.relative_to(root)
|
| 770 |
+
except ValueError:
|
| 771 |
+
# If we can't get a relative path to root, use the full path, except
|
| 772 |
+
# for the first part ("d:\\" or "/" depending on the platform, for example).
|
| 773 |
+
path_parts = path.parts[1:]
|
| 774 |
+
else:
|
| 775 |
+
# Use the parts for the relative path to the root path.
|
| 776 |
+
path_parts = relative_path.parts
|
| 777 |
+
|
| 778 |
+
# Module name for packages do not contain the __init__ file, unless
|
| 779 |
+
# the `__init__.py` file is at the root.
|
| 780 |
+
if len(path_parts) >= 2 and path_parts[-1] == "__init__":
|
| 781 |
+
path_parts = path_parts[:-1]
|
| 782 |
+
|
| 783 |
+
# Module names cannot contain ".", normalize them to "_". This prevents
|
| 784 |
+
# a directory having a "." in the name (".env.310" for example) causing extra intermediate modules.
|
| 785 |
+
# Also, important to replace "." at the start of paths, as those are considered relative imports.
|
| 786 |
+
path_parts = tuple(x.replace(".", "_") for x in path_parts)
|
| 787 |
+
|
| 788 |
+
return ".".join(path_parts)
|
| 789 |
+
|
| 790 |
+
|
| 791 |
+
def insert_missing_modules(modules: dict[str, ModuleType], module_name: str) -> None:
|
| 792 |
+
"""
|
| 793 |
+
Used by ``import_path`` to create intermediate modules when using mode=importlib.
|
| 794 |
+
|
| 795 |
+
When we want to import a module as "src.tests.test_foo" for example, we need
|
| 796 |
+
to create empty modules "src" and "src.tests" after inserting "src.tests.test_foo",
|
| 797 |
+
otherwise "src.tests.test_foo" is not importable by ``__import__``.
|
| 798 |
+
"""
|
| 799 |
+
module_parts = module_name.split(".")
|
| 800 |
+
while module_name:
|
| 801 |
+
parent_module_name, _, child_name = module_name.rpartition(".")
|
| 802 |
+
if parent_module_name:
|
| 803 |
+
parent_module = modules.get(parent_module_name)
|
| 804 |
+
if parent_module is None:
|
| 805 |
+
try:
|
| 806 |
+
# If sys.meta_path is empty, calling import_module will issue
|
| 807 |
+
# a warning and raise ModuleNotFoundError. To avoid the
|
| 808 |
+
# warning, we check sys.meta_path explicitly and raise the error
|
| 809 |
+
# ourselves to fall back to creating a dummy module.
|
| 810 |
+
if not sys.meta_path:
|
| 811 |
+
raise ModuleNotFoundError
|
| 812 |
+
parent_module = importlib.import_module(parent_module_name)
|
| 813 |
+
except ModuleNotFoundError:
|
| 814 |
+
parent_module = ModuleType(
|
| 815 |
+
module_name,
|
| 816 |
+
doc="Empty module created by pytest's importmode=importlib.",
|
| 817 |
+
)
|
| 818 |
+
modules[parent_module_name] = parent_module
|
| 819 |
+
|
| 820 |
+
# Add child attribute to the parent that can reference the child
|
| 821 |
+
# modules.
|
| 822 |
+
if not hasattr(parent_module, child_name):
|
| 823 |
+
setattr(parent_module, child_name, modules[module_name])
|
| 824 |
+
|
| 825 |
+
module_parts.pop(-1)
|
| 826 |
+
module_name = ".".join(module_parts)
|
| 827 |
+
|
| 828 |
+
|
| 829 |
+
def resolve_package_path(path: Path) -> Path | None:
|
| 830 |
+
"""Return the Python package path by looking for the last
|
| 831 |
+
directory upwards which still contains an __init__.py.
|
| 832 |
+
|
| 833 |
+
Returns None if it cannot be determined.
|
| 834 |
+
"""
|
| 835 |
+
result = None
|
| 836 |
+
for parent in itertools.chain((path,), path.parents):
|
| 837 |
+
if parent.is_dir():
|
| 838 |
+
if not (parent / "__init__.py").is_file():
|
| 839 |
+
break
|
| 840 |
+
if not parent.name.isidentifier():
|
| 841 |
+
break
|
| 842 |
+
result = parent
|
| 843 |
+
return result
|
| 844 |
+
|
| 845 |
+
|
| 846 |
+
def resolve_pkg_root_and_module_name(
|
| 847 |
+
path: Path, *, consider_namespace_packages: bool = False
|
| 848 |
+
) -> tuple[Path, str]:
|
| 849 |
+
"""
|
| 850 |
+
Return the path to the directory of the root package that contains the
|
| 851 |
+
given Python file, and its module name:
|
| 852 |
+
|
| 853 |
+
src/
|
| 854 |
+
app/
|
| 855 |
+
__init__.py
|
| 856 |
+
core/
|
| 857 |
+
__init__.py
|
| 858 |
+
models.py
|
| 859 |
+
|
| 860 |
+
Passing the full path to `models.py` will yield Path("src") and "app.core.models".
|
| 861 |
+
|
| 862 |
+
If consider_namespace_packages is True, then we additionally check upwards in the hierarchy
|
| 863 |
+
for namespace packages:
|
| 864 |
+
|
| 865 |
+
https://packaging.python.org/en/latest/guides/packaging-namespace-packages
|
| 866 |
+
|
| 867 |
+
Raises CouldNotResolvePathError if the given path does not belong to a package (missing any __init__.py files).
|
| 868 |
+
"""
|
| 869 |
+
pkg_root: Path | None = None
|
| 870 |
+
pkg_path = resolve_package_path(path)
|
| 871 |
+
if pkg_path is not None:
|
| 872 |
+
pkg_root = pkg_path.parent
|
| 873 |
+
if consider_namespace_packages:
|
| 874 |
+
start = pkg_root if pkg_root is not None else path.parent
|
| 875 |
+
for candidate in (start, *start.parents):
|
| 876 |
+
module_name = compute_module_name(candidate, path)
|
| 877 |
+
if module_name and is_importable(module_name, path):
|
| 878 |
+
# Point the pkg_root to the root of the namespace package.
|
| 879 |
+
pkg_root = candidate
|
| 880 |
+
break
|
| 881 |
+
|
| 882 |
+
if pkg_root is not None:
|
| 883 |
+
module_name = compute_module_name(pkg_root, path)
|
| 884 |
+
if module_name:
|
| 885 |
+
return pkg_root, module_name
|
| 886 |
+
|
| 887 |
+
raise CouldNotResolvePathError(f"Could not resolve for {path}")
|
| 888 |
+
|
| 889 |
+
|
| 890 |
+
def is_importable(module_name: str, module_path: Path) -> bool:
|
| 891 |
+
"""
|
| 892 |
+
Return if the given module path could be imported normally by Python, akin to the user
|
| 893 |
+
entering the REPL and importing the corresponding module name directly, and corresponds
|
| 894 |
+
to the module_path specified.
|
| 895 |
+
|
| 896 |
+
:param module_name:
|
| 897 |
+
Full module name that we want to check if is importable.
|
| 898 |
+
For example, "app.models".
|
| 899 |
+
|
| 900 |
+
:param module_path:
|
| 901 |
+
Full path to the python module/package we want to check if is importable.
|
| 902 |
+
For example, "/projects/src/app/models.py".
|
| 903 |
+
"""
|
| 904 |
+
try:
|
| 905 |
+
# Note this is different from what we do in ``_import_module_using_spec``, where we explicitly search through
|
| 906 |
+
# sys.meta_path to be able to pass the path of the module that we want to import (``meta_importer.find_spec``).
|
| 907 |
+
# Using importlib.util.find_spec() is different, it gives the same results as trying to import
|
| 908 |
+
# the module normally in the REPL.
|
| 909 |
+
spec = importlib.util.find_spec(module_name)
|
| 910 |
+
except (ImportError, ValueError, ImportWarning):
|
| 911 |
+
return False
|
| 912 |
+
else:
|
| 913 |
+
return spec_matches_module_path(spec, module_path)
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
def compute_module_name(root: Path, module_path: Path) -> str | None:
|
| 917 |
+
"""Compute a module name based on a path and a root anchor."""
|
| 918 |
+
try:
|
| 919 |
+
path_without_suffix = module_path.with_suffix("")
|
| 920 |
+
except ValueError:
|
| 921 |
+
# Empty paths (such as Path.cwd()) might break meta_path hooks (like our own assertion rewriter).
|
| 922 |
+
return None
|
| 923 |
+
|
| 924 |
+
try:
|
| 925 |
+
relative = path_without_suffix.relative_to(root)
|
| 926 |
+
except ValueError: # pragma: no cover
|
| 927 |
+
return None
|
| 928 |
+
names = list(relative.parts)
|
| 929 |
+
if not names:
|
| 930 |
+
return None
|
| 931 |
+
if names[-1] == "__init__":
|
| 932 |
+
names.pop()
|
| 933 |
+
return ".".join(names)
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
class CouldNotResolvePathError(Exception):
|
| 937 |
+
"""Custom exception raised by resolve_pkg_root_and_module_name."""
|
| 938 |
+
|
| 939 |
+
|
| 940 |
+
def scandir(
|
| 941 |
+
path: str | os.PathLike[str],
|
| 942 |
+
sort_key: Callable[[os.DirEntry[str]], object] = lambda entry: entry.name,
|
| 943 |
+
) -> list[os.DirEntry[str]]:
|
| 944 |
+
"""Scan a directory recursively, in breadth-first order.
|
| 945 |
+
|
| 946 |
+
The returned entries are sorted according to the given key.
|
| 947 |
+
The default is to sort by name.
|
| 948 |
+
"""
|
| 949 |
+
entries = []
|
| 950 |
+
with os.scandir(path) as s:
|
| 951 |
+
# Skip entries with symlink loops and other brokenness, so the caller
|
| 952 |
+
# doesn't have to deal with it.
|
| 953 |
+
for entry in s:
|
| 954 |
+
try:
|
| 955 |
+
entry.is_file()
|
| 956 |
+
except OSError as err:
|
| 957 |
+
if _ignore_error(err):
|
| 958 |
+
continue
|
| 959 |
+
raise
|
| 960 |
+
entries.append(entry)
|
| 961 |
+
entries.sort(key=sort_key) # type: ignore[arg-type]
|
| 962 |
+
return entries
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
def visit(
|
| 966 |
+
path: str | os.PathLike[str], recurse: Callable[[os.DirEntry[str]], bool]
|
| 967 |
+
) -> Iterator[os.DirEntry[str]]:
|
| 968 |
+
"""Walk a directory recursively, in breadth-first order.
|
| 969 |
+
|
| 970 |
+
The `recurse` predicate determines whether a directory is recursed.
|
| 971 |
+
|
| 972 |
+
Entries at each directory level are sorted.
|
| 973 |
+
"""
|
| 974 |
+
entries = scandir(path)
|
| 975 |
+
yield from entries
|
| 976 |
+
for entry in entries:
|
| 977 |
+
if entry.is_dir() and recurse(entry):
|
| 978 |
+
yield from visit(entry.path, recurse)
|
| 979 |
+
|
| 980 |
+
|
| 981 |
+
def absolutepath(path: str | os.PathLike[str]) -> Path:
|
| 982 |
+
"""Convert a path to an absolute path using os.path.abspath.
|
| 983 |
+
|
| 984 |
+
Prefer this over Path.resolve() (see #6523).
|
| 985 |
+
Prefer this over Path.absolute() (not public, doesn't normalize).
|
| 986 |
+
"""
|
| 987 |
+
return Path(os.path.abspath(path))
|
| 988 |
+
|
| 989 |
+
|
| 990 |
+
def commonpath(path1: Path, path2: Path) -> Path | None:
|
| 991 |
+
"""Return the common part shared with the other path, or None if there is
|
| 992 |
+
no common part.
|
| 993 |
+
|
| 994 |
+
If one path is relative and one is absolute, returns None.
|
| 995 |
+
"""
|
| 996 |
+
try:
|
| 997 |
+
return Path(os.path.commonpath((str(path1), str(path2))))
|
| 998 |
+
except ValueError:
|
| 999 |
+
return None
|
| 1000 |
+
|
| 1001 |
+
|
| 1002 |
+
def bestrelpath(directory: Path, dest: Path) -> str:
|
| 1003 |
+
"""Return a string which is a relative path from directory to dest such
|
| 1004 |
+
that directory/bestrelpath == dest.
|
| 1005 |
+
|
| 1006 |
+
The paths must be either both absolute or both relative.
|
| 1007 |
+
|
| 1008 |
+
If no such path can be determined, returns dest.
|
| 1009 |
+
"""
|
| 1010 |
+
assert isinstance(directory, Path)
|
| 1011 |
+
assert isinstance(dest, Path)
|
| 1012 |
+
if dest == directory:
|
| 1013 |
+
return os.curdir
|
| 1014 |
+
# Find the longest common directory.
|
| 1015 |
+
base = commonpath(directory, dest)
|
| 1016 |
+
# Can be the case on Windows for two absolute paths on different drives.
|
| 1017 |
+
# Can be the case for two relative paths without common prefix.
|
| 1018 |
+
# Can be the case for a relative path and an absolute path.
|
| 1019 |
+
if not base:
|
| 1020 |
+
return str(dest)
|
| 1021 |
+
reldirectory = directory.relative_to(base)
|
| 1022 |
+
reldest = dest.relative_to(base)
|
| 1023 |
+
return os.path.join(
|
| 1024 |
+
# Back from directory to base.
|
| 1025 |
+
*([os.pardir] * len(reldirectory.parts)),
|
| 1026 |
+
# Forward from base to dest.
|
| 1027 |
+
*reldest.parts,
|
| 1028 |
+
)
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
def safe_exists(p: Path) -> bool:
|
| 1032 |
+
"""Like Path.exists(), but account for input arguments that might be too long (#11394)."""
|
| 1033 |
+
try:
|
| 1034 |
+
return p.exists()
|
| 1035 |
+
except (ValueError, OSError):
|
| 1036 |
+
# ValueError: stat: path too long for Windows
|
| 1037 |
+
# OSError: [WinError 123] The filename, directory name, or volume label syntax is incorrect
|
| 1038 |
+
return False
|
.venv/lib/python3.11/site-packages/_pytest/py.typed
ADDED
|
File without changes
|
.venv/lib/python3.11/site-packages/_pytest/pytester.py
ADDED
|
@@ -0,0 +1,1766 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""(Disabled by default) support for testing pytest and pytest plugins.
|
| 3 |
+
|
| 4 |
+
PYTEST_DONT_REWRITE
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import collections.abc
|
| 10 |
+
import contextlib
|
| 11 |
+
from fnmatch import fnmatch
|
| 12 |
+
import gc
|
| 13 |
+
import importlib
|
| 14 |
+
from io import StringIO
|
| 15 |
+
import locale
|
| 16 |
+
import os
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
import platform
|
| 19 |
+
import re
|
| 20 |
+
import shutil
|
| 21 |
+
import subprocess
|
| 22 |
+
import sys
|
| 23 |
+
import traceback
|
| 24 |
+
from typing import Any
|
| 25 |
+
from typing import Callable
|
| 26 |
+
from typing import Final
|
| 27 |
+
from typing import final
|
| 28 |
+
from typing import Generator
|
| 29 |
+
from typing import IO
|
| 30 |
+
from typing import Iterable
|
| 31 |
+
from typing import Literal
|
| 32 |
+
from typing import overload
|
| 33 |
+
from typing import Sequence
|
| 34 |
+
from typing import TextIO
|
| 35 |
+
from typing import TYPE_CHECKING
|
| 36 |
+
from weakref import WeakKeyDictionary
|
| 37 |
+
|
| 38 |
+
from iniconfig import IniConfig
|
| 39 |
+
from iniconfig import SectionWrapper
|
| 40 |
+
|
| 41 |
+
from _pytest import timing
|
| 42 |
+
from _pytest._code import Source
|
| 43 |
+
from _pytest.capture import _get_multicapture
|
| 44 |
+
from _pytest.compat import NOTSET
|
| 45 |
+
from _pytest.compat import NotSetType
|
| 46 |
+
from _pytest.config import _PluggyPlugin
|
| 47 |
+
from _pytest.config import Config
|
| 48 |
+
from _pytest.config import ExitCode
|
| 49 |
+
from _pytest.config import hookimpl
|
| 50 |
+
from _pytest.config import main
|
| 51 |
+
from _pytest.config import PytestPluginManager
|
| 52 |
+
from _pytest.config.argparsing import Parser
|
| 53 |
+
from _pytest.deprecated import check_ispytest
|
| 54 |
+
from _pytest.fixtures import fixture
|
| 55 |
+
from _pytest.fixtures import FixtureRequest
|
| 56 |
+
from _pytest.main import Session
|
| 57 |
+
from _pytest.monkeypatch import MonkeyPatch
|
| 58 |
+
from _pytest.nodes import Collector
|
| 59 |
+
from _pytest.nodes import Item
|
| 60 |
+
from _pytest.outcomes import fail
|
| 61 |
+
from _pytest.outcomes import importorskip
|
| 62 |
+
from _pytest.outcomes import skip
|
| 63 |
+
from _pytest.pathlib import bestrelpath
|
| 64 |
+
from _pytest.pathlib import make_numbered_dir
|
| 65 |
+
from _pytest.reports import CollectReport
|
| 66 |
+
from _pytest.reports import TestReport
|
| 67 |
+
from _pytest.tmpdir import TempPathFactory
|
| 68 |
+
from _pytest.warning_types import PytestWarning
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if TYPE_CHECKING:
|
| 72 |
+
import pexpect
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
pytest_plugins = ["pytester_assertions"]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
IGNORE_PAM = [ # filenames added when obtaining details about the current user
|
| 79 |
+
"/var/lib/sss/mc/passwd"
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 84 |
+
parser.addoption(
|
| 85 |
+
"--lsof",
|
| 86 |
+
action="store_true",
|
| 87 |
+
dest="lsof",
|
| 88 |
+
default=False,
|
| 89 |
+
help="Run FD checks if lsof is available",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
parser.addoption(
|
| 93 |
+
"--runpytest",
|
| 94 |
+
default="inprocess",
|
| 95 |
+
dest="runpytest",
|
| 96 |
+
choices=("inprocess", "subprocess"),
|
| 97 |
+
help=(
|
| 98 |
+
"Run pytest sub runs in tests using an 'inprocess' "
|
| 99 |
+
"or 'subprocess' (python -m main) method"
|
| 100 |
+
),
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
parser.addini(
|
| 104 |
+
"pytester_example_dir", help="Directory to take the pytester example files from"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def pytest_configure(config: Config) -> None:
|
| 109 |
+
if config.getvalue("lsof"):
|
| 110 |
+
checker = LsofFdLeakChecker()
|
| 111 |
+
if checker.matching_platform():
|
| 112 |
+
config.pluginmanager.register(checker)
|
| 113 |
+
|
| 114 |
+
config.addinivalue_line(
|
| 115 |
+
"markers",
|
| 116 |
+
"pytester_example_path(*path_segments): join the given path "
|
| 117 |
+
"segments to `pytester_example_dir` for this test.",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
class LsofFdLeakChecker:
|
| 122 |
+
def get_open_files(self) -> list[tuple[str, str]]:
|
| 123 |
+
if sys.version_info >= (3, 11):
|
| 124 |
+
# New in Python 3.11, ignores utf-8 mode
|
| 125 |
+
encoding = locale.getencoding()
|
| 126 |
+
else:
|
| 127 |
+
encoding = locale.getpreferredencoding(False)
|
| 128 |
+
out = subprocess.run(
|
| 129 |
+
("lsof", "-Ffn0", "-p", str(os.getpid())),
|
| 130 |
+
stdout=subprocess.PIPE,
|
| 131 |
+
stderr=subprocess.DEVNULL,
|
| 132 |
+
check=True,
|
| 133 |
+
text=True,
|
| 134 |
+
encoding=encoding,
|
| 135 |
+
).stdout
|
| 136 |
+
|
| 137 |
+
def isopen(line: str) -> bool:
|
| 138 |
+
return line.startswith("f") and (
|
| 139 |
+
"deleted" not in line
|
| 140 |
+
and "mem" not in line
|
| 141 |
+
and "txt" not in line
|
| 142 |
+
and "cwd" not in line
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
open_files = []
|
| 146 |
+
|
| 147 |
+
for line in out.split("\n"):
|
| 148 |
+
if isopen(line):
|
| 149 |
+
fields = line.split("\0")
|
| 150 |
+
fd = fields[0][1:]
|
| 151 |
+
filename = fields[1][1:]
|
| 152 |
+
if filename in IGNORE_PAM:
|
| 153 |
+
continue
|
| 154 |
+
if filename.startswith("/"):
|
| 155 |
+
open_files.append((fd, filename))
|
| 156 |
+
|
| 157 |
+
return open_files
|
| 158 |
+
|
| 159 |
+
def matching_platform(self) -> bool:
|
| 160 |
+
try:
|
| 161 |
+
subprocess.run(("lsof", "-v"), check=True)
|
| 162 |
+
except (OSError, subprocess.CalledProcessError):
|
| 163 |
+
return False
|
| 164 |
+
else:
|
| 165 |
+
return True
|
| 166 |
+
|
| 167 |
+
@hookimpl(wrapper=True, tryfirst=True)
|
| 168 |
+
def pytest_runtest_protocol(self, item: Item) -> Generator[None, object, object]:
|
| 169 |
+
lines1 = self.get_open_files()
|
| 170 |
+
try:
|
| 171 |
+
return (yield)
|
| 172 |
+
finally:
|
| 173 |
+
if hasattr(sys, "pypy_version_info"):
|
| 174 |
+
gc.collect()
|
| 175 |
+
lines2 = self.get_open_files()
|
| 176 |
+
|
| 177 |
+
new_fds = {t[0] for t in lines2} - {t[0] for t in lines1}
|
| 178 |
+
leaked_files = [t for t in lines2 if t[0] in new_fds]
|
| 179 |
+
if leaked_files:
|
| 180 |
+
error = [
|
| 181 |
+
f"***** {len(leaked_files)} FD leakage detected",
|
| 182 |
+
*(str(f) for f in leaked_files),
|
| 183 |
+
"*** Before:",
|
| 184 |
+
*(str(f) for f in lines1),
|
| 185 |
+
"*** After:",
|
| 186 |
+
*(str(f) for f in lines2),
|
| 187 |
+
f"***** {len(leaked_files)} FD leakage detected",
|
| 188 |
+
"*** function {}:{}: {} ".format(*item.location),
|
| 189 |
+
"See issue #2366",
|
| 190 |
+
]
|
| 191 |
+
item.warn(PytestWarning("\n".join(error)))
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# used at least by pytest-xdist plugin
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
@fixture
|
| 198 |
+
def _pytest(request: FixtureRequest) -> PytestArg:
|
| 199 |
+
"""Return a helper which offers a gethookrecorder(hook) method which
|
| 200 |
+
returns a HookRecorder instance which helps to make assertions about called
|
| 201 |
+
hooks."""
|
| 202 |
+
return PytestArg(request)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class PytestArg:
|
| 206 |
+
def __init__(self, request: FixtureRequest) -> None:
|
| 207 |
+
self._request = request
|
| 208 |
+
|
| 209 |
+
def gethookrecorder(self, hook) -> HookRecorder:
|
| 210 |
+
hookrecorder = HookRecorder(hook._pm)
|
| 211 |
+
self._request.addfinalizer(hookrecorder.finish_recording)
|
| 212 |
+
return hookrecorder
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def get_public_names(values: Iterable[str]) -> list[str]:
|
| 216 |
+
"""Only return names from iterator values without a leading underscore."""
|
| 217 |
+
return [x for x in values if x[0] != "_"]
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@final
|
| 221 |
+
class RecordedHookCall:
|
| 222 |
+
"""A recorded call to a hook.
|
| 223 |
+
|
| 224 |
+
The arguments to the hook call are set as attributes.
|
| 225 |
+
For example:
|
| 226 |
+
|
| 227 |
+
.. code-block:: python
|
| 228 |
+
|
| 229 |
+
calls = hook_recorder.getcalls("pytest_runtest_setup")
|
| 230 |
+
# Suppose pytest_runtest_setup was called once with `item=an_item`.
|
| 231 |
+
assert calls[0].item is an_item
|
| 232 |
+
"""
|
| 233 |
+
|
| 234 |
+
def __init__(self, name: str, kwargs) -> None:
|
| 235 |
+
self.__dict__.update(kwargs)
|
| 236 |
+
self._name = name
|
| 237 |
+
|
| 238 |
+
def __repr__(self) -> str:
|
| 239 |
+
d = self.__dict__.copy()
|
| 240 |
+
del d["_name"]
|
| 241 |
+
return f"<RecordedHookCall {self._name!r}(**{d!r})>"
|
| 242 |
+
|
| 243 |
+
if TYPE_CHECKING:
|
| 244 |
+
# The class has undetermined attributes, this tells mypy about it.
|
| 245 |
+
def __getattr__(self, key: str): ...
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@final
|
| 249 |
+
class HookRecorder:
|
| 250 |
+
"""Record all hooks called in a plugin manager.
|
| 251 |
+
|
| 252 |
+
Hook recorders are created by :class:`Pytester`.
|
| 253 |
+
|
| 254 |
+
This wraps all the hook calls in the plugin manager, recording each call
|
| 255 |
+
before propagating the normal calls.
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
def __init__(
|
| 259 |
+
self, pluginmanager: PytestPluginManager, *, _ispytest: bool = False
|
| 260 |
+
) -> None:
|
| 261 |
+
check_ispytest(_ispytest)
|
| 262 |
+
|
| 263 |
+
self._pluginmanager = pluginmanager
|
| 264 |
+
self.calls: list[RecordedHookCall] = []
|
| 265 |
+
self.ret: int | ExitCode | None = None
|
| 266 |
+
|
| 267 |
+
def before(hook_name: str, hook_impls, kwargs) -> None:
|
| 268 |
+
self.calls.append(RecordedHookCall(hook_name, kwargs))
|
| 269 |
+
|
| 270 |
+
def after(outcome, hook_name: str, hook_impls, kwargs) -> None:
|
| 271 |
+
pass
|
| 272 |
+
|
| 273 |
+
self._undo_wrapping = pluginmanager.add_hookcall_monitoring(before, after)
|
| 274 |
+
|
| 275 |
+
def finish_recording(self) -> None:
|
| 276 |
+
self._undo_wrapping()
|
| 277 |
+
|
| 278 |
+
def getcalls(self, names: str | Iterable[str]) -> list[RecordedHookCall]:
|
| 279 |
+
"""Get all recorded calls to hooks with the given names (or name)."""
|
| 280 |
+
if isinstance(names, str):
|
| 281 |
+
names = names.split()
|
| 282 |
+
return [call for call in self.calls if call._name in names]
|
| 283 |
+
|
| 284 |
+
def assert_contains(self, entries: Sequence[tuple[str, str]]) -> None:
|
| 285 |
+
__tracebackhide__ = True
|
| 286 |
+
i = 0
|
| 287 |
+
entries = list(entries)
|
| 288 |
+
# Since Python 3.13, f_locals is not a dict, but eval requires a dict.
|
| 289 |
+
backlocals = dict(sys._getframe(1).f_locals)
|
| 290 |
+
while entries:
|
| 291 |
+
name, check = entries.pop(0)
|
| 292 |
+
for ind, call in enumerate(self.calls[i:]):
|
| 293 |
+
if call._name == name:
|
| 294 |
+
print("NAMEMATCH", name, call)
|
| 295 |
+
if eval(check, backlocals, call.__dict__):
|
| 296 |
+
print("CHECKERMATCH", repr(check), "->", call)
|
| 297 |
+
else:
|
| 298 |
+
print("NOCHECKERMATCH", repr(check), "-", call)
|
| 299 |
+
continue
|
| 300 |
+
i += ind + 1
|
| 301 |
+
break
|
| 302 |
+
print("NONAMEMATCH", name, "with", call)
|
| 303 |
+
else:
|
| 304 |
+
fail(f"could not find {name!r} check {check!r}")
|
| 305 |
+
|
| 306 |
+
def popcall(self, name: str) -> RecordedHookCall:
|
| 307 |
+
__tracebackhide__ = True
|
| 308 |
+
for i, call in enumerate(self.calls):
|
| 309 |
+
if call._name == name:
|
| 310 |
+
del self.calls[i]
|
| 311 |
+
return call
|
| 312 |
+
lines = [f"could not find call {name!r}, in:"]
|
| 313 |
+
lines.extend([f" {x}" for x in self.calls])
|
| 314 |
+
fail("\n".join(lines))
|
| 315 |
+
|
| 316 |
+
def getcall(self, name: str) -> RecordedHookCall:
|
| 317 |
+
values = self.getcalls(name)
|
| 318 |
+
assert len(values) == 1, (name, values)
|
| 319 |
+
return values[0]
|
| 320 |
+
|
| 321 |
+
# functionality for test reports
|
| 322 |
+
|
| 323 |
+
@overload
|
| 324 |
+
def getreports(
|
| 325 |
+
self,
|
| 326 |
+
names: Literal["pytest_collectreport"],
|
| 327 |
+
) -> Sequence[CollectReport]: ...
|
| 328 |
+
|
| 329 |
+
@overload
|
| 330 |
+
def getreports(
|
| 331 |
+
self,
|
| 332 |
+
names: Literal["pytest_runtest_logreport"],
|
| 333 |
+
) -> Sequence[TestReport]: ...
|
| 334 |
+
|
| 335 |
+
@overload
|
| 336 |
+
def getreports(
|
| 337 |
+
self,
|
| 338 |
+
names: str | Iterable[str] = (
|
| 339 |
+
"pytest_collectreport",
|
| 340 |
+
"pytest_runtest_logreport",
|
| 341 |
+
),
|
| 342 |
+
) -> Sequence[CollectReport | TestReport]: ...
|
| 343 |
+
|
| 344 |
+
def getreports(
|
| 345 |
+
self,
|
| 346 |
+
names: str | Iterable[str] = (
|
| 347 |
+
"pytest_collectreport",
|
| 348 |
+
"pytest_runtest_logreport",
|
| 349 |
+
),
|
| 350 |
+
) -> Sequence[CollectReport | TestReport]:
|
| 351 |
+
return [x.report for x in self.getcalls(names)]
|
| 352 |
+
|
| 353 |
+
def matchreport(
|
| 354 |
+
self,
|
| 355 |
+
inamepart: str = "",
|
| 356 |
+
names: str | Iterable[str] = (
|
| 357 |
+
"pytest_runtest_logreport",
|
| 358 |
+
"pytest_collectreport",
|
| 359 |
+
),
|
| 360 |
+
when: str | None = None,
|
| 361 |
+
) -> CollectReport | TestReport:
|
| 362 |
+
"""Return a testreport whose dotted import path matches."""
|
| 363 |
+
values = []
|
| 364 |
+
for rep in self.getreports(names=names):
|
| 365 |
+
if not when and rep.when != "call" and rep.passed:
|
| 366 |
+
# setup/teardown passing reports - let's ignore those
|
| 367 |
+
continue
|
| 368 |
+
if when and rep.when != when:
|
| 369 |
+
continue
|
| 370 |
+
if not inamepart or inamepart in rep.nodeid.split("::"):
|
| 371 |
+
values.append(rep)
|
| 372 |
+
if not values:
|
| 373 |
+
raise ValueError(
|
| 374 |
+
f"could not find test report matching {inamepart!r}: "
|
| 375 |
+
"no test reports at all!"
|
| 376 |
+
)
|
| 377 |
+
if len(values) > 1:
|
| 378 |
+
raise ValueError(
|
| 379 |
+
f"found 2 or more testreports matching {inamepart!r}: {values}"
|
| 380 |
+
)
|
| 381 |
+
return values[0]
|
| 382 |
+
|
| 383 |
+
@overload
|
| 384 |
+
def getfailures(
|
| 385 |
+
self,
|
| 386 |
+
names: Literal["pytest_collectreport"],
|
| 387 |
+
) -> Sequence[CollectReport]: ...
|
| 388 |
+
|
| 389 |
+
@overload
|
| 390 |
+
def getfailures(
|
| 391 |
+
self,
|
| 392 |
+
names: Literal["pytest_runtest_logreport"],
|
| 393 |
+
) -> Sequence[TestReport]: ...
|
| 394 |
+
|
| 395 |
+
@overload
|
| 396 |
+
def getfailures(
|
| 397 |
+
self,
|
| 398 |
+
names: str | Iterable[str] = (
|
| 399 |
+
"pytest_collectreport",
|
| 400 |
+
"pytest_runtest_logreport",
|
| 401 |
+
),
|
| 402 |
+
) -> Sequence[CollectReport | TestReport]: ...
|
| 403 |
+
|
| 404 |
+
def getfailures(
|
| 405 |
+
self,
|
| 406 |
+
names: str | Iterable[str] = (
|
| 407 |
+
"pytest_collectreport",
|
| 408 |
+
"pytest_runtest_logreport",
|
| 409 |
+
),
|
| 410 |
+
) -> Sequence[CollectReport | TestReport]:
|
| 411 |
+
return [rep for rep in self.getreports(names) if rep.failed]
|
| 412 |
+
|
| 413 |
+
def getfailedcollections(self) -> Sequence[CollectReport]:
|
| 414 |
+
return self.getfailures("pytest_collectreport")
|
| 415 |
+
|
| 416 |
+
def listoutcomes(
|
| 417 |
+
self,
|
| 418 |
+
) -> tuple[
|
| 419 |
+
Sequence[TestReport],
|
| 420 |
+
Sequence[CollectReport | TestReport],
|
| 421 |
+
Sequence[CollectReport | TestReport],
|
| 422 |
+
]:
|
| 423 |
+
passed = []
|
| 424 |
+
skipped = []
|
| 425 |
+
failed = []
|
| 426 |
+
for rep in self.getreports(
|
| 427 |
+
("pytest_collectreport", "pytest_runtest_logreport")
|
| 428 |
+
):
|
| 429 |
+
if rep.passed:
|
| 430 |
+
if rep.when == "call":
|
| 431 |
+
assert isinstance(rep, TestReport)
|
| 432 |
+
passed.append(rep)
|
| 433 |
+
elif rep.skipped:
|
| 434 |
+
skipped.append(rep)
|
| 435 |
+
else:
|
| 436 |
+
assert rep.failed, f"Unexpected outcome: {rep!r}"
|
| 437 |
+
failed.append(rep)
|
| 438 |
+
return passed, skipped, failed
|
| 439 |
+
|
| 440 |
+
def countoutcomes(self) -> list[int]:
|
| 441 |
+
return [len(x) for x in self.listoutcomes()]
|
| 442 |
+
|
| 443 |
+
def assertoutcome(self, passed: int = 0, skipped: int = 0, failed: int = 0) -> None:
|
| 444 |
+
__tracebackhide__ = True
|
| 445 |
+
from _pytest.pytester_assertions import assertoutcome
|
| 446 |
+
|
| 447 |
+
outcomes = self.listoutcomes()
|
| 448 |
+
assertoutcome(
|
| 449 |
+
outcomes,
|
| 450 |
+
passed=passed,
|
| 451 |
+
skipped=skipped,
|
| 452 |
+
failed=failed,
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
def clear(self) -> None:
|
| 456 |
+
self.calls[:] = []
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
@fixture
|
| 460 |
+
def linecomp() -> LineComp:
|
| 461 |
+
"""A :class: `LineComp` instance for checking that an input linearly
|
| 462 |
+
contains a sequence of strings."""
|
| 463 |
+
return LineComp()
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
@fixture(name="LineMatcher")
|
| 467 |
+
def LineMatcher_fixture(request: FixtureRequest) -> type[LineMatcher]:
|
| 468 |
+
"""A reference to the :class: `LineMatcher`.
|
| 469 |
+
|
| 470 |
+
This is instantiable with a list of lines (without their trailing newlines).
|
| 471 |
+
This is useful for testing large texts, such as the output of commands.
|
| 472 |
+
"""
|
| 473 |
+
return LineMatcher
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
@fixture
|
| 477 |
+
def pytester(
|
| 478 |
+
request: FixtureRequest, tmp_path_factory: TempPathFactory, monkeypatch: MonkeyPatch
|
| 479 |
+
) -> Pytester:
|
| 480 |
+
"""
|
| 481 |
+
Facilities to write tests/configuration files, execute pytest in isolation, and match
|
| 482 |
+
against expected output, perfect for black-box testing of pytest plugins.
|
| 483 |
+
|
| 484 |
+
It attempts to isolate the test run from external factors as much as possible, modifying
|
| 485 |
+
the current working directory to ``path`` and environment variables during initialization.
|
| 486 |
+
|
| 487 |
+
It is particularly useful for testing plugins. It is similar to the :fixture:`tmp_path`
|
| 488 |
+
fixture but provides methods which aid in testing pytest itself.
|
| 489 |
+
"""
|
| 490 |
+
return Pytester(request, tmp_path_factory, monkeypatch, _ispytest=True)
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
@fixture
|
| 494 |
+
def _sys_snapshot() -> Generator[None]:
|
| 495 |
+
snappaths = SysPathsSnapshot()
|
| 496 |
+
snapmods = SysModulesSnapshot()
|
| 497 |
+
yield
|
| 498 |
+
snapmods.restore()
|
| 499 |
+
snappaths.restore()
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
@fixture
|
| 503 |
+
def _config_for_test() -> Generator[Config]:
|
| 504 |
+
from _pytest.config import get_config
|
| 505 |
+
|
| 506 |
+
config = get_config()
|
| 507 |
+
yield config
|
| 508 |
+
config._ensure_unconfigure() # cleanup, e.g. capman closing tmpfiles.
|
| 509 |
+
|
| 510 |
+
|
| 511 |
+
# Regex to match the session duration string in the summary: "74.34s".
|
| 512 |
+
rex_session_duration = re.compile(r"\d+\.\d\ds")
|
| 513 |
+
# Regex to match all the counts and phrases in the summary line: "34 passed, 111 skipped".
|
| 514 |
+
rex_outcome = re.compile(r"(\d+) (\w+)")
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
@final
|
| 518 |
+
class RunResult:
|
| 519 |
+
"""The result of running a command from :class:`~pytest.Pytester`."""
|
| 520 |
+
|
| 521 |
+
def __init__(
|
| 522 |
+
self,
|
| 523 |
+
ret: int | ExitCode,
|
| 524 |
+
outlines: list[str],
|
| 525 |
+
errlines: list[str],
|
| 526 |
+
duration: float,
|
| 527 |
+
) -> None:
|
| 528 |
+
try:
|
| 529 |
+
self.ret: int | ExitCode = ExitCode(ret)
|
| 530 |
+
"""The return value."""
|
| 531 |
+
except ValueError:
|
| 532 |
+
self.ret = ret
|
| 533 |
+
self.outlines = outlines
|
| 534 |
+
"""List of lines captured from stdout."""
|
| 535 |
+
self.errlines = errlines
|
| 536 |
+
"""List of lines captured from stderr."""
|
| 537 |
+
self.stdout = LineMatcher(outlines)
|
| 538 |
+
""":class:`~pytest.LineMatcher` of stdout.
|
| 539 |
+
|
| 540 |
+
Use e.g. :func:`str(stdout) <pytest.LineMatcher.__str__()>` to reconstruct stdout, or the commonly used
|
| 541 |
+
:func:`stdout.fnmatch_lines() <pytest.LineMatcher.fnmatch_lines()>` method.
|
| 542 |
+
"""
|
| 543 |
+
self.stderr = LineMatcher(errlines)
|
| 544 |
+
""":class:`~pytest.LineMatcher` of stderr."""
|
| 545 |
+
self.duration = duration
|
| 546 |
+
"""Duration in seconds."""
|
| 547 |
+
|
| 548 |
+
def __repr__(self) -> str:
|
| 549 |
+
return (
|
| 550 |
+
"<RunResult ret=%s len(stdout.lines)=%d len(stderr.lines)=%d duration=%.2fs>"
|
| 551 |
+
% (self.ret, len(self.stdout.lines), len(self.stderr.lines), self.duration)
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
def parseoutcomes(self) -> dict[str, int]:
|
| 555 |
+
"""Return a dictionary of outcome noun -> count from parsing the terminal
|
| 556 |
+
output that the test process produced.
|
| 557 |
+
|
| 558 |
+
The returned nouns will always be in plural form::
|
| 559 |
+
|
| 560 |
+
======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ====
|
| 561 |
+
|
| 562 |
+
Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}``.
|
| 563 |
+
"""
|
| 564 |
+
return self.parse_summary_nouns(self.outlines)
|
| 565 |
+
|
| 566 |
+
@classmethod
|
| 567 |
+
def parse_summary_nouns(cls, lines) -> dict[str, int]:
|
| 568 |
+
"""Extract the nouns from a pytest terminal summary line.
|
| 569 |
+
|
| 570 |
+
It always returns the plural noun for consistency::
|
| 571 |
+
|
| 572 |
+
======= 1 failed, 1 passed, 1 warning, 1 error in 0.13s ====
|
| 573 |
+
|
| 574 |
+
Will return ``{"failed": 1, "passed": 1, "warnings": 1, "errors": 1}``.
|
| 575 |
+
"""
|
| 576 |
+
for line in reversed(lines):
|
| 577 |
+
if rex_session_duration.search(line):
|
| 578 |
+
outcomes = rex_outcome.findall(line)
|
| 579 |
+
ret = {noun: int(count) for (count, noun) in outcomes}
|
| 580 |
+
break
|
| 581 |
+
else:
|
| 582 |
+
raise ValueError("Pytest terminal summary report not found")
|
| 583 |
+
|
| 584 |
+
to_plural = {
|
| 585 |
+
"warning": "warnings",
|
| 586 |
+
"error": "errors",
|
| 587 |
+
}
|
| 588 |
+
return {to_plural.get(k, k): v for k, v in ret.items()}
|
| 589 |
+
|
| 590 |
+
def assert_outcomes(
|
| 591 |
+
self,
|
| 592 |
+
passed: int = 0,
|
| 593 |
+
skipped: int = 0,
|
| 594 |
+
failed: int = 0,
|
| 595 |
+
errors: int = 0,
|
| 596 |
+
xpassed: int = 0,
|
| 597 |
+
xfailed: int = 0,
|
| 598 |
+
warnings: int | None = None,
|
| 599 |
+
deselected: int | None = None,
|
| 600 |
+
) -> None:
|
| 601 |
+
"""
|
| 602 |
+
Assert that the specified outcomes appear with the respective
|
| 603 |
+
numbers (0 means it didn't occur) in the text output from a test run.
|
| 604 |
+
|
| 605 |
+
``warnings`` and ``deselected`` are only checked if not None.
|
| 606 |
+
"""
|
| 607 |
+
__tracebackhide__ = True
|
| 608 |
+
from _pytest.pytester_assertions import assert_outcomes
|
| 609 |
+
|
| 610 |
+
outcomes = self.parseoutcomes()
|
| 611 |
+
assert_outcomes(
|
| 612 |
+
outcomes,
|
| 613 |
+
passed=passed,
|
| 614 |
+
skipped=skipped,
|
| 615 |
+
failed=failed,
|
| 616 |
+
errors=errors,
|
| 617 |
+
xpassed=xpassed,
|
| 618 |
+
xfailed=xfailed,
|
| 619 |
+
warnings=warnings,
|
| 620 |
+
deselected=deselected,
|
| 621 |
+
)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
class SysModulesSnapshot:
|
| 625 |
+
def __init__(self, preserve: Callable[[str], bool] | None = None) -> None:
|
| 626 |
+
self.__preserve = preserve
|
| 627 |
+
self.__saved = dict(sys.modules)
|
| 628 |
+
|
| 629 |
+
def restore(self) -> None:
|
| 630 |
+
if self.__preserve:
|
| 631 |
+
self.__saved.update(
|
| 632 |
+
(k, m) for k, m in sys.modules.items() if self.__preserve(k)
|
| 633 |
+
)
|
| 634 |
+
sys.modules.clear()
|
| 635 |
+
sys.modules.update(self.__saved)
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
class SysPathsSnapshot:
|
| 639 |
+
def __init__(self) -> None:
|
| 640 |
+
self.__saved = list(sys.path), list(sys.meta_path)
|
| 641 |
+
|
| 642 |
+
def restore(self) -> None:
|
| 643 |
+
sys.path[:], sys.meta_path[:] = self.__saved
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
@final
|
| 647 |
+
class Pytester:
|
| 648 |
+
"""
|
| 649 |
+
Facilities to write tests/configuration files, execute pytest in isolation, and match
|
| 650 |
+
against expected output, perfect for black-box testing of pytest plugins.
|
| 651 |
+
|
| 652 |
+
It attempts to isolate the test run from external factors as much as possible, modifying
|
| 653 |
+
the current working directory to :attr:`path` and environment variables during initialization.
|
| 654 |
+
"""
|
| 655 |
+
|
| 656 |
+
__test__ = False
|
| 657 |
+
|
| 658 |
+
CLOSE_STDIN: Final = NOTSET
|
| 659 |
+
|
| 660 |
+
class TimeoutExpired(Exception):
|
| 661 |
+
pass
|
| 662 |
+
|
| 663 |
+
def __init__(
|
| 664 |
+
self,
|
| 665 |
+
request: FixtureRequest,
|
| 666 |
+
tmp_path_factory: TempPathFactory,
|
| 667 |
+
monkeypatch: MonkeyPatch,
|
| 668 |
+
*,
|
| 669 |
+
_ispytest: bool = False,
|
| 670 |
+
) -> None:
|
| 671 |
+
check_ispytest(_ispytest)
|
| 672 |
+
self._request = request
|
| 673 |
+
self._mod_collections: WeakKeyDictionary[Collector, list[Item | Collector]] = (
|
| 674 |
+
WeakKeyDictionary()
|
| 675 |
+
)
|
| 676 |
+
if request.function:
|
| 677 |
+
name: str = request.function.__name__
|
| 678 |
+
else:
|
| 679 |
+
name = request.node.name
|
| 680 |
+
self._name = name
|
| 681 |
+
self._path: Path = tmp_path_factory.mktemp(name, numbered=True)
|
| 682 |
+
#: A list of plugins to use with :py:meth:`parseconfig` and
|
| 683 |
+
#: :py:meth:`runpytest`. Initially this is an empty list but plugins can
|
| 684 |
+
#: be added to the list. The type of items to add to the list depends on
|
| 685 |
+
#: the method using them so refer to them for details.
|
| 686 |
+
self.plugins: list[str | _PluggyPlugin] = []
|
| 687 |
+
self._sys_path_snapshot = SysPathsSnapshot()
|
| 688 |
+
self._sys_modules_snapshot = self.__take_sys_modules_snapshot()
|
| 689 |
+
self._request.addfinalizer(self._finalize)
|
| 690 |
+
self._method = self._request.config.getoption("--runpytest")
|
| 691 |
+
self._test_tmproot = tmp_path_factory.mktemp(f"tmp-{name}", numbered=True)
|
| 692 |
+
|
| 693 |
+
self._monkeypatch = mp = monkeypatch
|
| 694 |
+
self.chdir()
|
| 695 |
+
mp.setenv("PYTEST_DEBUG_TEMPROOT", str(self._test_tmproot))
|
| 696 |
+
# Ensure no unexpected caching via tox.
|
| 697 |
+
mp.delenv("TOX_ENV_DIR", raising=False)
|
| 698 |
+
# Discard outer pytest options.
|
| 699 |
+
mp.delenv("PYTEST_ADDOPTS", raising=False)
|
| 700 |
+
# Ensure no user config is used.
|
| 701 |
+
tmphome = str(self.path)
|
| 702 |
+
mp.setenv("HOME", tmphome)
|
| 703 |
+
mp.setenv("USERPROFILE", tmphome)
|
| 704 |
+
# Do not use colors for inner runs by default.
|
| 705 |
+
mp.setenv("PY_COLORS", "0")
|
| 706 |
+
|
| 707 |
+
@property
|
| 708 |
+
def path(self) -> Path:
|
| 709 |
+
"""Temporary directory path used to create files/run tests from, etc."""
|
| 710 |
+
return self._path
|
| 711 |
+
|
| 712 |
+
def __repr__(self) -> str:
|
| 713 |
+
return f"<Pytester {self.path!r}>"
|
| 714 |
+
|
| 715 |
+
def _finalize(self) -> None:
|
| 716 |
+
"""
|
| 717 |
+
Clean up global state artifacts.
|
| 718 |
+
|
| 719 |
+
Some methods modify the global interpreter state and this tries to
|
| 720 |
+
clean this up. It does not remove the temporary directory however so
|
| 721 |
+
it can be looked at after the test run has finished.
|
| 722 |
+
"""
|
| 723 |
+
self._sys_modules_snapshot.restore()
|
| 724 |
+
self._sys_path_snapshot.restore()
|
| 725 |
+
|
| 726 |
+
def __take_sys_modules_snapshot(self) -> SysModulesSnapshot:
|
| 727 |
+
# Some zope modules used by twisted-related tests keep internal state
|
| 728 |
+
# and can't be deleted; we had some trouble in the past with
|
| 729 |
+
# `zope.interface` for example.
|
| 730 |
+
#
|
| 731 |
+
# Preserve readline due to https://bugs.python.org/issue41033.
|
| 732 |
+
# pexpect issues a SIGWINCH.
|
| 733 |
+
def preserve_module(name):
|
| 734 |
+
return name.startswith(("zope", "readline"))
|
| 735 |
+
|
| 736 |
+
return SysModulesSnapshot(preserve=preserve_module)
|
| 737 |
+
|
| 738 |
+
def make_hook_recorder(self, pluginmanager: PytestPluginManager) -> HookRecorder:
|
| 739 |
+
"""Create a new :class:`HookRecorder` for a :class:`PytestPluginManager`."""
|
| 740 |
+
pluginmanager.reprec = reprec = HookRecorder(pluginmanager, _ispytest=True) # type: ignore[attr-defined]
|
| 741 |
+
self._request.addfinalizer(reprec.finish_recording)
|
| 742 |
+
return reprec
|
| 743 |
+
|
| 744 |
+
def chdir(self) -> None:
|
| 745 |
+
"""Cd into the temporary directory.
|
| 746 |
+
|
| 747 |
+
This is done automatically upon instantiation.
|
| 748 |
+
"""
|
| 749 |
+
self._monkeypatch.chdir(self.path)
|
| 750 |
+
|
| 751 |
+
def _makefile(
|
| 752 |
+
self,
|
| 753 |
+
ext: str,
|
| 754 |
+
lines: Sequence[Any | bytes],
|
| 755 |
+
files: dict[str, str],
|
| 756 |
+
encoding: str = "utf-8",
|
| 757 |
+
) -> Path:
|
| 758 |
+
items = list(files.items())
|
| 759 |
+
|
| 760 |
+
if ext is None:
|
| 761 |
+
raise TypeError("ext must not be None")
|
| 762 |
+
|
| 763 |
+
if ext and not ext.startswith("."):
|
| 764 |
+
raise ValueError(
|
| 765 |
+
f"pytester.makefile expects a file extension, try .{ext} instead of {ext}"
|
| 766 |
+
)
|
| 767 |
+
|
| 768 |
+
def to_text(s: Any | bytes) -> str:
|
| 769 |
+
return s.decode(encoding) if isinstance(s, bytes) else str(s)
|
| 770 |
+
|
| 771 |
+
if lines:
|
| 772 |
+
source = "\n".join(to_text(x) for x in lines)
|
| 773 |
+
basename = self._name
|
| 774 |
+
items.insert(0, (basename, source))
|
| 775 |
+
|
| 776 |
+
ret = None
|
| 777 |
+
for basename, value in items:
|
| 778 |
+
p = self.path.joinpath(basename).with_suffix(ext)
|
| 779 |
+
p.parent.mkdir(parents=True, exist_ok=True)
|
| 780 |
+
source_ = Source(value)
|
| 781 |
+
source = "\n".join(to_text(line) for line in source_.lines)
|
| 782 |
+
p.write_text(source.strip(), encoding=encoding)
|
| 783 |
+
if ret is None:
|
| 784 |
+
ret = p
|
| 785 |
+
assert ret is not None
|
| 786 |
+
return ret
|
| 787 |
+
|
| 788 |
+
def makefile(self, ext: str, *args: str, **kwargs: str) -> Path:
|
| 789 |
+
r"""Create new text file(s) in the test directory.
|
| 790 |
+
|
| 791 |
+
:param ext:
|
| 792 |
+
The extension the file(s) should use, including the dot, e.g. `.py`.
|
| 793 |
+
:param args:
|
| 794 |
+
All args are treated as strings and joined using newlines.
|
| 795 |
+
The result is written as contents to the file. The name of the
|
| 796 |
+
file is based on the test function requesting this fixture.
|
| 797 |
+
:param kwargs:
|
| 798 |
+
Each keyword is the name of a file, while the value of it will
|
| 799 |
+
be written as contents of the file.
|
| 800 |
+
:returns:
|
| 801 |
+
The first created file.
|
| 802 |
+
|
| 803 |
+
Examples:
|
| 804 |
+
|
| 805 |
+
.. code-block:: python
|
| 806 |
+
|
| 807 |
+
pytester.makefile(".txt", "line1", "line2")
|
| 808 |
+
|
| 809 |
+
pytester.makefile(".ini", pytest="[pytest]\naddopts=-rs\n")
|
| 810 |
+
|
| 811 |
+
To create binary files, use :meth:`pathlib.Path.write_bytes` directly:
|
| 812 |
+
|
| 813 |
+
.. code-block:: python
|
| 814 |
+
|
| 815 |
+
filename = pytester.path.joinpath("foo.bin")
|
| 816 |
+
filename.write_bytes(b"...")
|
| 817 |
+
"""
|
| 818 |
+
return self._makefile(ext, args, kwargs)
|
| 819 |
+
|
| 820 |
+
def makeconftest(self, source: str) -> Path:
|
| 821 |
+
"""Write a conftest.py file.
|
| 822 |
+
|
| 823 |
+
:param source: The contents.
|
| 824 |
+
:returns: The conftest.py file.
|
| 825 |
+
"""
|
| 826 |
+
return self.makepyfile(conftest=source)
|
| 827 |
+
|
| 828 |
+
def makeini(self, source: str) -> Path:
|
| 829 |
+
"""Write a tox.ini file.
|
| 830 |
+
|
| 831 |
+
:param source: The contents.
|
| 832 |
+
:returns: The tox.ini file.
|
| 833 |
+
"""
|
| 834 |
+
return self.makefile(".ini", tox=source)
|
| 835 |
+
|
| 836 |
+
def getinicfg(self, source: str) -> SectionWrapper:
|
| 837 |
+
"""Return the pytest section from the tox.ini config file."""
|
| 838 |
+
p = self.makeini(source)
|
| 839 |
+
return IniConfig(str(p))["pytest"]
|
| 840 |
+
|
| 841 |
+
def makepyprojecttoml(self, source: str) -> Path:
|
| 842 |
+
"""Write a pyproject.toml file.
|
| 843 |
+
|
| 844 |
+
:param source: The contents.
|
| 845 |
+
:returns: The pyproject.ini file.
|
| 846 |
+
|
| 847 |
+
.. versionadded:: 6.0
|
| 848 |
+
"""
|
| 849 |
+
return self.makefile(".toml", pyproject=source)
|
| 850 |
+
|
| 851 |
+
def makepyfile(self, *args, **kwargs) -> Path:
|
| 852 |
+
r"""Shortcut for .makefile() with a .py extension.
|
| 853 |
+
|
| 854 |
+
Defaults to the test name with a '.py' extension, e.g test_foobar.py, overwriting
|
| 855 |
+
existing files.
|
| 856 |
+
|
| 857 |
+
Examples:
|
| 858 |
+
|
| 859 |
+
.. code-block:: python
|
| 860 |
+
|
| 861 |
+
def test_something(pytester):
|
| 862 |
+
# Initial file is created test_something.py.
|
| 863 |
+
pytester.makepyfile("foobar")
|
| 864 |
+
# To create multiple files, pass kwargs accordingly.
|
| 865 |
+
pytester.makepyfile(custom="foobar")
|
| 866 |
+
# At this point, both 'test_something.py' & 'custom.py' exist in the test directory.
|
| 867 |
+
|
| 868 |
+
"""
|
| 869 |
+
return self._makefile(".py", args, kwargs)
|
| 870 |
+
|
| 871 |
+
def maketxtfile(self, *args, **kwargs) -> Path:
|
| 872 |
+
r"""Shortcut for .makefile() with a .txt extension.
|
| 873 |
+
|
| 874 |
+
Defaults to the test name with a '.txt' extension, e.g test_foobar.txt, overwriting
|
| 875 |
+
existing files.
|
| 876 |
+
|
| 877 |
+
Examples:
|
| 878 |
+
|
| 879 |
+
.. code-block:: python
|
| 880 |
+
|
| 881 |
+
def test_something(pytester):
|
| 882 |
+
# Initial file is created test_something.txt.
|
| 883 |
+
pytester.maketxtfile("foobar")
|
| 884 |
+
# To create multiple files, pass kwargs accordingly.
|
| 885 |
+
pytester.maketxtfile(custom="foobar")
|
| 886 |
+
# At this point, both 'test_something.txt' & 'custom.txt' exist in the test directory.
|
| 887 |
+
|
| 888 |
+
"""
|
| 889 |
+
return self._makefile(".txt", args, kwargs)
|
| 890 |
+
|
| 891 |
+
def syspathinsert(self, path: str | os.PathLike[str] | None = None) -> None:
|
| 892 |
+
"""Prepend a directory to sys.path, defaults to :attr:`path`.
|
| 893 |
+
|
| 894 |
+
This is undone automatically when this object dies at the end of each
|
| 895 |
+
test.
|
| 896 |
+
|
| 897 |
+
:param path:
|
| 898 |
+
The path.
|
| 899 |
+
"""
|
| 900 |
+
if path is None:
|
| 901 |
+
path = self.path
|
| 902 |
+
|
| 903 |
+
self._monkeypatch.syspath_prepend(str(path))
|
| 904 |
+
|
| 905 |
+
def mkdir(self, name: str | os.PathLike[str]) -> Path:
|
| 906 |
+
"""Create a new (sub)directory.
|
| 907 |
+
|
| 908 |
+
:param name:
|
| 909 |
+
The name of the directory, relative to the pytester path.
|
| 910 |
+
:returns:
|
| 911 |
+
The created directory.
|
| 912 |
+
:rtype: pathlib.Path
|
| 913 |
+
"""
|
| 914 |
+
p = self.path / name
|
| 915 |
+
p.mkdir()
|
| 916 |
+
return p
|
| 917 |
+
|
| 918 |
+
def mkpydir(self, name: str | os.PathLike[str]) -> Path:
|
| 919 |
+
"""Create a new python package.
|
| 920 |
+
|
| 921 |
+
This creates a (sub)directory with an empty ``__init__.py`` file so it
|
| 922 |
+
gets recognised as a Python package.
|
| 923 |
+
"""
|
| 924 |
+
p = self.path / name
|
| 925 |
+
p.mkdir()
|
| 926 |
+
p.joinpath("__init__.py").touch()
|
| 927 |
+
return p
|
| 928 |
+
|
| 929 |
+
def copy_example(self, name: str | None = None) -> Path:
|
| 930 |
+
"""Copy file from project's directory into the testdir.
|
| 931 |
+
|
| 932 |
+
:param name:
|
| 933 |
+
The name of the file to copy.
|
| 934 |
+
:return:
|
| 935 |
+
Path to the copied directory (inside ``self.path``).
|
| 936 |
+
:rtype: pathlib.Path
|
| 937 |
+
"""
|
| 938 |
+
example_dir_ = self._request.config.getini("pytester_example_dir")
|
| 939 |
+
if example_dir_ is None:
|
| 940 |
+
raise ValueError("pytester_example_dir is unset, can't copy examples")
|
| 941 |
+
example_dir: Path = self._request.config.rootpath / example_dir_
|
| 942 |
+
|
| 943 |
+
for extra_element in self._request.node.iter_markers("pytester_example_path"):
|
| 944 |
+
assert extra_element.args
|
| 945 |
+
example_dir = example_dir.joinpath(*extra_element.args)
|
| 946 |
+
|
| 947 |
+
if name is None:
|
| 948 |
+
func_name = self._name
|
| 949 |
+
maybe_dir = example_dir / func_name
|
| 950 |
+
maybe_file = example_dir / (func_name + ".py")
|
| 951 |
+
|
| 952 |
+
if maybe_dir.is_dir():
|
| 953 |
+
example_path = maybe_dir
|
| 954 |
+
elif maybe_file.is_file():
|
| 955 |
+
example_path = maybe_file
|
| 956 |
+
else:
|
| 957 |
+
raise LookupError(
|
| 958 |
+
f"{func_name} can't be found as module or package in {example_dir}"
|
| 959 |
+
)
|
| 960 |
+
else:
|
| 961 |
+
example_path = example_dir.joinpath(name)
|
| 962 |
+
|
| 963 |
+
if example_path.is_dir() and not example_path.joinpath("__init__.py").is_file():
|
| 964 |
+
shutil.copytree(example_path, self.path, symlinks=True, dirs_exist_ok=True)
|
| 965 |
+
return self.path
|
| 966 |
+
elif example_path.is_file():
|
| 967 |
+
result = self.path.joinpath(example_path.name)
|
| 968 |
+
shutil.copy(example_path, result)
|
| 969 |
+
return result
|
| 970 |
+
else:
|
| 971 |
+
raise LookupError(
|
| 972 |
+
f'example "{example_path}" is not found as a file or directory'
|
| 973 |
+
)
|
| 974 |
+
|
| 975 |
+
def getnode(self, config: Config, arg: str | os.PathLike[str]) -> Collector | Item:
|
| 976 |
+
"""Get the collection node of a file.
|
| 977 |
+
|
| 978 |
+
:param config:
|
| 979 |
+
A pytest config.
|
| 980 |
+
See :py:meth:`parseconfig` and :py:meth:`parseconfigure` for creating it.
|
| 981 |
+
:param arg:
|
| 982 |
+
Path to the file.
|
| 983 |
+
:returns:
|
| 984 |
+
The node.
|
| 985 |
+
"""
|
| 986 |
+
session = Session.from_config(config)
|
| 987 |
+
assert "::" not in str(arg)
|
| 988 |
+
p = Path(os.path.abspath(arg))
|
| 989 |
+
config.hook.pytest_sessionstart(session=session)
|
| 990 |
+
res = session.perform_collect([str(p)], genitems=False)[0]
|
| 991 |
+
config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK)
|
| 992 |
+
return res
|
| 993 |
+
|
| 994 |
+
def getpathnode(self, path: str | os.PathLike[str]) -> Collector | Item:
|
| 995 |
+
"""Return the collection node of a file.
|
| 996 |
+
|
| 997 |
+
This is like :py:meth:`getnode` but uses :py:meth:`parseconfigure` to
|
| 998 |
+
create the (configured) pytest Config instance.
|
| 999 |
+
|
| 1000 |
+
:param path:
|
| 1001 |
+
Path to the file.
|
| 1002 |
+
:returns:
|
| 1003 |
+
The node.
|
| 1004 |
+
"""
|
| 1005 |
+
path = Path(path)
|
| 1006 |
+
config = self.parseconfigure(path)
|
| 1007 |
+
session = Session.from_config(config)
|
| 1008 |
+
x = bestrelpath(session.path, path)
|
| 1009 |
+
config.hook.pytest_sessionstart(session=session)
|
| 1010 |
+
res = session.perform_collect([x], genitems=False)[0]
|
| 1011 |
+
config.hook.pytest_sessionfinish(session=session, exitstatus=ExitCode.OK)
|
| 1012 |
+
return res
|
| 1013 |
+
|
| 1014 |
+
def genitems(self, colitems: Sequence[Item | Collector]) -> list[Item]:
|
| 1015 |
+
"""Generate all test items from a collection node.
|
| 1016 |
+
|
| 1017 |
+
This recurses into the collection node and returns a list of all the
|
| 1018 |
+
test items contained within.
|
| 1019 |
+
|
| 1020 |
+
:param colitems:
|
| 1021 |
+
The collection nodes.
|
| 1022 |
+
:returns:
|
| 1023 |
+
The collected items.
|
| 1024 |
+
"""
|
| 1025 |
+
session = colitems[0].session
|
| 1026 |
+
result: list[Item] = []
|
| 1027 |
+
for colitem in colitems:
|
| 1028 |
+
result.extend(session.genitems(colitem))
|
| 1029 |
+
return result
|
| 1030 |
+
|
| 1031 |
+
def runitem(self, source: str) -> Any:
|
| 1032 |
+
"""Run the "test_func" Item.
|
| 1033 |
+
|
| 1034 |
+
The calling test instance (class containing the test method) must
|
| 1035 |
+
provide a ``.getrunner()`` method which should return a runner which
|
| 1036 |
+
can run the test protocol for a single item, e.g.
|
| 1037 |
+
``_pytest.runner.runtestprotocol``.
|
| 1038 |
+
"""
|
| 1039 |
+
# used from runner functional tests
|
| 1040 |
+
item = self.getitem(source)
|
| 1041 |
+
# the test class where we are called from wants to provide the runner
|
| 1042 |
+
testclassinstance = self._request.instance
|
| 1043 |
+
runner = testclassinstance.getrunner()
|
| 1044 |
+
return runner(item)
|
| 1045 |
+
|
| 1046 |
+
def inline_runsource(self, source: str, *cmdlineargs) -> HookRecorder:
|
| 1047 |
+
"""Run a test module in process using ``pytest.main()``.
|
| 1048 |
+
|
| 1049 |
+
This run writes "source" into a temporary file and runs
|
| 1050 |
+
``pytest.main()`` on it, returning a :py:class:`HookRecorder` instance
|
| 1051 |
+
for the result.
|
| 1052 |
+
|
| 1053 |
+
:param source: The source code of the test module.
|
| 1054 |
+
:param cmdlineargs: Any extra command line arguments to use.
|
| 1055 |
+
"""
|
| 1056 |
+
p = self.makepyfile(source)
|
| 1057 |
+
values = [*list(cmdlineargs), p]
|
| 1058 |
+
return self.inline_run(*values)
|
| 1059 |
+
|
| 1060 |
+
def inline_genitems(self, *args) -> tuple[list[Item], HookRecorder]:
|
| 1061 |
+
"""Run ``pytest.main(['--collect-only'])`` in-process.
|
| 1062 |
+
|
| 1063 |
+
Runs the :py:func:`pytest.main` function to run all of pytest inside
|
| 1064 |
+
the test process itself like :py:meth:`inline_run`, but returns a
|
| 1065 |
+
tuple of the collected items and a :py:class:`HookRecorder` instance.
|
| 1066 |
+
"""
|
| 1067 |
+
rec = self.inline_run("--collect-only", *args)
|
| 1068 |
+
items = [x.item for x in rec.getcalls("pytest_itemcollected")]
|
| 1069 |
+
return items, rec
|
| 1070 |
+
|
| 1071 |
+
def inline_run(
|
| 1072 |
+
self,
|
| 1073 |
+
*args: str | os.PathLike[str],
|
| 1074 |
+
plugins=(),
|
| 1075 |
+
no_reraise_ctrlc: bool = False,
|
| 1076 |
+
) -> HookRecorder:
|
| 1077 |
+
"""Run ``pytest.main()`` in-process, returning a HookRecorder.
|
| 1078 |
+
|
| 1079 |
+
Runs the :py:func:`pytest.main` function to run all of pytest inside
|
| 1080 |
+
the test process itself. This means it can return a
|
| 1081 |
+
:py:class:`HookRecorder` instance which gives more detailed results
|
| 1082 |
+
from that run than can be done by matching stdout/stderr from
|
| 1083 |
+
:py:meth:`runpytest`.
|
| 1084 |
+
|
| 1085 |
+
:param args:
|
| 1086 |
+
Command line arguments to pass to :py:func:`pytest.main`.
|
| 1087 |
+
:param plugins:
|
| 1088 |
+
Extra plugin instances the ``pytest.main()`` instance should use.
|
| 1089 |
+
:param no_reraise_ctrlc:
|
| 1090 |
+
Typically we reraise keyboard interrupts from the child run. If
|
| 1091 |
+
True, the KeyboardInterrupt exception is captured.
|
| 1092 |
+
"""
|
| 1093 |
+
# (maybe a cpython bug?) the importlib cache sometimes isn't updated
|
| 1094 |
+
# properly between file creation and inline_run (especially if imports
|
| 1095 |
+
# are interspersed with file creation)
|
| 1096 |
+
importlib.invalidate_caches()
|
| 1097 |
+
|
| 1098 |
+
plugins = list(plugins)
|
| 1099 |
+
finalizers = []
|
| 1100 |
+
try:
|
| 1101 |
+
# Any sys.module or sys.path changes done while running pytest
|
| 1102 |
+
# inline should be reverted after the test run completes to avoid
|
| 1103 |
+
# clashing with later inline tests run within the same pytest test,
|
| 1104 |
+
# e.g. just because they use matching test module names.
|
| 1105 |
+
finalizers.append(self.__take_sys_modules_snapshot().restore)
|
| 1106 |
+
finalizers.append(SysPathsSnapshot().restore)
|
| 1107 |
+
|
| 1108 |
+
# Important note:
|
| 1109 |
+
# - our tests should not leave any other references/registrations
|
| 1110 |
+
# laying around other than possibly loaded test modules
|
| 1111 |
+
# referenced from sys.modules, as nothing will clean those up
|
| 1112 |
+
# automatically
|
| 1113 |
+
|
| 1114 |
+
rec = []
|
| 1115 |
+
|
| 1116 |
+
class Collect:
|
| 1117 |
+
def pytest_configure(x, config: Config) -> None:
|
| 1118 |
+
rec.append(self.make_hook_recorder(config.pluginmanager))
|
| 1119 |
+
|
| 1120 |
+
plugins.append(Collect())
|
| 1121 |
+
ret = main([str(x) for x in args], plugins=plugins)
|
| 1122 |
+
if len(rec) == 1:
|
| 1123 |
+
reprec = rec.pop()
|
| 1124 |
+
else:
|
| 1125 |
+
|
| 1126 |
+
class reprec: # type: ignore
|
| 1127 |
+
pass
|
| 1128 |
+
|
| 1129 |
+
reprec.ret = ret
|
| 1130 |
+
|
| 1131 |
+
# Typically we reraise keyboard interrupts from the child run
|
| 1132 |
+
# because it's our user requesting interruption of the testing.
|
| 1133 |
+
if ret == ExitCode.INTERRUPTED and not no_reraise_ctrlc:
|
| 1134 |
+
calls = reprec.getcalls("pytest_keyboard_interrupt")
|
| 1135 |
+
if calls and calls[-1].excinfo.type == KeyboardInterrupt:
|
| 1136 |
+
raise KeyboardInterrupt()
|
| 1137 |
+
return reprec
|
| 1138 |
+
finally:
|
| 1139 |
+
for finalizer in finalizers:
|
| 1140 |
+
finalizer()
|
| 1141 |
+
|
| 1142 |
+
def runpytest_inprocess(
|
| 1143 |
+
self, *args: str | os.PathLike[str], **kwargs: Any
|
| 1144 |
+
) -> RunResult:
|
| 1145 |
+
"""Return result of running pytest in-process, providing a similar
|
| 1146 |
+
interface to what self.runpytest() provides."""
|
| 1147 |
+
syspathinsert = kwargs.pop("syspathinsert", False)
|
| 1148 |
+
|
| 1149 |
+
if syspathinsert:
|
| 1150 |
+
self.syspathinsert()
|
| 1151 |
+
now = timing.time()
|
| 1152 |
+
capture = _get_multicapture("sys")
|
| 1153 |
+
capture.start_capturing()
|
| 1154 |
+
try:
|
| 1155 |
+
try:
|
| 1156 |
+
reprec = self.inline_run(*args, **kwargs)
|
| 1157 |
+
except SystemExit as e:
|
| 1158 |
+
ret = e.args[0]
|
| 1159 |
+
try:
|
| 1160 |
+
ret = ExitCode(e.args[0])
|
| 1161 |
+
except ValueError:
|
| 1162 |
+
pass
|
| 1163 |
+
|
| 1164 |
+
class reprec: # type: ignore
|
| 1165 |
+
ret = ret
|
| 1166 |
+
|
| 1167 |
+
except Exception:
|
| 1168 |
+
traceback.print_exc()
|
| 1169 |
+
|
| 1170 |
+
class reprec: # type: ignore
|
| 1171 |
+
ret = ExitCode(3)
|
| 1172 |
+
|
| 1173 |
+
finally:
|
| 1174 |
+
out, err = capture.readouterr()
|
| 1175 |
+
capture.stop_capturing()
|
| 1176 |
+
sys.stdout.write(out)
|
| 1177 |
+
sys.stderr.write(err)
|
| 1178 |
+
|
| 1179 |
+
assert reprec.ret is not None
|
| 1180 |
+
res = RunResult(
|
| 1181 |
+
reprec.ret, out.splitlines(), err.splitlines(), timing.time() - now
|
| 1182 |
+
)
|
| 1183 |
+
res.reprec = reprec # type: ignore
|
| 1184 |
+
return res
|
| 1185 |
+
|
| 1186 |
+
def runpytest(self, *args: str | os.PathLike[str], **kwargs: Any) -> RunResult:
|
| 1187 |
+
"""Run pytest inline or in a subprocess, depending on the command line
|
| 1188 |
+
option "--runpytest" and return a :py:class:`~pytest.RunResult`."""
|
| 1189 |
+
new_args = self._ensure_basetemp(args)
|
| 1190 |
+
if self._method == "inprocess":
|
| 1191 |
+
return self.runpytest_inprocess(*new_args, **kwargs)
|
| 1192 |
+
elif self._method == "subprocess":
|
| 1193 |
+
return self.runpytest_subprocess(*new_args, **kwargs)
|
| 1194 |
+
raise RuntimeError(f"Unrecognized runpytest option: {self._method}")
|
| 1195 |
+
|
| 1196 |
+
def _ensure_basetemp(
|
| 1197 |
+
self, args: Sequence[str | os.PathLike[str]]
|
| 1198 |
+
) -> list[str | os.PathLike[str]]:
|
| 1199 |
+
new_args = list(args)
|
| 1200 |
+
for x in new_args:
|
| 1201 |
+
if str(x).startswith("--basetemp"):
|
| 1202 |
+
break
|
| 1203 |
+
else:
|
| 1204 |
+
new_args.append(
|
| 1205 |
+
"--basetemp={}".format(self.path.parent.joinpath("basetemp"))
|
| 1206 |
+
)
|
| 1207 |
+
return new_args
|
| 1208 |
+
|
| 1209 |
+
def parseconfig(self, *args: str | os.PathLike[str]) -> Config:
|
| 1210 |
+
"""Return a new pytest :class:`pytest.Config` instance from given
|
| 1211 |
+
commandline args.
|
| 1212 |
+
|
| 1213 |
+
This invokes the pytest bootstrapping code in _pytest.config to create a
|
| 1214 |
+
new :py:class:`pytest.PytestPluginManager` and call the
|
| 1215 |
+
:hook:`pytest_cmdline_parse` hook to create a new :class:`pytest.Config`
|
| 1216 |
+
instance.
|
| 1217 |
+
|
| 1218 |
+
If :attr:`plugins` has been populated they should be plugin modules
|
| 1219 |
+
to be registered with the plugin manager.
|
| 1220 |
+
"""
|
| 1221 |
+
import _pytest.config
|
| 1222 |
+
|
| 1223 |
+
new_args = self._ensure_basetemp(args)
|
| 1224 |
+
new_args = [str(x) for x in new_args]
|
| 1225 |
+
|
| 1226 |
+
config = _pytest.config._prepareconfig(new_args, self.plugins) # type: ignore[arg-type]
|
| 1227 |
+
# we don't know what the test will do with this half-setup config
|
| 1228 |
+
# object and thus we make sure it gets unconfigured properly in any
|
| 1229 |
+
# case (otherwise capturing could still be active, for example)
|
| 1230 |
+
self._request.addfinalizer(config._ensure_unconfigure)
|
| 1231 |
+
return config
|
| 1232 |
+
|
| 1233 |
+
def parseconfigure(self, *args: str | os.PathLike[str]) -> Config:
|
| 1234 |
+
"""Return a new pytest configured Config instance.
|
| 1235 |
+
|
| 1236 |
+
Returns a new :py:class:`pytest.Config` instance like
|
| 1237 |
+
:py:meth:`parseconfig`, but also calls the :hook:`pytest_configure`
|
| 1238 |
+
hook.
|
| 1239 |
+
"""
|
| 1240 |
+
config = self.parseconfig(*args)
|
| 1241 |
+
config._do_configure()
|
| 1242 |
+
return config
|
| 1243 |
+
|
| 1244 |
+
def getitem(
|
| 1245 |
+
self, source: str | os.PathLike[str], funcname: str = "test_func"
|
| 1246 |
+
) -> Item:
|
| 1247 |
+
"""Return the test item for a test function.
|
| 1248 |
+
|
| 1249 |
+
Writes the source to a python file and runs pytest's collection on
|
| 1250 |
+
the resulting module, returning the test item for the requested
|
| 1251 |
+
function name.
|
| 1252 |
+
|
| 1253 |
+
:param source:
|
| 1254 |
+
The module source.
|
| 1255 |
+
:param funcname:
|
| 1256 |
+
The name of the test function for which to return a test item.
|
| 1257 |
+
:returns:
|
| 1258 |
+
The test item.
|
| 1259 |
+
"""
|
| 1260 |
+
items = self.getitems(source)
|
| 1261 |
+
for item in items:
|
| 1262 |
+
if item.name == funcname:
|
| 1263 |
+
return item
|
| 1264 |
+
assert 0, f"{funcname!r} item not found in module:\n{source}\nitems: {items}"
|
| 1265 |
+
|
| 1266 |
+
def getitems(self, source: str | os.PathLike[str]) -> list[Item]:
|
| 1267 |
+
"""Return all test items collected from the module.
|
| 1268 |
+
|
| 1269 |
+
Writes the source to a Python file and runs pytest's collection on
|
| 1270 |
+
the resulting module, returning all test items contained within.
|
| 1271 |
+
"""
|
| 1272 |
+
modcol = self.getmodulecol(source)
|
| 1273 |
+
return self.genitems([modcol])
|
| 1274 |
+
|
| 1275 |
+
def getmodulecol(
|
| 1276 |
+
self,
|
| 1277 |
+
source: str | os.PathLike[str],
|
| 1278 |
+
configargs=(),
|
| 1279 |
+
*,
|
| 1280 |
+
withinit: bool = False,
|
| 1281 |
+
):
|
| 1282 |
+
"""Return the module collection node for ``source``.
|
| 1283 |
+
|
| 1284 |
+
Writes ``source`` to a file using :py:meth:`makepyfile` and then
|
| 1285 |
+
runs the pytest collection on it, returning the collection node for the
|
| 1286 |
+
test module.
|
| 1287 |
+
|
| 1288 |
+
:param source:
|
| 1289 |
+
The source code of the module to collect.
|
| 1290 |
+
|
| 1291 |
+
:param configargs:
|
| 1292 |
+
Any extra arguments to pass to :py:meth:`parseconfigure`.
|
| 1293 |
+
|
| 1294 |
+
:param withinit:
|
| 1295 |
+
Whether to also write an ``__init__.py`` file to the same
|
| 1296 |
+
directory to ensure it is a package.
|
| 1297 |
+
"""
|
| 1298 |
+
if isinstance(source, os.PathLike):
|
| 1299 |
+
path = self.path.joinpath(source)
|
| 1300 |
+
assert not withinit, "not supported for paths"
|
| 1301 |
+
else:
|
| 1302 |
+
kw = {self._name: str(source)}
|
| 1303 |
+
path = self.makepyfile(**kw)
|
| 1304 |
+
if withinit:
|
| 1305 |
+
self.makepyfile(__init__="#")
|
| 1306 |
+
self.config = config = self.parseconfigure(path, *configargs)
|
| 1307 |
+
return self.getnode(config, path)
|
| 1308 |
+
|
| 1309 |
+
def collect_by_name(self, modcol: Collector, name: str) -> Item | Collector | None:
|
| 1310 |
+
"""Return the collection node for name from the module collection.
|
| 1311 |
+
|
| 1312 |
+
Searches a module collection node for a collection node matching the
|
| 1313 |
+
given name.
|
| 1314 |
+
|
| 1315 |
+
:param modcol: A module collection node; see :py:meth:`getmodulecol`.
|
| 1316 |
+
:param name: The name of the node to return.
|
| 1317 |
+
"""
|
| 1318 |
+
if modcol not in self._mod_collections:
|
| 1319 |
+
self._mod_collections[modcol] = list(modcol.collect())
|
| 1320 |
+
for colitem in self._mod_collections[modcol]:
|
| 1321 |
+
if colitem.name == name:
|
| 1322 |
+
return colitem
|
| 1323 |
+
return None
|
| 1324 |
+
|
| 1325 |
+
def popen(
|
| 1326 |
+
self,
|
| 1327 |
+
cmdargs: Sequence[str | os.PathLike[str]],
|
| 1328 |
+
stdout: int | TextIO = subprocess.PIPE,
|
| 1329 |
+
stderr: int | TextIO = subprocess.PIPE,
|
| 1330 |
+
stdin: NotSetType | bytes | IO[Any] | int = CLOSE_STDIN,
|
| 1331 |
+
**kw,
|
| 1332 |
+
):
|
| 1333 |
+
"""Invoke :py:class:`subprocess.Popen`.
|
| 1334 |
+
|
| 1335 |
+
Calls :py:class:`subprocess.Popen` making sure the current working
|
| 1336 |
+
directory is in ``PYTHONPATH``.
|
| 1337 |
+
|
| 1338 |
+
You probably want to use :py:meth:`run` instead.
|
| 1339 |
+
"""
|
| 1340 |
+
env = os.environ.copy()
|
| 1341 |
+
env["PYTHONPATH"] = os.pathsep.join(
|
| 1342 |
+
filter(None, [os.getcwd(), env.get("PYTHONPATH", "")])
|
| 1343 |
+
)
|
| 1344 |
+
kw["env"] = env
|
| 1345 |
+
|
| 1346 |
+
if stdin is self.CLOSE_STDIN:
|
| 1347 |
+
kw["stdin"] = subprocess.PIPE
|
| 1348 |
+
elif isinstance(stdin, bytes):
|
| 1349 |
+
kw["stdin"] = subprocess.PIPE
|
| 1350 |
+
else:
|
| 1351 |
+
kw["stdin"] = stdin
|
| 1352 |
+
|
| 1353 |
+
popen = subprocess.Popen(cmdargs, stdout=stdout, stderr=stderr, **kw)
|
| 1354 |
+
if stdin is self.CLOSE_STDIN:
|
| 1355 |
+
assert popen.stdin is not None
|
| 1356 |
+
popen.stdin.close()
|
| 1357 |
+
elif isinstance(stdin, bytes):
|
| 1358 |
+
assert popen.stdin is not None
|
| 1359 |
+
popen.stdin.write(stdin)
|
| 1360 |
+
|
| 1361 |
+
return popen
|
| 1362 |
+
|
| 1363 |
+
def run(
|
| 1364 |
+
self,
|
| 1365 |
+
*cmdargs: str | os.PathLike[str],
|
| 1366 |
+
timeout: float | None = None,
|
| 1367 |
+
stdin: NotSetType | bytes | IO[Any] | int = CLOSE_STDIN,
|
| 1368 |
+
) -> RunResult:
|
| 1369 |
+
"""Run a command with arguments.
|
| 1370 |
+
|
| 1371 |
+
Run a process using :py:class:`subprocess.Popen` saving the stdout and
|
| 1372 |
+
stderr.
|
| 1373 |
+
|
| 1374 |
+
:param cmdargs:
|
| 1375 |
+
The sequence of arguments to pass to :py:class:`subprocess.Popen`,
|
| 1376 |
+
with path-like objects being converted to :py:class:`str`
|
| 1377 |
+
automatically.
|
| 1378 |
+
:param timeout:
|
| 1379 |
+
The period in seconds after which to timeout and raise
|
| 1380 |
+
:py:class:`Pytester.TimeoutExpired`.
|
| 1381 |
+
:param stdin:
|
| 1382 |
+
Optional standard input.
|
| 1383 |
+
|
| 1384 |
+
- If it is ``CLOSE_STDIN`` (Default), then this method calls
|
| 1385 |
+
:py:class:`subprocess.Popen` with ``stdin=subprocess.PIPE``, and
|
| 1386 |
+
the standard input is closed immediately after the new command is
|
| 1387 |
+
started.
|
| 1388 |
+
|
| 1389 |
+
- If it is of type :py:class:`bytes`, these bytes are sent to the
|
| 1390 |
+
standard input of the command.
|
| 1391 |
+
|
| 1392 |
+
- Otherwise, it is passed through to :py:class:`subprocess.Popen`.
|
| 1393 |
+
For further information in this case, consult the document of the
|
| 1394 |
+
``stdin`` parameter in :py:class:`subprocess.Popen`.
|
| 1395 |
+
:type stdin: _pytest.compat.NotSetType | bytes | IO[Any] | int
|
| 1396 |
+
:returns:
|
| 1397 |
+
The result.
|
| 1398 |
+
|
| 1399 |
+
"""
|
| 1400 |
+
__tracebackhide__ = True
|
| 1401 |
+
|
| 1402 |
+
cmdargs = tuple(os.fspath(arg) for arg in cmdargs)
|
| 1403 |
+
p1 = self.path.joinpath("stdout")
|
| 1404 |
+
p2 = self.path.joinpath("stderr")
|
| 1405 |
+
print("running:", *cmdargs)
|
| 1406 |
+
print(" in:", Path.cwd())
|
| 1407 |
+
|
| 1408 |
+
with p1.open("w", encoding="utf8") as f1, p2.open("w", encoding="utf8") as f2:
|
| 1409 |
+
now = timing.time()
|
| 1410 |
+
popen = self.popen(
|
| 1411 |
+
cmdargs,
|
| 1412 |
+
stdin=stdin,
|
| 1413 |
+
stdout=f1,
|
| 1414 |
+
stderr=f2,
|
| 1415 |
+
close_fds=(sys.platform != "win32"),
|
| 1416 |
+
)
|
| 1417 |
+
if popen.stdin is not None:
|
| 1418 |
+
popen.stdin.close()
|
| 1419 |
+
|
| 1420 |
+
def handle_timeout() -> None:
|
| 1421 |
+
__tracebackhide__ = True
|
| 1422 |
+
|
| 1423 |
+
timeout_message = f"{timeout} second timeout expired running: {cmdargs}"
|
| 1424 |
+
|
| 1425 |
+
popen.kill()
|
| 1426 |
+
popen.wait()
|
| 1427 |
+
raise self.TimeoutExpired(timeout_message)
|
| 1428 |
+
|
| 1429 |
+
if timeout is None:
|
| 1430 |
+
ret = popen.wait()
|
| 1431 |
+
else:
|
| 1432 |
+
try:
|
| 1433 |
+
ret = popen.wait(timeout)
|
| 1434 |
+
except subprocess.TimeoutExpired:
|
| 1435 |
+
handle_timeout()
|
| 1436 |
+
|
| 1437 |
+
with p1.open(encoding="utf8") as f1, p2.open(encoding="utf8") as f2:
|
| 1438 |
+
out = f1.read().splitlines()
|
| 1439 |
+
err = f2.read().splitlines()
|
| 1440 |
+
|
| 1441 |
+
self._dump_lines(out, sys.stdout)
|
| 1442 |
+
self._dump_lines(err, sys.stderr)
|
| 1443 |
+
|
| 1444 |
+
with contextlib.suppress(ValueError):
|
| 1445 |
+
ret = ExitCode(ret)
|
| 1446 |
+
return RunResult(ret, out, err, timing.time() - now)
|
| 1447 |
+
|
| 1448 |
+
def _dump_lines(self, lines, fp):
|
| 1449 |
+
try:
|
| 1450 |
+
for line in lines:
|
| 1451 |
+
print(line, file=fp)
|
| 1452 |
+
except UnicodeEncodeError:
|
| 1453 |
+
print(f"couldn't print to {fp} because of encoding")
|
| 1454 |
+
|
| 1455 |
+
def _getpytestargs(self) -> tuple[str, ...]:
|
| 1456 |
+
return sys.executable, "-mpytest"
|
| 1457 |
+
|
| 1458 |
+
def runpython(self, script: os.PathLike[str]) -> RunResult:
|
| 1459 |
+
"""Run a python script using sys.executable as interpreter."""
|
| 1460 |
+
return self.run(sys.executable, script)
|
| 1461 |
+
|
| 1462 |
+
def runpython_c(self, command: str) -> RunResult:
|
| 1463 |
+
"""Run ``python -c "command"``."""
|
| 1464 |
+
return self.run(sys.executable, "-c", command)
|
| 1465 |
+
|
| 1466 |
+
def runpytest_subprocess(
|
| 1467 |
+
self, *args: str | os.PathLike[str], timeout: float | None = None
|
| 1468 |
+
) -> RunResult:
|
| 1469 |
+
"""Run pytest as a subprocess with given arguments.
|
| 1470 |
+
|
| 1471 |
+
Any plugins added to the :py:attr:`plugins` list will be added using the
|
| 1472 |
+
``-p`` command line option. Additionally ``--basetemp`` is used to put
|
| 1473 |
+
any temporary files and directories in a numbered directory prefixed
|
| 1474 |
+
with "runpytest-" to not conflict with the normal numbered pytest
|
| 1475 |
+
location for temporary files and directories.
|
| 1476 |
+
|
| 1477 |
+
:param args:
|
| 1478 |
+
The sequence of arguments to pass to the pytest subprocess.
|
| 1479 |
+
:param timeout:
|
| 1480 |
+
The period in seconds after which to timeout and raise
|
| 1481 |
+
:py:class:`Pytester.TimeoutExpired`.
|
| 1482 |
+
:returns:
|
| 1483 |
+
The result.
|
| 1484 |
+
"""
|
| 1485 |
+
__tracebackhide__ = True
|
| 1486 |
+
p = make_numbered_dir(root=self.path, prefix="runpytest-", mode=0o700)
|
| 1487 |
+
args = (f"--basetemp={p}", *args)
|
| 1488 |
+
plugins = [x for x in self.plugins if isinstance(x, str)]
|
| 1489 |
+
if plugins:
|
| 1490 |
+
args = ("-p", plugins[0], *args)
|
| 1491 |
+
args = self._getpytestargs() + args
|
| 1492 |
+
return self.run(*args, timeout=timeout)
|
| 1493 |
+
|
| 1494 |
+
def spawn_pytest(self, string: str, expect_timeout: float = 10.0) -> pexpect.spawn:
|
| 1495 |
+
"""Run pytest using pexpect.
|
| 1496 |
+
|
| 1497 |
+
This makes sure to use the right pytest and sets up the temporary
|
| 1498 |
+
directory locations.
|
| 1499 |
+
|
| 1500 |
+
The pexpect child is returned.
|
| 1501 |
+
"""
|
| 1502 |
+
basetemp = self.path / "temp-pexpect"
|
| 1503 |
+
basetemp.mkdir(mode=0o700)
|
| 1504 |
+
invoke = " ".join(map(str, self._getpytestargs()))
|
| 1505 |
+
cmd = f"{invoke} --basetemp={basetemp} {string}"
|
| 1506 |
+
return self.spawn(cmd, expect_timeout=expect_timeout)
|
| 1507 |
+
|
| 1508 |
+
def spawn(self, cmd: str, expect_timeout: float = 10.0) -> pexpect.spawn:
|
| 1509 |
+
"""Run a command using pexpect.
|
| 1510 |
+
|
| 1511 |
+
The pexpect child is returned.
|
| 1512 |
+
"""
|
| 1513 |
+
pexpect = importorskip("pexpect", "3.0")
|
| 1514 |
+
if hasattr(sys, "pypy_version_info") and "64" in platform.machine():
|
| 1515 |
+
skip("pypy-64 bit not supported")
|
| 1516 |
+
if not hasattr(pexpect, "spawn"):
|
| 1517 |
+
skip("pexpect.spawn not available")
|
| 1518 |
+
logfile = self.path.joinpath("spawn.out").open("wb")
|
| 1519 |
+
|
| 1520 |
+
child = pexpect.spawn(cmd, logfile=logfile, timeout=expect_timeout)
|
| 1521 |
+
self._request.addfinalizer(logfile.close)
|
| 1522 |
+
return child
|
| 1523 |
+
|
| 1524 |
+
|
| 1525 |
+
class LineComp:
|
| 1526 |
+
def __init__(self) -> None:
|
| 1527 |
+
self.stringio = StringIO()
|
| 1528 |
+
""":class:`python:io.StringIO()` instance used for input."""
|
| 1529 |
+
|
| 1530 |
+
def assert_contains_lines(self, lines2: Sequence[str]) -> None:
|
| 1531 |
+
"""Assert that ``lines2`` are contained (linearly) in :attr:`stringio`'s value.
|
| 1532 |
+
|
| 1533 |
+
Lines are matched using :func:`LineMatcher.fnmatch_lines <pytest.LineMatcher.fnmatch_lines>`.
|
| 1534 |
+
"""
|
| 1535 |
+
__tracebackhide__ = True
|
| 1536 |
+
val = self.stringio.getvalue()
|
| 1537 |
+
self.stringio.truncate(0)
|
| 1538 |
+
self.stringio.seek(0)
|
| 1539 |
+
lines1 = val.split("\n")
|
| 1540 |
+
LineMatcher(lines1).fnmatch_lines(lines2)
|
| 1541 |
+
|
| 1542 |
+
|
| 1543 |
+
class LineMatcher:
|
| 1544 |
+
"""Flexible matching of text.
|
| 1545 |
+
|
| 1546 |
+
This is a convenience class to test large texts like the output of
|
| 1547 |
+
commands.
|
| 1548 |
+
|
| 1549 |
+
The constructor takes a list of lines without their trailing newlines, i.e.
|
| 1550 |
+
``text.splitlines()``.
|
| 1551 |
+
"""
|
| 1552 |
+
|
| 1553 |
+
def __init__(self, lines: list[str]) -> None:
|
| 1554 |
+
self.lines = lines
|
| 1555 |
+
self._log_output: list[str] = []
|
| 1556 |
+
|
| 1557 |
+
def __str__(self) -> str:
|
| 1558 |
+
"""Return the entire original text.
|
| 1559 |
+
|
| 1560 |
+
.. versionadded:: 6.2
|
| 1561 |
+
You can use :meth:`str` in older versions.
|
| 1562 |
+
"""
|
| 1563 |
+
return "\n".join(self.lines)
|
| 1564 |
+
|
| 1565 |
+
def _getlines(self, lines2: str | Sequence[str] | Source) -> Sequence[str]:
|
| 1566 |
+
if isinstance(lines2, str):
|
| 1567 |
+
lines2 = Source(lines2)
|
| 1568 |
+
if isinstance(lines2, Source):
|
| 1569 |
+
lines2 = lines2.strip().lines
|
| 1570 |
+
return lines2
|
| 1571 |
+
|
| 1572 |
+
def fnmatch_lines_random(self, lines2: Sequence[str]) -> None:
|
| 1573 |
+
"""Check lines exist in the output in any order (using :func:`python:fnmatch.fnmatch`)."""
|
| 1574 |
+
__tracebackhide__ = True
|
| 1575 |
+
self._match_lines_random(lines2, fnmatch)
|
| 1576 |
+
|
| 1577 |
+
def re_match_lines_random(self, lines2: Sequence[str]) -> None:
|
| 1578 |
+
"""Check lines exist in the output in any order (using :func:`python:re.match`)."""
|
| 1579 |
+
__tracebackhide__ = True
|
| 1580 |
+
self._match_lines_random(lines2, lambda name, pat: bool(re.match(pat, name)))
|
| 1581 |
+
|
| 1582 |
+
def _match_lines_random(
|
| 1583 |
+
self, lines2: Sequence[str], match_func: Callable[[str, str], bool]
|
| 1584 |
+
) -> None:
|
| 1585 |
+
__tracebackhide__ = True
|
| 1586 |
+
lines2 = self._getlines(lines2)
|
| 1587 |
+
for line in lines2:
|
| 1588 |
+
for x in self.lines:
|
| 1589 |
+
if line == x or match_func(x, line):
|
| 1590 |
+
self._log("matched: ", repr(line))
|
| 1591 |
+
break
|
| 1592 |
+
else:
|
| 1593 |
+
msg = f"line {line!r} not found in output"
|
| 1594 |
+
self._log(msg)
|
| 1595 |
+
self._fail(msg)
|
| 1596 |
+
|
| 1597 |
+
def get_lines_after(self, fnline: str) -> Sequence[str]:
|
| 1598 |
+
"""Return all lines following the given line in the text.
|
| 1599 |
+
|
| 1600 |
+
The given line can contain glob wildcards.
|
| 1601 |
+
"""
|
| 1602 |
+
for i, line in enumerate(self.lines):
|
| 1603 |
+
if fnline == line or fnmatch(line, fnline):
|
| 1604 |
+
return self.lines[i + 1 :]
|
| 1605 |
+
raise ValueError(f"line {fnline!r} not found in output")
|
| 1606 |
+
|
| 1607 |
+
def _log(self, *args) -> None:
|
| 1608 |
+
self._log_output.append(" ".join(str(x) for x in args))
|
| 1609 |
+
|
| 1610 |
+
@property
|
| 1611 |
+
def _log_text(self) -> str:
|
| 1612 |
+
return "\n".join(self._log_output)
|
| 1613 |
+
|
| 1614 |
+
def fnmatch_lines(
|
| 1615 |
+
self, lines2: Sequence[str], *, consecutive: bool = False
|
| 1616 |
+
) -> None:
|
| 1617 |
+
"""Check lines exist in the output (using :func:`python:fnmatch.fnmatch`).
|
| 1618 |
+
|
| 1619 |
+
The argument is a list of lines which have to match and can use glob
|
| 1620 |
+
wildcards. If they do not match a pytest.fail() is called. The
|
| 1621 |
+
matches and non-matches are also shown as part of the error message.
|
| 1622 |
+
|
| 1623 |
+
:param lines2: String patterns to match.
|
| 1624 |
+
:param consecutive: Match lines consecutively?
|
| 1625 |
+
"""
|
| 1626 |
+
__tracebackhide__ = True
|
| 1627 |
+
self._match_lines(lines2, fnmatch, "fnmatch", consecutive=consecutive)
|
| 1628 |
+
|
| 1629 |
+
def re_match_lines(
|
| 1630 |
+
self, lines2: Sequence[str], *, consecutive: bool = False
|
| 1631 |
+
) -> None:
|
| 1632 |
+
"""Check lines exist in the output (using :func:`python:re.match`).
|
| 1633 |
+
|
| 1634 |
+
The argument is a list of lines which have to match using ``re.match``.
|
| 1635 |
+
If they do not match a pytest.fail() is called.
|
| 1636 |
+
|
| 1637 |
+
The matches and non-matches are also shown as part of the error message.
|
| 1638 |
+
|
| 1639 |
+
:param lines2: string patterns to match.
|
| 1640 |
+
:param consecutive: match lines consecutively?
|
| 1641 |
+
"""
|
| 1642 |
+
__tracebackhide__ = True
|
| 1643 |
+
self._match_lines(
|
| 1644 |
+
lines2,
|
| 1645 |
+
lambda name, pat: bool(re.match(pat, name)),
|
| 1646 |
+
"re.match",
|
| 1647 |
+
consecutive=consecutive,
|
| 1648 |
+
)
|
| 1649 |
+
|
| 1650 |
+
def _match_lines(
|
| 1651 |
+
self,
|
| 1652 |
+
lines2: Sequence[str],
|
| 1653 |
+
match_func: Callable[[str, str], bool],
|
| 1654 |
+
match_nickname: str,
|
| 1655 |
+
*,
|
| 1656 |
+
consecutive: bool = False,
|
| 1657 |
+
) -> None:
|
| 1658 |
+
"""Underlying implementation of ``fnmatch_lines`` and ``re_match_lines``.
|
| 1659 |
+
|
| 1660 |
+
:param Sequence[str] lines2:
|
| 1661 |
+
List of string patterns to match. The actual format depends on
|
| 1662 |
+
``match_func``.
|
| 1663 |
+
:param match_func:
|
| 1664 |
+
A callable ``match_func(line, pattern)`` where line is the
|
| 1665 |
+
captured line from stdout/stderr and pattern is the matching
|
| 1666 |
+
pattern.
|
| 1667 |
+
:param str match_nickname:
|
| 1668 |
+
The nickname for the match function that will be logged to stdout
|
| 1669 |
+
when a match occurs.
|
| 1670 |
+
:param consecutive:
|
| 1671 |
+
Match lines consecutively?
|
| 1672 |
+
"""
|
| 1673 |
+
if not isinstance(lines2, collections.abc.Sequence):
|
| 1674 |
+
raise TypeError(f"invalid type for lines2: {type(lines2).__name__}")
|
| 1675 |
+
lines2 = self._getlines(lines2)
|
| 1676 |
+
lines1 = self.lines[:]
|
| 1677 |
+
extralines = []
|
| 1678 |
+
__tracebackhide__ = True
|
| 1679 |
+
wnick = len(match_nickname) + 1
|
| 1680 |
+
started = False
|
| 1681 |
+
for line in lines2:
|
| 1682 |
+
nomatchprinted = False
|
| 1683 |
+
while lines1:
|
| 1684 |
+
nextline = lines1.pop(0)
|
| 1685 |
+
if line == nextline:
|
| 1686 |
+
self._log("exact match:", repr(line))
|
| 1687 |
+
started = True
|
| 1688 |
+
break
|
| 1689 |
+
elif match_func(nextline, line):
|
| 1690 |
+
self._log(f"{match_nickname}:", repr(line))
|
| 1691 |
+
self._log(
|
| 1692 |
+
"{:>{width}}".format("with:", width=wnick), repr(nextline)
|
| 1693 |
+
)
|
| 1694 |
+
started = True
|
| 1695 |
+
break
|
| 1696 |
+
else:
|
| 1697 |
+
if consecutive and started:
|
| 1698 |
+
msg = f"no consecutive match: {line!r}"
|
| 1699 |
+
self._log(msg)
|
| 1700 |
+
self._log(
|
| 1701 |
+
"{:>{width}}".format("with:", width=wnick), repr(nextline)
|
| 1702 |
+
)
|
| 1703 |
+
self._fail(msg)
|
| 1704 |
+
if not nomatchprinted:
|
| 1705 |
+
self._log(
|
| 1706 |
+
"{:>{width}}".format("nomatch:", width=wnick), repr(line)
|
| 1707 |
+
)
|
| 1708 |
+
nomatchprinted = True
|
| 1709 |
+
self._log("{:>{width}}".format("and:", width=wnick), repr(nextline))
|
| 1710 |
+
extralines.append(nextline)
|
| 1711 |
+
else:
|
| 1712 |
+
msg = f"remains unmatched: {line!r}"
|
| 1713 |
+
self._log(msg)
|
| 1714 |
+
self._fail(msg)
|
| 1715 |
+
self._log_output = []
|
| 1716 |
+
|
| 1717 |
+
def no_fnmatch_line(self, pat: str) -> None:
|
| 1718 |
+
"""Ensure captured lines do not match the given pattern, using ``fnmatch.fnmatch``.
|
| 1719 |
+
|
| 1720 |
+
:param str pat: The pattern to match lines.
|
| 1721 |
+
"""
|
| 1722 |
+
__tracebackhide__ = True
|
| 1723 |
+
self._no_match_line(pat, fnmatch, "fnmatch")
|
| 1724 |
+
|
| 1725 |
+
def no_re_match_line(self, pat: str) -> None:
|
| 1726 |
+
"""Ensure captured lines do not match the given pattern, using ``re.match``.
|
| 1727 |
+
|
| 1728 |
+
:param str pat: The regular expression to match lines.
|
| 1729 |
+
"""
|
| 1730 |
+
__tracebackhide__ = True
|
| 1731 |
+
self._no_match_line(
|
| 1732 |
+
pat, lambda name, pat: bool(re.match(pat, name)), "re.match"
|
| 1733 |
+
)
|
| 1734 |
+
|
| 1735 |
+
def _no_match_line(
|
| 1736 |
+
self, pat: str, match_func: Callable[[str, str], bool], match_nickname: str
|
| 1737 |
+
) -> None:
|
| 1738 |
+
"""Ensure captured lines does not have a the given pattern, using ``fnmatch.fnmatch``.
|
| 1739 |
+
|
| 1740 |
+
:param str pat: The pattern to match lines.
|
| 1741 |
+
"""
|
| 1742 |
+
__tracebackhide__ = True
|
| 1743 |
+
nomatch_printed = False
|
| 1744 |
+
wnick = len(match_nickname) + 1
|
| 1745 |
+
for line in self.lines:
|
| 1746 |
+
if match_func(line, pat):
|
| 1747 |
+
msg = f"{match_nickname}: {pat!r}"
|
| 1748 |
+
self._log(msg)
|
| 1749 |
+
self._log("{:>{width}}".format("with:", width=wnick), repr(line))
|
| 1750 |
+
self._fail(msg)
|
| 1751 |
+
else:
|
| 1752 |
+
if not nomatch_printed:
|
| 1753 |
+
self._log("{:>{width}}".format("nomatch:", width=wnick), repr(pat))
|
| 1754 |
+
nomatch_printed = True
|
| 1755 |
+
self._log("{:>{width}}".format("and:", width=wnick), repr(line))
|
| 1756 |
+
self._log_output = []
|
| 1757 |
+
|
| 1758 |
+
def _fail(self, msg: str) -> None:
|
| 1759 |
+
__tracebackhide__ = True
|
| 1760 |
+
log_text = self._log_text
|
| 1761 |
+
self._log_output = []
|
| 1762 |
+
fail(log_text)
|
| 1763 |
+
|
| 1764 |
+
def str(self) -> str:
|
| 1765 |
+
"""Return the entire original text."""
|
| 1766 |
+
return str(self)
|