Text Generation
GGUF
English
code
coder
qwen2.5
qwen2.5-coder
llama-cpp
llama.cpp
ollama
code-generation
tool-calling
conversational
cpu-inference
small-language-model
offline
sakthai
house-of-sak
Eval Results (legacy)
Eval Results
Instructions to use Nanthasit/sakthai-coder-1.5b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use Nanthasit/sakthai-coder-1.5b with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M # Run inference directly in the terminal: llama cli -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M # Run inference directly in the terminal: llama cli -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M # Run inference directly in the terminal: ./llama-cli -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M # Run inference directly in the terminal: ./build/bin/llama-cli -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Use Docker
docker model run hf.co/Nanthasit/sakthai-coder-1.5b:Q4_K_M
- LM Studio
- Jan
- vLLM
How to use Nanthasit/sakthai-coder-1.5b with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "Nanthasit/sakthai-coder-1.5b" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/chat/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "Nanthasit/sakthai-coder-1.5b", "messages": [ { "role": "user", "content": "What is the capital of France?" } ] }'Use Docker
docker model run hf.co/Nanthasit/sakthai-coder-1.5b:Q4_K_M
- Ollama
How to use Nanthasit/sakthai-coder-1.5b with Ollama:
ollama run hf.co/Nanthasit/sakthai-coder-1.5b:Q4_K_M
- Unsloth Desktop
- Pi
How to use Nanthasit/sakthai-coder-1.5b with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Configure the model in Pi
# Install Pi: npm install -g @earendil-works/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "Nanthasit/sakthai-coder-1.5b:Q4_K_M" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Docker Model Runner
How to use Nanthasit/sakthai-coder-1.5b with Docker Model Runner:
docker model run hf.co/Nanthasit/sakthai-coder-1.5b:Q4_K_M
- Lemonade
How to use Nanthasit/sakthai-coder-1.5b with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull Nanthasit/sakthai-coder-1.5b:Q4_K_M
Run and chat with the model
lemonade run user.sakthai-coder-1.5b-Q4_K_M
List all available models
lemonade list
- Hermes Agent
How to use Nanthasit/sakthai-coder-1.5b with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default Nanthasit/sakthai-coder-1.5b:Q4_K_M
Run Hermes
hermes
- Atomic Chat
- OpenClaw
How to use Nanthasit/sakthai-coder-1.5b with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf Nanthasit/sakthai-coder-1.5b:Q4_K_M
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "Nanthasit/sakthai-coder-1.5b:Q4_K_M" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
Remove accidentally uploaded coder-readme.md (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +6 -0
- .venv/bin/python3 +3 -0
- .venv/bin/python3.14 +3 -0
- .venv/bin/ruff +3 -0
- .venv/bin/sakthai +10 -0
- .venv/bin/stubgen +10 -0
- .venv/bin/stubtest +10 -0
- .venv/bin/websockets +10 -0
- .venv/lib/python3.14/site-packages/08ae81f72d5a2b5fa9e0__mypyc.cpython-314-x86_64-linux-gnu.so +3 -0
- .venv/lib/python3.14/site-packages/81d243bd2c585b0f4821__mypyc.cpython-314-x86_64-linux-gnu.so +3 -0
- .venv/lib/python3.14/site-packages/__editable__.sakthai_agent-2.0.0.pth +3 -0
- .venv/lib/python3.14/site-packages/_cffi_backend.cpython-314-x86_64-linux-gnu.so +3 -0
- .venv/lib/python3.14/site-packages/_hypothesis_ftz_detector.py +159 -0
- .venv/lib/python3.14/site-packages/_hypothesis_globals.py +28 -0
- .venv/lib/python3.14/site-packages/_hypothesis_pytestplugin.py +492 -0
- .venv/lib/python3.14/site-packages/_pytest/__init__.py +13 -0
- .venv/lib/python3.14/site-packages/_pytest/_argcomplete.py +117 -0
- .venv/lib/python3.14/site-packages/_pytest/_code/__init__.py +26 -0
- .venv/lib/python3.14/site-packages/_pytest/_code/code.py +1632 -0
- .venv/lib/python3.14/site-packages/_pytest/_code/source.py +228 -0
- .venv/lib/python3.14/site-packages/_pytest/_io/__init__.py +10 -0
- .venv/lib/python3.14/site-packages/_pytest/_io/pprint.py +673 -0
- .venv/lib/python3.14/site-packages/_pytest/_io/saferepr.py +155 -0
- .venv/lib/python3.14/site-packages/_pytest/_io/terminalwriter.py +258 -0
- .venv/lib/python3.14/site-packages/_pytest/_io/wcwidth.py +57 -0
- .venv/lib/python3.14/site-packages/_pytest/_py/__init__.py +0 -0
- .venv/lib/python3.14/site-packages/_pytest/_py/error.py +119 -0
- .venv/lib/python3.14/site-packages/_pytest/_py/path.py +1475 -0
- .venv/lib/python3.14/site-packages/_pytest/_version.py +24 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/__init__.py +236 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_compare_any.py +131 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_compare_mapping.py +48 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_compare_sequence.py +90 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_compare_set.py +101 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_guards.py +60 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/_typing.py +12 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/compare_text.py +104 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/highlight.py +11 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/rewrite.py +1193 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/truncate.py +128 -0
- .venv/lib/python3.14/site-packages/_pytest/assertion/util.py +215 -0
- .venv/lib/python3.14/site-packages/_pytest/cacheprovider.py +640 -0
- .venv/lib/python3.14/site-packages/_pytest/capture.py +1151 -0
- .venv/lib/python3.14/site-packages/_pytest/compat.py +329 -0
- .venv/lib/python3.14/site-packages/_pytest/config/__init__.py +2246 -0
- .venv/lib/python3.14/site-packages/_pytest/config/argparsing.py +503 -0
- .venv/lib/python3.14/site-packages/_pytest/config/exceptions.py +15 -0
- .venv/lib/python3.14/site-packages/_pytest/config/findpaths.py +350 -0
- .venv/lib/python3.14/site-packages/_pytest/debugging.py +404 -0
- .venv/lib/python3.14/site-packages/_pytest/deprecated.py +139 -0
.gitattributes
CHANGED
|
@@ -35,3 +35,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
gguf/sakthai-coder-q4_k_m.gguf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
.venv/bin/python filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
gguf/sakthai-coder-q4_k_m.gguf filter=lfs diff=lfs merge=lfs -text
|
| 37 |
.venv/bin/python filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
.venv/bin/python3 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
.venv/bin/python3.14 filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
.venv/bin/ruff filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
.venv/lib/python3.14/site-packages/08ae81f72d5a2b5fa9e0__mypyc.cpython-314-x86_64-linux-gnu.so filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
.venv/lib/python3.14/site-packages/81d243bd2c585b0f4821__mypyc.cpython-314-x86_64-linux-gnu.so filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
.venv/lib/python3.14/site-packages/_cffi_backend.cpython-314-x86_64-linux-gnu.so filter=lfs diff=lfs merge=lfs -text
|
.venv/bin/python3
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3033d7dd6bc07b45575eeb52dd886c05391f2464e4c51c9cac5e63226d41c9db
|
| 3 |
+
size 32306368
|
.venv/bin/python3.14
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3033d7dd6bc07b45575eeb52dd886c05391f2464e4c51c9cac5e63226d41c9db
|
| 3 |
+
size 32306368
|
.venv/bin/ruff
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:64aae5e444938e33121c3b940dff9b3d8ef8fc2a88c477e7f3a4fae2584a8fe8
|
| 3 |
+
size 27832160
|
.venv/bin/sakthai
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/opt/data/Sak-Family-Agent/.venv/bin/python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import sys
|
| 4 |
+
from sakthai.cli import main
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
if sys.argv[0].endswith("-script.pyw"):
|
| 7 |
+
sys.argv[0] = sys.argv[0][:-11]
|
| 8 |
+
elif sys.argv[0].endswith(".exe"):
|
| 9 |
+
sys.argv[0] = sys.argv[0][:-4]
|
| 10 |
+
sys.exit(main())
|
.venv/bin/stubgen
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/opt/data/sakthai-skills-repo/.venv/bin/python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import sys
|
| 4 |
+
from mypy.stubgen import main
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
if sys.argv[0].endswith("-script.pyw"):
|
| 7 |
+
sys.argv[0] = sys.argv[0][:-11]
|
| 8 |
+
elif sys.argv[0].endswith(".exe"):
|
| 9 |
+
sys.argv[0] = sys.argv[0][:-4]
|
| 10 |
+
sys.exit(main())
|
.venv/bin/stubtest
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/opt/data/sakthai-skills-repo/.venv/bin/python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import sys
|
| 4 |
+
from mypy.stubtest import main
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
if sys.argv[0].endswith("-script.pyw"):
|
| 7 |
+
sys.argv[0] = sys.argv[0][:-11]
|
| 8 |
+
elif sys.argv[0].endswith(".exe"):
|
| 9 |
+
sys.argv[0] = sys.argv[0][:-4]
|
| 10 |
+
sys.exit(main())
|
.venv/bin/websockets
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/opt/data/sakthai-skills-repo/.venv/bin/python
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
import sys
|
| 4 |
+
from websockets.cli import main
|
| 5 |
+
if __name__ == "__main__":
|
| 6 |
+
if sys.argv[0].endswith("-script.pyw"):
|
| 7 |
+
sys.argv[0] = sys.argv[0][:-11]
|
| 8 |
+
elif sys.argv[0].endswith(".exe"):
|
| 9 |
+
sys.argv[0] = sys.argv[0][:-4]
|
| 10 |
+
sys.exit(main())
|
.venv/lib/python3.14/site-packages/08ae81f72d5a2b5fa9e0__mypyc.cpython-314-x86_64-linux-gnu.so
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:984c73beda1391a9b2cb7d423d8f7c1736bcafb8625fced8089eecb90892426b
|
| 3 |
+
size 36745144
|
.venv/lib/python3.14/site-packages/81d243bd2c585b0f4821__mypyc.cpython-314-x86_64-linux-gnu.so
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:128abe84767022efa02b02c588bd2ec1955c5aaa22f6fdc655ae690f9592dec1
|
| 3 |
+
size 433360
|
.venv/lib/python3.14/site-packages/__editable__.sakthai_agent-2.0.0.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:12aca8d236f52b1e052f8b809515f049c3e162c5f343a250bd259d3c8e2c4e49
|
| 3 |
+
size 44
|
.venv/lib/python3.14/site-packages/_cffi_backend.cpython-314-x86_64-linux-gnu.so
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:202efbf6eaa01ba2dcf833edf7c6bf9449c599c38348169a583864b796029d87
|
| 3 |
+
size 344664
|
.venv/lib/python3.14/site-packages/_hypothesis_ftz_detector.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is part of Hypothesis, which may be found at
|
| 2 |
+
# https://github.com/HypothesisWorks/hypothesis/
|
| 3 |
+
#
|
| 4 |
+
# Copyright the Hypothesis Authors.
|
| 5 |
+
# Individual contributors are listed in AUTHORS.rst and the git log.
|
| 6 |
+
#
|
| 7 |
+
# This Source Code Form is subject to the terms of the Mozilla Public License,
|
| 8 |
+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
| 9 |
+
# obtain one at https://mozilla.org/MPL/2.0/.
|
| 10 |
+
|
| 11 |
+
"""
|
| 12 |
+
This is a toolkit for determining which module set the "flush to zero" flag.
|
| 13 |
+
|
| 14 |
+
For details, see the docstring and comments in `identify_ftz_culprit()`. This module
|
| 15 |
+
is defined outside the main Hypothesis namespace so that we can avoid triggering
|
| 16 |
+
import of Hypothesis itself from each subprocess which must import the worker function.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import importlib
|
| 20 |
+
import sys
|
| 21 |
+
from collections.abc import Callable
|
| 22 |
+
from typing import TYPE_CHECKING, TypeAlias
|
| 23 |
+
|
| 24 |
+
if TYPE_CHECKING:
|
| 25 |
+
from multiprocessing import Queue
|
| 26 |
+
|
| 27 |
+
FTZCulprits: TypeAlias = tuple[bool | None, set[str]]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
KNOWN_EVER_CULPRITS = (
|
| 31 |
+
# https://moyix.blogspot.com/2022/09/someones-been-messing-with-my-subnormals.html
|
| 32 |
+
# fmt: off
|
| 33 |
+
"archive-pdf-tools", "bgfx-python", "bicleaner-ai-glove", "BTrees", "cadbiom",
|
| 34 |
+
"ctranslate2", "dyNET", "dyNET38", "gevent", "glove-python-binary", "higra",
|
| 35 |
+
"hybridq", "ikomia", "ioh", "jij-cimod", "lavavu", "lavavu-osmesa", "MulticoreTSNE",
|
| 36 |
+
"neural-compressor", "nwhy", "openjij", "openturns", "perfmetrics", "pHashPy",
|
| 37 |
+
"pyace-lite", "pyapr", "pycompadre", "pycompadre-serial", "PyKEP", "pykep",
|
| 38 |
+
"pylimer-tools", "pyqubo", "pyscf", "PyTAT", "python-prtree", "qiskit-aer",
|
| 39 |
+
"qiskit-aer-gpu", "RelStorage", "sail-ml", "segmentation", "sente", "sinr",
|
| 40 |
+
"snapml", "superman", "symengine", "systran-align", "texture-tool", "tsne-mp",
|
| 41 |
+
"xcsf",
|
| 42 |
+
# fmt: on
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def flush_to_zero() -> bool:
|
| 47 |
+
# If this subnormal number compares equal to zero we have a problem
|
| 48 |
+
return 2.0**-1073 == 0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def run_in_process(fn: Callable[..., FTZCulprits], *args: object) -> FTZCulprits:
|
| 52 |
+
import multiprocessing as mp
|
| 53 |
+
|
| 54 |
+
mp.set_start_method("spawn", force=True)
|
| 55 |
+
q: Queue[FTZCulprits] = mp.Queue()
|
| 56 |
+
p = mp.Process(target=target, args=(q, fn, *args))
|
| 57 |
+
p.start()
|
| 58 |
+
retval = q.get()
|
| 59 |
+
p.join()
|
| 60 |
+
return retval
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def target(
|
| 64 |
+
q: "Queue[FTZCulprits]", fn: Callable[..., FTZCulprits], *args: object
|
| 65 |
+
) -> None:
|
| 66 |
+
q.put(fn(*args))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def always_imported_modules() -> FTZCulprits:
|
| 70 |
+
return flush_to_zero(), set(sys.modules)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def modules_imported_by(mod: str) -> FTZCulprits:
|
| 74 |
+
"""Return the set of modules imported transitively by mod."""
|
| 75 |
+
before = set(sys.modules)
|
| 76 |
+
try:
|
| 77 |
+
importlib.import_module(mod)
|
| 78 |
+
except Exception:
|
| 79 |
+
return None, set()
|
| 80 |
+
imports = set(sys.modules) - before
|
| 81 |
+
return flush_to_zero(), imports
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# We don't want to redo all the expensive process-spawning checks when we've already
|
| 85 |
+
# done them, so we cache known-good packages and a known-FTZ result if we have one.
|
| 86 |
+
KNOWN_FTZ: str | None = None
|
| 87 |
+
CHECKED_CACHE: set[str] = set()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def identify_ftz_culprits() -> str:
|
| 91 |
+
"""Find the modules in sys.modules which cause "mod" to be imported."""
|
| 92 |
+
# If we've run this function before, return the same result.
|
| 93 |
+
global KNOWN_FTZ
|
| 94 |
+
if KNOWN_FTZ:
|
| 95 |
+
return KNOWN_FTZ
|
| 96 |
+
# Start by determining our baseline: the FTZ and sys.modules state in a fresh
|
| 97 |
+
# process which has only imported this module and nothing else.
|
| 98 |
+
always_enables_ftz, always_imports = run_in_process(always_imported_modules)
|
| 99 |
+
if always_enables_ftz:
|
| 100 |
+
raise RuntimeError("Python is always in FTZ mode, even without imports!")
|
| 101 |
+
CHECKED_CACHE.update(always_imports)
|
| 102 |
+
|
| 103 |
+
# Next, we'll search through sys.modules looking for a package (or packages) such
|
| 104 |
+
# that importing them in a new process sets the FTZ state. As a heuristic, we'll
|
| 105 |
+
# start with packages known to have ever enabled FTZ, then top-level packages as
|
| 106 |
+
# a way to eliminate large fractions of the search space relatively quickly.
|
| 107 |
+
def key(name: str) -> tuple[bool, int, str]:
|
| 108 |
+
"""Prefer known-FTZ modules, then top-level packages, then alphabetical."""
|
| 109 |
+
return (name not in KNOWN_EVER_CULPRITS, name.count("."), name)
|
| 110 |
+
|
| 111 |
+
# We'll track the set of modules to be checked, and those which do trigger FTZ.
|
| 112 |
+
candidates = set(sys.modules) - CHECKED_CACHE
|
| 113 |
+
triggering_modules = {}
|
| 114 |
+
while candidates:
|
| 115 |
+
mod = min(candidates, key=key)
|
| 116 |
+
candidates.discard(mod)
|
| 117 |
+
enables_ftz, imports = run_in_process(modules_imported_by, mod)
|
| 118 |
+
imports -= CHECKED_CACHE
|
| 119 |
+
if enables_ftz:
|
| 120 |
+
triggering_modules[mod] = imports
|
| 121 |
+
candidates &= imports
|
| 122 |
+
else:
|
| 123 |
+
candidates -= imports
|
| 124 |
+
CHECKED_CACHE.update(imports)
|
| 125 |
+
|
| 126 |
+
# We only want to report the 'top level' packages which enable FTZ - for example,
|
| 127 |
+
# if the enabling code is in `a.b`, and `a` in turn imports `a.b`, we prefer to
|
| 128 |
+
# report `a`. On the other hand, if `a` does _not_ import `a.b`, as is the case
|
| 129 |
+
# for `hypothesis.extra.*` modules, then `a` will not be in `triggering_modules`
|
| 130 |
+
# and we'll report `a.b` here instead.
|
| 131 |
+
prefixes = tuple(n + "." for n in triggering_modules)
|
| 132 |
+
result = {k for k in triggering_modules if not k.startswith(prefixes)}
|
| 133 |
+
|
| 134 |
+
# Suppose that `bar` enables FTZ, and `foo` imports `bar`. At this point we're
|
| 135 |
+
# tracking both, but only want to report the latter.
|
| 136 |
+
for a in sorted(result):
|
| 137 |
+
for b in sorted(result):
|
| 138 |
+
if a in triggering_modules[b] and b not in triggering_modules[a]:
|
| 139 |
+
result.discard(b)
|
| 140 |
+
|
| 141 |
+
# There may be a cyclic dependency which that didn't handle, or simply two
|
| 142 |
+
# separate modules which both enable FTZ. We already gave up comprehensive
|
| 143 |
+
# reporting for speed above (`candidates &= imports`), so we'll also buy
|
| 144 |
+
# simpler reporting by arbitrarily selecting the alphabetically first package.
|
| 145 |
+
KNOWN_FTZ = min(result) # Cache the result - it's likely this will trigger again!
|
| 146 |
+
return KNOWN_FTZ
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
if __name__ == "__main__":
|
| 150 |
+
# This would be really really annoying to write automated tests for, so I've
|
| 151 |
+
# done some manual exploratory testing: `pip install grequests gevent==21.12.0`,
|
| 152 |
+
# and call print() as desired to observe behavior.
|
| 153 |
+
import grequests # noqa
|
| 154 |
+
|
| 155 |
+
# To test without skipping to a known answer, uncomment the following line and
|
| 156 |
+
# change the last element of key from `name` to `-len(name)` so that we check
|
| 157 |
+
# grequests before gevent.
|
| 158 |
+
# KNOWN_EVER_CULPRITS = [c for c in KNOWN_EVER_CULPRITS if c != "gevent"]
|
| 159 |
+
print(identify_ftz_culprits()) # noqa: T201
|
.venv/lib/python3.14/site-packages/_hypothesis_globals.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is part of Hypothesis, which may be found at
|
| 2 |
+
# https://github.com/HypothesisWorks/hypothesis/
|
| 3 |
+
#
|
| 4 |
+
# Copyright the Hypothesis Authors.
|
| 5 |
+
# Individual contributors are listed in AUTHORS.rst and the git log.
|
| 6 |
+
#
|
| 7 |
+
# This Source Code Form is subject to the terms of the Mozilla Public License,
|
| 8 |
+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
| 9 |
+
# obtain one at https://mozilla.org/MPL/2.0/.
|
| 10 |
+
|
| 11 |
+
"""
|
| 12 |
+
Module for globals shared between plugin(s) and the main hypothesis module, without
|
| 13 |
+
depending on either. This file should have no imports outside of stdlib.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
in_initialization = 1
|
| 19 |
+
"""If >0, indicates that hypothesis is still initializing (importing or loading
|
| 20 |
+
the test environment). `import hypothesis` will cause this number to be decremented,
|
| 21 |
+
and the pytest plugin increments at load time, then decrements it just before each test
|
| 22 |
+
session starts. However, this leads to a hole in coverage if another pytest plugin
|
| 23 |
+
imports hypothesis before our plugin is loaded. HYPOTHESIS_EXTEND_INITIALIZATION may
|
| 24 |
+
be set to pre-increment the value on behalf of _hypothesis_pytestplugin, plugging the
|
| 25 |
+
hole."""
|
| 26 |
+
|
| 27 |
+
if os.environ.get("HYPOTHESIS_EXTEND_INITIALIZATION"):
|
| 28 |
+
in_initialization += 1
|
.venv/lib/python3.14/site-packages/_hypothesis_pytestplugin.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file is part of Hypothesis, which may be found at
|
| 2 |
+
# https://github.com/HypothesisWorks/hypothesis/
|
| 3 |
+
#
|
| 4 |
+
# Copyright the Hypothesis Authors.
|
| 5 |
+
# Individual contributors are listed in AUTHORS.rst and the git log.
|
| 6 |
+
#
|
| 7 |
+
# This Source Code Form is subject to the terms of the Mozilla Public License,
|
| 8 |
+
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
|
| 9 |
+
# obtain one at https://mozilla.org/MPL/2.0/.
|
| 10 |
+
|
| 11 |
+
"""
|
| 12 |
+
The pytest plugin for Hypothesis.
|
| 13 |
+
|
| 14 |
+
We move this from the old location at `hypothesis.extra.pytestplugin` so that it
|
| 15 |
+
can be loaded by Pytest without importing Hypothesis. In turn, this means that
|
| 16 |
+
Hypothesis will not load our own third-party plugins (with associated side-effects)
|
| 17 |
+
unless and until the user explicitly runs `import hypothesis`.
|
| 18 |
+
|
| 19 |
+
See https://github.com/HypothesisWorks/hypothesis/issues/3140 for details.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import base64
|
| 23 |
+
import json
|
| 24 |
+
import os
|
| 25 |
+
import sys
|
| 26 |
+
import warnings
|
| 27 |
+
from fnmatch import fnmatch
|
| 28 |
+
from inspect import signature
|
| 29 |
+
|
| 30 |
+
import _hypothesis_globals
|
| 31 |
+
import pytest
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
from _pytest.junitxml import xml_key
|
| 35 |
+
except ImportError:
|
| 36 |
+
xml_key = "_xml" # type: ignore
|
| 37 |
+
|
| 38 |
+
LOAD_PROFILE_OPTION = "--hypothesis-profile"
|
| 39 |
+
VERBOSITY_OPTION = "--hypothesis-verbosity"
|
| 40 |
+
PRINT_STATISTICS_OPTION = "--hypothesis-show-statistics"
|
| 41 |
+
SEED_OPTION = "--hypothesis-seed"
|
| 42 |
+
EXPLAIN_OPTION = "--hypothesis-explain"
|
| 43 |
+
|
| 44 |
+
_VERBOSITY_NAMES = ["quiet", "normal", "verbose", "debug"]
|
| 45 |
+
_ALL_OPTIONS = [
|
| 46 |
+
LOAD_PROFILE_OPTION,
|
| 47 |
+
VERBOSITY_OPTION,
|
| 48 |
+
PRINT_STATISTICS_OPTION,
|
| 49 |
+
SEED_OPTION,
|
| 50 |
+
EXPLAIN_OPTION,
|
| 51 |
+
]
|
| 52 |
+
STATS_KEY = "_hypothesis_stats"
|
| 53 |
+
FAILING_EXAMPLES_KEY = "_hypothesis_failing_examples"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class StoringReporter:
|
| 57 |
+
def __init__(self, config):
|
| 58 |
+
assert "hypothesis" in sys.modules
|
| 59 |
+
from hypothesis.reporting import default
|
| 60 |
+
|
| 61 |
+
self.report = default
|
| 62 |
+
self.config = config
|
| 63 |
+
self.results = []
|
| 64 |
+
|
| 65 |
+
def __call__(self, msg):
|
| 66 |
+
if self.config.getoption("capture", "fd") == "no":
|
| 67 |
+
self.report(msg)
|
| 68 |
+
if not isinstance(msg, str):
|
| 69 |
+
msg = repr(msg)
|
| 70 |
+
self.results.append(msg)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# Avoiding distutils.version.LooseVersion due to
|
| 74 |
+
# https://github.com/HypothesisWorks/hypothesis/issues/2490
|
| 75 |
+
if tuple(map(int, pytest.__version__.split(".")[:2])) < (4, 6): # pragma: no cover
|
| 76 |
+
import warnings
|
| 77 |
+
|
| 78 |
+
PYTEST_TOO_OLD_MESSAGE = """
|
| 79 |
+
You are using pytest version %s. Hypothesis tests work with any test
|
| 80 |
+
runner, but our pytest plugin requires pytest 4.6 or newer.
|
| 81 |
+
Note that the pytest developers no longer support your version either!
|
| 82 |
+
Disabling the Hypothesis pytest plugin...
|
| 83 |
+
"""
|
| 84 |
+
warnings.warn(PYTEST_TOO_OLD_MESSAGE % (pytest.__version__,), stacklevel=1)
|
| 85 |
+
|
| 86 |
+
else:
|
| 87 |
+
# Restart side-effect detection as early as possible, to maximize coverage. We
|
| 88 |
+
# need balanced increment/decrement in configure/sessionstart to support nested
|
| 89 |
+
# pytest (e.g. runpytest_inprocess), so this early increment in effect replaces
|
| 90 |
+
# the first one in pytest_configure.
|
| 91 |
+
if not os.environ.get("HYPOTHESIS_EXTEND_INITIALIZATION"):
|
| 92 |
+
_hypothesis_globals.in_initialization += 1
|
| 93 |
+
if "hypothesis" in sys.modules:
|
| 94 |
+
# Some other plugin has imported hypothesis, so we'll check if there
|
| 95 |
+
# have been undetected side-effects and warn if so.
|
| 96 |
+
from hypothesis.configuration import notice_initialization_restarted
|
| 97 |
+
|
| 98 |
+
notice_initialization_restarted()
|
| 99 |
+
|
| 100 |
+
def pytest_addoption(parser):
|
| 101 |
+
group = parser.getgroup("hypothesis", "Hypothesis")
|
| 102 |
+
group.addoption(
|
| 103 |
+
LOAD_PROFILE_OPTION,
|
| 104 |
+
action="store",
|
| 105 |
+
help="Load in a registered hypothesis.settings profile",
|
| 106 |
+
)
|
| 107 |
+
group.addoption(
|
| 108 |
+
VERBOSITY_OPTION,
|
| 109 |
+
action="store",
|
| 110 |
+
choices=_VERBOSITY_NAMES,
|
| 111 |
+
help="Override profile with verbosity setting specified",
|
| 112 |
+
)
|
| 113 |
+
group.addoption(
|
| 114 |
+
PRINT_STATISTICS_OPTION,
|
| 115 |
+
action="store_true",
|
| 116 |
+
help="Configure when statistics are printed",
|
| 117 |
+
default=False,
|
| 118 |
+
)
|
| 119 |
+
group.addoption(
|
| 120 |
+
SEED_OPTION,
|
| 121 |
+
action="store",
|
| 122 |
+
help="Set a seed to use for all Hypothesis tests",
|
| 123 |
+
)
|
| 124 |
+
group.addoption(
|
| 125 |
+
EXPLAIN_OPTION,
|
| 126 |
+
action="store_true",
|
| 127 |
+
help="Enable the `explain` phase for failing Hypothesis tests",
|
| 128 |
+
default=False,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
def _any_hypothesis_option(config):
|
| 132 |
+
return bool(any(config.getoption(opt) for opt in _ALL_OPTIONS))
|
| 133 |
+
|
| 134 |
+
def pytest_report_header(config):
|
| 135 |
+
if not (
|
| 136 |
+
config.option.verbose >= 1
|
| 137 |
+
or "hypothesis" in sys.modules
|
| 138 |
+
or _any_hypothesis_option(config)
|
| 139 |
+
):
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
from hypothesis import Verbosity, settings
|
| 143 |
+
|
| 144 |
+
if config.option.verbose < 1 and settings.default.verbosity < Verbosity.verbose:
|
| 145 |
+
return None
|
| 146 |
+
settings_str = settings.default.show_changed()
|
| 147 |
+
if settings_str != "":
|
| 148 |
+
settings_str = f" -> {settings_str}"
|
| 149 |
+
return (
|
| 150 |
+
f"hypothesis profile {settings.get_current_profile_name()!r}{settings_str}"
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
def pytest_configure(config):
|
| 154 |
+
config.addinivalue_line("markers", "hypothesis: Tests which use hypothesis.")
|
| 155 |
+
if not _any_hypothesis_option(config):
|
| 156 |
+
return
|
| 157 |
+
from hypothesis import Phase, Verbosity, core, settings
|
| 158 |
+
|
| 159 |
+
profile = config.getoption(LOAD_PROFILE_OPTION)
|
| 160 |
+
if profile:
|
| 161 |
+
settings.load_profile(profile)
|
| 162 |
+
verbosity_name = config.getoption(VERBOSITY_OPTION)
|
| 163 |
+
if verbosity_name and verbosity_name != settings.default.verbosity.name:
|
| 164 |
+
verbosity_value = Verbosity[verbosity_name]
|
| 165 |
+
name = (
|
| 166 |
+
f"{settings.get_current_profile_name()}-with-{verbosity_name}-verbosity"
|
| 167 |
+
)
|
| 168 |
+
# register_profile creates a new profile, exactly like the current one,
|
| 169 |
+
# with the extra values given (in this case 'verbosity')
|
| 170 |
+
settings.register_profile(name, verbosity=verbosity_value)
|
| 171 |
+
settings.load_profile(name)
|
| 172 |
+
if (
|
| 173 |
+
config.getoption(EXPLAIN_OPTION)
|
| 174 |
+
and Phase.explain not in settings.default.phases
|
| 175 |
+
):
|
| 176 |
+
name = f"{settings.get_current_profile_name()}-with-explain-phase"
|
| 177 |
+
phases = (*settings.default.phases, Phase.explain)
|
| 178 |
+
settings.register_profile(name, phases=phases)
|
| 179 |
+
settings.load_profile(name)
|
| 180 |
+
|
| 181 |
+
seed = config.getoption(SEED_OPTION)
|
| 182 |
+
if seed is not None:
|
| 183 |
+
try:
|
| 184 |
+
seed = int(seed)
|
| 185 |
+
except ValueError:
|
| 186 |
+
pass
|
| 187 |
+
core.global_force_seed = seed
|
| 188 |
+
|
| 189 |
+
@pytest.hookimpl(hookwrapper=True)
|
| 190 |
+
def pytest_runtest_call(item):
|
| 191 |
+
__tracebackhide__ = True
|
| 192 |
+
if not (hasattr(item, "obj") and "hypothesis" in sys.modules):
|
| 193 |
+
yield
|
| 194 |
+
return
|
| 195 |
+
|
| 196 |
+
from hypothesis import core, is_hypothesis_test
|
| 197 |
+
|
| 198 |
+
# See https://github.com/pytest-dev/pytest/issues/9159
|
| 199 |
+
core.pytest_shows_exceptiongroups = (
|
| 200 |
+
getattr(pytest, "version_tuple", ())[:2] >= (7, 2)
|
| 201 |
+
or item.config.getoption("tbstyle", "auto") == "native"
|
| 202 |
+
)
|
| 203 |
+
core.running_under_pytest = True
|
| 204 |
+
|
| 205 |
+
if not is_hypothesis_test(item.obj):
|
| 206 |
+
# If @given was not applied, check whether other hypothesis
|
| 207 |
+
# decorators were applied, and raise an error if they were.
|
| 208 |
+
# We add this frame of indirection to enable __tracebackhide__.
|
| 209 |
+
def raise_hypothesis_usage_error(msg):
|
| 210 |
+
raise InvalidArgument(msg)
|
| 211 |
+
|
| 212 |
+
if getattr(item.obj, "is_hypothesis_strategy_function", False):
|
| 213 |
+
from hypothesis.errors import InvalidArgument
|
| 214 |
+
|
| 215 |
+
raise_hypothesis_usage_error(
|
| 216 |
+
f"{item.nodeid} is a function that returns a Hypothesis strategy, "
|
| 217 |
+
"but pytest has collected it as a test function. This is useless "
|
| 218 |
+
"as the function body will never be executed. To define a test "
|
| 219 |
+
"function, use @given instead of @composite."
|
| 220 |
+
)
|
| 221 |
+
message = "Using `@%s` on a test without `@given` is completely pointless."
|
| 222 |
+
for name, attribute in [
|
| 223 |
+
("example", "hypothesis_explicit_examples"),
|
| 224 |
+
("seed", "_hypothesis_internal_use_seed"),
|
| 225 |
+
("settings", "_hypothesis_internal_settings_applied"),
|
| 226 |
+
("reproduce_example", "_hypothesis_internal_use_reproduce_failure"),
|
| 227 |
+
]:
|
| 228 |
+
if hasattr(item.obj, attribute):
|
| 229 |
+
from hypothesis.errors import InvalidArgument
|
| 230 |
+
|
| 231 |
+
raise_hypothesis_usage_error(message % (name,))
|
| 232 |
+
yield
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
from hypothesis import HealthCheck, settings as Settings
|
| 236 |
+
from hypothesis.internal.escalation import current_pytest_item
|
| 237 |
+
from hypothesis.internal.healthcheck import fail_health_check
|
| 238 |
+
from hypothesis.reporting import with_reporter
|
| 239 |
+
from hypothesis.statistics import collector, describe_statistics
|
| 240 |
+
|
| 241 |
+
# Retrieve the settings for this test from the test object, which
|
| 242 |
+
# is normally a Hypothesis wrapped_test wrapper. If this doesn't
|
| 243 |
+
# work, the test object is probably something weird
|
| 244 |
+
# (e.g a stateful test wrapper), so we skip the function-scoped
|
| 245 |
+
# fixture check.
|
| 246 |
+
settings = getattr(
|
| 247 |
+
item.obj, "_hypothesis_internal_use_settings", Settings.default
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Check for suspicious use of function-scoped fixtures, but only
|
| 251 |
+
# if the corresponding health check is not suppressed.
|
| 252 |
+
fixture_params = False
|
| 253 |
+
if not set(settings.suppress_health_check).issuperset(
|
| 254 |
+
{HealthCheck.function_scoped_fixture, HealthCheck.differing_executors}
|
| 255 |
+
):
|
| 256 |
+
# Warn about function-scoped fixtures, excluding autouse fixtures because
|
| 257 |
+
# the advice is probably not actionable and the status quo seems OK...
|
| 258 |
+
# See https://github.com/HypothesisWorks/hypothesis/issues/377 for detail.
|
| 259 |
+
argnames = None
|
| 260 |
+
for fx_defs in item._request._fixturemanager.getfixtureinfo(
|
| 261 |
+
node=item, func=item.function, cls=None
|
| 262 |
+
).name2fixturedefs.values():
|
| 263 |
+
if argnames is None:
|
| 264 |
+
argnames = frozenset(signature(item.function).parameters)
|
| 265 |
+
for fx in fx_defs:
|
| 266 |
+
fixture_params |= bool(fx.params)
|
| 267 |
+
if fx.argname not in argnames:
|
| 268 |
+
continue
|
| 269 |
+
active_fx = item._request._get_active_fixturedef(fx.argname)
|
| 270 |
+
if active_fx.scope == "function":
|
| 271 |
+
fail_health_check(
|
| 272 |
+
settings,
|
| 273 |
+
f"{item.nodeid!r} uses a function-scoped fixture {fx.argname!r}."
|
| 274 |
+
"\n\n"
|
| 275 |
+
"Function-scoped fixtures are not reset between inputs "
|
| 276 |
+
"generated by `@given(...)`, which is often surprising and "
|
| 277 |
+
"can cause subtle test bugs."
|
| 278 |
+
"\n\n"
|
| 279 |
+
"If you were expecting the fixture to run separately "
|
| 280 |
+
"for each generated input, then unfortunately you "
|
| 281 |
+
"will need to find a different way to achieve your "
|
| 282 |
+
"goal (for example, replacing the fixture with a similar "
|
| 283 |
+
"context manager inside of the test)."
|
| 284 |
+
"\n\n"
|
| 285 |
+
"If you are confident that your test will work correctly "
|
| 286 |
+
"even though the fixture is not reset between generated "
|
| 287 |
+
"inputs, you can suppress this health check with "
|
| 288 |
+
"@settings(suppress_health_check=[HealthCheck.function_scoped_fixture]). "
|
| 289 |
+
"See "
|
| 290 |
+
"https://hypothesis.readthedocs.io/en/latest/reference/api.html#hypothesis.HealthCheck "
|
| 291 |
+
"for details.",
|
| 292 |
+
HealthCheck.function_scoped_fixture,
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
if fixture_params or (item.get_closest_marker("parametrize") is not None):
|
| 296 |
+
# Disable the differing_executors health check due to false alarms:
|
| 297 |
+
# see https://github.com/HypothesisWorks/hypothesis/issues/3733
|
| 298 |
+
fn = getattr(item.obj, "__func__", item.obj)
|
| 299 |
+
fn._hypothesis_internal_use_settings = Settings(
|
| 300 |
+
parent=settings,
|
| 301 |
+
suppress_health_check={HealthCheck.differing_executors}
|
| 302 |
+
| set(settings.suppress_health_check),
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# Give every parametrized test invocation a unique database key
|
| 306 |
+
key = item.nodeid.encode()
|
| 307 |
+
item.obj.hypothesis.inner_test._hypothesis_internal_add_digest = key
|
| 308 |
+
|
| 309 |
+
store = StoringReporter(item.config)
|
| 310 |
+
|
| 311 |
+
def note_statistics(stats):
|
| 312 |
+
stats["nodeid"] = item.nodeid
|
| 313 |
+
item.hypothesis_statistics = describe_statistics(stats)
|
| 314 |
+
|
| 315 |
+
with (
|
| 316 |
+
collector.with_value(note_statistics),
|
| 317 |
+
with_reporter(store),
|
| 318 |
+
current_pytest_item.with_value(item),
|
| 319 |
+
):
|
| 320 |
+
yield
|
| 321 |
+
|
| 322 |
+
if store.results:
|
| 323 |
+
item.hypothesis_report_information = "\n".join(store.results)
|
| 324 |
+
|
| 325 |
+
def _stash_get(config, key, default):
|
| 326 |
+
if hasattr(config, "stash"):
|
| 327 |
+
# pytest 7
|
| 328 |
+
return config.stash.get(key, default)
|
| 329 |
+
elif hasattr(config, "_store"):
|
| 330 |
+
# pytest 5.4
|
| 331 |
+
return config._store.get(key, default)
|
| 332 |
+
else:
|
| 333 |
+
return getattr(config, key, default)
|
| 334 |
+
|
| 335 |
+
@pytest.hookimpl(hookwrapper=True)
|
| 336 |
+
def pytest_runtest_makereport(item, call):
|
| 337 |
+
report = (yield).get_result()
|
| 338 |
+
if hasattr(item, "hypothesis_report_information"):
|
| 339 |
+
report.sections.append(("Hypothesis", item.hypothesis_report_information))
|
| 340 |
+
if report.when != "teardown":
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
terminalreporter = item.config.pluginmanager.getplugin("terminalreporter")
|
| 344 |
+
|
| 345 |
+
if hasattr(item, "hypothesis_statistics"):
|
| 346 |
+
stats = item.hypothesis_statistics
|
| 347 |
+
stats_base64 = base64.b64encode(stats.encode()).decode()
|
| 348 |
+
|
| 349 |
+
name = "hypothesis-statistics-" + item.nodeid
|
| 350 |
+
|
| 351 |
+
# Include hypothesis information to the junit XML report.
|
| 352 |
+
#
|
| 353 |
+
# Note that when `pytest-xdist` is enabled, `xml_key` is not present in the
|
| 354 |
+
# stash, so we don't add anything to the junit XML report in that scenario.
|
| 355 |
+
# https://github.com/pytest-dev/pytest/issues/7767#issuecomment-1082436256
|
| 356 |
+
xml = _stash_get(item.config, xml_key, None)
|
| 357 |
+
if xml:
|
| 358 |
+
xml.add_global_property(name, stats_base64)
|
| 359 |
+
|
| 360 |
+
# If there's a terminal report, include our summary stats for each test
|
| 361 |
+
if terminalreporter is not None:
|
| 362 |
+
report.__dict__[STATS_KEY] = stats
|
| 363 |
+
|
| 364 |
+
# If there's an HTML report, include our summary stats for each test
|
| 365 |
+
pytest_html = item.config.pluginmanager.getplugin("html")
|
| 366 |
+
if pytest_html is not None: # pragma: no cover
|
| 367 |
+
report.extra = [
|
| 368 |
+
*getattr(report, "extra", []),
|
| 369 |
+
pytest_html.extras.text(stats, name="Hypothesis stats"),
|
| 370 |
+
]
|
| 371 |
+
|
| 372 |
+
# This doesn't intrinsically have anything to do with the terminalreporter;
|
| 373 |
+
# we're just cargo-culting a way to get strings back to a single function
|
| 374 |
+
# even if the test were distributed with pytest-xdist.
|
| 375 |
+
failing_examples = getattr(item, FAILING_EXAMPLES_KEY, None)
|
| 376 |
+
if failing_examples and terminalreporter is not None:
|
| 377 |
+
try:
|
| 378 |
+
from hypothesis.extra._patching import FAIL_MSG, get_patch_for
|
| 379 |
+
except ImportError:
|
| 380 |
+
return
|
| 381 |
+
# We'll save this as a triple of [filename, hunk_before, hunk_after].
|
| 382 |
+
triple = get_patch_for(item.obj, [(x, FAIL_MSG) for x in failing_examples])
|
| 383 |
+
if triple is not None:
|
| 384 |
+
report.__dict__[FAILING_EXAMPLES_KEY] = json.dumps(triple)
|
| 385 |
+
|
| 386 |
+
def pytest_terminal_summary(terminalreporter):
|
| 387 |
+
failing_examples = []
|
| 388 |
+
print_stats = terminalreporter.config.getoption(PRINT_STATISTICS_OPTION)
|
| 389 |
+
if print_stats:
|
| 390 |
+
terminalreporter.section("Hypothesis Statistics")
|
| 391 |
+
for reports in terminalreporter.stats.values():
|
| 392 |
+
for report in reports:
|
| 393 |
+
stats = report.__dict__.get(STATS_KEY)
|
| 394 |
+
if stats and print_stats:
|
| 395 |
+
terminalreporter.write_line(stats + "\n\n")
|
| 396 |
+
examples = report.__dict__.get(FAILING_EXAMPLES_KEY)
|
| 397 |
+
if examples:
|
| 398 |
+
failing_examples.append(json.loads(examples))
|
| 399 |
+
|
| 400 |
+
from hypothesis.internal.observability import _WROTE_TO
|
| 401 |
+
|
| 402 |
+
if _WROTE_TO:
|
| 403 |
+
terminalreporter.section("Hypothesis")
|
| 404 |
+
for fname in sorted(_WROTE_TO):
|
| 405 |
+
terminalreporter.write_line(f"observations written to {fname}")
|
| 406 |
+
|
| 407 |
+
if failing_examples:
|
| 408 |
+
# This must have been imported already to write the failing examples
|
| 409 |
+
from hypothesis.extra._patching import gc_patches, make_patch, save_patch
|
| 410 |
+
|
| 411 |
+
patch = make_patch(failing_examples)
|
| 412 |
+
try:
|
| 413 |
+
gc_patches()
|
| 414 |
+
fname = save_patch(patch)
|
| 415 |
+
except Exception:
|
| 416 |
+
# fail gracefully if we hit any filesystem or permissions problems
|
| 417 |
+
return
|
| 418 |
+
if not _WROTE_TO:
|
| 419 |
+
terminalreporter.section("Hypothesis")
|
| 420 |
+
terminalreporter.write_line(
|
| 421 |
+
f"`git apply {fname}` to add failing examples to your code."
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
def pytest_collection_modifyitems(items):
|
| 425 |
+
if "hypothesis" not in sys.modules:
|
| 426 |
+
return
|
| 427 |
+
|
| 428 |
+
from hypothesis import is_hypothesis_test
|
| 429 |
+
|
| 430 |
+
has_hypothesis_tests = False
|
| 431 |
+
for item in items:
|
| 432 |
+
if isinstance(item, pytest.Function) and is_hypothesis_test(item.obj):
|
| 433 |
+
item.add_marker("hypothesis")
|
| 434 |
+
has_hypothesis_tests = True
|
| 435 |
+
|
| 436 |
+
if has_hypothesis_tests:
|
| 437 |
+
# Collect local constants now, during test collection, so that this
|
| 438 |
+
# time is not attributed to whatever test happens to run first.
|
| 439 |
+
# (see https://github.com/HypothesisWorks/hypothesis/issues/4627)
|
| 440 |
+
# We might miss weird wrapped uses (if there are no other PBTs!),
|
| 441 |
+
# in order to avoid the latency from inefficient 3rd party plugins.
|
| 442 |
+
from hypothesis.internal.conjecture.providers import _get_local_constants
|
| 443 |
+
|
| 444 |
+
_get_local_constants()
|
| 445 |
+
|
| 446 |
+
def pytest_sessionstart(session):
|
| 447 |
+
# Note: may be called multiple times, so we can go negative
|
| 448 |
+
_hypothesis_globals.in_initialization -= 1
|
| 449 |
+
|
| 450 |
+
# Monkeypatch some internals to prevent applying @pytest.fixture() to a
|
| 451 |
+
# function which has already been decorated with @hypothesis.given().
|
| 452 |
+
# (the reverse case is already an explicit error in Hypothesis)
|
| 453 |
+
# We do this here so that it catches people on old Pytest versions too.
|
| 454 |
+
from _pytest import fixtures
|
| 455 |
+
|
| 456 |
+
def _ban_given_call(self, function):
|
| 457 |
+
if "hypothesis" in sys.modules:
|
| 458 |
+
from hypothesis import is_hypothesis_test
|
| 459 |
+
|
| 460 |
+
if is_hypothesis_test(function):
|
| 461 |
+
raise RuntimeError(
|
| 462 |
+
f"Can't apply @pytest.fixture() to {function.__name__} because "
|
| 463 |
+
"it is already decorated with @hypothesis.given()"
|
| 464 |
+
)
|
| 465 |
+
return _orig_call(self, function)
|
| 466 |
+
|
| 467 |
+
_orig_call = fixtures.FixtureFunctionMarker.__call__
|
| 468 |
+
fixtures.FixtureFunctionMarker.__call__ = _ban_given_call # type: ignore
|
| 469 |
+
|
| 470 |
+
if int(pytest.__version__.split(".")[0]) >= 7: # pragma: no branch
|
| 471 |
+
# Hook has had this signature since Pytest 7.0, so skip on older versions
|
| 472 |
+
|
| 473 |
+
def pytest_ignore_collect(collection_path, config):
|
| 474 |
+
# Detect, warn about, and mititgate certain misconfigurations;
|
| 475 |
+
# this is mostly educational but can also speed up collection.
|
| 476 |
+
if (
|
| 477 |
+
(name := collection_path.name) == ".hypothesis"
|
| 478 |
+
and collection_path.is_dir()
|
| 479 |
+
and not any(fnmatch(name, p) for p in config.getini("norecursedirs"))
|
| 480 |
+
):
|
| 481 |
+
warnings.warn(
|
| 482 |
+
"Skipping collection of '.hypothesis' directory - this usually "
|
| 483 |
+
"means you've explicitly set the `norecursedirs` pytest config "
|
| 484 |
+
"option, replacing rather than extending the default ignores.",
|
| 485 |
+
stacklevel=1,
|
| 486 |
+
)
|
| 487 |
+
return True
|
| 488 |
+
return None # let other hooks decide
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def load():
|
| 492 |
+
"""Required for `pluggy` to load a plugin from setuptools entrypoints."""
|
.venv/lib/python3.14/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.14/site-packages/_pytest/_argcomplete.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Allow bash-completion for argparse with argcomplete if installed.
|
| 2 |
+
|
| 3 |
+
Needs argcomplete>=0.5.6 for python 3.2/3.3 (older versions fail
|
| 4 |
+
to find the magic string, so _ARGCOMPLETE env. var is never set, and
|
| 5 |
+
this does not need special code).
|
| 6 |
+
|
| 7 |
+
Function try_argcomplete(parser) should be called directly before
|
| 8 |
+
the call to ArgumentParser.parse_args().
|
| 9 |
+
|
| 10 |
+
The filescompleter is what you normally would use on the positional
|
| 11 |
+
arguments specification, in order to get "dirname/" after "dirn<TAB>"
|
| 12 |
+
instead of the default "dirname ":
|
| 13 |
+
|
| 14 |
+
optparser.add_argument(Config._file_or_dir, nargs='*').completer=filescompleter
|
| 15 |
+
|
| 16 |
+
Other, application specific, completers should go in the file
|
| 17 |
+
doing the add_argument calls as they need to be specified as .completer
|
| 18 |
+
attributes as well. (If argcomplete is not installed, the function the
|
| 19 |
+
attribute points to will not be used).
|
| 20 |
+
|
| 21 |
+
SPEEDUP
|
| 22 |
+
=======
|
| 23 |
+
|
| 24 |
+
The generic argcomplete script for bash-completion
|
| 25 |
+
(/etc/bash_completion.d/python-argcomplete.sh)
|
| 26 |
+
uses a python program to determine startup script generated by pip.
|
| 27 |
+
You can speed up completion somewhat by changing this script to include
|
| 28 |
+
# PYTHON_ARGCOMPLETE_OK
|
| 29 |
+
so the python-argcomplete-check-easy-install-script does not
|
| 30 |
+
need to be called to find the entry point of the code and see if that is
|
| 31 |
+
marked with PYTHON_ARGCOMPLETE_OK.
|
| 32 |
+
|
| 33 |
+
INSTALL/DEBUGGING
|
| 34 |
+
=================
|
| 35 |
+
|
| 36 |
+
To include this support in another application that has setup.py generated
|
| 37 |
+
scripts:
|
| 38 |
+
|
| 39 |
+
- Add the line:
|
| 40 |
+
# PYTHON_ARGCOMPLETE_OK
|
| 41 |
+
near the top of the main python entry point.
|
| 42 |
+
|
| 43 |
+
- Include in the file calling parse_args():
|
| 44 |
+
from _argcomplete import try_argcomplete, filescompleter
|
| 45 |
+
Call try_argcomplete just before parse_args(), and optionally add
|
| 46 |
+
filescompleter to the positional arguments' add_argument().
|
| 47 |
+
|
| 48 |
+
If things do not work right away:
|
| 49 |
+
|
| 50 |
+
- Switch on argcomplete debugging with (also helpful when doing custom
|
| 51 |
+
completers):
|
| 52 |
+
export _ARC_DEBUG=1
|
| 53 |
+
|
| 54 |
+
- Run:
|
| 55 |
+
python-argcomplete-check-easy-install-script $(which appname)
|
| 56 |
+
echo $?
|
| 57 |
+
will echo 0 if the magic line has been found, 1 if not.
|
| 58 |
+
|
| 59 |
+
- Sometimes it helps to find early on errors using:
|
| 60 |
+
_ARGCOMPLETE=1 _ARC_DEBUG=1 appname
|
| 61 |
+
which should throw a KeyError: 'COMPLINE' (which is properly set by the
|
| 62 |
+
global argcomplete script).
|
| 63 |
+
"""
|
| 64 |
+
|
| 65 |
+
from __future__ import annotations
|
| 66 |
+
|
| 67 |
+
import argparse
|
| 68 |
+
from glob import glob
|
| 69 |
+
import os
|
| 70 |
+
import sys
|
| 71 |
+
from typing import Any
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class FastFilesCompleter:
|
| 75 |
+
"""Fast file completer class."""
|
| 76 |
+
|
| 77 |
+
def __init__(self, directories: bool = True) -> None:
|
| 78 |
+
self.directories = directories
|
| 79 |
+
|
| 80 |
+
def __call__(self, prefix: str, **kwargs: Any) -> list[str]:
|
| 81 |
+
# Only called on non option completions.
|
| 82 |
+
if os.sep in prefix[1:]:
|
| 83 |
+
prefix_dir = len(os.path.dirname(prefix) + os.sep)
|
| 84 |
+
else:
|
| 85 |
+
prefix_dir = 0
|
| 86 |
+
completion = []
|
| 87 |
+
globbed = []
|
| 88 |
+
if "*" not in prefix and "?" not in prefix:
|
| 89 |
+
# We are on unix, otherwise no bash.
|
| 90 |
+
if not prefix or prefix[-1] == os.sep:
|
| 91 |
+
globbed.extend(glob(prefix + ".*"))
|
| 92 |
+
prefix += "*"
|
| 93 |
+
globbed.extend(glob(prefix))
|
| 94 |
+
for x in sorted(globbed):
|
| 95 |
+
if os.path.isdir(x):
|
| 96 |
+
x += "/"
|
| 97 |
+
# Append stripping the prefix (like bash, not like compgen).
|
| 98 |
+
completion.append(x[prefix_dir:])
|
| 99 |
+
return completion
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if os.environ.get("_ARGCOMPLETE"):
|
| 103 |
+
try:
|
| 104 |
+
import argcomplete.completers
|
| 105 |
+
except ImportError:
|
| 106 |
+
sys.exit(-1)
|
| 107 |
+
filescompleter: FastFilesCompleter | None = FastFilesCompleter()
|
| 108 |
+
|
| 109 |
+
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
|
| 110 |
+
argcomplete.autocomplete(parser, always_complete_options=False)
|
| 111 |
+
|
| 112 |
+
else:
|
| 113 |
+
|
| 114 |
+
def try_argcomplete(parser: argparse.ArgumentParser) -> None:
|
| 115 |
+
pass
|
| 116 |
+
|
| 117 |
+
filescompleter = None
|
.venv/lib/python3.14/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 |
+
"Frame",
|
| 20 |
+
"Source",
|
| 21 |
+
"Traceback",
|
| 22 |
+
"TracebackEntry",
|
| 23 |
+
"filter_traceback",
|
| 24 |
+
"getfslineno",
|
| 25 |
+
"getrawcode",
|
| 26 |
+
]
|
.venv/lib/python3.14/site-packages/_pytest/_code/code.py
ADDED
|
@@ -0,0 +1,1632 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import ast
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from collections.abc import Mapping
|
| 8 |
+
from collections.abc import Sequence
|
| 9 |
+
import dataclasses
|
| 10 |
+
import inspect
|
| 11 |
+
from io import StringIO
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
from traceback import extract_tb
|
| 17 |
+
from traceback import format_exception
|
| 18 |
+
from traceback import format_exception_only
|
| 19 |
+
from traceback import FrameSummary
|
| 20 |
+
from types import CodeType
|
| 21 |
+
from types import FrameType
|
| 22 |
+
from types import TracebackType
|
| 23 |
+
from typing import Any
|
| 24 |
+
from typing import ClassVar
|
| 25 |
+
from typing import Final
|
| 26 |
+
from typing import final
|
| 27 |
+
from typing import Generic
|
| 28 |
+
from typing import Literal
|
| 29 |
+
from typing import overload
|
| 30 |
+
from typing import SupportsIndex
|
| 31 |
+
from typing import TypeAlias
|
| 32 |
+
from typing import TypeVar
|
| 33 |
+
|
| 34 |
+
import pluggy
|
| 35 |
+
|
| 36 |
+
import _pytest
|
| 37 |
+
from _pytest._code.source import findsource
|
| 38 |
+
from _pytest._code.source import getrawcode
|
| 39 |
+
from _pytest._code.source import getstatementrange_ast
|
| 40 |
+
from _pytest._code.source import Source
|
| 41 |
+
from _pytest._io import TerminalWriter
|
| 42 |
+
from _pytest._io.saferepr import safeformat
|
| 43 |
+
from _pytest._io.saferepr import saferepr
|
| 44 |
+
from _pytest.compat import get_real_func
|
| 45 |
+
from _pytest.deprecated import check_ispytest
|
| 46 |
+
from _pytest.pathlib import absolutepath
|
| 47 |
+
from _pytest.pathlib import bestrelpath
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
if sys.version_info < (3, 11):
|
| 51 |
+
from exceptiongroup import BaseExceptionGroup
|
| 52 |
+
|
| 53 |
+
TracebackStyle = Literal["long", "short", "line", "no", "native", "value", "auto"]
|
| 54 |
+
|
| 55 |
+
EXCEPTION_OR_MORE = type[BaseException] | tuple[type[BaseException], ...]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Code:
|
| 59 |
+
"""Wrapper around Python code objects."""
|
| 60 |
+
|
| 61 |
+
__slots__ = ("raw",)
|
| 62 |
+
|
| 63 |
+
def __init__(self, obj: CodeType) -> None:
|
| 64 |
+
self.raw = obj
|
| 65 |
+
|
| 66 |
+
@classmethod
|
| 67 |
+
def from_function(cls, obj: object) -> Code:
|
| 68 |
+
return cls(getrawcode(obj))
|
| 69 |
+
|
| 70 |
+
def __eq__(self, other):
|
| 71 |
+
return self.raw == other.raw
|
| 72 |
+
|
| 73 |
+
# Ignore type because of https://github.com/python/mypy/issues/4266.
|
| 74 |
+
__hash__ = None # type: ignore
|
| 75 |
+
|
| 76 |
+
@property
|
| 77 |
+
def firstlineno(self) -> int:
|
| 78 |
+
return self.raw.co_firstlineno - 1
|
| 79 |
+
|
| 80 |
+
@property
|
| 81 |
+
def name(self) -> str:
|
| 82 |
+
return self.raw.co_name
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def path(self) -> Path | str:
|
| 86 |
+
"""Return a path object pointing to source code, or an ``str`` in
|
| 87 |
+
case of ``OSError`` / non-existing file."""
|
| 88 |
+
filename = inspect.getfile(self.raw)
|
| 89 |
+
if not filename:
|
| 90 |
+
return ""
|
| 91 |
+
try:
|
| 92 |
+
p = absolutepath(filename)
|
| 93 |
+
# maybe don't try this checking
|
| 94 |
+
if not p.exists():
|
| 95 |
+
raise OSError("path check failed.")
|
| 96 |
+
return p
|
| 97 |
+
except OSError:
|
| 98 |
+
# XXX maybe try harder like the weird logic
|
| 99 |
+
# in the standard lib [linecache.updatecache] does?
|
| 100 |
+
return filename
|
| 101 |
+
|
| 102 |
+
@property
|
| 103 |
+
def fullsource(self) -> Source | None:
|
| 104 |
+
"""Return a _pytest._code.Source object for the full source file of the code."""
|
| 105 |
+
full, _ = findsource(self.raw)
|
| 106 |
+
return full
|
| 107 |
+
|
| 108 |
+
def source(self) -> Source:
|
| 109 |
+
"""Return a _pytest._code.Source object for the code object's source only."""
|
| 110 |
+
# return source only for that part of code
|
| 111 |
+
return Source(self.raw)
|
| 112 |
+
|
| 113 |
+
def getargs(self, var: bool = False) -> tuple[str, ...]:
|
| 114 |
+
"""Return a tuple with the argument names for the code object.
|
| 115 |
+
|
| 116 |
+
If 'var' is set True also return the names of the variable and
|
| 117 |
+
keyword arguments when present.
|
| 118 |
+
"""
|
| 119 |
+
# inspect.getargs merges positional and kwonly into a single list;
|
| 120 |
+
# co_argcount is needed to exclude kwonly when var=False.
|
| 121 |
+
args, varargs, varkw = inspect.getargs(self.raw)
|
| 122 |
+
if not var:
|
| 123 |
+
return tuple(args[: self.raw.co_argcount])
|
| 124 |
+
result = list(args)
|
| 125 |
+
if varargs is not None:
|
| 126 |
+
result.append(varargs)
|
| 127 |
+
if varkw is not None:
|
| 128 |
+
result.append(varkw)
|
| 129 |
+
return tuple(result)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
class Frame:
|
| 133 |
+
"""Wrapper around a Python frame holding f_locals and f_globals
|
| 134 |
+
in which expressions can be evaluated."""
|
| 135 |
+
|
| 136 |
+
__slots__ = ("raw",)
|
| 137 |
+
|
| 138 |
+
def __init__(self, frame: FrameType) -> None:
|
| 139 |
+
self.raw = frame
|
| 140 |
+
|
| 141 |
+
@property
|
| 142 |
+
def lineno(self) -> int:
|
| 143 |
+
return self.raw.f_lineno - 1
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def f_globals(self) -> dict[str, Any]:
|
| 147 |
+
return self.raw.f_globals
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def f_locals(self) -> dict[str, Any]:
|
| 151 |
+
return self.raw.f_locals
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def code(self) -> Code:
|
| 155 |
+
return Code(self.raw.f_code)
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def statement(self) -> Source:
|
| 159 |
+
"""Statement this frame is at."""
|
| 160 |
+
if self.code.fullsource is None:
|
| 161 |
+
return Source("")
|
| 162 |
+
return self.code.fullsource.getstatement(self.lineno)
|
| 163 |
+
|
| 164 |
+
def eval(self, code, **vars):
|
| 165 |
+
"""Evaluate 'code' in the frame.
|
| 166 |
+
|
| 167 |
+
'vars' are optional additional local variables.
|
| 168 |
+
|
| 169 |
+
Returns the result of the evaluation.
|
| 170 |
+
"""
|
| 171 |
+
f_locals = self.f_locals.copy()
|
| 172 |
+
f_locals.update(vars)
|
| 173 |
+
return eval(code, self.f_globals, f_locals)
|
| 174 |
+
|
| 175 |
+
def repr(self, object: object) -> str:
|
| 176 |
+
"""Return a 'safe' (non-recursive, one-line) string repr for 'object'."""
|
| 177 |
+
return saferepr(object)
|
| 178 |
+
|
| 179 |
+
def getargs(self, var: bool = False):
|
| 180 |
+
"""Return a list of tuples (name, value) for all arguments.
|
| 181 |
+
|
| 182 |
+
If 'var' is set True, also include the variable and keyword arguments
|
| 183 |
+
when present.
|
| 184 |
+
"""
|
| 185 |
+
retval = []
|
| 186 |
+
for arg in self.code.getargs(var):
|
| 187 |
+
try:
|
| 188 |
+
retval.append((arg, self.f_locals[arg]))
|
| 189 |
+
except KeyError:
|
| 190 |
+
pass # this can occur when using Psyco
|
| 191 |
+
return retval
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class TracebackEntry:
|
| 195 |
+
"""A single entry in a Traceback."""
|
| 196 |
+
|
| 197 |
+
__slots__ = ("_rawentry", "_repr_style")
|
| 198 |
+
|
| 199 |
+
def __init__(
|
| 200 |
+
self,
|
| 201 |
+
rawentry: TracebackType,
|
| 202 |
+
repr_style: Literal["short", "long"] | None = None,
|
| 203 |
+
) -> None:
|
| 204 |
+
self._rawentry: Final = rawentry
|
| 205 |
+
self._repr_style: Final = repr_style
|
| 206 |
+
|
| 207 |
+
def with_repr_style(
|
| 208 |
+
self, repr_style: Literal["short", "long"] | None
|
| 209 |
+
) -> TracebackEntry:
|
| 210 |
+
return TracebackEntry(self._rawentry, repr_style)
|
| 211 |
+
|
| 212 |
+
@property
|
| 213 |
+
def lineno(self) -> int:
|
| 214 |
+
return self._rawentry.tb_lineno - 1
|
| 215 |
+
|
| 216 |
+
def get_python_framesummary(self) -> FrameSummary:
|
| 217 |
+
# Python's built-in traceback module implements all the nitty gritty
|
| 218 |
+
# details to get column numbers of out frames.
|
| 219 |
+
stack_summary = extract_tb(self._rawentry, limit=1)
|
| 220 |
+
return stack_summary[0]
|
| 221 |
+
|
| 222 |
+
# Column and end line numbers introduced in python 3.11
|
| 223 |
+
if sys.version_info < (3, 11):
|
| 224 |
+
|
| 225 |
+
@property
|
| 226 |
+
def end_lineno_relative(self) -> int | None:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
@property
|
| 230 |
+
def colno(self) -> int | None:
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
@property
|
| 234 |
+
def end_colno(self) -> int | None:
|
| 235 |
+
return None
|
| 236 |
+
else:
|
| 237 |
+
|
| 238 |
+
@property
|
| 239 |
+
def end_lineno_relative(self) -> int | None:
|
| 240 |
+
frame_summary = self.get_python_framesummary()
|
| 241 |
+
if frame_summary.end_lineno is None: # pragma: no cover
|
| 242 |
+
return None
|
| 243 |
+
return frame_summary.end_lineno - 1 - self.frame.code.firstlineno
|
| 244 |
+
|
| 245 |
+
@property
|
| 246 |
+
def colno(self) -> int | None:
|
| 247 |
+
"""Starting byte offset of the expression in the traceback entry."""
|
| 248 |
+
return self.get_python_framesummary().colno
|
| 249 |
+
|
| 250 |
+
@property
|
| 251 |
+
def end_colno(self) -> int | None:
|
| 252 |
+
"""Ending byte offset of the expression in the traceback entry."""
|
| 253 |
+
return self.get_python_framesummary().end_colno
|
| 254 |
+
|
| 255 |
+
@property
|
| 256 |
+
def frame(self) -> Frame:
|
| 257 |
+
return Frame(self._rawentry.tb_frame)
|
| 258 |
+
|
| 259 |
+
@property
|
| 260 |
+
def relline(self) -> int:
|
| 261 |
+
return self.lineno - self.frame.code.firstlineno
|
| 262 |
+
|
| 263 |
+
def __repr__(self) -> str:
|
| 264 |
+
return f"<TracebackEntry {self.frame.code.path}:{self.lineno + 1}>"
|
| 265 |
+
|
| 266 |
+
@property
|
| 267 |
+
def statement(self) -> Source:
|
| 268 |
+
"""_pytest._code.Source object for the current statement."""
|
| 269 |
+
source = self.frame.code.fullsource
|
| 270 |
+
assert source is not None
|
| 271 |
+
return source.getstatement(self.lineno)
|
| 272 |
+
|
| 273 |
+
@property
|
| 274 |
+
def path(self) -> Path | str:
|
| 275 |
+
"""Path to the source code."""
|
| 276 |
+
return self.frame.code.path
|
| 277 |
+
|
| 278 |
+
@property
|
| 279 |
+
def locals(self) -> dict[str, Any]:
|
| 280 |
+
"""Locals of underlying frame."""
|
| 281 |
+
return self.frame.f_locals
|
| 282 |
+
|
| 283 |
+
def getfirstlinesource(self) -> int:
|
| 284 |
+
return self.frame.code.firstlineno
|
| 285 |
+
|
| 286 |
+
def getsource(
|
| 287 |
+
self, astcache: dict[str | Path, ast.AST] | None = None
|
| 288 |
+
) -> Source | None:
|
| 289 |
+
"""Return failing source code."""
|
| 290 |
+
# we use the passed in astcache to not reparse asttrees
|
| 291 |
+
# within exception info printing
|
| 292 |
+
source = self.frame.code.fullsource
|
| 293 |
+
if source is None:
|
| 294 |
+
return None
|
| 295 |
+
key = astnode = None
|
| 296 |
+
if astcache is not None:
|
| 297 |
+
key = self.frame.code.path
|
| 298 |
+
if key is not None:
|
| 299 |
+
astnode = astcache.get(key, None)
|
| 300 |
+
start = self.getfirstlinesource()
|
| 301 |
+
try:
|
| 302 |
+
astnode, _, end = getstatementrange_ast(
|
| 303 |
+
self.lineno, source, astnode=astnode
|
| 304 |
+
)
|
| 305 |
+
except SyntaxError:
|
| 306 |
+
end = self.lineno + 1
|
| 307 |
+
else:
|
| 308 |
+
if key is not None and astcache is not None:
|
| 309 |
+
astcache[key] = astnode
|
| 310 |
+
return source[start:end]
|
| 311 |
+
|
| 312 |
+
source = property(getsource)
|
| 313 |
+
|
| 314 |
+
def ishidden(self, excinfo: ExceptionInfo[BaseException] | None) -> bool:
|
| 315 |
+
"""Return True if the current frame has a var __tracebackhide__
|
| 316 |
+
resolving to True.
|
| 317 |
+
|
| 318 |
+
If __tracebackhide__ is a callable, it gets called with the
|
| 319 |
+
ExceptionInfo instance and can decide whether to hide the traceback.
|
| 320 |
+
|
| 321 |
+
Mostly for internal use.
|
| 322 |
+
"""
|
| 323 |
+
tbh: bool | Callable[[ExceptionInfo[BaseException] | None], bool] = False
|
| 324 |
+
for maybe_ns_dct in (self.frame.f_locals, self.frame.f_globals):
|
| 325 |
+
# in normal cases, f_locals and f_globals are dictionaries
|
| 326 |
+
# however via `exec(...)` / `eval(...)` they can be other types
|
| 327 |
+
# (even incorrect types!).
|
| 328 |
+
# as such, we suppress all exceptions while accessing __tracebackhide__
|
| 329 |
+
try:
|
| 330 |
+
tbh = maybe_ns_dct["__tracebackhide__"]
|
| 331 |
+
except Exception:
|
| 332 |
+
pass
|
| 333 |
+
else:
|
| 334 |
+
break
|
| 335 |
+
if tbh and callable(tbh):
|
| 336 |
+
return tbh(excinfo)
|
| 337 |
+
return tbh
|
| 338 |
+
|
| 339 |
+
def __str__(self) -> str:
|
| 340 |
+
name = self.frame.code.name
|
| 341 |
+
try:
|
| 342 |
+
line = str(self.statement).lstrip()
|
| 343 |
+
except KeyboardInterrupt:
|
| 344 |
+
raise
|
| 345 |
+
except BaseException:
|
| 346 |
+
line = "???"
|
| 347 |
+
# This output does not quite match Python's repr for traceback entries,
|
| 348 |
+
# but changing it to do so would break certain plugins. See
|
| 349 |
+
# https://github.com/pytest-dev/pytest/pull/7535/ for details.
|
| 350 |
+
return f" File '{self.path}':{self.lineno + 1} in {name}\n {line}\n"
|
| 351 |
+
|
| 352 |
+
@property
|
| 353 |
+
def name(self) -> str:
|
| 354 |
+
"""co_name of underlying code."""
|
| 355 |
+
return self.frame.code.raw.co_name
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
class Traceback(list[TracebackEntry]):
|
| 359 |
+
"""Traceback objects encapsulate and offer higher level access to Traceback entries."""
|
| 360 |
+
|
| 361 |
+
def __init__(
|
| 362 |
+
self,
|
| 363 |
+
tb: TracebackType | Iterable[TracebackEntry],
|
| 364 |
+
) -> None:
|
| 365 |
+
"""Initialize from given python traceback object and ExceptionInfo."""
|
| 366 |
+
if isinstance(tb, TracebackType):
|
| 367 |
+
|
| 368 |
+
def f(cur: TracebackType) -> Iterable[TracebackEntry]:
|
| 369 |
+
cur_: TracebackType | None = cur
|
| 370 |
+
while cur_ is not None:
|
| 371 |
+
yield TracebackEntry(cur_)
|
| 372 |
+
cur_ = cur_.tb_next
|
| 373 |
+
|
| 374 |
+
super().__init__(f(tb))
|
| 375 |
+
else:
|
| 376 |
+
super().__init__(tb)
|
| 377 |
+
|
| 378 |
+
def cut(
|
| 379 |
+
self,
|
| 380 |
+
path: os.PathLike[str] | str | None = None,
|
| 381 |
+
lineno: int | None = None,
|
| 382 |
+
firstlineno: int | None = None,
|
| 383 |
+
excludepath: os.PathLike[str] | None = None,
|
| 384 |
+
) -> Traceback:
|
| 385 |
+
"""Return a Traceback instance wrapping part of this Traceback.
|
| 386 |
+
|
| 387 |
+
By providing any combination of path, lineno and firstlineno, the
|
| 388 |
+
first frame to start the to-be-returned traceback is determined.
|
| 389 |
+
|
| 390 |
+
This allows cutting the first part of a Traceback instance e.g.
|
| 391 |
+
for formatting reasons (removing some uninteresting bits that deal
|
| 392 |
+
with handling of the exception/traceback).
|
| 393 |
+
"""
|
| 394 |
+
path_ = None if path is None else os.fspath(path)
|
| 395 |
+
excludepath_ = None if excludepath is None else os.fspath(excludepath)
|
| 396 |
+
for x in self:
|
| 397 |
+
code = x.frame.code
|
| 398 |
+
codepath = code.path
|
| 399 |
+
if path is not None and str(codepath) != path_:
|
| 400 |
+
continue
|
| 401 |
+
if (
|
| 402 |
+
excludepath is not None
|
| 403 |
+
and isinstance(codepath, Path)
|
| 404 |
+
and excludepath_ in (str(p) for p in codepath.parents) # type: ignore[operator]
|
| 405 |
+
):
|
| 406 |
+
continue
|
| 407 |
+
if lineno is not None and x.lineno != lineno:
|
| 408 |
+
continue
|
| 409 |
+
if firstlineno is not None and x.frame.code.firstlineno != firstlineno:
|
| 410 |
+
continue
|
| 411 |
+
return Traceback(x._rawentry)
|
| 412 |
+
return self
|
| 413 |
+
|
| 414 |
+
@overload
|
| 415 |
+
def __getitem__(self, key: SupportsIndex) -> TracebackEntry: ...
|
| 416 |
+
|
| 417 |
+
@overload
|
| 418 |
+
def __getitem__(self, key: slice) -> Traceback: ...
|
| 419 |
+
|
| 420 |
+
def __getitem__(self, key: SupportsIndex | slice) -> TracebackEntry | Traceback:
|
| 421 |
+
if isinstance(key, slice):
|
| 422 |
+
return self.__class__(super().__getitem__(key))
|
| 423 |
+
else:
|
| 424 |
+
return super().__getitem__(key)
|
| 425 |
+
|
| 426 |
+
def filter(
|
| 427 |
+
self,
|
| 428 |
+
excinfo_or_fn: ExceptionInfo[BaseException] | Callable[[TracebackEntry], bool],
|
| 429 |
+
/,
|
| 430 |
+
) -> Traceback:
|
| 431 |
+
"""Return a Traceback instance with certain items removed.
|
| 432 |
+
|
| 433 |
+
If the filter is an `ExceptionInfo`, removes all the ``TracebackEntry``s
|
| 434 |
+
which are hidden (see ishidden() above).
|
| 435 |
+
|
| 436 |
+
Otherwise, the filter is a function that gets a single argument, a
|
| 437 |
+
``TracebackEntry`` instance, and should return True when the item should
|
| 438 |
+
be added to the ``Traceback``, False when not.
|
| 439 |
+
"""
|
| 440 |
+
if isinstance(excinfo_or_fn, ExceptionInfo):
|
| 441 |
+
fn = lambda x: not x.ishidden(excinfo_or_fn) # noqa: E731
|
| 442 |
+
else:
|
| 443 |
+
fn = excinfo_or_fn
|
| 444 |
+
return Traceback(filter(fn, self))
|
| 445 |
+
|
| 446 |
+
def recursionindex(self) -> int | None:
|
| 447 |
+
"""Return the index of the frame/TracebackEntry where recursion originates if
|
| 448 |
+
appropriate, None if no recursion occurred."""
|
| 449 |
+
cache: dict[tuple[Any, int, int], list[dict[str, Any]]] = {}
|
| 450 |
+
for i, entry in enumerate(self):
|
| 451 |
+
# id for the code.raw is needed to work around
|
| 452 |
+
# the strange metaprogramming in the decorator lib from pypi
|
| 453 |
+
# which generates code objects that have hash/value equality
|
| 454 |
+
# XXX needs a test
|
| 455 |
+
key = entry.frame.code.path, id(entry.frame.code.raw), entry.lineno
|
| 456 |
+
values = cache.setdefault(key, [])
|
| 457 |
+
# Since Python 3.13 f_locals is a proxy, freeze it.
|
| 458 |
+
loc = dict(entry.frame.f_locals)
|
| 459 |
+
if values:
|
| 460 |
+
for otherloc in values:
|
| 461 |
+
if otherloc == loc:
|
| 462 |
+
return i
|
| 463 |
+
values.append(loc)
|
| 464 |
+
return None
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def stringify_exception(
|
| 468 |
+
exc: BaseException, include_subexception_msg: bool = True
|
| 469 |
+
) -> str:
|
| 470 |
+
try:
|
| 471 |
+
notes = getattr(exc, "__notes__", [])
|
| 472 |
+
except KeyError:
|
| 473 |
+
# Workaround for https://github.com/python/cpython/issues/98778 on
|
| 474 |
+
# some 3.10 and 3.11 patch versions.
|
| 475 |
+
HTTPError = getattr(sys.modules.get("urllib.error", None), "HTTPError", ())
|
| 476 |
+
if sys.version_info < (3, 12) and isinstance(exc, HTTPError):
|
| 477 |
+
notes = []
|
| 478 |
+
else: # pragma: no cover
|
| 479 |
+
# exception not related to above bug, reraise
|
| 480 |
+
raise
|
| 481 |
+
if not include_subexception_msg and isinstance(exc, BaseExceptionGroup):
|
| 482 |
+
message = exc.message
|
| 483 |
+
else:
|
| 484 |
+
message = str(exc)
|
| 485 |
+
|
| 486 |
+
return "\n".join(
|
| 487 |
+
[
|
| 488 |
+
message,
|
| 489 |
+
*notes,
|
| 490 |
+
]
|
| 491 |
+
)
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
E = TypeVar("E", bound=BaseException, covariant=True)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
@final
|
| 498 |
+
@dataclasses.dataclass
|
| 499 |
+
class ExceptionInfo(Generic[E]):
|
| 500 |
+
"""Wraps sys.exc_info() objects and offers help for navigating the traceback."""
|
| 501 |
+
|
| 502 |
+
_assert_start_repr: ClassVar = "AssertionError('assert "
|
| 503 |
+
|
| 504 |
+
_excinfo: tuple[type[E], E, TracebackType] | None
|
| 505 |
+
_striptext: str
|
| 506 |
+
_traceback: Traceback | None
|
| 507 |
+
|
| 508 |
+
def __init__(
|
| 509 |
+
self,
|
| 510 |
+
excinfo: tuple[type[E], E, TracebackType] | None,
|
| 511 |
+
striptext: str = "",
|
| 512 |
+
traceback: Traceback | None = None,
|
| 513 |
+
*,
|
| 514 |
+
_ispytest: bool = False,
|
| 515 |
+
) -> None:
|
| 516 |
+
check_ispytest(_ispytest)
|
| 517 |
+
self._excinfo = excinfo
|
| 518 |
+
self._striptext = striptext
|
| 519 |
+
self._traceback = traceback
|
| 520 |
+
|
| 521 |
+
@classmethod
|
| 522 |
+
def from_exception(
|
| 523 |
+
cls,
|
| 524 |
+
# Ignoring error: "Cannot use a covariant type variable as a parameter".
|
| 525 |
+
# This is OK to ignore because this class is (conceptually) readonly.
|
| 526 |
+
# See https://github.com/python/mypy/issues/7049.
|
| 527 |
+
exception: E, # type: ignore[misc]
|
| 528 |
+
exprinfo: str | None = None,
|
| 529 |
+
) -> ExceptionInfo[E]:
|
| 530 |
+
"""Return an ExceptionInfo for an existing exception.
|
| 531 |
+
|
| 532 |
+
The exception must have a non-``None`` ``__traceback__`` attribute,
|
| 533 |
+
otherwise this function fails with an assertion error. This means that
|
| 534 |
+
the exception must have been raised, or added a traceback with the
|
| 535 |
+
:py:meth:`~BaseException.with_traceback()` method.
|
| 536 |
+
|
| 537 |
+
:param exprinfo:
|
| 538 |
+
A text string helping to determine if we should strip
|
| 539 |
+
``AssertionError`` from the output. Defaults to the exception
|
| 540 |
+
message/``__str__()``.
|
| 541 |
+
|
| 542 |
+
.. versionadded:: 7.4
|
| 543 |
+
"""
|
| 544 |
+
assert exception.__traceback__, (
|
| 545 |
+
"Exceptions passed to ExcInfo.from_exception(...)"
|
| 546 |
+
" must have a non-None __traceback__."
|
| 547 |
+
)
|
| 548 |
+
exc_info = (type(exception), exception, exception.__traceback__)
|
| 549 |
+
return cls.from_exc_info(exc_info, exprinfo)
|
| 550 |
+
|
| 551 |
+
@classmethod
|
| 552 |
+
def from_exc_info(
|
| 553 |
+
cls,
|
| 554 |
+
exc_info: tuple[type[E], E, TracebackType],
|
| 555 |
+
exprinfo: str | None = None,
|
| 556 |
+
) -> ExceptionInfo[E]:
|
| 557 |
+
"""Like :func:`from_exception`, but using old-style exc_info tuple."""
|
| 558 |
+
_striptext = ""
|
| 559 |
+
if exprinfo is None and isinstance(exc_info[1], AssertionError):
|
| 560 |
+
exprinfo = getattr(exc_info[1], "msg", None)
|
| 561 |
+
if exprinfo is None:
|
| 562 |
+
exprinfo = saferepr(exc_info[1])
|
| 563 |
+
if exprinfo and exprinfo.startswith(cls._assert_start_repr):
|
| 564 |
+
_striptext = "AssertionError: "
|
| 565 |
+
|
| 566 |
+
return cls(exc_info, _striptext, _ispytest=True)
|
| 567 |
+
|
| 568 |
+
@classmethod
|
| 569 |
+
def from_current(cls, exprinfo: str | None = None) -> ExceptionInfo[BaseException]:
|
| 570 |
+
"""Return an ExceptionInfo matching the current traceback.
|
| 571 |
+
|
| 572 |
+
.. warning::
|
| 573 |
+
|
| 574 |
+
Experimental API
|
| 575 |
+
|
| 576 |
+
:param exprinfo:
|
| 577 |
+
A text string helping to determine if we should strip
|
| 578 |
+
``AssertionError`` from the output. Defaults to the exception
|
| 579 |
+
message/``__str__()``.
|
| 580 |
+
"""
|
| 581 |
+
tup = sys.exc_info()
|
| 582 |
+
assert tup[0] is not None, "no current exception"
|
| 583 |
+
assert tup[1] is not None, "no current exception"
|
| 584 |
+
assert tup[2] is not None, "no current exception"
|
| 585 |
+
exc_info = (tup[0], tup[1], tup[2])
|
| 586 |
+
return ExceptionInfo.from_exc_info(exc_info, exprinfo)
|
| 587 |
+
|
| 588 |
+
@classmethod
|
| 589 |
+
def for_later(cls) -> ExceptionInfo[E]:
|
| 590 |
+
"""Return an unfilled ExceptionInfo."""
|
| 591 |
+
return cls(None, _ispytest=True)
|
| 592 |
+
|
| 593 |
+
def fill_unfilled(self, exc_info: tuple[type[E], E, TracebackType]) -> None:
|
| 594 |
+
"""Fill an unfilled ExceptionInfo created with ``for_later()``."""
|
| 595 |
+
assert self._excinfo is None, "ExceptionInfo was already filled"
|
| 596 |
+
self._excinfo = exc_info
|
| 597 |
+
|
| 598 |
+
@property
|
| 599 |
+
def type(self) -> type[E]:
|
| 600 |
+
"""The exception class."""
|
| 601 |
+
assert self._excinfo is not None, (
|
| 602 |
+
".type can only be used after the context manager exits"
|
| 603 |
+
)
|
| 604 |
+
return self._excinfo[0]
|
| 605 |
+
|
| 606 |
+
@property
|
| 607 |
+
def value(self) -> E:
|
| 608 |
+
"""The exception value."""
|
| 609 |
+
assert self._excinfo is not None, (
|
| 610 |
+
".value can only be used after the context manager exits"
|
| 611 |
+
)
|
| 612 |
+
return self._excinfo[1]
|
| 613 |
+
|
| 614 |
+
@property
|
| 615 |
+
def tb(self) -> TracebackType:
|
| 616 |
+
"""The exception raw traceback."""
|
| 617 |
+
assert self._excinfo is not None, (
|
| 618 |
+
".tb can only be used after the context manager exits"
|
| 619 |
+
)
|
| 620 |
+
return self._excinfo[2]
|
| 621 |
+
|
| 622 |
+
@property
|
| 623 |
+
def typename(self) -> str:
|
| 624 |
+
"""The type name of the exception."""
|
| 625 |
+
assert self._excinfo is not None, (
|
| 626 |
+
".typename can only be used after the context manager exits"
|
| 627 |
+
)
|
| 628 |
+
return self.type.__name__
|
| 629 |
+
|
| 630 |
+
@property
|
| 631 |
+
def traceback(self) -> Traceback:
|
| 632 |
+
"""The traceback."""
|
| 633 |
+
if self._traceback is None:
|
| 634 |
+
self._traceback = Traceback(self.tb)
|
| 635 |
+
return self._traceback
|
| 636 |
+
|
| 637 |
+
@traceback.setter
|
| 638 |
+
def traceback(self, value: Traceback) -> None:
|
| 639 |
+
self._traceback = value
|
| 640 |
+
|
| 641 |
+
def __repr__(self) -> str:
|
| 642 |
+
if self._excinfo is None:
|
| 643 |
+
return "<ExceptionInfo for raises contextmanager>"
|
| 644 |
+
return f"<{self.__class__.__name__} {saferepr(self._excinfo[1])} tblen={len(self.traceback)}>"
|
| 645 |
+
|
| 646 |
+
def exconly(self, tryshort: bool = False) -> str:
|
| 647 |
+
"""Return the exception as a string.
|
| 648 |
+
|
| 649 |
+
This is usually a single line "<exception type>: <exception str>", but
|
| 650 |
+
may also include additional lines for the exception notes, and detailed
|
| 651 |
+
information for SyntaxError's.
|
| 652 |
+
|
| 653 |
+
:param tryshort:
|
| 654 |
+
If true, and the exception is an AssertionError, strip
|
| 655 |
+
'AssertionError: ' from the beginning.
|
| 656 |
+
"""
|
| 657 |
+
|
| 658 |
+
def _get_single_subexc(
|
| 659 |
+
eg: BaseExceptionGroup[BaseException],
|
| 660 |
+
) -> BaseException | None:
|
| 661 |
+
if len(eg.exceptions) != 1:
|
| 662 |
+
return None
|
| 663 |
+
if isinstance(e := eg.exceptions[0], BaseExceptionGroup):
|
| 664 |
+
return _get_single_subexc(e)
|
| 665 |
+
return e
|
| 666 |
+
|
| 667 |
+
if (
|
| 668 |
+
tryshort
|
| 669 |
+
and isinstance(self.value, BaseExceptionGroup)
|
| 670 |
+
and (subexc := _get_single_subexc(self.value)) is not None
|
| 671 |
+
):
|
| 672 |
+
return f"{subexc!r} [single exception in {type(self.value).__name__}]"
|
| 673 |
+
|
| 674 |
+
lines = format_exception_only(self.value)
|
| 675 |
+
# The lines already include line separators.
|
| 676 |
+
text = "".join(lines)
|
| 677 |
+
text = text.rstrip()
|
| 678 |
+
if tryshort:
|
| 679 |
+
if text.startswith(self._striptext):
|
| 680 |
+
text = text[len(self._striptext) :]
|
| 681 |
+
return text
|
| 682 |
+
|
| 683 |
+
def errisinstance(self, exc: EXCEPTION_OR_MORE) -> bool:
|
| 684 |
+
"""Return True if the exception is an instance of exc.
|
| 685 |
+
|
| 686 |
+
Consider using ``isinstance(excinfo.value, exc)`` instead.
|
| 687 |
+
"""
|
| 688 |
+
return isinstance(self.value, exc)
|
| 689 |
+
|
| 690 |
+
def _getreprcrash(self) -> ReprFileLocation | None:
|
| 691 |
+
# Find last non-hidden traceback entry that led to the exception of the
|
| 692 |
+
# traceback, or None if all hidden.
|
| 693 |
+
for i in range(-1, -len(self.traceback) - 1, -1):
|
| 694 |
+
entry = self.traceback[i]
|
| 695 |
+
if not entry.ishidden(self):
|
| 696 |
+
path, lineno = entry.frame.code.raw.co_filename, entry.lineno
|
| 697 |
+
exconly = self.exconly(tryshort=True)
|
| 698 |
+
return ReprFileLocation(path, lineno + 1, exconly)
|
| 699 |
+
return None
|
| 700 |
+
|
| 701 |
+
def getrepr(
|
| 702 |
+
self,
|
| 703 |
+
showlocals: bool = False,
|
| 704 |
+
style: TracebackStyle = "long",
|
| 705 |
+
abspath: bool = False,
|
| 706 |
+
tbfilter: bool | Callable[[ExceptionInfo[BaseException]], Traceback] = True,
|
| 707 |
+
funcargs: bool = False,
|
| 708 |
+
truncate_locals: bool = True,
|
| 709 |
+
truncate_args: bool = True,
|
| 710 |
+
chain: bool = True,
|
| 711 |
+
) -> ReprExceptionInfo | ExceptionChainRepr:
|
| 712 |
+
"""Return str()able representation of this exception info.
|
| 713 |
+
|
| 714 |
+
The formatting parameters are ineffective if ``style="native"``,
|
| 715 |
+
since in this case the native formatting is used.
|
| 716 |
+
|
| 717 |
+
:param bool showlocals:
|
| 718 |
+
Show locals per traceback entry.
|
| 719 |
+
|
| 720 |
+
:param str style:
|
| 721 |
+
long|short|line|no|native|value traceback style.
|
| 722 |
+
|
| 723 |
+
:param bool abspath:
|
| 724 |
+
If paths should be changed to absolute or left unchanged.
|
| 725 |
+
|
| 726 |
+
:param tbfilter:
|
| 727 |
+
A filter for traceback entries.
|
| 728 |
+
|
| 729 |
+
* If false, don't hide any entries.
|
| 730 |
+
* If true, hide internal entries and entries that contain a local
|
| 731 |
+
variable ``__tracebackhide__ = True``.
|
| 732 |
+
* If a callable, delegates the filtering to the callable.
|
| 733 |
+
|
| 734 |
+
:param bool funcargs:
|
| 735 |
+
Show function arguments per traceback entry.
|
| 736 |
+
|
| 737 |
+
:param bool truncate_locals:
|
| 738 |
+
Whether to show a size-limited `repr()` of locals, or a full
|
| 739 |
+
pretty-printing.
|
| 740 |
+
|
| 741 |
+
:param bool truncate_args:
|
| 742 |
+
Whether to show a size-limited truncated `repr()` of function
|
| 743 |
+
arguments, or a full pretty-printing.
|
| 744 |
+
|
| 745 |
+
:param bool chain:
|
| 746 |
+
If chained exceptions should be shown.
|
| 747 |
+
|
| 748 |
+
.. versionchanged:: 3.9
|
| 749 |
+
|
| 750 |
+
Added the ``chain`` parameter.
|
| 751 |
+
"""
|
| 752 |
+
if style == "native":
|
| 753 |
+
return ReprExceptionInfo(
|
| 754 |
+
reprtraceback=ReprTracebackNative(
|
| 755 |
+
format_exception(
|
| 756 |
+
self.type,
|
| 757 |
+
self.value,
|
| 758 |
+
self.traceback[0]._rawentry if self.traceback else None,
|
| 759 |
+
)
|
| 760 |
+
),
|
| 761 |
+
reprcrash=self._getreprcrash(),
|
| 762 |
+
)
|
| 763 |
+
|
| 764 |
+
fmt = ExceptionInfoFormatter(
|
| 765 |
+
showlocals=showlocals,
|
| 766 |
+
style=style,
|
| 767 |
+
abspath=abspath,
|
| 768 |
+
tbfilter=tbfilter,
|
| 769 |
+
funcargs=funcargs,
|
| 770 |
+
truncate_locals=truncate_locals,
|
| 771 |
+
truncate_args=truncate_args,
|
| 772 |
+
chain=chain,
|
| 773 |
+
)
|
| 774 |
+
return fmt.repr_excinfo(self)
|
| 775 |
+
|
| 776 |
+
def match(self, regexp: str | re.Pattern[str]) -> Literal[True]:
|
| 777 |
+
"""Check whether the regular expression `regexp` matches the string
|
| 778 |
+
representation of the exception using :func:`python:re.search`.
|
| 779 |
+
|
| 780 |
+
If it matches `True` is returned, otherwise an `AssertionError` is raised.
|
| 781 |
+
"""
|
| 782 |
+
__tracebackhide__ = True
|
| 783 |
+
value = stringify_exception(self.value)
|
| 784 |
+
msg = (
|
| 785 |
+
f"Regex pattern did not match.\n"
|
| 786 |
+
f" Expected regex: {regexp!r}\n"
|
| 787 |
+
f" Actual message: {value!r}"
|
| 788 |
+
)
|
| 789 |
+
if regexp == value:
|
| 790 |
+
msg += "\n Did you mean to `re.escape()` the regex?"
|
| 791 |
+
assert re.search(regexp, value), msg
|
| 792 |
+
# Return True to allow for "assert excinfo.match()".
|
| 793 |
+
return True
|
| 794 |
+
|
| 795 |
+
def _group_contains(
|
| 796 |
+
self,
|
| 797 |
+
exc_group: BaseExceptionGroup[BaseException],
|
| 798 |
+
expected_exception: EXCEPTION_OR_MORE,
|
| 799 |
+
match: str | re.Pattern[str] | None,
|
| 800 |
+
target_depth: int | None = None,
|
| 801 |
+
current_depth: int = 1,
|
| 802 |
+
) -> bool:
|
| 803 |
+
"""Return `True` if a `BaseExceptionGroup` contains a matching exception."""
|
| 804 |
+
if (target_depth is not None) and (current_depth > target_depth):
|
| 805 |
+
# already descended past the target depth
|
| 806 |
+
return False
|
| 807 |
+
for exc in exc_group.exceptions:
|
| 808 |
+
if isinstance(exc, BaseExceptionGroup):
|
| 809 |
+
if self._group_contains(
|
| 810 |
+
exc, expected_exception, match, target_depth, current_depth + 1
|
| 811 |
+
):
|
| 812 |
+
return True
|
| 813 |
+
if (target_depth is not None) and (current_depth != target_depth):
|
| 814 |
+
# not at the target depth, no match
|
| 815 |
+
continue
|
| 816 |
+
if not isinstance(exc, expected_exception):
|
| 817 |
+
continue
|
| 818 |
+
if match is not None:
|
| 819 |
+
value = stringify_exception(exc)
|
| 820 |
+
if not re.search(match, value):
|
| 821 |
+
continue
|
| 822 |
+
return True
|
| 823 |
+
return False
|
| 824 |
+
|
| 825 |
+
def group_contains(
|
| 826 |
+
self,
|
| 827 |
+
expected_exception: EXCEPTION_OR_MORE,
|
| 828 |
+
*,
|
| 829 |
+
match: str | re.Pattern[str] | None = None,
|
| 830 |
+
depth: int | None = None,
|
| 831 |
+
) -> bool:
|
| 832 |
+
"""Check whether a captured exception group contains a matching exception.
|
| 833 |
+
|
| 834 |
+
:param Type[BaseException] | Tuple[Type[BaseException]] expected_exception:
|
| 835 |
+
The expected exception type, or a tuple if one of multiple possible
|
| 836 |
+
exception types are expected.
|
| 837 |
+
|
| 838 |
+
:param str | re.Pattern[str] | None match:
|
| 839 |
+
If specified, a string containing a regular expression,
|
| 840 |
+
or a regular expression object, that is tested against the string
|
| 841 |
+
representation of the exception and its `PEP-678 <https://peps.python.org/pep-0678/>` `__notes__`
|
| 842 |
+
using :func:`re.search`.
|
| 843 |
+
|
| 844 |
+
To match a literal string that may contain :ref:`special characters
|
| 845 |
+
<re-syntax>`, the pattern can first be escaped with :func:`re.escape`.
|
| 846 |
+
|
| 847 |
+
:param Optional[int] depth:
|
| 848 |
+
If `None`, will search for a matching exception at any nesting depth.
|
| 849 |
+
If >= 1, will only match an exception if it's at the specified depth (depth = 1 being
|
| 850 |
+
the exceptions contained within the topmost exception group).
|
| 851 |
+
|
| 852 |
+
.. versionadded:: 8.0
|
| 853 |
+
|
| 854 |
+
.. warning::
|
| 855 |
+
This helper makes it easy to check for the presence of specific exceptions,
|
| 856 |
+
but it is very bad for checking that the group does *not* contain
|
| 857 |
+
*any other exceptions*.
|
| 858 |
+
You should instead consider using :class:`pytest.RaisesGroup`
|
| 859 |
+
|
| 860 |
+
"""
|
| 861 |
+
msg = "Captured exception is not an instance of `BaseExceptionGroup`"
|
| 862 |
+
assert isinstance(self.value, BaseExceptionGroup), msg
|
| 863 |
+
msg = "`depth` must be >= 1 if specified"
|
| 864 |
+
assert (depth is None) or (depth >= 1), msg
|
| 865 |
+
return self._group_contains(self.value, expected_exception, match, depth)
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
# Type alias for the `tbfilter` setting:
|
| 869 |
+
# bool: If True, it should be filtered using Traceback.filter()
|
| 870 |
+
# callable: A callable that takes an ExceptionInfo and returns the filtered traceback.
|
| 871 |
+
TracebackFilter: TypeAlias = bool | Callable[[ExceptionInfo[BaseException]], Traceback]
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
@dataclasses.dataclass
|
| 875 |
+
class ExceptionInfoFormatter:
|
| 876 |
+
"""Helper object to format ExceptionInfo's and individual exception parts
|
| 877 |
+
into TerminalRepr's.
|
| 878 |
+
|
| 879 |
+
See :func:`ExceptionInfo.getrepr` for parameters.
|
| 880 |
+
"""
|
| 881 |
+
|
| 882 |
+
# for traceback entries
|
| 883 |
+
flow_marker: ClassVar = ">"
|
| 884 |
+
fail_marker: ClassVar = "E"
|
| 885 |
+
|
| 886 |
+
showlocals: bool = False
|
| 887 |
+
# Note: "native" is handled outside of ExceptionInfoFormatter.
|
| 888 |
+
style: TracebackStyle = "long"
|
| 889 |
+
abspath: bool = True
|
| 890 |
+
tbfilter: TracebackFilter = True
|
| 891 |
+
funcargs: bool = False
|
| 892 |
+
truncate_locals: bool = True
|
| 893 |
+
truncate_args: bool = True
|
| 894 |
+
chain: bool = True
|
| 895 |
+
|
| 896 |
+
astcache: dict[str | Path, ast.AST] = dataclasses.field(
|
| 897 |
+
default_factory=dict, init=False, repr=False
|
| 898 |
+
)
|
| 899 |
+
|
| 900 |
+
def _getindent(self, source: Source) -> int:
|
| 901 |
+
# Figure out indent for the given source.
|
| 902 |
+
try:
|
| 903 |
+
s = str(source.getstatement(len(source) - 1))
|
| 904 |
+
except KeyboardInterrupt:
|
| 905 |
+
raise
|
| 906 |
+
except BaseException:
|
| 907 |
+
try:
|
| 908 |
+
s = str(source[-1])
|
| 909 |
+
except KeyboardInterrupt:
|
| 910 |
+
raise
|
| 911 |
+
except BaseException:
|
| 912 |
+
return 0
|
| 913 |
+
return 4 + (len(s) - len(s.lstrip()))
|
| 914 |
+
|
| 915 |
+
def _getentrysource(self, entry: TracebackEntry) -> Source | None:
|
| 916 |
+
source = entry.getsource(self.astcache)
|
| 917 |
+
if source is not None:
|
| 918 |
+
source = source.deindent()
|
| 919 |
+
return source
|
| 920 |
+
|
| 921 |
+
def repr_args(self, entry: TracebackEntry) -> ReprFuncArgs | None:
|
| 922 |
+
if self.funcargs:
|
| 923 |
+
args = []
|
| 924 |
+
for argname, argvalue in entry.frame.getargs(var=True):
|
| 925 |
+
if self.truncate_args:
|
| 926 |
+
str_repr = saferepr(argvalue)
|
| 927 |
+
else:
|
| 928 |
+
str_repr = saferepr(argvalue, maxsize=None)
|
| 929 |
+
args.append((argname, str_repr))
|
| 930 |
+
return ReprFuncArgs(args)
|
| 931 |
+
return None
|
| 932 |
+
|
| 933 |
+
def get_source(
|
| 934 |
+
self,
|
| 935 |
+
source: Source | None,
|
| 936 |
+
line_index: int = -1,
|
| 937 |
+
excinfo: ExceptionInfo[BaseException] | None = None,
|
| 938 |
+
short: bool = False,
|
| 939 |
+
end_line_index: int | None = None,
|
| 940 |
+
colno: int | None = None,
|
| 941 |
+
end_colno: int | None = None,
|
| 942 |
+
) -> list[str]:
|
| 943 |
+
"""Return formatted and marked up source lines."""
|
| 944 |
+
lines = []
|
| 945 |
+
if source is not None and line_index < 0:
|
| 946 |
+
line_index += len(source)
|
| 947 |
+
if source is None or line_index >= len(source.lines) or line_index < 0:
|
| 948 |
+
# `line_index` could still be outside `range(len(source.lines))` if
|
| 949 |
+
# we're processing AST with pathological position attributes.
|
| 950 |
+
source = Source("???")
|
| 951 |
+
line_index = 0
|
| 952 |
+
space_prefix = " "
|
| 953 |
+
if short:
|
| 954 |
+
lines.append(space_prefix + source.lines[line_index].strip())
|
| 955 |
+
lines.extend(
|
| 956 |
+
self.get_highlight_arrows_for_line(
|
| 957 |
+
raw_line=source.raw_lines[line_index],
|
| 958 |
+
line=source.lines[line_index].strip(),
|
| 959 |
+
lineno=line_index,
|
| 960 |
+
end_lineno=end_line_index,
|
| 961 |
+
colno=colno,
|
| 962 |
+
end_colno=end_colno,
|
| 963 |
+
)
|
| 964 |
+
)
|
| 965 |
+
else:
|
| 966 |
+
for line in source.lines[:line_index]:
|
| 967 |
+
lines.append(space_prefix + line)
|
| 968 |
+
lines.append(self.flow_marker + " " + source.lines[line_index])
|
| 969 |
+
lines.extend(
|
| 970 |
+
self.get_highlight_arrows_for_line(
|
| 971 |
+
raw_line=source.raw_lines[line_index],
|
| 972 |
+
line=source.lines[line_index],
|
| 973 |
+
lineno=line_index,
|
| 974 |
+
end_lineno=end_line_index,
|
| 975 |
+
colno=colno,
|
| 976 |
+
end_colno=end_colno,
|
| 977 |
+
)
|
| 978 |
+
)
|
| 979 |
+
for line in source.lines[line_index + 1 :]:
|
| 980 |
+
lines.append(space_prefix + line)
|
| 981 |
+
if excinfo is not None:
|
| 982 |
+
indent = 4 if short else self._getindent(source)
|
| 983 |
+
lines.extend(self.get_exconly(excinfo, indent=indent, markall=True))
|
| 984 |
+
return lines
|
| 985 |
+
|
| 986 |
+
def get_highlight_arrows_for_line(
|
| 987 |
+
self,
|
| 988 |
+
line: str,
|
| 989 |
+
raw_line: str,
|
| 990 |
+
lineno: int | None,
|
| 991 |
+
end_lineno: int | None,
|
| 992 |
+
colno: int | None,
|
| 993 |
+
end_colno: int | None,
|
| 994 |
+
) -> list[str]:
|
| 995 |
+
"""Return characters highlighting a source line.
|
| 996 |
+
|
| 997 |
+
Example with colno and end_colno pointing to the bar expression:
|
| 998 |
+
"foo() + bar()"
|
| 999 |
+
returns " ^^^^^"
|
| 1000 |
+
"""
|
| 1001 |
+
if lineno != end_lineno:
|
| 1002 |
+
# Don't handle expressions that span multiple lines.
|
| 1003 |
+
return []
|
| 1004 |
+
if colno is None or end_colno is None:
|
| 1005 |
+
# Can't do anything without column information.
|
| 1006 |
+
return []
|
| 1007 |
+
|
| 1008 |
+
num_stripped_chars = len(raw_line) - len(line)
|
| 1009 |
+
|
| 1010 |
+
start_char_offset = _byte_offset_to_character_offset(raw_line, colno)
|
| 1011 |
+
end_char_offset = _byte_offset_to_character_offset(raw_line, end_colno)
|
| 1012 |
+
num_carets = end_char_offset - start_char_offset
|
| 1013 |
+
# If the highlight would span the whole line, it is redundant, don't
|
| 1014 |
+
# show it.
|
| 1015 |
+
if num_carets >= len(line.strip()):
|
| 1016 |
+
return []
|
| 1017 |
+
|
| 1018 |
+
highlights = " "
|
| 1019 |
+
highlights += " " * (start_char_offset - num_stripped_chars + 1)
|
| 1020 |
+
highlights += "^" * num_carets
|
| 1021 |
+
return [highlights]
|
| 1022 |
+
|
| 1023 |
+
def get_exconly(
|
| 1024 |
+
self,
|
| 1025 |
+
excinfo: ExceptionInfo[BaseException],
|
| 1026 |
+
indent: int = 4,
|
| 1027 |
+
markall: bool = False,
|
| 1028 |
+
) -> list[str]:
|
| 1029 |
+
lines = []
|
| 1030 |
+
indentstr = " " * indent
|
| 1031 |
+
# Get the real exception information out.
|
| 1032 |
+
exlines = excinfo.exconly(tryshort=True).split("\n")
|
| 1033 |
+
failindent = self.fail_marker + indentstr[1:]
|
| 1034 |
+
for line in exlines:
|
| 1035 |
+
lines.append(failindent + line)
|
| 1036 |
+
if not markall:
|
| 1037 |
+
failindent = indentstr
|
| 1038 |
+
return lines
|
| 1039 |
+
|
| 1040 |
+
def repr_locals(self, locals: Mapping[str, object]) -> ReprLocals | None:
|
| 1041 |
+
if self.showlocals:
|
| 1042 |
+
lines = []
|
| 1043 |
+
# Variables starting with `@` are helpers injected by assertion
|
| 1044 |
+
# rewriting, not user variables, so hide them.
|
| 1045 |
+
keys = [loc for loc in locals if loc[0] != "@"]
|
| 1046 |
+
keys.sort()
|
| 1047 |
+
for name in keys:
|
| 1048 |
+
value = locals[name]
|
| 1049 |
+
if name == "__builtins__":
|
| 1050 |
+
lines.append("__builtins__ = <builtins>")
|
| 1051 |
+
else:
|
| 1052 |
+
# This formatting could all be handled by the
|
| 1053 |
+
# _repr() function, which is only reprlib.Repr in
|
| 1054 |
+
# disguise, so is very configurable.
|
| 1055 |
+
if self.truncate_locals:
|
| 1056 |
+
str_repr = saferepr(value)
|
| 1057 |
+
else:
|
| 1058 |
+
str_repr = safeformat(value)
|
| 1059 |
+
# if len(str_repr) < 70 or not isinstance(value, (list, tuple, dict)):
|
| 1060 |
+
lines.append(f"{name:<10} = {str_repr}")
|
| 1061 |
+
# else:
|
| 1062 |
+
# self._line("%-10s =\\" % (name,))
|
| 1063 |
+
# # XXX
|
| 1064 |
+
# pprint.pprint(value, stream=self.excinfowriter)
|
| 1065 |
+
return ReprLocals(lines)
|
| 1066 |
+
return None
|
| 1067 |
+
|
| 1068 |
+
def repr_traceback_entry(
|
| 1069 |
+
self,
|
| 1070 |
+
entry: TracebackEntry | None,
|
| 1071 |
+
excinfo: ExceptionInfo[BaseException] | None = None,
|
| 1072 |
+
) -> ReprEntry:
|
| 1073 |
+
lines: list[str] = []
|
| 1074 |
+
style = (
|
| 1075 |
+
entry._repr_style
|
| 1076 |
+
if entry is not None and entry._repr_style is not None
|
| 1077 |
+
else self.style
|
| 1078 |
+
)
|
| 1079 |
+
if style in ("short", "long") and entry is not None:
|
| 1080 |
+
source = self._getentrysource(entry)
|
| 1081 |
+
if source is None:
|
| 1082 |
+
source = Source("???")
|
| 1083 |
+
line_index = 0
|
| 1084 |
+
end_line_index, colno, end_colno = None, None, None
|
| 1085 |
+
else:
|
| 1086 |
+
line_index = entry.relline
|
| 1087 |
+
end_line_index = entry.end_lineno_relative
|
| 1088 |
+
colno = entry.colno
|
| 1089 |
+
end_colno = entry.end_colno
|
| 1090 |
+
short = style == "short"
|
| 1091 |
+
reprargs = self.repr_args(entry) if not short else None
|
| 1092 |
+
s = self.get_source(
|
| 1093 |
+
source=source,
|
| 1094 |
+
line_index=line_index,
|
| 1095 |
+
excinfo=excinfo,
|
| 1096 |
+
short=short,
|
| 1097 |
+
end_line_index=end_line_index,
|
| 1098 |
+
colno=colno,
|
| 1099 |
+
end_colno=end_colno,
|
| 1100 |
+
)
|
| 1101 |
+
lines.extend(s)
|
| 1102 |
+
if short:
|
| 1103 |
+
message = f"in {entry.name}"
|
| 1104 |
+
else:
|
| 1105 |
+
message = (excinfo and excinfo.typename) or ""
|
| 1106 |
+
entry_path = entry.path
|
| 1107 |
+
path = self._makepath(entry_path)
|
| 1108 |
+
reprfileloc = ReprFileLocation(path, entry.lineno + 1, message)
|
| 1109 |
+
localsrepr = self.repr_locals(entry.locals)
|
| 1110 |
+
return ReprEntry(lines, reprargs, localsrepr, reprfileloc, style)
|
| 1111 |
+
elif style == "value":
|
| 1112 |
+
if excinfo:
|
| 1113 |
+
lines.extend(str(excinfo.value).split("\n"))
|
| 1114 |
+
return ReprEntry(lines, None, None, None, style)
|
| 1115 |
+
else:
|
| 1116 |
+
if excinfo:
|
| 1117 |
+
lines.extend(self.get_exconly(excinfo, indent=4))
|
| 1118 |
+
return ReprEntry(lines, None, None, None, style)
|
| 1119 |
+
|
| 1120 |
+
def _makepath(self, path: Path | str) -> str:
|
| 1121 |
+
if not self.abspath and isinstance(path, Path):
|
| 1122 |
+
try:
|
| 1123 |
+
np = bestrelpath(Path.cwd(), path)
|
| 1124 |
+
except OSError:
|
| 1125 |
+
return str(path)
|
| 1126 |
+
if len(np) < len(str(path)):
|
| 1127 |
+
return np
|
| 1128 |
+
return str(path)
|
| 1129 |
+
|
| 1130 |
+
def repr_traceback(self, excinfo: ExceptionInfo[BaseException]) -> ReprTraceback:
|
| 1131 |
+
traceback = filter_excinfo_traceback(self.tbfilter, excinfo)
|
| 1132 |
+
|
| 1133 |
+
if isinstance(excinfo.value, RecursionError):
|
| 1134 |
+
traceback, extraline = self._truncate_recursive_traceback(traceback)
|
| 1135 |
+
else:
|
| 1136 |
+
extraline = None
|
| 1137 |
+
|
| 1138 |
+
if not traceback:
|
| 1139 |
+
if extraline is None:
|
| 1140 |
+
extraline = "All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
|
| 1141 |
+
entries = [self.repr_traceback_entry(None, excinfo)]
|
| 1142 |
+
return ReprTraceback(entries, extraline, style=self.style)
|
| 1143 |
+
|
| 1144 |
+
last = traceback[-1]
|
| 1145 |
+
if self.style == "value":
|
| 1146 |
+
entries = [self.repr_traceback_entry(last, excinfo)]
|
| 1147 |
+
return ReprTraceback(entries, None, style=self.style)
|
| 1148 |
+
|
| 1149 |
+
entries = [
|
| 1150 |
+
self.repr_traceback_entry(entry, excinfo if last == entry else None)
|
| 1151 |
+
for entry in traceback
|
| 1152 |
+
]
|
| 1153 |
+
return ReprTraceback(entries, extraline, style=self.style)
|
| 1154 |
+
|
| 1155 |
+
def _truncate_recursive_traceback(
|
| 1156 |
+
self, traceback: Traceback
|
| 1157 |
+
) -> tuple[Traceback, str | None]:
|
| 1158 |
+
"""Truncate the given recursive traceback trying to find the starting
|
| 1159 |
+
point of the recursion.
|
| 1160 |
+
|
| 1161 |
+
The detection is done by going through each traceback entry and
|
| 1162 |
+
finding the point in which the locals of the frame are equal to the
|
| 1163 |
+
locals of a previous frame (see ``recursionindex()``).
|
| 1164 |
+
|
| 1165 |
+
Handle the situation where the recursion process might raise an
|
| 1166 |
+
exception (for example comparing numpy arrays using equality raises a
|
| 1167 |
+
TypeError), in which case we do our best to warn the user of the
|
| 1168 |
+
error and show a limited traceback.
|
| 1169 |
+
"""
|
| 1170 |
+
try:
|
| 1171 |
+
recursionindex = traceback.recursionindex()
|
| 1172 |
+
except Exception as e:
|
| 1173 |
+
max_frames = 10
|
| 1174 |
+
extraline: str | None = (
|
| 1175 |
+
"!!! Recursion error detected, but an error occurred locating the origin of recursion.\n"
|
| 1176 |
+
" The following exception happened when comparing locals in the stack frame:\n"
|
| 1177 |
+
f" {type(e).__name__}: {e!s}\n"
|
| 1178 |
+
f" Displaying first and last {max_frames} stack frames out of {len(traceback)}."
|
| 1179 |
+
)
|
| 1180 |
+
# Type ignored because adding two instances of a List subtype
|
| 1181 |
+
# currently incorrectly has type List instead of the subtype.
|
| 1182 |
+
traceback = traceback[:max_frames] + traceback[-max_frames:] # type: ignore
|
| 1183 |
+
else:
|
| 1184 |
+
if recursionindex is not None:
|
| 1185 |
+
extraline = "!!! Recursion detected (same locals & position)"
|
| 1186 |
+
traceback = traceback[: recursionindex + 1]
|
| 1187 |
+
else:
|
| 1188 |
+
extraline = None
|
| 1189 |
+
|
| 1190 |
+
return traceback, extraline
|
| 1191 |
+
|
| 1192 |
+
def repr_excinfo(self, excinfo: ExceptionInfo[BaseException]) -> ExceptionChainRepr:
|
| 1193 |
+
repr_chain: list[tuple[ReprTraceback, ReprFileLocation | None, str | None]] = []
|
| 1194 |
+
e: BaseException | None = excinfo.value
|
| 1195 |
+
excinfo_: ExceptionInfo[BaseException] | None = excinfo
|
| 1196 |
+
description = None
|
| 1197 |
+
seen: set[int] = set()
|
| 1198 |
+
while e is not None and id(e) not in seen:
|
| 1199 |
+
seen.add(id(e))
|
| 1200 |
+
|
| 1201 |
+
if excinfo_:
|
| 1202 |
+
# Fall back to native traceback as a temporary workaround until
|
| 1203 |
+
# full support for exception groups added to ExceptionInfo.
|
| 1204 |
+
# See https://github.com/pytest-dev/pytest/issues/9159
|
| 1205 |
+
reprtraceback: ReprTraceback | ReprTracebackNative
|
| 1206 |
+
if isinstance(e, BaseExceptionGroup):
|
| 1207 |
+
# don't filter any sub-exceptions since they shouldn't have any internal frames
|
| 1208 |
+
traceback = filter_excinfo_traceback(self.tbfilter, excinfo)
|
| 1209 |
+
extraline = (
|
| 1210 |
+
"All traceback entries are hidden. Pass `--full-trace` to see hidden and internal frames."
|
| 1211 |
+
if not traceback
|
| 1212 |
+
else None
|
| 1213 |
+
)
|
| 1214 |
+
reprtraceback = ReprTracebackNative(
|
| 1215 |
+
format_exception(
|
| 1216 |
+
type(excinfo.value),
|
| 1217 |
+
excinfo.value,
|
| 1218 |
+
traceback[0]._rawentry if traceback else None,
|
| 1219 |
+
),
|
| 1220 |
+
extraline=extraline,
|
| 1221 |
+
)
|
| 1222 |
+
|
| 1223 |
+
else:
|
| 1224 |
+
reprtraceback = self.repr_traceback(excinfo_)
|
| 1225 |
+
reprcrash = excinfo_._getreprcrash()
|
| 1226 |
+
else:
|
| 1227 |
+
# Fallback to native repr if the exception doesn't have a traceback:
|
| 1228 |
+
# ExceptionInfo objects require a full traceback to work.
|
| 1229 |
+
reprtraceback = ReprTracebackNative(format_exception(type(e), e, None))
|
| 1230 |
+
reprcrash = None
|
| 1231 |
+
repr_chain.append((reprtraceback, reprcrash, description))
|
| 1232 |
+
|
| 1233 |
+
if e.__cause__ is not None and self.chain:
|
| 1234 |
+
e = e.__cause__
|
| 1235 |
+
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
| 1236 |
+
description = "The above exception was the direct cause of the following exception:"
|
| 1237 |
+
elif (
|
| 1238 |
+
e.__context__ is not None and not e.__suppress_context__ and self.chain
|
| 1239 |
+
):
|
| 1240 |
+
e = e.__context__
|
| 1241 |
+
excinfo_ = ExceptionInfo.from_exception(e) if e.__traceback__ else None
|
| 1242 |
+
description = "During handling of the above exception, another exception occurred:"
|
| 1243 |
+
else:
|
| 1244 |
+
e = None
|
| 1245 |
+
repr_chain.reverse()
|
| 1246 |
+
return ExceptionChainRepr(repr_chain)
|
| 1247 |
+
|
| 1248 |
+
|
| 1249 |
+
@dataclasses.dataclass(eq=False)
|
| 1250 |
+
class TerminalRepr:
|
| 1251 |
+
"""Base class for terminal representations -- pieces of data that display
|
| 1252 |
+
themselves to a terminal."""
|
| 1253 |
+
|
| 1254 |
+
def __str__(self) -> str:
|
| 1255 |
+
# FYI this is called from pytest-xdist's serialization of exception
|
| 1256 |
+
# information.
|
| 1257 |
+
io = StringIO()
|
| 1258 |
+
tw = TerminalWriter(file=io)
|
| 1259 |
+
self.toterminal(tw)
|
| 1260 |
+
return io.getvalue().strip()
|
| 1261 |
+
|
| 1262 |
+
def __repr__(self) -> str:
|
| 1263 |
+
return f"<{self.__class__} instance at {id(self):0x}>"
|
| 1264 |
+
|
| 1265 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1266 |
+
raise NotImplementedError()
|
| 1267 |
+
|
| 1268 |
+
|
| 1269 |
+
@dataclasses.dataclass(eq=False)
|
| 1270 |
+
class ExceptionRepr(TerminalRepr):
|
| 1271 |
+
"""Base class for exception terminal representations.
|
| 1272 |
+
|
| 1273 |
+
The representation generally contains:
|
| 1274 |
+
- The exception traceback (`reprtraceback`)
|
| 1275 |
+
- The exception message and location (`reprcrash`)
|
| 1276 |
+
- Separated, titled sections with additional data (pytest core doesn't use
|
| 1277 |
+
this currently).
|
| 1278 |
+
"""
|
| 1279 |
+
|
| 1280 |
+
reprtraceback: ReprTraceback
|
| 1281 |
+
reprcrash: ReprFileLocation | None
|
| 1282 |
+
sections: list[tuple[str, str, str]] = dataclasses.field(
|
| 1283 |
+
init=False, default_factory=list
|
| 1284 |
+
)
|
| 1285 |
+
|
| 1286 |
+
def addsection(self, name: str, content: str, sep: str = "-") -> None:
|
| 1287 |
+
self.sections.append((name, content, sep))
|
| 1288 |
+
|
| 1289 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1290 |
+
for name, content, sep in self.sections:
|
| 1291 |
+
tw.sep(sep, name)
|
| 1292 |
+
tw.line(content)
|
| 1293 |
+
|
| 1294 |
+
|
| 1295 |
+
@dataclasses.dataclass(eq=False)
|
| 1296 |
+
class ExceptionChainRepr(ExceptionRepr):
|
| 1297 |
+
"""A chain of exceptions, separated by descriptions (e.g. "The above
|
| 1298 |
+
exception was the direct cause of the following exception")."""
|
| 1299 |
+
|
| 1300 |
+
chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]]
|
| 1301 |
+
|
| 1302 |
+
def __init__(
|
| 1303 |
+
self,
|
| 1304 |
+
chain: Sequence[tuple[ReprTraceback, ReprFileLocation | None, str | None]],
|
| 1305 |
+
) -> None:
|
| 1306 |
+
# reprcrash and reprtraceback of the outermost (the newest) exception
|
| 1307 |
+
# in the chain.
|
| 1308 |
+
super().__init__(
|
| 1309 |
+
reprtraceback=chain[-1][0],
|
| 1310 |
+
reprcrash=chain[-1][1],
|
| 1311 |
+
)
|
| 1312 |
+
self.chain = chain
|
| 1313 |
+
|
| 1314 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1315 |
+
for reprtraceback, reprcrash, description in self.chain:
|
| 1316 |
+
reprtraceback.toterminal(tw)
|
| 1317 |
+
if description is not None:
|
| 1318 |
+
tw.line("")
|
| 1319 |
+
tw.line(description, yellow=True)
|
| 1320 |
+
super().toterminal(tw)
|
| 1321 |
+
|
| 1322 |
+
|
| 1323 |
+
@dataclasses.dataclass(eq=False)
|
| 1324 |
+
class ReprExceptionInfo(ExceptionRepr):
|
| 1325 |
+
"""A single exception with optional extra details (function arguments,
|
| 1326 |
+
function locals, file location) and possible extra line and sections emitted
|
| 1327 |
+
at the end."""
|
| 1328 |
+
|
| 1329 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1330 |
+
self.reprtraceback.toterminal(tw)
|
| 1331 |
+
super().toterminal(tw)
|
| 1332 |
+
|
| 1333 |
+
|
| 1334 |
+
@dataclasses.dataclass(eq=False)
|
| 1335 |
+
class ReprTraceback(TerminalRepr):
|
| 1336 |
+
"""A traceback with optional extra details (function arguments, function
|
| 1337 |
+
locals, file location) and possible extra line emitted at the end."""
|
| 1338 |
+
|
| 1339 |
+
reprentries: Sequence[ReprEntry | ReprEntryNative]
|
| 1340 |
+
extraline: str | None
|
| 1341 |
+
style: TracebackStyle
|
| 1342 |
+
|
| 1343 |
+
entrysep: ClassVar = "_ "
|
| 1344 |
+
|
| 1345 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1346 |
+
# The entries might have different styles.
|
| 1347 |
+
for i, entry in enumerate(self.reprentries):
|
| 1348 |
+
if entry.style == "long":
|
| 1349 |
+
tw.line("")
|
| 1350 |
+
entry.toterminal(tw)
|
| 1351 |
+
if i < len(self.reprentries) - 1:
|
| 1352 |
+
next_entry = self.reprentries[i + 1]
|
| 1353 |
+
if entry.style == "long" or (
|
| 1354 |
+
entry.style == "short" and next_entry.style == "long"
|
| 1355 |
+
):
|
| 1356 |
+
tw.sep(self.entrysep)
|
| 1357 |
+
|
| 1358 |
+
if self.extraline:
|
| 1359 |
+
tw.line(self.extraline)
|
| 1360 |
+
|
| 1361 |
+
|
| 1362 |
+
class ReprTracebackNative(ReprTraceback):
|
| 1363 |
+
"""A traceback in native style.
|
| 1364 |
+
|
| 1365 |
+
The lines are emitted as is; uses a single entry for the entire native
|
| 1366 |
+
traceback.
|
| 1367 |
+
"""
|
| 1368 |
+
|
| 1369 |
+
def __init__(self, tblines: Sequence[str], *, extraline: str | None = None) -> None:
|
| 1370 |
+
self.reprentries = [ReprEntryNative(tblines)]
|
| 1371 |
+
self.extraline = extraline
|
| 1372 |
+
self.style = "native"
|
| 1373 |
+
|
| 1374 |
+
|
| 1375 |
+
@dataclasses.dataclass(eq=False)
|
| 1376 |
+
class ReprEntryNative(TerminalRepr):
|
| 1377 |
+
"""An entry in a traceback in native style.
|
| 1378 |
+
|
| 1379 |
+
Emits the lines as is. The lines are assumed to include the line separators.
|
| 1380 |
+
|
| 1381 |
+
[Note that we currently use a single "entry" for the entire native
|
| 1382 |
+
traceback, so this is a bit misleading, but there's no point trying to parse
|
| 1383 |
+
or split a native traceback.]
|
| 1384 |
+
"""
|
| 1385 |
+
|
| 1386 |
+
lines: Sequence[str]
|
| 1387 |
+
|
| 1388 |
+
style: ClassVar[TracebackStyle] = "native"
|
| 1389 |
+
|
| 1390 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1391 |
+
tw.write("".join(self.lines))
|
| 1392 |
+
|
| 1393 |
+
|
| 1394 |
+
@dataclasses.dataclass(eq=False)
|
| 1395 |
+
class ReprEntry(TerminalRepr):
|
| 1396 |
+
"""An entry in a traceback with possible extra details (function arguments,
|
| 1397 |
+
function locals, source snippet, function's file and line)."""
|
| 1398 |
+
|
| 1399 |
+
lines: Sequence[str]
|
| 1400 |
+
reprfuncargs: ReprFuncArgs | None
|
| 1401 |
+
reprlocals: ReprLocals | None
|
| 1402 |
+
reprfileloc: ReprFileLocation | None
|
| 1403 |
+
style: TracebackStyle
|
| 1404 |
+
|
| 1405 |
+
def _write_entry_lines(self, tw: TerminalWriter) -> None:
|
| 1406 |
+
"""Write the source code portions of a list of traceback entries with syntax highlighting.
|
| 1407 |
+
|
| 1408 |
+
Usually entries are lines like these:
|
| 1409 |
+
|
| 1410 |
+
" x = 1"
|
| 1411 |
+
"> assert x == 2"
|
| 1412 |
+
"E assert 1 == 2"
|
| 1413 |
+
|
| 1414 |
+
This function takes care of rendering the "source" portions of it (the lines without
|
| 1415 |
+
the "E" prefix) using syntax highlighting, taking care to not highlighting the ">"
|
| 1416 |
+
character, as doing so might break line continuations.
|
| 1417 |
+
"""
|
| 1418 |
+
if not self.lines:
|
| 1419 |
+
return
|
| 1420 |
+
|
| 1421 |
+
if self.style == "value":
|
| 1422 |
+
# Using tw.write instead of tw.line for testing purposes due to TWMock implementation;
|
| 1423 |
+
# lines written with TWMock.line and TWMock._write_source cannot be distinguished
|
| 1424 |
+
# from each other, whereas lines written with TWMock.write are marked with TWMock.WRITE
|
| 1425 |
+
for line in self.lines:
|
| 1426 |
+
tw.write(line)
|
| 1427 |
+
tw.write("\n")
|
| 1428 |
+
return
|
| 1429 |
+
|
| 1430 |
+
# separate indents and source lines that are not failures: we want to
|
| 1431 |
+
# highlight the code but not the indentation, which may contain markers
|
| 1432 |
+
# such as "> assert 0"
|
| 1433 |
+
fail_marker = f"{ExceptionInfoFormatter.fail_marker} "
|
| 1434 |
+
indent_size = len(fail_marker)
|
| 1435 |
+
indents: list[str] = []
|
| 1436 |
+
source_lines: list[str] = []
|
| 1437 |
+
failure_lines: list[str] = []
|
| 1438 |
+
for index, line in enumerate(self.lines):
|
| 1439 |
+
is_failure_line = line.startswith(fail_marker)
|
| 1440 |
+
if is_failure_line:
|
| 1441 |
+
# from this point on all lines are considered part of the failure
|
| 1442 |
+
failure_lines.extend(self.lines[index:])
|
| 1443 |
+
break
|
| 1444 |
+
else:
|
| 1445 |
+
indents.append(line[:indent_size])
|
| 1446 |
+
source_lines.append(line[indent_size:])
|
| 1447 |
+
|
| 1448 |
+
tw._write_source(source_lines, indents)
|
| 1449 |
+
|
| 1450 |
+
# failure lines are always completely red and bold
|
| 1451 |
+
for line in failure_lines:
|
| 1452 |
+
tw.line(line, bold=True, red=True)
|
| 1453 |
+
|
| 1454 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1455 |
+
if self.style == "short":
|
| 1456 |
+
if self.reprfileloc:
|
| 1457 |
+
self.reprfileloc.toterminal(tw)
|
| 1458 |
+
self._write_entry_lines(tw)
|
| 1459 |
+
if self.reprlocals:
|
| 1460 |
+
self.reprlocals.toterminal(tw, indent=" " * 8)
|
| 1461 |
+
return
|
| 1462 |
+
|
| 1463 |
+
if self.reprfuncargs:
|
| 1464 |
+
self.reprfuncargs.toterminal(tw)
|
| 1465 |
+
|
| 1466 |
+
self._write_entry_lines(tw)
|
| 1467 |
+
|
| 1468 |
+
if self.reprlocals:
|
| 1469 |
+
tw.line("")
|
| 1470 |
+
self.reprlocals.toterminal(tw)
|
| 1471 |
+
if self.reprfileloc:
|
| 1472 |
+
if self.lines:
|
| 1473 |
+
tw.line("")
|
| 1474 |
+
self.reprfileloc.toterminal(tw)
|
| 1475 |
+
|
| 1476 |
+
def __str__(self) -> str:
|
| 1477 |
+
return "{}\n{}\n{}".format(
|
| 1478 |
+
"\n".join(self.lines), self.reprlocals, self.reprfileloc
|
| 1479 |
+
)
|
| 1480 |
+
|
| 1481 |
+
|
| 1482 |
+
@dataclasses.dataclass(eq=False)
|
| 1483 |
+
class ReprFileLocation(TerminalRepr):
|
| 1484 |
+
"""A message at a file location, using the `<path>:<lineno>: <message>`
|
| 1485 |
+
format that most editors understand.
|
| 1486 |
+
|
| 1487 |
+
Only the first line of the message is emitted.
|
| 1488 |
+
"""
|
| 1489 |
+
|
| 1490 |
+
path: str
|
| 1491 |
+
lineno: int
|
| 1492 |
+
message: str
|
| 1493 |
+
|
| 1494 |
+
def __post_init__(self) -> None:
|
| 1495 |
+
self.path = str(self.path)
|
| 1496 |
+
|
| 1497 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1498 |
+
msg = self.message
|
| 1499 |
+
i = msg.find("\n")
|
| 1500 |
+
if i != -1:
|
| 1501 |
+
msg = msg[:i]
|
| 1502 |
+
tw.write(self.path, bold=True, red=True)
|
| 1503 |
+
tw.line(f":{self.lineno}: {msg}")
|
| 1504 |
+
|
| 1505 |
+
|
| 1506 |
+
@dataclasses.dataclass(eq=False)
|
| 1507 |
+
class ReprLocals(TerminalRepr):
|
| 1508 |
+
"""Function local variables (pre-formatted)."""
|
| 1509 |
+
|
| 1510 |
+
lines: Sequence[str]
|
| 1511 |
+
|
| 1512 |
+
def toterminal(self, tw: TerminalWriter, indent: str = "") -> None:
|
| 1513 |
+
for line in self.lines:
|
| 1514 |
+
tw.line(indent + line)
|
| 1515 |
+
|
| 1516 |
+
|
| 1517 |
+
@dataclasses.dataclass(eq=False)
|
| 1518 |
+
class ReprFuncArgs(TerminalRepr):
|
| 1519 |
+
"""Function arguments - name = value, comma separated."""
|
| 1520 |
+
|
| 1521 |
+
args: Sequence[tuple[str, object]]
|
| 1522 |
+
|
| 1523 |
+
def toterminal(self, tw: TerminalWriter) -> None:
|
| 1524 |
+
if self.args:
|
| 1525 |
+
linesofar = ""
|
| 1526 |
+
for name, value in self.args:
|
| 1527 |
+
ns = f"{name} = {value}"
|
| 1528 |
+
if len(ns) + len(linesofar) + 2 > tw.fullwidth:
|
| 1529 |
+
if linesofar:
|
| 1530 |
+
tw.line(linesofar)
|
| 1531 |
+
linesofar = ns
|
| 1532 |
+
else:
|
| 1533 |
+
if linesofar:
|
| 1534 |
+
linesofar += ", " + ns
|
| 1535 |
+
else:
|
| 1536 |
+
linesofar = ns
|
| 1537 |
+
if linesofar:
|
| 1538 |
+
tw.line(linesofar)
|
| 1539 |
+
tw.line("")
|
| 1540 |
+
|
| 1541 |
+
|
| 1542 |
+
def getfslineno(obj: object) -> tuple[str | Path, int]:
|
| 1543 |
+
"""Return source location (path, lineno) for the given object.
|
| 1544 |
+
|
| 1545 |
+
If the source cannot be determined return ("", -1).
|
| 1546 |
+
|
| 1547 |
+
The line number is 0-based.
|
| 1548 |
+
"""
|
| 1549 |
+
# xxx let decorators etc specify a sane ordering
|
| 1550 |
+
# NOTE: this used to be done in _pytest.compat.getfslineno, initially added
|
| 1551 |
+
# in 6ec13a2b9. It ("place_as") appears to be something very custom.
|
| 1552 |
+
obj = get_real_func(obj)
|
| 1553 |
+
if hasattr(obj, "place_as"):
|
| 1554 |
+
obj = obj.place_as
|
| 1555 |
+
|
| 1556 |
+
try:
|
| 1557 |
+
code = Code.from_function(obj)
|
| 1558 |
+
except TypeError:
|
| 1559 |
+
try:
|
| 1560 |
+
fn = inspect.getsourcefile(obj) or inspect.getfile(obj) # type: ignore[arg-type]
|
| 1561 |
+
except TypeError:
|
| 1562 |
+
return "", -1
|
| 1563 |
+
|
| 1564 |
+
fspath = (fn and absolutepath(fn)) or ""
|
| 1565 |
+
lineno = -1
|
| 1566 |
+
if fspath:
|
| 1567 |
+
try:
|
| 1568 |
+
_, lineno = findsource(obj)
|
| 1569 |
+
except OSError:
|
| 1570 |
+
pass
|
| 1571 |
+
return fspath, lineno
|
| 1572 |
+
|
| 1573 |
+
return code.path, code.firstlineno
|
| 1574 |
+
|
| 1575 |
+
|
| 1576 |
+
def _byte_offset_to_character_offset(str, offset):
|
| 1577 |
+
"""Converts a byte based offset in a string to a code-point."""
|
| 1578 |
+
as_utf8 = str.encode("utf-8")
|
| 1579 |
+
return len(as_utf8[:offset].decode("utf-8", errors="replace"))
|
| 1580 |
+
|
| 1581 |
+
|
| 1582 |
+
# Relative paths that we use to filter traceback entries from appearing to the user;
|
| 1583 |
+
# see filter_traceback.
|
| 1584 |
+
# note: if we need to add more paths than what we have now we should probably use a list
|
| 1585 |
+
# for better maintenance.
|
| 1586 |
+
|
| 1587 |
+
_PLUGGY_DIR = Path(pluggy.__file__.rstrip("oc"))
|
| 1588 |
+
# pluggy is either a package or a single module depending on the version
|
| 1589 |
+
if _PLUGGY_DIR.name == "__init__.py":
|
| 1590 |
+
_PLUGGY_DIR = _PLUGGY_DIR.parent
|
| 1591 |
+
_PYTEST_DIR = Path(_pytest.__file__).parent
|
| 1592 |
+
|
| 1593 |
+
|
| 1594 |
+
def filter_traceback(entry: TracebackEntry) -> bool:
|
| 1595 |
+
"""Return True if a TracebackEntry instance should be included in tracebacks.
|
| 1596 |
+
|
| 1597 |
+
We hide traceback entries of:
|
| 1598 |
+
|
| 1599 |
+
* dynamically generated code (no code to show up for it);
|
| 1600 |
+
* internal traceback from pytest or its internal libraries, py and pluggy.
|
| 1601 |
+
"""
|
| 1602 |
+
# entry.path might sometimes return a str object when the entry
|
| 1603 |
+
# points to dynamically generated code.
|
| 1604 |
+
# See https://bitbucket.org/pytest-dev/py/issues/71.
|
| 1605 |
+
raw_filename = entry.frame.code.raw.co_filename
|
| 1606 |
+
is_generated = "<" in raw_filename and ">" in raw_filename
|
| 1607 |
+
if is_generated:
|
| 1608 |
+
return False
|
| 1609 |
+
|
| 1610 |
+
# entry.path might point to a non-existing file, in which case it will
|
| 1611 |
+
# also return a str object. See #1133.
|
| 1612 |
+
p = Path(entry.path)
|
| 1613 |
+
|
| 1614 |
+
parents = p.parents
|
| 1615 |
+
if _PLUGGY_DIR in parents:
|
| 1616 |
+
return False
|
| 1617 |
+
if _PYTEST_DIR in parents:
|
| 1618 |
+
return False
|
| 1619 |
+
|
| 1620 |
+
return True
|
| 1621 |
+
|
| 1622 |
+
|
| 1623 |
+
def filter_excinfo_traceback(
|
| 1624 |
+
tbfilter: TracebackFilter, excinfo: ExceptionInfo[BaseException]
|
| 1625 |
+
) -> Traceback:
|
| 1626 |
+
"""Filter the exception traceback in ``excinfo`` according to ``tbfilter``."""
|
| 1627 |
+
if callable(tbfilter):
|
| 1628 |
+
return tbfilter(excinfo)
|
| 1629 |
+
elif tbfilter:
|
| 1630 |
+
return excinfo.traceback.filter(excinfo)
|
| 1631 |
+
else:
|
| 1632 |
+
return excinfo.traceback
|
.venv/lib/python3.14/site-packages/_pytest/_code/source.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import ast
|
| 5 |
+
from bisect import bisect_right
|
| 6 |
+
from collections.abc import Iterable
|
| 7 |
+
from collections.abc import Iterator
|
| 8 |
+
import inspect
|
| 9 |
+
import textwrap
|
| 10 |
+
import tokenize
|
| 11 |
+
import types
|
| 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 |
+
self.raw_lines: list[str] = []
|
| 26 |
+
elif isinstance(obj, Source):
|
| 27 |
+
self.lines = obj.lines
|
| 28 |
+
self.raw_lines = obj.raw_lines
|
| 29 |
+
elif isinstance(obj, tuple | list):
|
| 30 |
+
self.lines = deindent(x.rstrip("\n") for x in obj)
|
| 31 |
+
self.raw_lines = list(x.rstrip("\n") for x in obj)
|
| 32 |
+
elif isinstance(obj, str):
|
| 33 |
+
self.lines = deindent(obj.split("\n"))
|
| 34 |
+
self.raw_lines = obj.split("\n")
|
| 35 |
+
else:
|
| 36 |
+
try:
|
| 37 |
+
rawcode = getrawcode(obj)
|
| 38 |
+
src = inspect.getsource(rawcode)
|
| 39 |
+
except TypeError:
|
| 40 |
+
src = inspect.getsource(obj) # type: ignore[arg-type]
|
| 41 |
+
self.lines = deindent(src.split("\n"))
|
| 42 |
+
self.raw_lines = src.split("\n")
|
| 43 |
+
|
| 44 |
+
def __eq__(self, other: object) -> bool:
|
| 45 |
+
if not isinstance(other, Source):
|
| 46 |
+
return NotImplemented
|
| 47 |
+
return self.lines == other.lines
|
| 48 |
+
|
| 49 |
+
# Ignore type because of https://github.com/python/mypy/issues/4266.
|
| 50 |
+
__hash__ = None # type: ignore
|
| 51 |
+
|
| 52 |
+
@overload
|
| 53 |
+
def __getitem__(self, key: int) -> str: ...
|
| 54 |
+
|
| 55 |
+
@overload
|
| 56 |
+
def __getitem__(self, key: slice) -> Source: ...
|
| 57 |
+
|
| 58 |
+
def __getitem__(self, key: int | slice) -> str | Source:
|
| 59 |
+
if isinstance(key, int):
|
| 60 |
+
return self.lines[key]
|
| 61 |
+
else:
|
| 62 |
+
if key.step not in (None, 1):
|
| 63 |
+
raise IndexError("cannot slice a Source with a step")
|
| 64 |
+
newsource = Source()
|
| 65 |
+
newsource.lines = self.lines[key.start : key.stop]
|
| 66 |
+
newsource.raw_lines = self.raw_lines[key.start : key.stop]
|
| 67 |
+
return newsource
|
| 68 |
+
|
| 69 |
+
def __iter__(self) -> Iterator[str]:
|
| 70 |
+
return iter(self.lines)
|
| 71 |
+
|
| 72 |
+
def __len__(self) -> int:
|
| 73 |
+
return len(self.lines)
|
| 74 |
+
|
| 75 |
+
def strip(self) -> Source:
|
| 76 |
+
"""Return new Source object with trailing and leading blank lines removed."""
|
| 77 |
+
start, end = 0, len(self)
|
| 78 |
+
while start < end and not self.lines[start].strip():
|
| 79 |
+
start += 1
|
| 80 |
+
while end > start and not self.lines[end - 1].strip():
|
| 81 |
+
end -= 1
|
| 82 |
+
source = Source()
|
| 83 |
+
source.raw_lines = self.raw_lines
|
| 84 |
+
source.lines[:] = self.lines[start:end]
|
| 85 |
+
return source
|
| 86 |
+
|
| 87 |
+
def indent(self, indent: str = " " * 4) -> Source:
|
| 88 |
+
"""Return a copy of the source object with all lines indented by the
|
| 89 |
+
given indent-string."""
|
| 90 |
+
newsource = Source()
|
| 91 |
+
newsource.raw_lines = self.raw_lines
|
| 92 |
+
newsource.lines = [(indent + line) for line in self.lines]
|
| 93 |
+
return newsource
|
| 94 |
+
|
| 95 |
+
def getstatement(self, lineno: int) -> Source:
|
| 96 |
+
"""Return Source statement which contains the given linenumber
|
| 97 |
+
(counted from 0)."""
|
| 98 |
+
start, end = self.getstatementrange(lineno)
|
| 99 |
+
return self[start:end]
|
| 100 |
+
|
| 101 |
+
def getstatementrange(self, lineno: int) -> tuple[int, int]:
|
| 102 |
+
"""Return (start, end) tuple which spans the minimal statement region
|
| 103 |
+
which containing the given lineno."""
|
| 104 |
+
if not (0 <= lineno < len(self)):
|
| 105 |
+
raise IndexError("lineno out of range")
|
| 106 |
+
_ast, start, end = getstatementrange_ast(lineno, self)
|
| 107 |
+
return start, end
|
| 108 |
+
|
| 109 |
+
def deindent(self) -> Source:
|
| 110 |
+
"""Return a new Source object deindented."""
|
| 111 |
+
newsource = Source()
|
| 112 |
+
newsource.lines[:] = deindent(self.lines)
|
| 113 |
+
newsource.raw_lines = self.raw_lines
|
| 114 |
+
return newsource
|
| 115 |
+
|
| 116 |
+
def __str__(self) -> str:
|
| 117 |
+
return "\n".join(self.lines)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
#
|
| 121 |
+
# helper functions
|
| 122 |
+
#
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def findsource(obj) -> tuple[Source | None, int]:
|
| 126 |
+
try:
|
| 127 |
+
sourcelines, lineno = inspect.findsource(obj)
|
| 128 |
+
except Exception:
|
| 129 |
+
return None, -1
|
| 130 |
+
source = Source()
|
| 131 |
+
source.lines = [line.rstrip() for line in sourcelines]
|
| 132 |
+
source.raw_lines = sourcelines
|
| 133 |
+
return source, lineno
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def getrawcode(obj: object, trycall: bool = True) -> types.CodeType:
|
| 137 |
+
"""Return code object for given function."""
|
| 138 |
+
try:
|
| 139 |
+
return obj.__code__ # type: ignore[attr-defined,no-any-return]
|
| 140 |
+
except AttributeError:
|
| 141 |
+
pass
|
| 142 |
+
if trycall:
|
| 143 |
+
call = getattr(obj, "__call__", None)
|
| 144 |
+
if call and not isinstance(obj, type):
|
| 145 |
+
return getrawcode(call, trycall=False)
|
| 146 |
+
raise TypeError(f"could not get code object for {obj!r}")
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def deindent(lines: Iterable[str]) -> list[str]:
|
| 150 |
+
return textwrap.dedent("\n".join(lines)).splitlines()
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def get_statement_startend2(lineno: int, node: ast.AST) -> tuple[int, int | None]:
|
| 154 |
+
# Flatten all statements and except handlers into one lineno-list.
|
| 155 |
+
# AST's line numbers start indexing at 1.
|
| 156 |
+
values: list[int] = []
|
| 157 |
+
for x in ast.walk(node):
|
| 158 |
+
if isinstance(x, ast.stmt | ast.ExceptHandler):
|
| 159 |
+
# The lineno points to the class/def, so need to include the decorators.
|
| 160 |
+
if isinstance(x, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef):
|
| 161 |
+
for d in x.decorator_list:
|
| 162 |
+
values.append(d.lineno - 1)
|
| 163 |
+
values.append(x.lineno - 1)
|
| 164 |
+
for name in ("finalbody", "orelse"):
|
| 165 |
+
val: list[ast.stmt] | None = getattr(x, name, None)
|
| 166 |
+
if val:
|
| 167 |
+
# Treat the finally/orelse part as its own statement.
|
| 168 |
+
values.append(val[0].lineno - 1 - 1)
|
| 169 |
+
values.sort()
|
| 170 |
+
insert_index = bisect_right(values, lineno)
|
| 171 |
+
if insert_index == 0:
|
| 172 |
+
return 0, None
|
| 173 |
+
start = values[insert_index - 1]
|
| 174 |
+
if insert_index >= len(values):
|
| 175 |
+
end = None
|
| 176 |
+
else:
|
| 177 |
+
end = values[insert_index]
|
| 178 |
+
return start, end
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def getstatementrange_ast(
|
| 182 |
+
lineno: int,
|
| 183 |
+
source: Source,
|
| 184 |
+
assertion: bool = False,
|
| 185 |
+
astnode: ast.AST | None = None,
|
| 186 |
+
) -> tuple[ast.AST, int, int]:
|
| 187 |
+
if astnode is None:
|
| 188 |
+
content = str(source)
|
| 189 |
+
# See #4260:
|
| 190 |
+
# Don't produce duplicate warnings when compiling source to find AST.
|
| 191 |
+
with warnings.catch_warnings():
|
| 192 |
+
warnings.simplefilter("ignore")
|
| 193 |
+
astnode = ast.parse(content, "source", "exec")
|
| 194 |
+
|
| 195 |
+
start, end = get_statement_startend2(lineno, astnode)
|
| 196 |
+
# We need to correct the end:
|
| 197 |
+
# - ast-parsing strips comments
|
| 198 |
+
# - there might be empty lines
|
| 199 |
+
# - we might have lesser indented code blocks at the end
|
| 200 |
+
if end is None:
|
| 201 |
+
end = len(source.lines)
|
| 202 |
+
|
| 203 |
+
if end > start + 1:
|
| 204 |
+
# Make sure we don't span differently indented code blocks
|
| 205 |
+
# by using the BlockFinder helper used which inspect.getsource() uses itself.
|
| 206 |
+
block_finder = inspect.BlockFinder()
|
| 207 |
+
# If we start with an indented line, put blockfinder to "started" mode.
|
| 208 |
+
block_finder.started = (
|
| 209 |
+
bool(source.lines[start]) and source.lines[start][0].isspace()
|
| 210 |
+
)
|
| 211 |
+
it = ((x + "\n") for x in source.lines[start:end])
|
| 212 |
+
try:
|
| 213 |
+
for tok in tokenize.generate_tokens(lambda: next(it)):
|
| 214 |
+
block_finder.tokeneater(*tok)
|
| 215 |
+
except (inspect.EndOfBlock, IndentationError):
|
| 216 |
+
end = block_finder.last + start
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
# The end might still point to a comment or empty line, correct it.
|
| 221 |
+
end = min(end, len(source.lines))
|
| 222 |
+
while end:
|
| 223 |
+
line = source.lines[end - 1].lstrip()
|
| 224 |
+
if line.startswith("#") or not line:
|
| 225 |
+
end -= 1
|
| 226 |
+
else:
|
| 227 |
+
break
|
| 228 |
+
return astnode, start, end
|
.venv/lib/python3.14/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.14/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 |
+
from collections.abc import Callable
|
| 20 |
+
from collections.abc import Iterator
|
| 21 |
+
import dataclasses as _dataclasses
|
| 22 |
+
from io import StringIO as _StringIO
|
| 23 |
+
import re
|
| 24 |
+
import types as _types
|
| 25 |
+
from typing import Any
|
| 26 |
+
from typing import IO
|
| 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)
|
| 115 |
+
and not isinstance(object, type)
|
| 116 |
+
and object.__dataclass_params__.repr # type:ignore[attr-defined]
|
| 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)
|
| 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 = object.items()
|
| 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(f"maxlen={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 object.items():
|
| 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.14/site-packages/_pytest/_io/saferepr.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from itertools import islice
|
| 4 |
+
import pprint
|
| 5 |
+
import reprlib
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _try_repr_or_str(obj: object) -> str:
|
| 9 |
+
try:
|
| 10 |
+
return repr(obj)
|
| 11 |
+
except (KeyboardInterrupt, SystemExit):
|
| 12 |
+
raise
|
| 13 |
+
except BaseException:
|
| 14 |
+
return f'{type(obj).__name__}("{obj}")'
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _format_repr_exception(exc: BaseException, obj: object) -> str:
|
| 18 |
+
try:
|
| 19 |
+
exc_info = _try_repr_or_str(exc)
|
| 20 |
+
except (KeyboardInterrupt, SystemExit):
|
| 21 |
+
raise
|
| 22 |
+
except BaseException as inner_exc:
|
| 23 |
+
exc_info = f"unpresentable exception ({_try_repr_or_str(inner_exc)})"
|
| 24 |
+
return (
|
| 25 |
+
f"<[{exc_info} raised in repr()] {type(obj).__name__} object at 0x{id(obj):x}>"
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _ellipsize(s: str, maxsize: int) -> str:
|
| 30 |
+
if len(s) > maxsize:
|
| 31 |
+
i = max(0, (maxsize - 3) // 2)
|
| 32 |
+
j = max(0, maxsize - 3 - i)
|
| 33 |
+
return s[:i] + "..." + s[len(s) - j :]
|
| 34 |
+
return s
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class SafeRepr(reprlib.Repr):
|
| 38 |
+
"""
|
| 39 |
+
repr.Repr that limits the resulting size of repr() and includes
|
| 40 |
+
information on exceptions raised during the call.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
def __init__(self, maxsize: int | None, use_ascii: bool = False) -> None:
|
| 44 |
+
"""
|
| 45 |
+
:param maxsize:
|
| 46 |
+
If not None, will truncate the resulting repr to that specific size, using ellipsis
|
| 47 |
+
somewhere in the middle to hide the extra text.
|
| 48 |
+
If None, will not impose any size limits on the returning repr.
|
| 49 |
+
"""
|
| 50 |
+
super().__init__()
|
| 51 |
+
# ``maxstring`` is used by the superclass, and needs to be an int; using a
|
| 52 |
+
# very large number in case maxsize is None, meaning we want to disable
|
| 53 |
+
# truncation.
|
| 54 |
+
self.maxstring = maxsize if maxsize is not None else 1_000_000_000
|
| 55 |
+
self.maxsize = maxsize
|
| 56 |
+
self.use_ascii = use_ascii
|
| 57 |
+
|
| 58 |
+
def repr(self, x: object) -> str:
|
| 59 |
+
try:
|
| 60 |
+
if self.use_ascii:
|
| 61 |
+
s = ascii(x)
|
| 62 |
+
else:
|
| 63 |
+
s = super().repr(x)
|
| 64 |
+
except (KeyboardInterrupt, SystemExit):
|
| 65 |
+
raise
|
| 66 |
+
except BaseException as exc:
|
| 67 |
+
s = _format_repr_exception(exc, x)
|
| 68 |
+
if self.maxsize is not None:
|
| 69 |
+
s = _ellipsize(s, self.maxsize)
|
| 70 |
+
return s
|
| 71 |
+
|
| 72 |
+
def repr_instance(self, x: object, level: int) -> str:
|
| 73 |
+
try:
|
| 74 |
+
s = repr(x)
|
| 75 |
+
except (KeyboardInterrupt, SystemExit):
|
| 76 |
+
raise
|
| 77 |
+
except BaseException as exc:
|
| 78 |
+
s = _format_repr_exception(exc, x)
|
| 79 |
+
if self.maxsize is not None:
|
| 80 |
+
s = _ellipsize(s, self.maxsize)
|
| 81 |
+
|
| 82 |
+
return s
|
| 83 |
+
|
| 84 |
+
def repr_dict(self, x: dict[object, object], level: int) -> str:
|
| 85 |
+
"""Represent a dict while preserving its insertion order.
|
| 86 |
+
|
| 87 |
+
Differs from ``reprlib.Repr.repr_dict`` by iterating directly over ``x``
|
| 88 |
+
rather than using the stdlib's sorting helper.
|
| 89 |
+
"""
|
| 90 |
+
fillvalue = "..."
|
| 91 |
+
n = len(x)
|
| 92 |
+
if n == 0:
|
| 93 |
+
return "{}"
|
| 94 |
+
if level <= 0:
|
| 95 |
+
return "{" + fillvalue + "}"
|
| 96 |
+
newlevel = level - 1
|
| 97 |
+
repr1 = self.repr1
|
| 98 |
+
pieces = []
|
| 99 |
+
for key in islice(x, self.maxdict):
|
| 100 |
+
keyrepr = repr1(key, newlevel)
|
| 101 |
+
valrepr = repr1(x[key], newlevel)
|
| 102 |
+
pieces.append(f"{keyrepr}: {valrepr}")
|
| 103 |
+
if n > self.maxdict:
|
| 104 |
+
pieces.append(fillvalue)
|
| 105 |
+
return "{" + ", ".join(pieces) + "}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def safeformat(obj: object) -> str:
|
| 109 |
+
"""Return a pretty printed string for the given object.
|
| 110 |
+
|
| 111 |
+
Failing __repr__ functions of user instances will be represented
|
| 112 |
+
with a short exception info.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
return pprint.pformat(obj)
|
| 116 |
+
except Exception as exc:
|
| 117 |
+
return _format_repr_exception(exc, obj)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Maximum size of overall repr of objects to display during assertion errors.
|
| 121 |
+
DEFAULT_REPR_MAX_SIZE = 240
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def saferepr(
|
| 125 |
+
obj: object, maxsize: int | None = DEFAULT_REPR_MAX_SIZE, use_ascii: bool = False
|
| 126 |
+
) -> str:
|
| 127 |
+
"""Return a size-limited safe repr-string for the given object.
|
| 128 |
+
|
| 129 |
+
Failing __repr__ functions of user instances will be represented
|
| 130 |
+
with a short exception info and 'saferepr' generally takes
|
| 131 |
+
care to never raise exceptions itself.
|
| 132 |
+
|
| 133 |
+
This function is a wrapper around the Repr/reprlib functionality of the
|
| 134 |
+
stdlib.
|
| 135 |
+
"""
|
| 136 |
+
return SafeRepr(maxsize, use_ascii).repr(obj)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def saferepr_unlimited(obj: object, use_ascii: bool = True) -> str:
|
| 140 |
+
"""Return an unlimited-size safe repr-string for the given object.
|
| 141 |
+
|
| 142 |
+
As with saferepr, failing __repr__ functions of user instances
|
| 143 |
+
will be represented with a short exception info.
|
| 144 |
+
|
| 145 |
+
This function is a wrapper around simple repr.
|
| 146 |
+
|
| 147 |
+
Note: a cleaner solution would be to alter ``saferepr``this way
|
| 148 |
+
when maxsize=None, but that might affect some other code.
|
| 149 |
+
"""
|
| 150 |
+
try:
|
| 151 |
+
if use_ascii:
|
| 152 |
+
return ascii(obj)
|
| 153 |
+
return repr(obj)
|
| 154 |
+
except Exception as exc:
|
| 155 |
+
return _format_repr_exception(exc, obj)
|
.venv/lib/python3.14/site-packages/_pytest/_io/terminalwriter.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helper functions for writing to terminals and files."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
import os
|
| 7 |
+
import shutil
|
| 8 |
+
import sys
|
| 9 |
+
from typing import final
|
| 10 |
+
from typing import Literal
|
| 11 |
+
from typing import TextIO
|
| 12 |
+
|
| 13 |
+
import pygments
|
| 14 |
+
from pygments.formatters.terminal import TerminalFormatter
|
| 15 |
+
from pygments.lexer import Lexer
|
| 16 |
+
from pygments.lexers.diff import DiffLexer
|
| 17 |
+
from pygments.lexers.python import PythonLexer
|
| 18 |
+
|
| 19 |
+
from ..compat import assert_never
|
| 20 |
+
from .wcwidth import wcswidth
|
| 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 |
+
self.write_raw(msg, flush=flush)
|
| 165 |
+
|
| 166 |
+
def write_raw(self, msg: str, *, flush: bool = False) -> None:
|
| 167 |
+
try:
|
| 168 |
+
self._file.write(msg)
|
| 169 |
+
except UnicodeEncodeError:
|
| 170 |
+
# Some environments don't support printing general Unicode
|
| 171 |
+
# strings, due to misconfiguration or otherwise; in that case,
|
| 172 |
+
# print the string escaped to ASCII.
|
| 173 |
+
# When the Unicode situation improves we should consider
|
| 174 |
+
# letting the error propagate instead of masking it (see #7475
|
| 175 |
+
# for one brief attempt).
|
| 176 |
+
msg = msg.encode("unicode-escape").decode("ascii")
|
| 177 |
+
self._file.write(msg)
|
| 178 |
+
|
| 179 |
+
if flush:
|
| 180 |
+
self.flush()
|
| 181 |
+
|
| 182 |
+
def line(self, s: str = "", **markup: bool) -> None:
|
| 183 |
+
self.write(s, **markup)
|
| 184 |
+
self.write("\n")
|
| 185 |
+
|
| 186 |
+
def flush(self) -> None:
|
| 187 |
+
self._file.flush()
|
| 188 |
+
|
| 189 |
+
def _write_source(self, lines: Sequence[str], indents: Sequence[str] = ()) -> None:
|
| 190 |
+
"""Write lines of source code possibly highlighted.
|
| 191 |
+
|
| 192 |
+
Keeping this private for now because the API is clunky. We should discuss how
|
| 193 |
+
to evolve the terminal writer so we can have more precise color support, for example
|
| 194 |
+
being able to write part of a line in one color and the rest in another, and so on.
|
| 195 |
+
"""
|
| 196 |
+
if indents and len(indents) != len(lines):
|
| 197 |
+
raise ValueError(
|
| 198 |
+
f"indents size ({len(indents)}) should have same size as lines ({len(lines)})"
|
| 199 |
+
)
|
| 200 |
+
if not indents:
|
| 201 |
+
indents = [""] * len(lines)
|
| 202 |
+
source = "\n".join(lines)
|
| 203 |
+
new_lines = self._highlight(source).splitlines()
|
| 204 |
+
# Would be better to strict=True but that fails some CI jobs.
|
| 205 |
+
for indent, new_line in zip(indents, new_lines, strict=False):
|
| 206 |
+
self.line(indent + new_line)
|
| 207 |
+
|
| 208 |
+
def _get_pygments_lexer(self, lexer: Literal["python", "diff"]) -> Lexer:
|
| 209 |
+
if lexer == "python":
|
| 210 |
+
return PythonLexer()
|
| 211 |
+
elif lexer == "diff":
|
| 212 |
+
return DiffLexer()
|
| 213 |
+
else:
|
| 214 |
+
assert_never(lexer)
|
| 215 |
+
|
| 216 |
+
def _get_pygments_formatter(self) -> TerminalFormatter:
|
| 217 |
+
from _pytest.config.exceptions import UsageError
|
| 218 |
+
|
| 219 |
+
theme = os.getenv("PYTEST_THEME")
|
| 220 |
+
theme_mode = os.getenv("PYTEST_THEME_MODE", "dark")
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
return TerminalFormatter(bg=theme_mode, style=theme)
|
| 224 |
+
except pygments.util.ClassNotFound as e:
|
| 225 |
+
raise UsageError(
|
| 226 |
+
f"PYTEST_THEME environment variable has an invalid value: '{theme}'. "
|
| 227 |
+
"Hint: See available pygments styles with `pygmentize -L styles`."
|
| 228 |
+
) from e
|
| 229 |
+
except pygments.util.OptionError as e:
|
| 230 |
+
raise UsageError(
|
| 231 |
+
f"PYTEST_THEME_MODE environment variable has an invalid value: '{theme_mode}'. "
|
| 232 |
+
"The allowed values are 'dark' (default) and 'light'."
|
| 233 |
+
) from e
|
| 234 |
+
|
| 235 |
+
def _highlight(
|
| 236 |
+
self, source: str, lexer: Literal["diff", "python"] = "python"
|
| 237 |
+
) -> str:
|
| 238 |
+
"""Highlight the given source if we have markup support."""
|
| 239 |
+
if not source or not self.hasmarkup or not self.code_highlight:
|
| 240 |
+
return source
|
| 241 |
+
|
| 242 |
+
pygments_lexer = self._get_pygments_lexer(lexer)
|
| 243 |
+
pygments_formatter = self._get_pygments_formatter()
|
| 244 |
+
|
| 245 |
+
highlighted: str = pygments.highlight(
|
| 246 |
+
source, pygments_lexer, pygments_formatter
|
| 247 |
+
)
|
| 248 |
+
# pygments terminal formatter may add a newline when there wasn't one.
|
| 249 |
+
# We don't want this, remove.
|
| 250 |
+
if highlighted[-1] == "\n" and source[-1] != "\n":
|
| 251 |
+
highlighted = highlighted[:-1]
|
| 252 |
+
|
| 253 |
+
# Some lexers will not set the initial color explicitly
|
| 254 |
+
# which may lead to the previous color being propagated to the
|
| 255 |
+
# start of the expression, so reset first.
|
| 256 |
+
highlighted = "\x1b[0m" + highlighted
|
| 257 |
+
|
| 258 |
+
return highlighted
|
.venv/lib/python3.14/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.14/site-packages/_pytest/_py/__init__.py
ADDED
|
File without changes
|
.venv/lib/python3.14/site-packages/_pytest/_py/error.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""create errno-specific classes for IO or os calls."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
import errno
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
from typing import TypeVar
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
if TYPE_CHECKING:
|
| 14 |
+
from typing_extensions import ParamSpec
|
| 15 |
+
|
| 16 |
+
P = ParamSpec("P")
|
| 17 |
+
|
| 18 |
+
R = TypeVar("R")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Error(EnvironmentError):
|
| 22 |
+
def __repr__(self) -> str:
|
| 23 |
+
return "{}.{} {!r}: {} ".format(
|
| 24 |
+
self.__class__.__module__,
|
| 25 |
+
self.__class__.__name__,
|
| 26 |
+
self.__class__.__doc__,
|
| 27 |
+
" ".join(map(str, self.args)),
|
| 28 |
+
# repr(self.args)
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
def __str__(self) -> str:
|
| 32 |
+
s = "[{}]: {}".format(
|
| 33 |
+
self.__class__.__doc__,
|
| 34 |
+
" ".join(map(str, self.args)),
|
| 35 |
+
)
|
| 36 |
+
return s
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
_winerrnomap = {
|
| 40 |
+
2: errno.ENOENT,
|
| 41 |
+
3: errno.ENOENT,
|
| 42 |
+
17: errno.EEXIST,
|
| 43 |
+
18: errno.EXDEV,
|
| 44 |
+
13: errno.EBUSY, # empty cd drive, but ENOMEDIUM seems unavailable
|
| 45 |
+
22: errno.ENOTDIR,
|
| 46 |
+
20: errno.ENOTDIR,
|
| 47 |
+
267: errno.ENOTDIR,
|
| 48 |
+
5: errno.EACCES, # anything better?
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class ErrorMaker:
|
| 53 |
+
"""lazily provides Exception classes for each possible POSIX errno
|
| 54 |
+
(as defined per the 'errno' module). All such instances
|
| 55 |
+
subclass EnvironmentError.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
_errno2class: dict[int, type[Error]] = {}
|
| 59 |
+
|
| 60 |
+
def __getattr__(self, name: str) -> type[Error]:
|
| 61 |
+
if name[0] == "_":
|
| 62 |
+
raise AttributeError(name)
|
| 63 |
+
eno = getattr(errno, name)
|
| 64 |
+
cls = self._geterrnoclass(eno)
|
| 65 |
+
setattr(self, name, cls)
|
| 66 |
+
return cls
|
| 67 |
+
|
| 68 |
+
def _geterrnoclass(self, eno: int) -> type[Error]:
|
| 69 |
+
try:
|
| 70 |
+
return self._errno2class[eno]
|
| 71 |
+
except KeyError:
|
| 72 |
+
clsname = errno.errorcode.get(eno, f"UnknownErrno{eno}")
|
| 73 |
+
errorcls = type(
|
| 74 |
+
clsname,
|
| 75 |
+
(Error,),
|
| 76 |
+
{"__module__": "py.error", "__doc__": os.strerror(eno)},
|
| 77 |
+
)
|
| 78 |
+
self._errno2class[eno] = errorcls
|
| 79 |
+
return errorcls
|
| 80 |
+
|
| 81 |
+
def checked_call(
|
| 82 |
+
self, func: Callable[P, R], *args: P.args, **kwargs: P.kwargs
|
| 83 |
+
) -> R:
|
| 84 |
+
"""Call a function and raise an errno-exception if applicable."""
|
| 85 |
+
__tracebackhide__ = True
|
| 86 |
+
try:
|
| 87 |
+
return func(*args, **kwargs)
|
| 88 |
+
except Error:
|
| 89 |
+
raise
|
| 90 |
+
except OSError as value:
|
| 91 |
+
if not hasattr(value, "errno"):
|
| 92 |
+
raise
|
| 93 |
+
if sys.platform == "win32":
|
| 94 |
+
try:
|
| 95 |
+
# error: Invalid index type "Optional[int]" for "dict[int, int]"; expected type "int" [index]
|
| 96 |
+
# OK to ignore because we catch the KeyError below.
|
| 97 |
+
cls = self._geterrnoclass(_winerrnomap[value.errno]) # type:ignore[index]
|
| 98 |
+
except KeyError:
|
| 99 |
+
raise value
|
| 100 |
+
else:
|
| 101 |
+
# we are not on Windows, or we got a proper OSError
|
| 102 |
+
if value.errno is None:
|
| 103 |
+
cls = type(
|
| 104 |
+
"UnknownErrnoNone",
|
| 105 |
+
(Error,),
|
| 106 |
+
{"__module__": "py.error", "__doc__": None},
|
| 107 |
+
)
|
| 108 |
+
else:
|
| 109 |
+
cls = self._geterrnoclass(value.errno)
|
| 110 |
+
|
| 111 |
+
raise cls(f"{func.__name__}{args!r}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
_error_maker = ErrorMaker()
|
| 115 |
+
checked_call = _error_maker.checked_call
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def __getattr__(attr: str) -> type[Error]:
|
| 119 |
+
return getattr(_error_maker, attr) # type: ignore[no-any-return]
|
.venv/lib/python3.14/site-packages/_pytest/_py/path.py
ADDED
|
@@ -0,0 +1,1475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""local path implementation."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import atexit
|
| 7 |
+
from collections.abc import Callable
|
| 8 |
+
from contextlib import contextmanager
|
| 9 |
+
import fnmatch
|
| 10 |
+
import importlib.util
|
| 11 |
+
import io
|
| 12 |
+
import os
|
| 13 |
+
from os.path import abspath
|
| 14 |
+
from os.path import dirname
|
| 15 |
+
from os.path import exists
|
| 16 |
+
from os.path import isabs
|
| 17 |
+
from os.path import isdir
|
| 18 |
+
from os.path import isfile
|
| 19 |
+
from os.path import islink
|
| 20 |
+
from os.path import normpath
|
| 21 |
+
import posixpath
|
| 22 |
+
from stat import S_ISDIR
|
| 23 |
+
from stat import S_ISLNK
|
| 24 |
+
from stat import S_ISREG
|
| 25 |
+
import sys
|
| 26 |
+
from typing import Any
|
| 27 |
+
from typing import cast
|
| 28 |
+
from typing import Literal
|
| 29 |
+
from typing import overload
|
| 30 |
+
from typing import TYPE_CHECKING
|
| 31 |
+
import uuid
|
| 32 |
+
import warnings
|
| 33 |
+
|
| 34 |
+
from . import error
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Moved from local.py.
|
| 38 |
+
iswin32 = sys.platform == "win32" or (getattr(os, "_name", False) == "nt")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class Checkers:
|
| 42 |
+
_depend_on_existence = "exists", "link", "dir", "file"
|
| 43 |
+
|
| 44 |
+
def __init__(self, path):
|
| 45 |
+
self.path = path
|
| 46 |
+
|
| 47 |
+
def dotfile(self):
|
| 48 |
+
return self.path.basename.startswith(".")
|
| 49 |
+
|
| 50 |
+
def ext(self, arg):
|
| 51 |
+
if not arg.startswith("."):
|
| 52 |
+
arg = "." + arg
|
| 53 |
+
return self.path.ext == arg
|
| 54 |
+
|
| 55 |
+
def basename(self, arg):
|
| 56 |
+
return self.path.basename == arg
|
| 57 |
+
|
| 58 |
+
def basestarts(self, arg):
|
| 59 |
+
return self.path.basename.startswith(arg)
|
| 60 |
+
|
| 61 |
+
def relto(self, arg):
|
| 62 |
+
return self.path.relto(arg)
|
| 63 |
+
|
| 64 |
+
def fnmatch(self, arg):
|
| 65 |
+
return self.path.fnmatch(arg)
|
| 66 |
+
|
| 67 |
+
def endswith(self, arg):
|
| 68 |
+
return str(self.path).endswith(arg)
|
| 69 |
+
|
| 70 |
+
def _evaluate(self, kw):
|
| 71 |
+
from .._code.source import getrawcode
|
| 72 |
+
|
| 73 |
+
for name, value in kw.items():
|
| 74 |
+
invert = False
|
| 75 |
+
meth = None
|
| 76 |
+
try:
|
| 77 |
+
meth = getattr(self, name)
|
| 78 |
+
except AttributeError:
|
| 79 |
+
if name[:3] == "not":
|
| 80 |
+
invert = True
|
| 81 |
+
try:
|
| 82 |
+
meth = getattr(self, name[3:])
|
| 83 |
+
except AttributeError:
|
| 84 |
+
pass
|
| 85 |
+
if meth is None:
|
| 86 |
+
raise TypeError(f"no {name!r} checker available for {self.path!r}")
|
| 87 |
+
try:
|
| 88 |
+
if getrawcode(meth).co_argcount > 1:
|
| 89 |
+
if (not meth(value)) ^ invert:
|
| 90 |
+
return False
|
| 91 |
+
else:
|
| 92 |
+
if bool(value) ^ bool(meth()) ^ invert:
|
| 93 |
+
return False
|
| 94 |
+
except (error.ENOENT, error.ENOTDIR, error.EBUSY):
|
| 95 |
+
# EBUSY feels not entirely correct,
|
| 96 |
+
# but its kind of necessary since ENOMEDIUM
|
| 97 |
+
# is not accessible in python
|
| 98 |
+
for name in self._depend_on_existence:
|
| 99 |
+
if name in kw:
|
| 100 |
+
if kw.get(name):
|
| 101 |
+
return False
|
| 102 |
+
name = "not" + name
|
| 103 |
+
if name in kw:
|
| 104 |
+
if not kw.get(name):
|
| 105 |
+
return False
|
| 106 |
+
return True
|
| 107 |
+
|
| 108 |
+
_statcache: Stat
|
| 109 |
+
|
| 110 |
+
def _stat(self) -> Stat:
|
| 111 |
+
try:
|
| 112 |
+
return self._statcache
|
| 113 |
+
except AttributeError:
|
| 114 |
+
try:
|
| 115 |
+
self._statcache = self.path.stat()
|
| 116 |
+
except error.ELOOP:
|
| 117 |
+
self._statcache = self.path.lstat()
|
| 118 |
+
return self._statcache
|
| 119 |
+
|
| 120 |
+
def dir(self):
|
| 121 |
+
return S_ISDIR(self._stat().mode)
|
| 122 |
+
|
| 123 |
+
def file(self):
|
| 124 |
+
return S_ISREG(self._stat().mode)
|
| 125 |
+
|
| 126 |
+
def exists(self):
|
| 127 |
+
return self._stat()
|
| 128 |
+
|
| 129 |
+
def link(self):
|
| 130 |
+
st = self.path.lstat()
|
| 131 |
+
return S_ISLNK(st.mode)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class NeverRaised(Exception):
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
class Visitor:
|
| 139 |
+
def __init__(self, fil, rec, ignore, bf, sort):
|
| 140 |
+
if isinstance(fil, (str, bytes)):
|
| 141 |
+
fil = FNMatcher(fil)
|
| 142 |
+
if isinstance(rec, str):
|
| 143 |
+
self.rec: Callable[[LocalPath], bool] = FNMatcher(rec)
|
| 144 |
+
elif not hasattr(rec, "__call__") and rec:
|
| 145 |
+
self.rec = lambda path: True
|
| 146 |
+
else:
|
| 147 |
+
self.rec = rec
|
| 148 |
+
self.fil = fil
|
| 149 |
+
self.ignore = ignore
|
| 150 |
+
self.breadthfirst = bf
|
| 151 |
+
self.optsort = cast(Callable[[Any], Any], sorted) if sort else (lambda x: x)
|
| 152 |
+
|
| 153 |
+
def gen(self, path):
|
| 154 |
+
try:
|
| 155 |
+
entries = path.listdir()
|
| 156 |
+
except self.ignore:
|
| 157 |
+
return
|
| 158 |
+
rec = self.rec
|
| 159 |
+
dirs = self.optsort(
|
| 160 |
+
[p for p in entries if p.check(dir=1) and (rec is None or rec(p))]
|
| 161 |
+
)
|
| 162 |
+
if not self.breadthfirst:
|
| 163 |
+
for subdir in dirs:
|
| 164 |
+
yield from self.gen(subdir)
|
| 165 |
+
for p in self.optsort(entries):
|
| 166 |
+
if self.fil is None or self.fil(p):
|
| 167 |
+
yield p
|
| 168 |
+
if self.breadthfirst:
|
| 169 |
+
for subdir in dirs:
|
| 170 |
+
yield from self.gen(subdir)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class FNMatcher:
|
| 174 |
+
def __init__(self, pattern):
|
| 175 |
+
self.pattern = pattern
|
| 176 |
+
|
| 177 |
+
def __call__(self, path):
|
| 178 |
+
pattern = self.pattern
|
| 179 |
+
|
| 180 |
+
if (
|
| 181 |
+
pattern.find(path.sep) == -1
|
| 182 |
+
and iswin32
|
| 183 |
+
and pattern.find(posixpath.sep) != -1
|
| 184 |
+
):
|
| 185 |
+
# Running on Windows, the pattern has no Windows path separators,
|
| 186 |
+
# and the pattern has one or more Posix path separators. Replace
|
| 187 |
+
# the Posix path separators with the Windows path separator.
|
| 188 |
+
pattern = pattern.replace(posixpath.sep, path.sep)
|
| 189 |
+
|
| 190 |
+
if pattern.find(path.sep) == -1:
|
| 191 |
+
name = path.basename
|
| 192 |
+
else:
|
| 193 |
+
name = str(path) # path.strpath # XXX svn?
|
| 194 |
+
if not os.path.isabs(pattern):
|
| 195 |
+
pattern = "*" + path.sep + pattern
|
| 196 |
+
return fnmatch.fnmatch(name, pattern)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def map_as_list(func, iter):
|
| 200 |
+
return list(map(func, iter))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class Stat:
|
| 204 |
+
if TYPE_CHECKING:
|
| 205 |
+
|
| 206 |
+
@property
|
| 207 |
+
def size(self) -> int: ...
|
| 208 |
+
|
| 209 |
+
@property
|
| 210 |
+
def mtime(self) -> float: ...
|
| 211 |
+
|
| 212 |
+
def __getattr__(self, name: str) -> Any:
|
| 213 |
+
return getattr(self._osstatresult, "st_" + name)
|
| 214 |
+
|
| 215 |
+
def __init__(self, path, osstatresult):
|
| 216 |
+
self.path = path
|
| 217 |
+
self._osstatresult = osstatresult
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def owner(self):
|
| 221 |
+
if iswin32:
|
| 222 |
+
raise NotImplementedError("XXX win32")
|
| 223 |
+
import pwd
|
| 224 |
+
|
| 225 |
+
entry = error.checked_call(pwd.getpwuid, self.uid) # type:ignore[attr-defined,unused-ignore]
|
| 226 |
+
return entry[0]
|
| 227 |
+
|
| 228 |
+
@property
|
| 229 |
+
def group(self):
|
| 230 |
+
"""Return group name of file."""
|
| 231 |
+
if iswin32:
|
| 232 |
+
raise NotImplementedError("XXX win32")
|
| 233 |
+
import grp
|
| 234 |
+
|
| 235 |
+
entry = error.checked_call(grp.getgrgid, self.gid) # type:ignore[attr-defined,unused-ignore]
|
| 236 |
+
return entry[0]
|
| 237 |
+
|
| 238 |
+
def isdir(self):
|
| 239 |
+
return S_ISDIR(self._osstatresult.st_mode)
|
| 240 |
+
|
| 241 |
+
def isfile(self):
|
| 242 |
+
return S_ISREG(self._osstatresult.st_mode)
|
| 243 |
+
|
| 244 |
+
def islink(self):
|
| 245 |
+
self.path.lstat()
|
| 246 |
+
return S_ISLNK(self._osstatresult.st_mode)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def getuserid(user):
|
| 250 |
+
import pwd
|
| 251 |
+
|
| 252 |
+
if not isinstance(user, int):
|
| 253 |
+
user = pwd.getpwnam(user)[2] # type:ignore[attr-defined,unused-ignore]
|
| 254 |
+
return user
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def getgroupid(group):
|
| 258 |
+
import grp
|
| 259 |
+
|
| 260 |
+
if not isinstance(group, int):
|
| 261 |
+
group = grp.getgrnam(group)[2] # type:ignore[attr-defined,unused-ignore]
|
| 262 |
+
return group
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
class LocalPath:
|
| 266 |
+
"""Object oriented interface to os.path and other local filesystem
|
| 267 |
+
related information.
|
| 268 |
+
"""
|
| 269 |
+
|
| 270 |
+
class ImportMismatchError(ImportError):
|
| 271 |
+
"""raised on pyimport() if there is a mismatch of __file__'s"""
|
| 272 |
+
|
| 273 |
+
sep = os.sep
|
| 274 |
+
|
| 275 |
+
def __init__(self, path=None, expanduser=False):
|
| 276 |
+
"""Initialize and return a local Path instance.
|
| 277 |
+
|
| 278 |
+
Path can be relative to the current directory.
|
| 279 |
+
If path is None it defaults to the current working directory.
|
| 280 |
+
If expanduser is True, tilde-expansion is performed.
|
| 281 |
+
Note that Path instances always carry an absolute path.
|
| 282 |
+
Note also that passing in a local path object will simply return
|
| 283 |
+
the exact same path object. Use new() to get a new copy.
|
| 284 |
+
"""
|
| 285 |
+
if path is None:
|
| 286 |
+
self.strpath = error.checked_call(os.getcwd)
|
| 287 |
+
else:
|
| 288 |
+
try:
|
| 289 |
+
path = os.fspath(path)
|
| 290 |
+
except TypeError:
|
| 291 |
+
raise ValueError(
|
| 292 |
+
"can only pass None, Path instances "
|
| 293 |
+
"or non-empty strings to LocalPath"
|
| 294 |
+
)
|
| 295 |
+
if expanduser:
|
| 296 |
+
path = os.path.expanduser(path)
|
| 297 |
+
self.strpath = abspath(path)
|
| 298 |
+
|
| 299 |
+
if sys.platform != "win32":
|
| 300 |
+
|
| 301 |
+
def chown(self, user, group, rec=0):
|
| 302 |
+
"""Change ownership to the given user and group.
|
| 303 |
+
user and group may be specified by a number or
|
| 304 |
+
by a name. if rec is True change ownership
|
| 305 |
+
recursively.
|
| 306 |
+
"""
|
| 307 |
+
uid = getuserid(user)
|
| 308 |
+
gid = getgroupid(group)
|
| 309 |
+
if rec:
|
| 310 |
+
for x in self.visit(rec=lambda x: x.check(link=0)):
|
| 311 |
+
if x.check(link=0):
|
| 312 |
+
error.checked_call(os.chown, str(x), uid, gid)
|
| 313 |
+
error.checked_call(os.chown, str(self), uid, gid)
|
| 314 |
+
|
| 315 |
+
def readlink(self) -> str:
|
| 316 |
+
"""Return value of a symbolic link."""
|
| 317 |
+
# https://github.com/python/mypy/issues/12278
|
| 318 |
+
return error.checked_call(os.readlink, self.strpath) # type: ignore[arg-type,return-value,unused-ignore]
|
| 319 |
+
|
| 320 |
+
def mklinkto(self, oldname):
|
| 321 |
+
"""Posix style hard link to another name."""
|
| 322 |
+
error.checked_call(os.link, str(oldname), str(self))
|
| 323 |
+
|
| 324 |
+
def mksymlinkto(self, value, absolute=1):
|
| 325 |
+
"""Create a symbolic link with the given value (pointing to another name)."""
|
| 326 |
+
if absolute:
|
| 327 |
+
error.checked_call(os.symlink, str(value), self.strpath)
|
| 328 |
+
else:
|
| 329 |
+
base = self.common(value)
|
| 330 |
+
# with posix local paths '/' is always a common base
|
| 331 |
+
relsource = self.__class__(value).relto(base)
|
| 332 |
+
reldest = self.relto(base)
|
| 333 |
+
n = reldest.count(self.sep)
|
| 334 |
+
target = self.sep.join(("..",) * n + (relsource,))
|
| 335 |
+
error.checked_call(os.symlink, target, self.strpath)
|
| 336 |
+
|
| 337 |
+
def __div__(self, other):
|
| 338 |
+
return self.join(os.fspath(other))
|
| 339 |
+
|
| 340 |
+
__truediv__ = __div__ # py3k
|
| 341 |
+
|
| 342 |
+
@property
|
| 343 |
+
def basename(self):
|
| 344 |
+
"""Basename part of path."""
|
| 345 |
+
return self._getbyspec("basename")[0]
|
| 346 |
+
|
| 347 |
+
@property
|
| 348 |
+
def dirname(self):
|
| 349 |
+
"""Dirname part of path."""
|
| 350 |
+
return self._getbyspec("dirname")[0]
|
| 351 |
+
|
| 352 |
+
@property
|
| 353 |
+
def purebasename(self):
|
| 354 |
+
"""Pure base name of the path."""
|
| 355 |
+
return self._getbyspec("purebasename")[0]
|
| 356 |
+
|
| 357 |
+
@property
|
| 358 |
+
def ext(self):
|
| 359 |
+
"""Extension of the path (including the '.')."""
|
| 360 |
+
return self._getbyspec("ext")[0]
|
| 361 |
+
|
| 362 |
+
def read_binary(self):
|
| 363 |
+
"""Read and return a bytestring from reading the path."""
|
| 364 |
+
with self.open("rb") as f:
|
| 365 |
+
return f.read()
|
| 366 |
+
|
| 367 |
+
def read_text(self, encoding):
|
| 368 |
+
"""Read and return a Unicode string from reading the path."""
|
| 369 |
+
with self.open("r", encoding=encoding) as f:
|
| 370 |
+
return f.read()
|
| 371 |
+
|
| 372 |
+
def read(self, mode="r"):
|
| 373 |
+
"""Read and return a bytestring from reading the path."""
|
| 374 |
+
with self.open(mode) as f:
|
| 375 |
+
return f.read()
|
| 376 |
+
|
| 377 |
+
def readlines(self, cr=1):
|
| 378 |
+
"""Read and return a list of lines from the path. if cr is False, the
|
| 379 |
+
newline will be removed from the end of each line."""
|
| 380 |
+
mode = "r"
|
| 381 |
+
|
| 382 |
+
if not cr:
|
| 383 |
+
content = self.read(mode)
|
| 384 |
+
return content.split("\n")
|
| 385 |
+
else:
|
| 386 |
+
f = self.open(mode)
|
| 387 |
+
try:
|
| 388 |
+
return f.readlines()
|
| 389 |
+
finally:
|
| 390 |
+
f.close()
|
| 391 |
+
|
| 392 |
+
def load(self):
|
| 393 |
+
"""(deprecated) return object unpickled from self.read()"""
|
| 394 |
+
f = self.open("rb")
|
| 395 |
+
try:
|
| 396 |
+
import pickle
|
| 397 |
+
|
| 398 |
+
return error.checked_call(pickle.load, f)
|
| 399 |
+
finally:
|
| 400 |
+
f.close()
|
| 401 |
+
|
| 402 |
+
def move(self, target):
|
| 403 |
+
"""Move this path to target."""
|
| 404 |
+
if target.relto(self):
|
| 405 |
+
raise error.EINVAL(target, "cannot move path into a subdirectory of itself")
|
| 406 |
+
try:
|
| 407 |
+
self.rename(target)
|
| 408 |
+
except error.EXDEV: # invalid cross-device link
|
| 409 |
+
self.copy(target)
|
| 410 |
+
self.remove()
|
| 411 |
+
|
| 412 |
+
def fnmatch(self, pattern):
|
| 413 |
+
"""Return true if the basename/fullname matches the glob-'pattern'.
|
| 414 |
+
|
| 415 |
+
valid pattern characters::
|
| 416 |
+
|
| 417 |
+
* matches everything
|
| 418 |
+
? matches any single character
|
| 419 |
+
[seq] matches any character in seq
|
| 420 |
+
[!seq] matches any char not in seq
|
| 421 |
+
|
| 422 |
+
If the pattern contains a path-separator then the full path
|
| 423 |
+
is used for pattern matching and a '*' is prepended to the
|
| 424 |
+
pattern.
|
| 425 |
+
|
| 426 |
+
if the pattern doesn't contain a path-separator the pattern
|
| 427 |
+
is only matched against the basename.
|
| 428 |
+
"""
|
| 429 |
+
return FNMatcher(pattern)(self)
|
| 430 |
+
|
| 431 |
+
def relto(self, relpath):
|
| 432 |
+
"""Return a string which is the relative part of the path
|
| 433 |
+
to the given 'relpath'.
|
| 434 |
+
"""
|
| 435 |
+
if not isinstance(relpath, str | LocalPath):
|
| 436 |
+
raise TypeError(f"{relpath!r}: not a string or path object")
|
| 437 |
+
strrelpath = str(relpath)
|
| 438 |
+
if strrelpath and strrelpath[-1] != self.sep:
|
| 439 |
+
strrelpath += self.sep
|
| 440 |
+
# assert strrelpath[-1] == self.sep
|
| 441 |
+
# assert strrelpath[-2] != self.sep
|
| 442 |
+
strself = self.strpath
|
| 443 |
+
if sys.platform == "win32" or getattr(os, "_name", None) == "nt":
|
| 444 |
+
if os.path.normcase(strself).startswith(os.path.normcase(strrelpath)):
|
| 445 |
+
return strself[len(strrelpath) :]
|
| 446 |
+
elif strself.startswith(strrelpath):
|
| 447 |
+
return strself[len(strrelpath) :]
|
| 448 |
+
return ""
|
| 449 |
+
|
| 450 |
+
def ensure_dir(self, *args):
|
| 451 |
+
"""Ensure the path joined with args is a directory."""
|
| 452 |
+
return self.ensure(*args, dir=True)
|
| 453 |
+
|
| 454 |
+
def bestrelpath(self, dest):
|
| 455 |
+
"""Return a string which is a relative path from self
|
| 456 |
+
(assumed to be a directory) to dest such that
|
| 457 |
+
self.join(bestrelpath) == dest and if not such
|
| 458 |
+
path can be determined return dest.
|
| 459 |
+
"""
|
| 460 |
+
try:
|
| 461 |
+
if self == dest:
|
| 462 |
+
return os.curdir
|
| 463 |
+
base = self.common(dest)
|
| 464 |
+
if not base: # can be the case on windows
|
| 465 |
+
return str(dest)
|
| 466 |
+
self2base = self.relto(base)
|
| 467 |
+
reldest = dest.relto(base)
|
| 468 |
+
if self2base:
|
| 469 |
+
n = self2base.count(self.sep) + 1
|
| 470 |
+
else:
|
| 471 |
+
n = 0
|
| 472 |
+
lst = [os.pardir] * n
|
| 473 |
+
if reldest:
|
| 474 |
+
lst.append(reldest)
|
| 475 |
+
target = dest.sep.join(lst)
|
| 476 |
+
return target
|
| 477 |
+
except AttributeError:
|
| 478 |
+
return str(dest)
|
| 479 |
+
|
| 480 |
+
def exists(self):
|
| 481 |
+
return self.check()
|
| 482 |
+
|
| 483 |
+
def isdir(self):
|
| 484 |
+
return self.check(dir=1)
|
| 485 |
+
|
| 486 |
+
def isfile(self):
|
| 487 |
+
return self.check(file=1)
|
| 488 |
+
|
| 489 |
+
def parts(self, reverse=False):
|
| 490 |
+
"""Return a root-first list of all ancestor directories
|
| 491 |
+
plus the path itself.
|
| 492 |
+
"""
|
| 493 |
+
current = self
|
| 494 |
+
lst = [self]
|
| 495 |
+
while 1:
|
| 496 |
+
last = current
|
| 497 |
+
current = current.dirpath()
|
| 498 |
+
if last == current:
|
| 499 |
+
break
|
| 500 |
+
lst.append(current)
|
| 501 |
+
if not reverse:
|
| 502 |
+
lst.reverse()
|
| 503 |
+
return lst
|
| 504 |
+
|
| 505 |
+
def common(self, other):
|
| 506 |
+
"""Return the common part shared with the other path
|
| 507 |
+
or None if there is no common part.
|
| 508 |
+
"""
|
| 509 |
+
last = None
|
| 510 |
+
for x, y in zip(self.parts(), other.parts()):
|
| 511 |
+
if x != y:
|
| 512 |
+
return last
|
| 513 |
+
last = x
|
| 514 |
+
return last
|
| 515 |
+
|
| 516 |
+
def __add__(self, other):
|
| 517 |
+
"""Return new path object with 'other' added to the basename"""
|
| 518 |
+
return self.new(basename=self.basename + str(other))
|
| 519 |
+
|
| 520 |
+
def visit(self, fil=None, rec=None, ignore=NeverRaised, bf=False, sort=False):
|
| 521 |
+
"""Yields all paths below the current one
|
| 522 |
+
|
| 523 |
+
fil is a filter (glob pattern or callable), if not matching the
|
| 524 |
+
path will not be yielded, defaulting to None (everything is
|
| 525 |
+
returned)
|
| 526 |
+
|
| 527 |
+
rec is a filter (glob pattern or callable) that controls whether
|
| 528 |
+
a node is descended, defaulting to None
|
| 529 |
+
|
| 530 |
+
ignore is an Exception class that is ignoredwhen calling dirlist()
|
| 531 |
+
on any of the paths (by default, all exceptions are reported)
|
| 532 |
+
|
| 533 |
+
bf if True will cause a breadthfirst search instead of the
|
| 534 |
+
default depthfirst. Default: False
|
| 535 |
+
|
| 536 |
+
sort if True will sort entries within each directory level.
|
| 537 |
+
"""
|
| 538 |
+
yield from Visitor(fil, rec, ignore, bf, sort).gen(self)
|
| 539 |
+
|
| 540 |
+
def _sortlist(self, res, sort):
|
| 541 |
+
if sort:
|
| 542 |
+
if hasattr(sort, "__call__"):
|
| 543 |
+
warnings.warn(
|
| 544 |
+
DeprecationWarning(
|
| 545 |
+
"listdir(sort=callable) is deprecated and breaks on python3"
|
| 546 |
+
),
|
| 547 |
+
stacklevel=3,
|
| 548 |
+
)
|
| 549 |
+
res.sort(sort)
|
| 550 |
+
else:
|
| 551 |
+
res.sort()
|
| 552 |
+
|
| 553 |
+
def __fspath__(self):
|
| 554 |
+
return self.strpath
|
| 555 |
+
|
| 556 |
+
def __hash__(self):
|
| 557 |
+
s = self.strpath
|
| 558 |
+
if iswin32:
|
| 559 |
+
s = s.lower()
|
| 560 |
+
return hash(s)
|
| 561 |
+
|
| 562 |
+
def __eq__(self, other):
|
| 563 |
+
s1 = os.fspath(self)
|
| 564 |
+
try:
|
| 565 |
+
s2 = os.fspath(other)
|
| 566 |
+
except TypeError:
|
| 567 |
+
return False
|
| 568 |
+
if iswin32:
|
| 569 |
+
s1 = s1.lower()
|
| 570 |
+
try:
|
| 571 |
+
s2 = s2.lower()
|
| 572 |
+
except AttributeError:
|
| 573 |
+
return False
|
| 574 |
+
return s1 == s2
|
| 575 |
+
|
| 576 |
+
def __ne__(self, other):
|
| 577 |
+
return not (self == other)
|
| 578 |
+
|
| 579 |
+
def __lt__(self, other):
|
| 580 |
+
return os.fspath(self) < os.fspath(other)
|
| 581 |
+
|
| 582 |
+
def __gt__(self, other):
|
| 583 |
+
return os.fspath(self) > os.fspath(other)
|
| 584 |
+
|
| 585 |
+
def samefile(self, other):
|
| 586 |
+
"""Return True if 'other' references the same file as 'self'."""
|
| 587 |
+
other = os.fspath(other)
|
| 588 |
+
if not isabs(other):
|
| 589 |
+
other = abspath(other)
|
| 590 |
+
if self == other:
|
| 591 |
+
return True
|
| 592 |
+
if not hasattr(os.path, "samefile"):
|
| 593 |
+
return False
|
| 594 |
+
return error.checked_call(os.path.samefile, self.strpath, other)
|
| 595 |
+
|
| 596 |
+
def remove(self, rec=1, ignore_errors=False):
|
| 597 |
+
"""Remove a file or directory (or a directory tree if rec=1).
|
| 598 |
+
if ignore_errors is True, errors while removing directories will
|
| 599 |
+
be ignored.
|
| 600 |
+
"""
|
| 601 |
+
if self.check(dir=1, link=0):
|
| 602 |
+
if rec:
|
| 603 |
+
# force remove of readonly files on windows
|
| 604 |
+
if iswin32:
|
| 605 |
+
self.chmod(0o700, rec=1)
|
| 606 |
+
import shutil
|
| 607 |
+
|
| 608 |
+
error.checked_call(
|
| 609 |
+
shutil.rmtree, self.strpath, ignore_errors=ignore_errors
|
| 610 |
+
)
|
| 611 |
+
else:
|
| 612 |
+
error.checked_call(os.rmdir, self.strpath)
|
| 613 |
+
else:
|
| 614 |
+
if iswin32:
|
| 615 |
+
self.chmod(0o700)
|
| 616 |
+
error.checked_call(os.remove, self.strpath)
|
| 617 |
+
|
| 618 |
+
def computehash(self, hashtype="md5", chunksize=524288):
|
| 619 |
+
"""Return hexdigest of hashvalue for this file."""
|
| 620 |
+
try:
|
| 621 |
+
try:
|
| 622 |
+
import hashlib as mod
|
| 623 |
+
except ImportError:
|
| 624 |
+
if hashtype == "sha1":
|
| 625 |
+
hashtype = "sha"
|
| 626 |
+
mod = __import__(hashtype)
|
| 627 |
+
hash = getattr(mod, hashtype)()
|
| 628 |
+
except (AttributeError, ImportError):
|
| 629 |
+
raise ValueError(f"Don't know how to compute {hashtype!r} hash")
|
| 630 |
+
f = self.open("rb")
|
| 631 |
+
try:
|
| 632 |
+
while 1:
|
| 633 |
+
buf = f.read(chunksize)
|
| 634 |
+
if not buf:
|
| 635 |
+
return hash.hexdigest()
|
| 636 |
+
hash.update(buf)
|
| 637 |
+
finally:
|
| 638 |
+
f.close()
|
| 639 |
+
|
| 640 |
+
def new(self, **kw):
|
| 641 |
+
"""Create a modified version of this path.
|
| 642 |
+
the following keyword arguments modify various path parts::
|
| 643 |
+
|
| 644 |
+
a:/some/path/to/a/file.ext
|
| 645 |
+
xx drive
|
| 646 |
+
xxxxxxxxxxxxxxxxx dirname
|
| 647 |
+
xxxxxxxx basename
|
| 648 |
+
xxxx purebasename
|
| 649 |
+
xxx ext
|
| 650 |
+
"""
|
| 651 |
+
obj = object.__new__(self.__class__)
|
| 652 |
+
if not kw:
|
| 653 |
+
obj.strpath = self.strpath
|
| 654 |
+
return obj
|
| 655 |
+
drive, dirname, _basename, purebasename, ext = self._getbyspec(
|
| 656 |
+
"drive,dirname,basename,purebasename,ext"
|
| 657 |
+
)
|
| 658 |
+
if "basename" in kw:
|
| 659 |
+
if "purebasename" in kw or "ext" in kw:
|
| 660 |
+
raise ValueError(f"invalid specification {kw!r}")
|
| 661 |
+
else:
|
| 662 |
+
pb = kw.setdefault("purebasename", purebasename)
|
| 663 |
+
try:
|
| 664 |
+
ext = kw["ext"]
|
| 665 |
+
except KeyError:
|
| 666 |
+
pass
|
| 667 |
+
else:
|
| 668 |
+
if ext and not ext.startswith("."):
|
| 669 |
+
ext = "." + ext
|
| 670 |
+
kw["basename"] = pb + ext
|
| 671 |
+
|
| 672 |
+
if "dirname" in kw and not kw["dirname"]:
|
| 673 |
+
kw["dirname"] = drive
|
| 674 |
+
else:
|
| 675 |
+
kw.setdefault("dirname", dirname)
|
| 676 |
+
kw.setdefault("sep", self.sep)
|
| 677 |
+
obj.strpath = normpath("{dirname}{sep}{basename}".format(**kw))
|
| 678 |
+
return obj
|
| 679 |
+
|
| 680 |
+
def _getbyspec(self, spec: str) -> list[str]:
|
| 681 |
+
"""See new for what 'spec' can be."""
|
| 682 |
+
res = []
|
| 683 |
+
parts = self.strpath.split(self.sep)
|
| 684 |
+
|
| 685 |
+
args = filter(None, spec.split(","))
|
| 686 |
+
for name in args:
|
| 687 |
+
if name == "drive":
|
| 688 |
+
res.append(parts[0])
|
| 689 |
+
elif name == "dirname":
|
| 690 |
+
res.append(self.sep.join(parts[:-1]))
|
| 691 |
+
else:
|
| 692 |
+
basename = parts[-1]
|
| 693 |
+
if name == "basename":
|
| 694 |
+
res.append(basename)
|
| 695 |
+
else:
|
| 696 |
+
i = basename.rfind(".")
|
| 697 |
+
if i == -1:
|
| 698 |
+
purebasename, ext = basename, ""
|
| 699 |
+
else:
|
| 700 |
+
purebasename, ext = basename[:i], basename[i:]
|
| 701 |
+
if name == "purebasename":
|
| 702 |
+
res.append(purebasename)
|
| 703 |
+
elif name == "ext":
|
| 704 |
+
res.append(ext)
|
| 705 |
+
else:
|
| 706 |
+
raise ValueError(f"invalid part specification {name!r}")
|
| 707 |
+
return res
|
| 708 |
+
|
| 709 |
+
def dirpath(self, *args, **kwargs):
|
| 710 |
+
"""Return the directory path joined with any given path arguments."""
|
| 711 |
+
if not kwargs:
|
| 712 |
+
path = object.__new__(self.__class__)
|
| 713 |
+
path.strpath = dirname(self.strpath)
|
| 714 |
+
if args:
|
| 715 |
+
path = path.join(*args)
|
| 716 |
+
return path
|
| 717 |
+
return self.new(basename="").join(*args, **kwargs)
|
| 718 |
+
|
| 719 |
+
def join(self, *args: os.PathLike[str], abs: bool = False) -> LocalPath:
|
| 720 |
+
"""Return a new path by appending all 'args' as path
|
| 721 |
+
components. if abs=1 is used restart from root if any
|
| 722 |
+
of the args is an absolute path.
|
| 723 |
+
"""
|
| 724 |
+
sep = self.sep
|
| 725 |
+
strargs = [os.fspath(arg) for arg in args]
|
| 726 |
+
strpath = self.strpath
|
| 727 |
+
if abs:
|
| 728 |
+
newargs: list[str] = []
|
| 729 |
+
for arg in reversed(strargs):
|
| 730 |
+
if isabs(arg):
|
| 731 |
+
strpath = arg
|
| 732 |
+
strargs = newargs
|
| 733 |
+
break
|
| 734 |
+
newargs.insert(0, arg)
|
| 735 |
+
# special case for when we have e.g. strpath == "/"
|
| 736 |
+
actual_sep = "" if strpath.endswith(sep) else sep
|
| 737 |
+
for arg in strargs:
|
| 738 |
+
arg = arg.strip(sep)
|
| 739 |
+
if iswin32:
|
| 740 |
+
# allow unix style paths even on windows.
|
| 741 |
+
arg = arg.strip("/")
|
| 742 |
+
arg = arg.replace("/", sep)
|
| 743 |
+
strpath = strpath + actual_sep + arg
|
| 744 |
+
actual_sep = sep
|
| 745 |
+
obj = object.__new__(self.__class__)
|
| 746 |
+
obj.strpath = normpath(strpath)
|
| 747 |
+
return obj
|
| 748 |
+
|
| 749 |
+
def open(self, mode="r", ensure=False, encoding=None):
|
| 750 |
+
"""Return an opened file with the given mode.
|
| 751 |
+
|
| 752 |
+
If ensure is True, create parent directories if needed.
|
| 753 |
+
"""
|
| 754 |
+
if ensure:
|
| 755 |
+
self.dirpath().ensure(dir=1)
|
| 756 |
+
if encoding:
|
| 757 |
+
return error.checked_call(
|
| 758 |
+
io.open,
|
| 759 |
+
self.strpath,
|
| 760 |
+
mode,
|
| 761 |
+
encoding=encoding,
|
| 762 |
+
)
|
| 763 |
+
return error.checked_call(open, self.strpath, mode)
|
| 764 |
+
|
| 765 |
+
def _fastjoin(self, name):
|
| 766 |
+
child = object.__new__(self.__class__)
|
| 767 |
+
child.strpath = self.strpath + self.sep + name
|
| 768 |
+
return child
|
| 769 |
+
|
| 770 |
+
def islink(self):
|
| 771 |
+
return islink(self.strpath)
|
| 772 |
+
|
| 773 |
+
def check(self, **kw):
|
| 774 |
+
"""Check a path for existence and properties.
|
| 775 |
+
|
| 776 |
+
Without arguments, return True if the path exists, otherwise False.
|
| 777 |
+
|
| 778 |
+
valid checkers::
|
| 779 |
+
|
| 780 |
+
file = 1 # is a file
|
| 781 |
+
file = 0 # is not a file (may not even exist)
|
| 782 |
+
dir = 1 # is a dir
|
| 783 |
+
link = 1 # is a link
|
| 784 |
+
exists = 1 # exists
|
| 785 |
+
|
| 786 |
+
You can specify multiple checker definitions, for example::
|
| 787 |
+
|
| 788 |
+
path.check(file=1, link=1) # a link pointing to a file
|
| 789 |
+
"""
|
| 790 |
+
if not kw:
|
| 791 |
+
return exists(self.strpath)
|
| 792 |
+
if len(kw) == 1:
|
| 793 |
+
if "dir" in kw:
|
| 794 |
+
return not kw["dir"] ^ isdir(self.strpath)
|
| 795 |
+
if "file" in kw:
|
| 796 |
+
return not kw["file"] ^ isfile(self.strpath)
|
| 797 |
+
if not kw:
|
| 798 |
+
kw = {"exists": 1}
|
| 799 |
+
return Checkers(self)._evaluate(kw)
|
| 800 |
+
|
| 801 |
+
_patternchars = set("*?[" + os.sep)
|
| 802 |
+
|
| 803 |
+
def listdir(self, fil=None, sort=None):
|
| 804 |
+
"""List directory contents, possibly filter by the given fil func
|
| 805 |
+
and possibly sorted.
|
| 806 |
+
"""
|
| 807 |
+
if fil is None and sort is None:
|
| 808 |
+
names = error.checked_call(os.listdir, self.strpath)
|
| 809 |
+
return map_as_list(self._fastjoin, names)
|
| 810 |
+
if isinstance(fil, str):
|
| 811 |
+
if not self._patternchars.intersection(fil):
|
| 812 |
+
child = self._fastjoin(fil)
|
| 813 |
+
if exists(child.strpath):
|
| 814 |
+
return [child]
|
| 815 |
+
return []
|
| 816 |
+
fil = FNMatcher(fil)
|
| 817 |
+
names = error.checked_call(os.listdir, self.strpath)
|
| 818 |
+
res = []
|
| 819 |
+
for name in names:
|
| 820 |
+
child = self._fastjoin(name)
|
| 821 |
+
if fil is None or fil(child):
|
| 822 |
+
res.append(child)
|
| 823 |
+
self._sortlist(res, sort)
|
| 824 |
+
return res
|
| 825 |
+
|
| 826 |
+
def size(self) -> int:
|
| 827 |
+
"""Return size of the underlying file object"""
|
| 828 |
+
return self.stat().size
|
| 829 |
+
|
| 830 |
+
def mtime(self) -> float:
|
| 831 |
+
"""Return last modification time of the path."""
|
| 832 |
+
return self.stat().mtime
|
| 833 |
+
|
| 834 |
+
def copy(self, target, mode=False, stat=False):
|
| 835 |
+
"""Copy path to target.
|
| 836 |
+
|
| 837 |
+
If mode is True, will copy permission from path to target.
|
| 838 |
+
If stat is True, copy permission, last modification
|
| 839 |
+
time, last access time, and flags from path to target.
|
| 840 |
+
"""
|
| 841 |
+
if self.check(file=1):
|
| 842 |
+
if target.check(dir=1):
|
| 843 |
+
target = target.join(self.basename)
|
| 844 |
+
assert self != target
|
| 845 |
+
copychunked(self, target)
|
| 846 |
+
if mode:
|
| 847 |
+
copymode(self.strpath, target.strpath)
|
| 848 |
+
if stat:
|
| 849 |
+
copystat(self, target)
|
| 850 |
+
else:
|
| 851 |
+
|
| 852 |
+
def rec(p):
|
| 853 |
+
return p.check(link=0)
|
| 854 |
+
|
| 855 |
+
for x in self.visit(rec=rec):
|
| 856 |
+
relpath = x.relto(self)
|
| 857 |
+
newx = target.join(relpath)
|
| 858 |
+
newx.dirpath().ensure(dir=1)
|
| 859 |
+
if x.check(link=1):
|
| 860 |
+
newx.mksymlinkto(x.readlink())
|
| 861 |
+
continue
|
| 862 |
+
elif x.check(file=1):
|
| 863 |
+
copychunked(x, newx)
|
| 864 |
+
elif x.check(dir=1):
|
| 865 |
+
newx.ensure(dir=1)
|
| 866 |
+
if mode:
|
| 867 |
+
copymode(x.strpath, newx.strpath)
|
| 868 |
+
if stat:
|
| 869 |
+
copystat(x, newx)
|
| 870 |
+
|
| 871 |
+
def rename(self, target):
|
| 872 |
+
"""Rename this path to target."""
|
| 873 |
+
target = os.fspath(target)
|
| 874 |
+
return error.checked_call(os.rename, self.strpath, target)
|
| 875 |
+
|
| 876 |
+
def dump(self, obj, bin=1):
|
| 877 |
+
"""Pickle object into path location"""
|
| 878 |
+
f = self.open("wb")
|
| 879 |
+
import pickle
|
| 880 |
+
|
| 881 |
+
try:
|
| 882 |
+
error.checked_call(pickle.dump, obj, f, bin)
|
| 883 |
+
finally:
|
| 884 |
+
f.close()
|
| 885 |
+
|
| 886 |
+
def mkdir(self, *args):
|
| 887 |
+
"""Create & return the directory joined with args."""
|
| 888 |
+
p = self.join(*args)
|
| 889 |
+
error.checked_call(os.mkdir, os.fspath(p))
|
| 890 |
+
return p
|
| 891 |
+
|
| 892 |
+
def write_binary(self, data, ensure=False):
|
| 893 |
+
"""Write binary data into path. If ensure is True create
|
| 894 |
+
missing parent directories.
|
| 895 |
+
"""
|
| 896 |
+
if ensure:
|
| 897 |
+
self.dirpath().ensure(dir=1)
|
| 898 |
+
with self.open("wb") as f:
|
| 899 |
+
f.write(data)
|
| 900 |
+
|
| 901 |
+
def write_text(self, data, encoding, ensure=False):
|
| 902 |
+
"""Write text data into path using the specified encoding.
|
| 903 |
+
If ensure is True create missing parent directories.
|
| 904 |
+
"""
|
| 905 |
+
if ensure:
|
| 906 |
+
self.dirpath().ensure(dir=1)
|
| 907 |
+
with self.open("w", encoding=encoding) as f:
|
| 908 |
+
f.write(data)
|
| 909 |
+
|
| 910 |
+
def write(self, data, mode="w", ensure=False):
|
| 911 |
+
"""Write data into path. If ensure is True create
|
| 912 |
+
missing parent directories.
|
| 913 |
+
"""
|
| 914 |
+
if ensure:
|
| 915 |
+
self.dirpath().ensure(dir=1)
|
| 916 |
+
if "b" in mode:
|
| 917 |
+
if not isinstance(data, bytes):
|
| 918 |
+
raise ValueError("can only process bytes")
|
| 919 |
+
else:
|
| 920 |
+
if not isinstance(data, str):
|
| 921 |
+
if not isinstance(data, bytes):
|
| 922 |
+
data = str(data)
|
| 923 |
+
else:
|
| 924 |
+
data = data.decode(sys.getdefaultencoding())
|
| 925 |
+
f = self.open(mode)
|
| 926 |
+
try:
|
| 927 |
+
f.write(data)
|
| 928 |
+
finally:
|
| 929 |
+
f.close()
|
| 930 |
+
|
| 931 |
+
def _ensuredirs(self):
|
| 932 |
+
parent = self.dirpath()
|
| 933 |
+
if parent == self:
|
| 934 |
+
return self
|
| 935 |
+
if parent.check(dir=0):
|
| 936 |
+
parent._ensuredirs()
|
| 937 |
+
if self.check(dir=0):
|
| 938 |
+
try:
|
| 939 |
+
self.mkdir()
|
| 940 |
+
except error.EEXIST:
|
| 941 |
+
# race condition: file/dir created by another thread/process.
|
| 942 |
+
# complain if it is not a dir
|
| 943 |
+
if self.check(dir=0):
|
| 944 |
+
raise
|
| 945 |
+
return self
|
| 946 |
+
|
| 947 |
+
def ensure(self, *args, **kwargs):
|
| 948 |
+
"""Ensure that an args-joined path exists (by default as
|
| 949 |
+
a file). if you specify a keyword argument 'dir=True'
|
| 950 |
+
then the path is forced to be a directory path.
|
| 951 |
+
"""
|
| 952 |
+
p = self.join(*args)
|
| 953 |
+
if kwargs.get("dir", 0):
|
| 954 |
+
return p._ensuredirs()
|
| 955 |
+
else:
|
| 956 |
+
p.dirpath()._ensuredirs()
|
| 957 |
+
if not p.check(file=1):
|
| 958 |
+
p.open("wb").close()
|
| 959 |
+
return p
|
| 960 |
+
|
| 961 |
+
@overload
|
| 962 |
+
def stat(self, raising: Literal[True] = ...) -> Stat: ...
|
| 963 |
+
|
| 964 |
+
@overload
|
| 965 |
+
def stat(self, raising: Literal[False]) -> Stat | None: ...
|
| 966 |
+
|
| 967 |
+
def stat(self, raising: bool = True) -> Stat | None:
|
| 968 |
+
"""Return an os.stat() tuple."""
|
| 969 |
+
if raising:
|
| 970 |
+
return Stat(self, error.checked_call(os.stat, self.strpath))
|
| 971 |
+
try:
|
| 972 |
+
return Stat(self, os.stat(self.strpath))
|
| 973 |
+
except KeyboardInterrupt:
|
| 974 |
+
raise
|
| 975 |
+
except Exception:
|
| 976 |
+
return None
|
| 977 |
+
|
| 978 |
+
def lstat(self) -> Stat:
|
| 979 |
+
"""Return an os.lstat() tuple."""
|
| 980 |
+
return Stat(self, error.checked_call(os.lstat, self.strpath))
|
| 981 |
+
|
| 982 |
+
def setmtime(self, mtime=None):
|
| 983 |
+
"""Set modification time for the given path. if 'mtime' is None
|
| 984 |
+
(the default) then the file's mtime is set to current time.
|
| 985 |
+
|
| 986 |
+
Note that the resolution for 'mtime' is platform dependent.
|
| 987 |
+
"""
|
| 988 |
+
if mtime is None:
|
| 989 |
+
return error.checked_call(os.utime, self.strpath, mtime)
|
| 990 |
+
try:
|
| 991 |
+
return error.checked_call(os.utime, self.strpath, (-1, mtime))
|
| 992 |
+
except error.EINVAL:
|
| 993 |
+
return error.checked_call(os.utime, self.strpath, (self.atime(), mtime))
|
| 994 |
+
|
| 995 |
+
def chdir(self):
|
| 996 |
+
"""Change directory to self and return old current directory"""
|
| 997 |
+
try:
|
| 998 |
+
old = self.__class__()
|
| 999 |
+
except error.ENOENT:
|
| 1000 |
+
old = None
|
| 1001 |
+
error.checked_call(os.chdir, self.strpath)
|
| 1002 |
+
return old
|
| 1003 |
+
|
| 1004 |
+
@contextmanager
|
| 1005 |
+
def as_cwd(self):
|
| 1006 |
+
"""
|
| 1007 |
+
Return a context manager, which changes to the path's dir during the
|
| 1008 |
+
managed "with" context.
|
| 1009 |
+
On __enter__ it returns the old dir, which might be ``None``.
|
| 1010 |
+
"""
|
| 1011 |
+
old = self.chdir()
|
| 1012 |
+
try:
|
| 1013 |
+
yield old
|
| 1014 |
+
finally:
|
| 1015 |
+
if old is not None:
|
| 1016 |
+
old.chdir()
|
| 1017 |
+
|
| 1018 |
+
def realpath(self):
|
| 1019 |
+
"""Return a new path which contains no symbolic links."""
|
| 1020 |
+
return self.__class__(os.path.realpath(self.strpath))
|
| 1021 |
+
|
| 1022 |
+
def atime(self):
|
| 1023 |
+
"""Return last access time of the path."""
|
| 1024 |
+
return self.stat().atime
|
| 1025 |
+
|
| 1026 |
+
def __repr__(self):
|
| 1027 |
+
return f"local({self.strpath!r})"
|
| 1028 |
+
|
| 1029 |
+
def __str__(self):
|
| 1030 |
+
"""Return string representation of the Path."""
|
| 1031 |
+
return self.strpath
|
| 1032 |
+
|
| 1033 |
+
def chmod(self, mode, rec=0):
|
| 1034 |
+
"""Change permissions to the given mode. If mode is an
|
| 1035 |
+
integer it directly encodes the os-specific modes.
|
| 1036 |
+
if rec is True perform recursively.
|
| 1037 |
+
"""
|
| 1038 |
+
if not isinstance(mode, int):
|
| 1039 |
+
raise TypeError(f"mode {mode!r} must be an integer")
|
| 1040 |
+
if rec:
|
| 1041 |
+
for x in self.visit(rec=rec):
|
| 1042 |
+
error.checked_call(os.chmod, str(x), mode)
|
| 1043 |
+
error.checked_call(os.chmod, self.strpath, mode)
|
| 1044 |
+
|
| 1045 |
+
def pypkgpath(self):
|
| 1046 |
+
"""Return the Python package path by looking for the last
|
| 1047 |
+
directory upwards which still contains an __init__.py.
|
| 1048 |
+
Return None if a pkgpath cannot be determined.
|
| 1049 |
+
"""
|
| 1050 |
+
pkgpath = None
|
| 1051 |
+
for parent in self.parts(reverse=True):
|
| 1052 |
+
if parent.isdir():
|
| 1053 |
+
if not parent.join("__init__.py").exists():
|
| 1054 |
+
break
|
| 1055 |
+
if not isimportable(parent.basename):
|
| 1056 |
+
break
|
| 1057 |
+
pkgpath = parent
|
| 1058 |
+
return pkgpath
|
| 1059 |
+
|
| 1060 |
+
def _ensuresyspath(self, ensuremode, path):
|
| 1061 |
+
if ensuremode:
|
| 1062 |
+
s = str(path)
|
| 1063 |
+
if ensuremode == "append":
|
| 1064 |
+
if s not in sys.path:
|
| 1065 |
+
sys.path.append(s)
|
| 1066 |
+
else:
|
| 1067 |
+
if s != sys.path[0]:
|
| 1068 |
+
sys.path.insert(0, s)
|
| 1069 |
+
|
| 1070 |
+
def pyimport(self, modname=None, ensuresyspath=True):
|
| 1071 |
+
"""Return path as an imported python module.
|
| 1072 |
+
|
| 1073 |
+
If modname is None, look for the containing package
|
| 1074 |
+
and construct an according module name.
|
| 1075 |
+
The module will be put/looked up in sys.modules.
|
| 1076 |
+
if ensuresyspath is True then the root dir for importing
|
| 1077 |
+
the file (taking __init__.py files into account) will
|
| 1078 |
+
be prepended to sys.path if it isn't there already.
|
| 1079 |
+
If ensuresyspath=="append" the root dir will be appended
|
| 1080 |
+
if it isn't already contained in sys.path.
|
| 1081 |
+
if ensuresyspath is False no modification of syspath happens.
|
| 1082 |
+
|
| 1083 |
+
Special value of ensuresyspath=="importlib" is intended
|
| 1084 |
+
purely for using in pytest, it is capable only of importing
|
| 1085 |
+
separate .py files outside packages, e.g. for test suite
|
| 1086 |
+
without any __init__.py file. It effectively allows having
|
| 1087 |
+
same-named test modules in different places and offers
|
| 1088 |
+
mild opt-in via this option. Note that it works only in
|
| 1089 |
+
recent versions of python.
|
| 1090 |
+
"""
|
| 1091 |
+
if not self.check():
|
| 1092 |
+
raise error.ENOENT(self)
|
| 1093 |
+
|
| 1094 |
+
if ensuresyspath == "importlib":
|
| 1095 |
+
if modname is None:
|
| 1096 |
+
modname = self.purebasename
|
| 1097 |
+
spec = importlib.util.spec_from_file_location(modname, str(self))
|
| 1098 |
+
if spec is None or spec.loader is None:
|
| 1099 |
+
raise ImportError(f"Can't find module {modname} at location {self!s}")
|
| 1100 |
+
mod = importlib.util.module_from_spec(spec)
|
| 1101 |
+
spec.loader.exec_module(mod)
|
| 1102 |
+
return mod
|
| 1103 |
+
|
| 1104 |
+
pkgpath = None
|
| 1105 |
+
if modname is None:
|
| 1106 |
+
pkgpath = self.pypkgpath()
|
| 1107 |
+
if pkgpath is not None:
|
| 1108 |
+
pkgroot = pkgpath.dirpath()
|
| 1109 |
+
names = self.new(ext="").relto(pkgroot).split(self.sep)
|
| 1110 |
+
if names[-1] == "__init__":
|
| 1111 |
+
names.pop()
|
| 1112 |
+
modname = ".".join(names)
|
| 1113 |
+
else:
|
| 1114 |
+
pkgroot = self.dirpath()
|
| 1115 |
+
modname = self.purebasename
|
| 1116 |
+
|
| 1117 |
+
self._ensuresyspath(ensuresyspath, pkgroot)
|
| 1118 |
+
__import__(modname)
|
| 1119 |
+
mod = sys.modules[modname]
|
| 1120 |
+
if self.basename == "__init__.py":
|
| 1121 |
+
return mod # we don't check anything as we might
|
| 1122 |
+
# be in a namespace package ... too icky to check
|
| 1123 |
+
modfile = mod.__file__
|
| 1124 |
+
assert modfile is not None
|
| 1125 |
+
if modfile[-4:] in (".pyc", ".pyo"):
|
| 1126 |
+
modfile = modfile[:-1]
|
| 1127 |
+
elif modfile.endswith("$py.class"):
|
| 1128 |
+
modfile = modfile[:-9] + ".py"
|
| 1129 |
+
if modfile.endswith(os.sep + "__init__.py"):
|
| 1130 |
+
if self.basename != "__init__.py":
|
| 1131 |
+
modfile = modfile[:-12]
|
| 1132 |
+
try:
|
| 1133 |
+
issame = self.samefile(modfile)
|
| 1134 |
+
except error.ENOENT:
|
| 1135 |
+
issame = False
|
| 1136 |
+
if not issame:
|
| 1137 |
+
ignore = os.getenv("PY_IGNORE_IMPORTMISMATCH")
|
| 1138 |
+
if ignore != "1":
|
| 1139 |
+
raise self.ImportMismatchError(modname, modfile, self)
|
| 1140 |
+
return mod
|
| 1141 |
+
else:
|
| 1142 |
+
try:
|
| 1143 |
+
return sys.modules[modname]
|
| 1144 |
+
except KeyError:
|
| 1145 |
+
# we have a custom modname, do a pseudo-import
|
| 1146 |
+
import types
|
| 1147 |
+
|
| 1148 |
+
mod = types.ModuleType(modname)
|
| 1149 |
+
mod.__file__ = str(self)
|
| 1150 |
+
sys.modules[modname] = mod
|
| 1151 |
+
try:
|
| 1152 |
+
with open(str(self), "rb") as f:
|
| 1153 |
+
exec(f.read(), mod.__dict__)
|
| 1154 |
+
except BaseException:
|
| 1155 |
+
del sys.modules[modname]
|
| 1156 |
+
raise
|
| 1157 |
+
return mod
|
| 1158 |
+
|
| 1159 |
+
def sysexec(self, *argv: os.PathLike[str], **popen_opts: Any) -> str:
|
| 1160 |
+
"""Return stdout text from executing a system child process,
|
| 1161 |
+
where the 'self' path points to executable.
|
| 1162 |
+
The process is directly invoked and not through a system shell.
|
| 1163 |
+
"""
|
| 1164 |
+
from subprocess import PIPE
|
| 1165 |
+
from subprocess import Popen
|
| 1166 |
+
|
| 1167 |
+
popen_opts.pop("stdout", None)
|
| 1168 |
+
popen_opts.pop("stderr", None)
|
| 1169 |
+
proc = Popen(
|
| 1170 |
+
[str(self)] + [str(arg) for arg in argv],
|
| 1171 |
+
**popen_opts,
|
| 1172 |
+
stdout=PIPE,
|
| 1173 |
+
stderr=PIPE,
|
| 1174 |
+
)
|
| 1175 |
+
stdout: str | bytes
|
| 1176 |
+
stdout, stderr = proc.communicate()
|
| 1177 |
+
ret = proc.wait()
|
| 1178 |
+
if isinstance(stdout, bytes):
|
| 1179 |
+
stdout = stdout.decode(sys.getdefaultencoding())
|
| 1180 |
+
if ret != 0:
|
| 1181 |
+
if isinstance(stderr, bytes):
|
| 1182 |
+
stderr = stderr.decode(sys.getdefaultencoding())
|
| 1183 |
+
raise RuntimeError(
|
| 1184 |
+
ret,
|
| 1185 |
+
ret,
|
| 1186 |
+
str(self),
|
| 1187 |
+
stdout,
|
| 1188 |
+
stderr,
|
| 1189 |
+
)
|
| 1190 |
+
return stdout
|
| 1191 |
+
|
| 1192 |
+
@classmethod
|
| 1193 |
+
def sysfind(cls, name, checker=None, paths=None):
|
| 1194 |
+
"""Return a path object found by looking at the systems
|
| 1195 |
+
underlying PATH specification. If the checker is not None
|
| 1196 |
+
it will be invoked to filter matching paths. If a binary
|
| 1197 |
+
cannot be found, None is returned
|
| 1198 |
+
Note: This is probably not working on plain win32 systems
|
| 1199 |
+
but may work on cygwin.
|
| 1200 |
+
"""
|
| 1201 |
+
if isabs(name):
|
| 1202 |
+
p = local(name)
|
| 1203 |
+
if p.check(file=1):
|
| 1204 |
+
return p
|
| 1205 |
+
else:
|
| 1206 |
+
if paths is None:
|
| 1207 |
+
if iswin32:
|
| 1208 |
+
paths = os.environ["Path"].split(";")
|
| 1209 |
+
if "" not in paths and "." not in paths:
|
| 1210 |
+
paths.append(".")
|
| 1211 |
+
try:
|
| 1212 |
+
systemroot = os.environ["SYSTEMROOT"]
|
| 1213 |
+
except KeyError:
|
| 1214 |
+
pass
|
| 1215 |
+
else:
|
| 1216 |
+
paths = [
|
| 1217 |
+
path.replace("%SystemRoot%", systemroot) for path in paths
|
| 1218 |
+
]
|
| 1219 |
+
else:
|
| 1220 |
+
paths = os.environ["PATH"].split(":")
|
| 1221 |
+
tryadd = []
|
| 1222 |
+
if iswin32:
|
| 1223 |
+
tryadd += os.environ["PATHEXT"].split(os.pathsep)
|
| 1224 |
+
tryadd.append("")
|
| 1225 |
+
|
| 1226 |
+
for x in paths:
|
| 1227 |
+
for addext in tryadd:
|
| 1228 |
+
p = local(x).join(name, abs=True) + addext
|
| 1229 |
+
try:
|
| 1230 |
+
if p.check(file=1):
|
| 1231 |
+
if checker:
|
| 1232 |
+
if not checker(p):
|
| 1233 |
+
continue
|
| 1234 |
+
return p
|
| 1235 |
+
except error.EACCES:
|
| 1236 |
+
pass
|
| 1237 |
+
return None
|
| 1238 |
+
|
| 1239 |
+
@classmethod
|
| 1240 |
+
def _gethomedir(cls):
|
| 1241 |
+
try:
|
| 1242 |
+
x = os.environ["HOME"]
|
| 1243 |
+
except KeyError:
|
| 1244 |
+
try:
|
| 1245 |
+
x = os.environ["HOMEDRIVE"] + os.environ["HOMEPATH"]
|
| 1246 |
+
except KeyError:
|
| 1247 |
+
return None
|
| 1248 |
+
return cls(x)
|
| 1249 |
+
|
| 1250 |
+
# """
|
| 1251 |
+
# special class constructors for local filesystem paths
|
| 1252 |
+
# """
|
| 1253 |
+
@classmethod
|
| 1254 |
+
def get_temproot(cls):
|
| 1255 |
+
"""Return the system's temporary directory
|
| 1256 |
+
(where tempfiles are usually created in)
|
| 1257 |
+
"""
|
| 1258 |
+
import tempfile
|
| 1259 |
+
|
| 1260 |
+
return local(tempfile.gettempdir())
|
| 1261 |
+
|
| 1262 |
+
@classmethod
|
| 1263 |
+
def mkdtemp(cls, rootdir=None):
|
| 1264 |
+
"""Return a Path object pointing to a fresh new temporary directory
|
| 1265 |
+
(which we created ourselves).
|
| 1266 |
+
"""
|
| 1267 |
+
import tempfile
|
| 1268 |
+
|
| 1269 |
+
if rootdir is None:
|
| 1270 |
+
rootdir = cls.get_temproot()
|
| 1271 |
+
path = error.checked_call(tempfile.mkdtemp, dir=str(rootdir))
|
| 1272 |
+
return cls(path)
|
| 1273 |
+
|
| 1274 |
+
@classmethod
|
| 1275 |
+
def make_numbered_dir(
|
| 1276 |
+
cls, prefix="session-", rootdir=None, keep=3, lock_timeout=172800
|
| 1277 |
+
): # two days
|
| 1278 |
+
"""Return unique directory with a number greater than the current
|
| 1279 |
+
maximum one. The number is assumed to start directly after prefix.
|
| 1280 |
+
if keep is true directories with a number less than (maxnum-keep)
|
| 1281 |
+
will be removed. If .lock files are used (lock_timeout non-zero),
|
| 1282 |
+
algorithm is multi-process safe.
|
| 1283 |
+
"""
|
| 1284 |
+
if rootdir is None:
|
| 1285 |
+
rootdir = cls.get_temproot()
|
| 1286 |
+
|
| 1287 |
+
nprefix = prefix.lower()
|
| 1288 |
+
|
| 1289 |
+
def parse_num(path):
|
| 1290 |
+
"""Parse the number out of a path (if it matches the prefix)"""
|
| 1291 |
+
nbasename = path.basename.lower()
|
| 1292 |
+
if nbasename.startswith(nprefix):
|
| 1293 |
+
try:
|
| 1294 |
+
return int(nbasename[len(nprefix) :])
|
| 1295 |
+
except ValueError:
|
| 1296 |
+
pass
|
| 1297 |
+
|
| 1298 |
+
def create_lockfile(path):
|
| 1299 |
+
"""Exclusively create lockfile. Throws when failed"""
|
| 1300 |
+
mypid = os.getpid()
|
| 1301 |
+
lockfile = path.join(".lock")
|
| 1302 |
+
if hasattr(lockfile, "mksymlinkto"):
|
| 1303 |
+
lockfile.mksymlinkto(str(mypid))
|
| 1304 |
+
else:
|
| 1305 |
+
fd = error.checked_call(
|
| 1306 |
+
os.open, str(lockfile), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644
|
| 1307 |
+
)
|
| 1308 |
+
with os.fdopen(fd, "w") as f:
|
| 1309 |
+
f.write(str(mypid))
|
| 1310 |
+
return lockfile
|
| 1311 |
+
|
| 1312 |
+
def atexit_remove_lockfile(lockfile):
|
| 1313 |
+
"""Ensure lockfile is removed at process exit"""
|
| 1314 |
+
mypid = os.getpid()
|
| 1315 |
+
|
| 1316 |
+
def try_remove_lockfile():
|
| 1317 |
+
# in a fork() situation, only the last process should
|
| 1318 |
+
# remove the .lock, otherwise the other processes run the
|
| 1319 |
+
# risk of seeing their temporary dir disappear. For now
|
| 1320 |
+
# we remove the .lock in the parent only (i.e. we assume
|
| 1321 |
+
# that the children finish before the parent).
|
| 1322 |
+
if os.getpid() != mypid:
|
| 1323 |
+
return
|
| 1324 |
+
try:
|
| 1325 |
+
lockfile.remove()
|
| 1326 |
+
except error.Error:
|
| 1327 |
+
pass
|
| 1328 |
+
|
| 1329 |
+
atexit.register(try_remove_lockfile)
|
| 1330 |
+
|
| 1331 |
+
# compute the maximum number currently in use with the prefix
|
| 1332 |
+
lastmax = None
|
| 1333 |
+
while True:
|
| 1334 |
+
maxnum = -1
|
| 1335 |
+
for path in rootdir.listdir():
|
| 1336 |
+
num = parse_num(path)
|
| 1337 |
+
if num is not None:
|
| 1338 |
+
maxnum = max(maxnum, num)
|
| 1339 |
+
|
| 1340 |
+
# make the new directory
|
| 1341 |
+
try:
|
| 1342 |
+
udir = rootdir.mkdir(prefix + str(maxnum + 1))
|
| 1343 |
+
if lock_timeout:
|
| 1344 |
+
lockfile = create_lockfile(udir)
|
| 1345 |
+
atexit_remove_lockfile(lockfile)
|
| 1346 |
+
except (error.EEXIST, error.ENOENT, error.EBUSY):
|
| 1347 |
+
# race condition (1): another thread/process created the dir
|
| 1348 |
+
# in the meantime - try again
|
| 1349 |
+
# race condition (2): another thread/process spuriously acquired
|
| 1350 |
+
# lock treating empty directory as candidate
|
| 1351 |
+
# for removal - try again
|
| 1352 |
+
# race condition (3): another thread/process tried to create the lock at
|
| 1353 |
+
# the same time (happened in Python 3.3 on Windows)
|
| 1354 |
+
# https://ci.appveyor.com/project/pytestbot/py/build/1.0.21/job/ffi85j4c0lqwsfwa
|
| 1355 |
+
if lastmax == maxnum:
|
| 1356 |
+
raise
|
| 1357 |
+
lastmax = maxnum
|
| 1358 |
+
continue
|
| 1359 |
+
break
|
| 1360 |
+
|
| 1361 |
+
def get_mtime(path):
|
| 1362 |
+
"""Read file modification time"""
|
| 1363 |
+
try:
|
| 1364 |
+
return path.lstat().mtime
|
| 1365 |
+
except error.Error:
|
| 1366 |
+
pass
|
| 1367 |
+
|
| 1368 |
+
garbage_prefix = prefix + "garbage-"
|
| 1369 |
+
|
| 1370 |
+
def is_garbage(path):
|
| 1371 |
+
"""Check if path denotes directory scheduled for removal"""
|
| 1372 |
+
bn = path.basename
|
| 1373 |
+
return bn.startswith(garbage_prefix)
|
| 1374 |
+
|
| 1375 |
+
# prune old directories
|
| 1376 |
+
udir_time = get_mtime(udir)
|
| 1377 |
+
if keep and udir_time:
|
| 1378 |
+
for path in rootdir.listdir():
|
| 1379 |
+
num = parse_num(path)
|
| 1380 |
+
if num is not None and num <= (maxnum - keep):
|
| 1381 |
+
try:
|
| 1382 |
+
# try acquiring lock to remove directory as exclusive user
|
| 1383 |
+
if lock_timeout:
|
| 1384 |
+
create_lockfile(path)
|
| 1385 |
+
except (error.EEXIST, error.ENOENT, error.EBUSY):
|
| 1386 |
+
path_time = get_mtime(path)
|
| 1387 |
+
if not path_time:
|
| 1388 |
+
# assume directory doesn't exist now
|
| 1389 |
+
continue
|
| 1390 |
+
if abs(udir_time - path_time) < lock_timeout:
|
| 1391 |
+
# assume directory with lockfile exists
|
| 1392 |
+
# and lock timeout hasn't expired yet
|
| 1393 |
+
continue
|
| 1394 |
+
|
| 1395 |
+
# path dir locked for exclusive use
|
| 1396 |
+
# and scheduled for removal to avoid another thread/process
|
| 1397 |
+
# treating it as a new directory or removal candidate
|
| 1398 |
+
garbage_path = rootdir.join(garbage_prefix + str(uuid.uuid4()))
|
| 1399 |
+
try:
|
| 1400 |
+
path.rename(garbage_path)
|
| 1401 |
+
garbage_path.remove(rec=1)
|
| 1402 |
+
except KeyboardInterrupt:
|
| 1403 |
+
raise
|
| 1404 |
+
except Exception: # this might be error.Error, WindowsError ...
|
| 1405 |
+
pass
|
| 1406 |
+
if is_garbage(path):
|
| 1407 |
+
try:
|
| 1408 |
+
path.remove(rec=1)
|
| 1409 |
+
except KeyboardInterrupt:
|
| 1410 |
+
raise
|
| 1411 |
+
except Exception: # this might be error.Error, WindowsError ...
|
| 1412 |
+
pass
|
| 1413 |
+
|
| 1414 |
+
# make link...
|
| 1415 |
+
try:
|
| 1416 |
+
username = os.environ["USER"] # linux, et al
|
| 1417 |
+
except KeyError:
|
| 1418 |
+
try:
|
| 1419 |
+
username = os.environ["USERNAME"] # windows
|
| 1420 |
+
except KeyError:
|
| 1421 |
+
username = "current"
|
| 1422 |
+
|
| 1423 |
+
src = str(udir)
|
| 1424 |
+
dest = src[: src.rfind("-")] + "-" + username
|
| 1425 |
+
try:
|
| 1426 |
+
os.unlink(dest)
|
| 1427 |
+
except OSError:
|
| 1428 |
+
pass
|
| 1429 |
+
try:
|
| 1430 |
+
os.symlink(src, dest)
|
| 1431 |
+
except (OSError, AttributeError, NotImplementedError):
|
| 1432 |
+
pass
|
| 1433 |
+
|
| 1434 |
+
return udir
|
| 1435 |
+
|
| 1436 |
+
|
| 1437 |
+
def copymode(src, dest):
|
| 1438 |
+
"""Copy permission from src to dst."""
|
| 1439 |
+
import shutil
|
| 1440 |
+
|
| 1441 |
+
shutil.copymode(src, dest)
|
| 1442 |
+
|
| 1443 |
+
|
| 1444 |
+
def copystat(src, dest):
|
| 1445 |
+
"""Copy permission, last modification time,
|
| 1446 |
+
last access time, and flags from src to dst."""
|
| 1447 |
+
import shutil
|
| 1448 |
+
|
| 1449 |
+
shutil.copystat(str(src), str(dest))
|
| 1450 |
+
|
| 1451 |
+
|
| 1452 |
+
def copychunked(src, dest):
|
| 1453 |
+
chunksize = 524288 # half a meg of bytes
|
| 1454 |
+
fsrc = src.open("rb")
|
| 1455 |
+
try:
|
| 1456 |
+
fdest = dest.open("wb")
|
| 1457 |
+
try:
|
| 1458 |
+
while 1:
|
| 1459 |
+
buf = fsrc.read(chunksize)
|
| 1460 |
+
if not buf:
|
| 1461 |
+
break
|
| 1462 |
+
fdest.write(buf)
|
| 1463 |
+
finally:
|
| 1464 |
+
fdest.close()
|
| 1465 |
+
finally:
|
| 1466 |
+
fsrc.close()
|
| 1467 |
+
|
| 1468 |
+
|
| 1469 |
+
def isimportable(name):
|
| 1470 |
+
if name and (name[0].isalpha() or name[0] == "_"):
|
| 1471 |
+
name = name.replace("_", "")
|
| 1472 |
+
return not name or name.isalnum()
|
| 1473 |
+
|
| 1474 |
+
|
| 1475 |
+
local = LocalPath
|
.venv/lib/python3.14/site-packages/_pytest/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file generated by vcs-versioning
|
| 2 |
+
# don't change, don't track in version control
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"__version__",
|
| 7 |
+
"__version_tuple__",
|
| 8 |
+
"version",
|
| 9 |
+
"version_tuple",
|
| 10 |
+
"__commit_id__",
|
| 11 |
+
"commit_id",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
version: str
|
| 15 |
+
__version__: str
|
| 16 |
+
__version_tuple__: tuple[int | str, ...]
|
| 17 |
+
version_tuple: tuple[int | str, ...]
|
| 18 |
+
commit_id: str | None
|
| 19 |
+
__commit_id__: str | None
|
| 20 |
+
|
| 21 |
+
__version__ = version = '9.1.1'
|
| 22 |
+
__version_tuple__ = version_tuple = (9, 1, 1)
|
| 23 |
+
|
| 24 |
+
__commit_id__ = commit_id = None
|
.venv/lib/python3.14/site-packages/_pytest/assertion/__init__.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Support for presenting detailed information in failing assertions."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from collections.abc import Generator
|
| 7 |
+
import sys
|
| 8 |
+
from typing import Any
|
| 9 |
+
from typing import Protocol
|
| 10 |
+
from typing import TYPE_CHECKING
|
| 11 |
+
|
| 12 |
+
from _pytest.assertion import rewrite
|
| 13 |
+
from _pytest.assertion import truncate
|
| 14 |
+
from _pytest.assertion import util
|
| 15 |
+
from _pytest.assertion.rewrite import assertstate_key
|
| 16 |
+
from _pytest.config import Config
|
| 17 |
+
from _pytest.config import hookimpl
|
| 18 |
+
from _pytest.config.argparsing import Parser
|
| 19 |
+
from _pytest.nodes import Item
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
if TYPE_CHECKING:
|
| 23 |
+
from _pytest.main import Session
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 27 |
+
group = parser.getgroup("debugconfig")
|
| 28 |
+
group.addoption(
|
| 29 |
+
"--assert",
|
| 30 |
+
action="store",
|
| 31 |
+
dest="assertmode",
|
| 32 |
+
choices=("rewrite", "plain"),
|
| 33 |
+
default="rewrite",
|
| 34 |
+
metavar="MODE",
|
| 35 |
+
help=(
|
| 36 |
+
"Control assertion debugging tools.\n"
|
| 37 |
+
"'plain' performs no assertion debugging.\n"
|
| 38 |
+
"'rewrite' (the default) rewrites assert statements in test modules"
|
| 39 |
+
" on import to provide assert expression information."
|
| 40 |
+
),
|
| 41 |
+
)
|
| 42 |
+
parser.addini(
|
| 43 |
+
"enable_assertion_pass_hook",
|
| 44 |
+
type="bool",
|
| 45 |
+
default=False,
|
| 46 |
+
help="Enables the pytest_assertion_pass hook. "
|
| 47 |
+
"Make sure to delete any previously generated pyc cache files.",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
parser.addini(
|
| 51 |
+
"truncation_limit_lines",
|
| 52 |
+
default=None,
|
| 53 |
+
help="Set threshold of LINES after which truncation will take effect",
|
| 54 |
+
)
|
| 55 |
+
parser.addini(
|
| 56 |
+
"truncation_limit_chars",
|
| 57 |
+
default=None,
|
| 58 |
+
help=("Set threshold of CHARS after which truncation will take effect"),
|
| 59 |
+
)
|
| 60 |
+
parser.addini(
|
| 61 |
+
"assertion_text_diff_style",
|
| 62 |
+
default=util.ASSERTION_TEXT_DIFF_STYLE_NDIFF,
|
| 63 |
+
help=(
|
| 64 |
+
"Choose how pytest renders diffs for string equality assertions: "
|
| 65 |
+
f"{util.ASSERTION_TEXT_DIFF_STYLE_NDIFF} or "
|
| 66 |
+
f"{util.ASSERTION_TEXT_DIFF_STYLE_BLOCK}"
|
| 67 |
+
),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
Config._add_verbosity_ini(
|
| 71 |
+
parser,
|
| 72 |
+
Config.VERBOSITY_ASSERTIONS,
|
| 73 |
+
help=(
|
| 74 |
+
"Specify a verbosity level for assertions, overriding the main level. "
|
| 75 |
+
"Higher levels will provide more detailed explanation when an assertion fails."
|
| 76 |
+
),
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def pytest_configure(config: Config) -> None:
|
| 81 |
+
util.validate_assertion_text_diff_style(config)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def register_assert_rewrite(*names: str) -> None:
|
| 85 |
+
"""Register one or more module names to be rewritten on import.
|
| 86 |
+
|
| 87 |
+
This function will make sure that this module or all modules inside
|
| 88 |
+
the package will get their assert statements rewritten.
|
| 89 |
+
Thus you should make sure to call this before the module is
|
| 90 |
+
actually imported, usually in your __init__.py if you are a plugin
|
| 91 |
+
using a package.
|
| 92 |
+
|
| 93 |
+
:param names: The module names to register.
|
| 94 |
+
"""
|
| 95 |
+
for name in names:
|
| 96 |
+
if not isinstance(name, str):
|
| 97 |
+
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
|
| 98 |
+
raise TypeError(msg.format(repr(names)))
|
| 99 |
+
rewrite_hook: RewriteHook
|
| 100 |
+
for hook in sys.meta_path:
|
| 101 |
+
if isinstance(hook, rewrite.AssertionRewritingHook):
|
| 102 |
+
rewrite_hook = hook
|
| 103 |
+
break
|
| 104 |
+
else:
|
| 105 |
+
rewrite_hook = DummyRewriteHook()
|
| 106 |
+
rewrite_hook.mark_rewrite(*names)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class RewriteHook(Protocol):
|
| 110 |
+
def mark_rewrite(self, *names: str) -> None: ...
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class DummyRewriteHook:
|
| 114 |
+
"""A no-op import hook for when rewriting is disabled."""
|
| 115 |
+
|
| 116 |
+
def mark_rewrite(self, *names: str) -> None:
|
| 117 |
+
pass
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class AssertionState:
|
| 121 |
+
"""State for the assertion plugin."""
|
| 122 |
+
|
| 123 |
+
def __init__(self, config: Config, mode) -> None:
|
| 124 |
+
self.mode = mode
|
| 125 |
+
self.trace = config.trace.root.get("assertion")
|
| 126 |
+
self.hook: rewrite.AssertionRewritingHook | None = None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
|
| 130 |
+
"""Try to install the rewrite hook, raise SystemError if it fails."""
|
| 131 |
+
config.stash[assertstate_key] = AssertionState(config, "rewrite")
|
| 132 |
+
config.stash[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
|
| 133 |
+
sys.meta_path.insert(0, hook)
|
| 134 |
+
config.stash[assertstate_key].trace("installed rewrite import hook")
|
| 135 |
+
|
| 136 |
+
def undo() -> None:
|
| 137 |
+
hook = config.stash[assertstate_key].hook
|
| 138 |
+
if hook is not None and hook in sys.meta_path:
|
| 139 |
+
sys.meta_path.remove(hook)
|
| 140 |
+
|
| 141 |
+
config.add_cleanup(undo)
|
| 142 |
+
return hook
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def pytest_collection(session: Session) -> None:
|
| 146 |
+
# This hook is only called when test modules are collected
|
| 147 |
+
# so for example not in the managing process of pytest-xdist
|
| 148 |
+
# (which does not collect test modules).
|
| 149 |
+
assertstate = session.config.stash.get(assertstate_key, None)
|
| 150 |
+
if assertstate:
|
| 151 |
+
if assertstate.hook is not None:
|
| 152 |
+
assertstate.hook.set_session(session)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@hookimpl(wrapper=True, tryfirst=True)
|
| 156 |
+
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
|
| 157 |
+
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
|
| 158 |
+
|
| 159 |
+
The rewrite module will use util._reprcompare if it exists to use custom
|
| 160 |
+
reporting via the pytest_assertrepr_compare hook. This sets up this custom
|
| 161 |
+
comparison for the test.
|
| 162 |
+
"""
|
| 163 |
+
ihook = item.ihook
|
| 164 |
+
|
| 165 |
+
def callbinrepr(op, left: object, right: object) -> str | None:
|
| 166 |
+
"""Call the pytest_assertrepr_compare hook and prepare the result.
|
| 167 |
+
|
| 168 |
+
This uses the first result from the hook and then ensures the
|
| 169 |
+
following:
|
| 170 |
+
* Overly verbose explanations are truncated unless configured otherwise
|
| 171 |
+
(eg. if running in verbose mode).
|
| 172 |
+
* Embedded newlines are escaped to help util.format_explanation()
|
| 173 |
+
later.
|
| 174 |
+
* If the rewrite mode is used embedded %-characters are replaced
|
| 175 |
+
to protect later % formatting.
|
| 176 |
+
|
| 177 |
+
The result can be formatted by util.format_explanation() for
|
| 178 |
+
pretty printing.
|
| 179 |
+
"""
|
| 180 |
+
hook_result = ihook.pytest_assertrepr_compare(
|
| 181 |
+
config=item.config, op=op, left=left, right=right
|
| 182 |
+
)
|
| 183 |
+
for new_expl in hook_result:
|
| 184 |
+
if new_expl:
|
| 185 |
+
new_expl = truncate.truncate_if_required(new_expl, item)
|
| 186 |
+
new_expl = [line.replace("\n", "\\n") for line in new_expl]
|
| 187 |
+
res = "\n~".join(new_expl)
|
| 188 |
+
if item.config.getvalue("assertmode") == "rewrite":
|
| 189 |
+
res = res.replace("%", "%%")
|
| 190 |
+
return res
|
| 191 |
+
return None
|
| 192 |
+
|
| 193 |
+
saved_assert_hooks = util._reprcompare, util._assertion_pass
|
| 194 |
+
util._reprcompare = callbinrepr
|
| 195 |
+
util._config = item.config
|
| 196 |
+
|
| 197 |
+
if ihook.pytest_assertion_pass.get_hookimpls():
|
| 198 |
+
|
| 199 |
+
def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:
|
| 200 |
+
ihook.pytest_assertion_pass(item=item, lineno=lineno, orig=orig, expl=expl)
|
| 201 |
+
|
| 202 |
+
util._assertion_pass = call_assertion_pass_hook
|
| 203 |
+
|
| 204 |
+
try:
|
| 205 |
+
return (yield)
|
| 206 |
+
finally:
|
| 207 |
+
util._reprcompare, util._assertion_pass = saved_assert_hooks
|
| 208 |
+
util._config = None
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def pytest_sessionfinish(session: Session) -> None:
|
| 212 |
+
assertstate = session.config.stash.get(assertstate_key, None)
|
| 213 |
+
if assertstate:
|
| 214 |
+
if assertstate.hook is not None:
|
| 215 |
+
assertstate.hook.set_session(None)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def pytest_assertrepr_compare(
|
| 219 |
+
config: Config, op: str, left: Any, right: Any
|
| 220 |
+
) -> list[str] | None:
|
| 221 |
+
if config.pluginmanager.has_plugin("terminalreporter"):
|
| 222 |
+
highlighter = config.get_terminal_writer()._highlight
|
| 223 |
+
else:
|
| 224 |
+
# Keep it plaintext when not using terminalrepoterer (#14377).
|
| 225 |
+
highlighter = util.dummy_highlighter
|
| 226 |
+
explanation = list(
|
| 227 |
+
util.assertrepr_compare(
|
| 228 |
+
op=op,
|
| 229 |
+
left=left,
|
| 230 |
+
right=right,
|
| 231 |
+
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
|
| 232 |
+
highlighter=highlighter,
|
| 233 |
+
assertion_text_diff_style=util.get_assertion_text_diff_style(config),
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
return explanation or None
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_compare_any.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterator
|
| 4 |
+
import dataclasses
|
| 5 |
+
import pprint
|
| 6 |
+
|
| 7 |
+
from _pytest.assertion._compare_mapping import _compare_eq_mapping
|
| 8 |
+
from _pytest.assertion._compare_sequence import _compare_eq_iterable
|
| 9 |
+
from _pytest.assertion._compare_sequence import _compare_eq_sequence
|
| 10 |
+
from _pytest.assertion._compare_set import _compare_eq_set
|
| 11 |
+
from _pytest.assertion._guards import has_default_eq
|
| 12 |
+
from _pytest.assertion._guards import isattrs
|
| 13 |
+
from _pytest.assertion._guards import isdatacls
|
| 14 |
+
from _pytest.assertion._guards import isiterable
|
| 15 |
+
from _pytest.assertion._guards import ismapping
|
| 16 |
+
from _pytest.assertion._guards import isnamedtuple
|
| 17 |
+
from _pytest.assertion._guards import issequence
|
| 18 |
+
from _pytest.assertion._guards import isset
|
| 19 |
+
from _pytest.assertion._guards import istext
|
| 20 |
+
from _pytest.assertion._typing import _AssertionTextDiffStyle
|
| 21 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 22 |
+
from _pytest.assertion.compare_text import _compare_eq_text
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _compare_eq_any(
|
| 26 |
+
left: object,
|
| 27 |
+
right: object,
|
| 28 |
+
highlighter: _HighlightFunc,
|
| 29 |
+
verbose: int,
|
| 30 |
+
assertion_text_diff_style: _AssertionTextDiffStyle,
|
| 31 |
+
) -> Iterator[str]:
|
| 32 |
+
"""Yield the per-line explanation for ``left == right`` (without summary).
|
| 33 |
+
|
| 34 |
+
Yields nothing when no specialised explanation applies, so consumers
|
| 35 |
+
can stream the output and bail out early (e.g. for truncation) without
|
| 36 |
+
materialising the entire diff first.
|
| 37 |
+
"""
|
| 38 |
+
if istext(left) and istext(right):
|
| 39 |
+
yield from _compare_eq_text(
|
| 40 |
+
left,
|
| 41 |
+
right,
|
| 42 |
+
highlighter,
|
| 43 |
+
verbose,
|
| 44 |
+
assertion_text_diff_style,
|
| 45 |
+
)
|
| 46 |
+
else:
|
| 47 |
+
from _pytest.python_api import ApproxBase
|
| 48 |
+
|
| 49 |
+
# Although the common order should be obtained == approx(...), allow both ways.
|
| 50 |
+
if isinstance(right, ApproxBase):
|
| 51 |
+
yield from right._repr_compare(left)
|
| 52 |
+
elif isinstance(left, ApproxBase):
|
| 53 |
+
yield from left._repr_compare(right)
|
| 54 |
+
elif type(left) is type(right) and (
|
| 55 |
+
isdatacls(left) or isattrs(left) or isnamedtuple(left)
|
| 56 |
+
):
|
| 57 |
+
# Note: unlike dataclasses/attrs, namedtuples compare only the
|
| 58 |
+
# field values, not the type or field names. But this branch
|
| 59 |
+
# intentionally only handles the same-type case, which was often
|
| 60 |
+
# used in older code bases before dataclasses/attrs were available.
|
| 61 |
+
yield from _compare_eq_cls(
|
| 62 |
+
left,
|
| 63 |
+
right,
|
| 64 |
+
highlighter,
|
| 65 |
+
verbose,
|
| 66 |
+
assertion_text_diff_style,
|
| 67 |
+
)
|
| 68 |
+
elif issequence(left) and issequence(right):
|
| 69 |
+
yield from _compare_eq_sequence(left, right, highlighter, verbose)
|
| 70 |
+
elif isset(left) and isset(right):
|
| 71 |
+
yield from _compare_eq_set(left, right, highlighter, verbose)
|
| 72 |
+
elif ismapping(left) and ismapping(right):
|
| 73 |
+
yield from _compare_eq_mapping(left, right, highlighter, verbose)
|
| 74 |
+
|
| 75 |
+
if isiterable(left) and isiterable(right):
|
| 76 |
+
yield from _compare_eq_iterable(left, right, highlighter, verbose)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _compare_eq_cls(
|
| 80 |
+
left: object,
|
| 81 |
+
right: object,
|
| 82 |
+
highlighter: _HighlightFunc,
|
| 83 |
+
verbose: int,
|
| 84 |
+
assertion_text_diff_style: _AssertionTextDiffStyle,
|
| 85 |
+
) -> Iterator[str]:
|
| 86 |
+
if not has_default_eq(left):
|
| 87 |
+
return
|
| 88 |
+
if isdatacls(left):
|
| 89 |
+
all_fields = dataclasses.fields(left)
|
| 90 |
+
fields_to_check = [info.name for info in all_fields if info.compare]
|
| 91 |
+
elif isattrs(left):
|
| 92 |
+
all_fields = left.__attrs_attrs__ # type: ignore[attr-defined]
|
| 93 |
+
fields_to_check = [field.name for field in all_fields if getattr(field, "eq")]
|
| 94 |
+
elif isnamedtuple(left):
|
| 95 |
+
fields_to_check = left._fields # type: ignore[attr-defined]
|
| 96 |
+
else:
|
| 97 |
+
assert False
|
| 98 |
+
|
| 99 |
+
indent = " "
|
| 100 |
+
same = []
|
| 101 |
+
diff = []
|
| 102 |
+
for field in fields_to_check:
|
| 103 |
+
if getattr(left, field) == getattr(right, field):
|
| 104 |
+
same.append(field)
|
| 105 |
+
else:
|
| 106 |
+
diff.append(field)
|
| 107 |
+
|
| 108 |
+
if same or diff:
|
| 109 |
+
yield ""
|
| 110 |
+
if same and verbose < 2:
|
| 111 |
+
yield f"Omitting {len(same)} identical items, use -vv to show"
|
| 112 |
+
elif same:
|
| 113 |
+
yield "Matching attributes:"
|
| 114 |
+
yield from highlighter(pprint.pformat(same)).splitlines()
|
| 115 |
+
if diff:
|
| 116 |
+
yield "Differing attributes:"
|
| 117 |
+
yield from highlighter(pprint.pformat(diff)).splitlines()
|
| 118 |
+
for field in diff:
|
| 119 |
+
field_left = getattr(left, field)
|
| 120 |
+
field_right = getattr(right, field)
|
| 121 |
+
yield ""
|
| 122 |
+
yield f"Drill down into differing attribute {field}:"
|
| 123 |
+
yield f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}"
|
| 124 |
+
for line in _compare_eq_any(
|
| 125 |
+
field_left,
|
| 126 |
+
field_right,
|
| 127 |
+
highlighter,
|
| 128 |
+
verbose,
|
| 129 |
+
assertion_text_diff_style,
|
| 130 |
+
):
|
| 131 |
+
yield indent + line
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_compare_mapping.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterator
|
| 4 |
+
from collections.abc import Mapping
|
| 5 |
+
import pprint
|
| 6 |
+
|
| 7 |
+
from _pytest._io.saferepr import saferepr
|
| 8 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _compare_eq_mapping(
|
| 12 |
+
left: Mapping[object, object],
|
| 13 |
+
right: Mapping[object, object],
|
| 14 |
+
highlighter: _HighlightFunc,
|
| 15 |
+
verbose: int = 0,
|
| 16 |
+
) -> Iterator[str]:
|
| 17 |
+
set_left = set(left)
|
| 18 |
+
set_right = set(right)
|
| 19 |
+
common = set_left.intersection(set_right)
|
| 20 |
+
same = {k: left[k] for k in common if left[k] == right[k]}
|
| 21 |
+
if same and verbose < 2:
|
| 22 |
+
yield f"Omitting {len(same)} identical items, use -vv to show"
|
| 23 |
+
elif same:
|
| 24 |
+
yield "Common items:"
|
| 25 |
+
yield from highlighter(pprint.pformat(same)).splitlines()
|
| 26 |
+
diff = {k for k in common if left[k] != right[k]}
|
| 27 |
+
if diff:
|
| 28 |
+
yield "Differing items:"
|
| 29 |
+
for k in diff:
|
| 30 |
+
yield (
|
| 31 |
+
highlighter(saferepr({k: left[k]}))
|
| 32 |
+
+ " != "
|
| 33 |
+
+ highlighter(saferepr({k: right[k]}))
|
| 34 |
+
)
|
| 35 |
+
extra_left = set_left - set_right
|
| 36 |
+
len_extra_left = len(extra_left)
|
| 37 |
+
if len_extra_left:
|
| 38 |
+
yield f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
|
| 39 |
+
yield from highlighter(
|
| 40 |
+
pprint.pformat({k: left[k] for k in extra_left})
|
| 41 |
+
).splitlines()
|
| 42 |
+
extra_right = set_right - set_left
|
| 43 |
+
len_extra_right = len(extra_right)
|
| 44 |
+
if len_extra_right:
|
| 45 |
+
yield f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
|
| 46 |
+
yield from highlighter(
|
| 47 |
+
pprint.pformat({k: right[k] for k in extra_right})
|
| 48 |
+
).splitlines()
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_compare_sequence.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from collections.abc import Iterator
|
| 5 |
+
from collections.abc import Sequence
|
| 6 |
+
|
| 7 |
+
from _pytest._io.pprint import PrettyPrinter
|
| 8 |
+
from _pytest._io.saferepr import saferepr
|
| 9 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 10 |
+
from _pytest.compat import running_on_ci
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _compare_eq_iterable(
|
| 14 |
+
left: Iterable[object],
|
| 15 |
+
right: Iterable[object],
|
| 16 |
+
highlighter: _HighlightFunc,
|
| 17 |
+
verbose: int = 0,
|
| 18 |
+
) -> Iterator[str]:
|
| 19 |
+
if verbose <= 0 and not running_on_ci():
|
| 20 |
+
yield "Use -v to get more diff"
|
| 21 |
+
return
|
| 22 |
+
# dynamic import to speedup pytest
|
| 23 |
+
import difflib
|
| 24 |
+
|
| 25 |
+
left_formatting = PrettyPrinter().pformat(left).splitlines()
|
| 26 |
+
right_formatting = PrettyPrinter().pformat(right).splitlines()
|
| 27 |
+
|
| 28 |
+
yield ""
|
| 29 |
+
yield "Full diff:"
|
| 30 |
+
# "right" is the expected base against which we compare "left",
|
| 31 |
+
# see https://github.com/pytest-dev/pytest/issues/3333
|
| 32 |
+
yield from highlighter(
|
| 33 |
+
"\n".join(
|
| 34 |
+
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
|
| 35 |
+
),
|
| 36 |
+
lexer="diff",
|
| 37 |
+
).splitlines()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _compare_eq_sequence(
|
| 41 |
+
left: Sequence[object],
|
| 42 |
+
right: Sequence[object],
|
| 43 |
+
highlighter: _HighlightFunc,
|
| 44 |
+
verbose: int = 0,
|
| 45 |
+
) -> Iterator[str]:
|
| 46 |
+
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
|
| 47 |
+
len_left = len(left)
|
| 48 |
+
len_right = len(right)
|
| 49 |
+
for i in range(min(len_left, len_right)):
|
| 50 |
+
if left[i] != right[i]:
|
| 51 |
+
if comparing_bytes:
|
| 52 |
+
# when comparing bytes, we want to see their ascii representation
|
| 53 |
+
# instead of their numeric values (#5260)
|
| 54 |
+
# using a slice gives us the ascii representation:
|
| 55 |
+
# >>> s = b'foo'
|
| 56 |
+
# >>> s[0]
|
| 57 |
+
# 102
|
| 58 |
+
# >>> s[0:1]
|
| 59 |
+
# b'f'
|
| 60 |
+
left_value: object = left[i : i + 1]
|
| 61 |
+
right_value: object = right[i : i + 1]
|
| 62 |
+
else:
|
| 63 |
+
left_value = left[i]
|
| 64 |
+
right_value = right[i]
|
| 65 |
+
|
| 66 |
+
yield (
|
| 67 |
+
f"At index {i} diff:"
|
| 68 |
+
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
|
| 69 |
+
)
|
| 70 |
+
break
|
| 71 |
+
|
| 72 |
+
if comparing_bytes:
|
| 73 |
+
# when comparing bytes, it doesn't help to show the "sides contain one or more
|
| 74 |
+
# items" longer explanation, so skip it
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
len_diff = len_left - len_right
|
| 78 |
+
if len_diff:
|
| 79 |
+
if len_diff > 0:
|
| 80 |
+
dir_with_more = "Left"
|
| 81 |
+
extra = saferepr(left[len_right])
|
| 82 |
+
else:
|
| 83 |
+
len_diff = 0 - len_diff
|
| 84 |
+
dir_with_more = "Right"
|
| 85 |
+
extra = saferepr(right[len_left])
|
| 86 |
+
|
| 87 |
+
if len_diff == 1:
|
| 88 |
+
yield f"{dir_with_more} contains one more item: {highlighter(extra)}"
|
| 89 |
+
else:
|
| 90 |
+
yield f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_compare_set.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Callable
|
| 4 |
+
from collections.abc import Iterator
|
| 5 |
+
from collections.abc import Set as AbstractSet
|
| 6 |
+
from typing import TypeAlias
|
| 7 |
+
|
| 8 |
+
from _pytest._io.saferepr import saferepr
|
| 9 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _set_one_sided_diff(
|
| 13 |
+
posn: str,
|
| 14 |
+
set1: AbstractSet[object],
|
| 15 |
+
set2: AbstractSet[object],
|
| 16 |
+
highlighter: _HighlightFunc,
|
| 17 |
+
) -> Iterator[str]:
|
| 18 |
+
diff = set1 - set2
|
| 19 |
+
if diff:
|
| 20 |
+
yield f"Extra items in the {posn} set:"
|
| 21 |
+
for item in diff:
|
| 22 |
+
yield highlighter(saferepr(item))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _compare_eq_set(
|
| 26 |
+
left: AbstractSet[object],
|
| 27 |
+
right: AbstractSet[object],
|
| 28 |
+
highlighter: _HighlightFunc,
|
| 29 |
+
verbose: int = 0,
|
| 30 |
+
) -> Iterator[str]:
|
| 31 |
+
yield from _set_one_sided_diff("left", left, right, highlighter)
|
| 32 |
+
yield from _set_one_sided_diff("right", right, left, highlighter)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _compare_gte_set(
|
| 36 |
+
left: AbstractSet[object],
|
| 37 |
+
right: AbstractSet[object],
|
| 38 |
+
highlighter: _HighlightFunc,
|
| 39 |
+
verbose: int = 0,
|
| 40 |
+
) -> Iterator[str]:
|
| 41 |
+
yield from _set_one_sided_diff("right", right, left, highlighter)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _compare_lte_set(
|
| 45 |
+
left: AbstractSet[object],
|
| 46 |
+
right: AbstractSet[object],
|
| 47 |
+
highlighter: _HighlightFunc,
|
| 48 |
+
verbose: int = 0,
|
| 49 |
+
) -> Iterator[str]:
|
| 50 |
+
yield from _set_one_sided_diff("left", left, right, highlighter)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _compare_gt_set(
|
| 54 |
+
left: AbstractSet[object],
|
| 55 |
+
right: AbstractSet[object],
|
| 56 |
+
highlighter: _HighlightFunc,
|
| 57 |
+
verbose: int = 0,
|
| 58 |
+
) -> Iterator[str]:
|
| 59 |
+
if left == right:
|
| 60 |
+
yield "Both sets are equal"
|
| 61 |
+
else:
|
| 62 |
+
yield from _set_one_sided_diff("right", right, left, highlighter)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _compare_lt_set(
|
| 66 |
+
left: AbstractSet[object],
|
| 67 |
+
right: AbstractSet[object],
|
| 68 |
+
highlighter: _HighlightFunc,
|
| 69 |
+
verbose: int = 0,
|
| 70 |
+
) -> Iterator[str]:
|
| 71 |
+
if left == right:
|
| 72 |
+
yield "Both sets are equal"
|
| 73 |
+
else:
|
| 74 |
+
yield from _set_one_sided_diff("left", left, right, highlighter)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
SetComparisonFunction: TypeAlias = Callable[
|
| 78 |
+
[AbstractSet[object], AbstractSet[object], _HighlightFunc, int],
|
| 79 |
+
Iterator[str],
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _both_sets_are_equal(
|
| 84 |
+
left: AbstractSet[object],
|
| 85 |
+
right: AbstractSet[object],
|
| 86 |
+
highlighter: _HighlightFunc,
|
| 87 |
+
verbose: int = 0,
|
| 88 |
+
) -> Iterator[str]:
|
| 89 |
+
yield "Both sets are equal"
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = {
|
| 93 |
+
# == can't be done here without a prior refactor because there's an additional
|
| 94 |
+
# explanation for iterable in _compare_eq_any
|
| 95 |
+
# "==": _compare_eq_set,
|
| 96 |
+
"!=": _both_sets_are_equal,
|
| 97 |
+
">=": _compare_gte_set,
|
| 98 |
+
"<=": _compare_lte_set,
|
| 99 |
+
">": _compare_gt_set,
|
| 100 |
+
"<": _compare_lt_set,
|
| 101 |
+
}
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_guards.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import collections.abc
|
| 4 |
+
from collections.abc import Mapping
|
| 5 |
+
import dataclasses
|
| 6 |
+
from typing import TypeGuard
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def issequence(x: object) -> TypeGuard[collections.abc.Sequence[object]]:
|
| 10 |
+
return isinstance(x, collections.abc.Sequence) and not isinstance(x, str)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def istext(x: object) -> TypeGuard[str]:
|
| 14 |
+
return isinstance(x, str)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def ismapping(x: object) -> TypeGuard[Mapping[object, object]]:
|
| 18 |
+
return isinstance(x, Mapping)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def isset(x: object) -> TypeGuard[set[object] | frozenset[object]]:
|
| 22 |
+
return isinstance(x, set | frozenset)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def isnamedtuple(obj: object) -> bool:
|
| 26 |
+
return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
isdatacls = dataclasses.is_dataclass
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def isattrs(obj: object) -> bool:
|
| 33 |
+
return getattr(obj, "__attrs_attrs__", None) is not None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def isiterable(obj: object) -> TypeGuard[collections.abc.Iterable[object]]:
|
| 37 |
+
try:
|
| 38 |
+
iter(obj) # type: ignore[call-overload]
|
| 39 |
+
return not istext(obj)
|
| 40 |
+
except Exception:
|
| 41 |
+
return False
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def has_default_eq(obj: object) -> bool:
|
| 45 |
+
"""Check if an instance of an object contains the default eq
|
| 46 |
+
|
| 47 |
+
First, we check if the object's __eq__ attribute has __code__,
|
| 48 |
+
if so, we check the equally of the method code filename (__code__.co_filename)
|
| 49 |
+
to the default one generated by the dataclass and attr module
|
| 50 |
+
for dataclasses the default co_filename is <string>, for attrs class, the __eq__ should contain "attrs eq generated"
|
| 51 |
+
"""
|
| 52 |
+
# inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68
|
| 53 |
+
if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"):
|
| 54 |
+
code_filename = obj.__eq__.__code__.co_filename
|
| 55 |
+
|
| 56 |
+
if isattrs(obj):
|
| 57 |
+
return "attrs generated " in code_filename
|
| 58 |
+
|
| 59 |
+
return code_filename == "<string>" # data class
|
| 60 |
+
return True
|
.venv/lib/python3.14/site-packages/_pytest/assertion/_typing.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
from typing import Protocol
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
_AssertionTextDiffStyle = Literal["ndiff", "block"]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class _HighlightFunc(Protocol): # noqa: PYI046
|
| 11 |
+
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
|
| 12 |
+
"""Apply highlighting to the given source."""
|
.venv/lib/python3.14/site-packages/_pytest/assertion/compare_text.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterator
|
| 4 |
+
|
| 5 |
+
from _pytest._io.saferepr import saferepr
|
| 6 |
+
from _pytest.assertion._typing import _AssertionTextDiffStyle
|
| 7 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 8 |
+
from _pytest.assertion.highlight import dummy_highlighter
|
| 9 |
+
from _pytest.compat import assert_never
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _compare_eq_text(
|
| 13 |
+
left: str,
|
| 14 |
+
right: str,
|
| 15 |
+
highlighter: _HighlightFunc,
|
| 16 |
+
verbose: int,
|
| 17 |
+
assertion_text_diff_style: _AssertionTextDiffStyle,
|
| 18 |
+
) -> Iterator[str]:
|
| 19 |
+
match assertion_text_diff_style:
|
| 20 |
+
case "block":
|
| 21 |
+
yield from _diff_text_block(left, right)
|
| 22 |
+
case "ndiff":
|
| 23 |
+
yield from _diff_text(left, right, highlighter, verbose)
|
| 24 |
+
case unreachable:
|
| 25 |
+
assert_never(unreachable)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _diff_text_block(left: str, right: str) -> Iterator[str]:
|
| 29 |
+
yield "Left:"
|
| 30 |
+
yield from _format_text_block_lines(left)
|
| 31 |
+
yield ""
|
| 32 |
+
yield "Right:"
|
| 33 |
+
yield from _format_text_block_lines(right)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _format_text_block_lines(text: str) -> Iterator[str]:
|
| 37 |
+
for line in text.split("\n"):
|
| 38 |
+
yield f" {line}"
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _diff_text(
|
| 42 |
+
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
|
| 43 |
+
) -> Iterator[str]:
|
| 44 |
+
"""Yield the explanation for the diff between text.
|
| 45 |
+
|
| 46 |
+
Unless --verbose is used this will skip leading and trailing
|
| 47 |
+
characters which are identical to keep the diff minimal.
|
| 48 |
+
"""
|
| 49 |
+
from difflib import ndiff
|
| 50 |
+
|
| 51 |
+
if verbose < 1:
|
| 52 |
+
i = 0 # just in case left or right has zero length
|
| 53 |
+
for i in range(min(len(left), len(right))):
|
| 54 |
+
if left[i] != right[i]:
|
| 55 |
+
break
|
| 56 |
+
if i > 42:
|
| 57 |
+
i -= 10 # Provide some context
|
| 58 |
+
yield f"Skipping {i} identical leading characters in diff, use -v to show"
|
| 59 |
+
left = left[i:]
|
| 60 |
+
right = right[i:]
|
| 61 |
+
if len(left) == len(right):
|
| 62 |
+
for i in range(len(left)):
|
| 63 |
+
if left[-i] != right[-i]:
|
| 64 |
+
break
|
| 65 |
+
if i > 42:
|
| 66 |
+
i -= 10 # Provide some context
|
| 67 |
+
yield (
|
| 68 |
+
f"Skipping {i} identical trailing "
|
| 69 |
+
"characters in diff, use -v to show"
|
| 70 |
+
)
|
| 71 |
+
left = left[:-i]
|
| 72 |
+
right = right[:-i]
|
| 73 |
+
keepends = True
|
| 74 |
+
if left.isspace() or right.isspace():
|
| 75 |
+
left = repr(str(left))
|
| 76 |
+
right = repr(str(right))
|
| 77 |
+
yield "Strings contain only whitespace, escaping them using repr()"
|
| 78 |
+
# "right" is the expected base against which we compare "left",
|
| 79 |
+
# see https://github.com/pytest-dev/pytest/issues/3333
|
| 80 |
+
yield from highlighter(
|
| 81 |
+
"\n".join(
|
| 82 |
+
line.strip("\n")
|
| 83 |
+
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
|
| 84 |
+
),
|
| 85 |
+
lexer="diff",
|
| 86 |
+
).splitlines()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _notin_text(term: str, text: str, verbose: int = 0) -> Iterator[str]:
|
| 90 |
+
index = text.find(term)
|
| 91 |
+
head = text[:index]
|
| 92 |
+
tail = text[index + len(term) :]
|
| 93 |
+
correct_text = head + tail
|
| 94 |
+
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
|
| 95 |
+
yield f"{saferepr(term, maxsize=42)} is contained here:"
|
| 96 |
+
for line in diff:
|
| 97 |
+
if line.startswith("Skipping"):
|
| 98 |
+
continue
|
| 99 |
+
if line.startswith("- "):
|
| 100 |
+
continue
|
| 101 |
+
if line.startswith("+ "):
|
| 102 |
+
yield " " + line[2:]
|
| 103 |
+
else:
|
| 104 |
+
yield line
|
.venv/lib/python3.14/site-packages/_pytest/assertion/highlight.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str:
|
| 7 |
+
"""Dummy highlighter that returns the text unprocessed.
|
| 8 |
+
|
| 9 |
+
Needed for _notin_text, as the diff gets post-processed to only show the "+" part.
|
| 10 |
+
"""
|
| 11 |
+
return source
|
.venv/lib/python3.14/site-packages/_pytest/assertion/rewrite.py
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from collections.abc import Callable
|
| 8 |
+
from collections.abc import Iterable
|
| 9 |
+
from collections.abc import Iterator
|
| 10 |
+
from collections.abc import Sequence
|
| 11 |
+
import errno
|
| 12 |
+
import functools
|
| 13 |
+
import importlib.abc
|
| 14 |
+
import importlib.machinery
|
| 15 |
+
import importlib.util
|
| 16 |
+
import io
|
| 17 |
+
import itertools
|
| 18 |
+
import marshal
|
| 19 |
+
import os
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from pathlib import PurePath
|
| 22 |
+
import struct
|
| 23 |
+
import sys
|
| 24 |
+
import tokenize
|
| 25 |
+
import types
|
| 26 |
+
from typing import IO
|
| 27 |
+
from typing import TYPE_CHECKING
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
if sys.version_info >= (3, 12):
|
| 31 |
+
from importlib.resources.abc import TraversableResources
|
| 32 |
+
else:
|
| 33 |
+
from importlib.abc import TraversableResources
|
| 34 |
+
if sys.version_info < (3, 11):
|
| 35 |
+
from importlib.readers import FileReader
|
| 36 |
+
else:
|
| 37 |
+
from importlib.resources.readers import FileReader
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
from _pytest._io.saferepr import DEFAULT_REPR_MAX_SIZE
|
| 41 |
+
from _pytest._io.saferepr import saferepr
|
| 42 |
+
from _pytest._io.saferepr import saferepr_unlimited
|
| 43 |
+
from _pytest._version import version
|
| 44 |
+
from _pytest.assertion import util
|
| 45 |
+
from _pytest.config import Config
|
| 46 |
+
from _pytest.fixtures import FixtureFunctionDefinition
|
| 47 |
+
from _pytest.main import Session
|
| 48 |
+
from _pytest.pathlib import absolutepath
|
| 49 |
+
from _pytest.pathlib import fnmatch_ex
|
| 50 |
+
from _pytest.stash import StashKey
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# fmt: off
|
| 54 |
+
from _pytest.assertion.util import format_explanation as _format_explanation # noqa:F401, isort:skip
|
| 55 |
+
# fmt:on
|
| 56 |
+
|
| 57 |
+
if TYPE_CHECKING:
|
| 58 |
+
from _pytest.assertion import AssertionState
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class Sentinel:
|
| 62 |
+
pass
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
assertstate_key = StashKey["AssertionState"]()
|
| 66 |
+
|
| 67 |
+
# pytest caches rewritten pycs in pycache dirs
|
| 68 |
+
PYTEST_TAG = f"{sys.implementation.cache_tag}-pytest-{version}"
|
| 69 |
+
PYC_EXT = ".py" + ((__debug__ and "c") or "o")
|
| 70 |
+
PYC_TAIL = "." + PYTEST_TAG + PYC_EXT
|
| 71 |
+
|
| 72 |
+
# Special marker that denotes we have just left a scope definition
|
| 73 |
+
_SCOPE_END_MARKER = Sentinel()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class AssertionRewritingHook(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
| 77 |
+
"""PEP302/PEP451 import hook which rewrites asserts."""
|
| 78 |
+
|
| 79 |
+
def __init__(self, config: Config) -> None:
|
| 80 |
+
self.config = config
|
| 81 |
+
try:
|
| 82 |
+
self.fnpats = config.getini("python_files")
|
| 83 |
+
except ValueError:
|
| 84 |
+
self.fnpats = ["test_*.py", "*_test.py"]
|
| 85 |
+
self.session: Session | None = None
|
| 86 |
+
self._rewritten_names: dict[str, Path] = {}
|
| 87 |
+
self._must_rewrite: set[str] = set()
|
| 88 |
+
# flag to guard against trying to rewrite a pyc file while we are already writing another pyc file,
|
| 89 |
+
# which might result in infinite recursion (#3506)
|
| 90 |
+
self._writing_pyc = False
|
| 91 |
+
self._basenames_to_check_rewrite = {"conftest"}
|
| 92 |
+
self._marked_for_rewrite_cache: dict[str, bool] = {}
|
| 93 |
+
self._session_paths_checked = False
|
| 94 |
+
|
| 95 |
+
def set_session(self, session: Session | None) -> None:
|
| 96 |
+
self.session = session
|
| 97 |
+
self._session_paths_checked = False
|
| 98 |
+
|
| 99 |
+
# Indirection so we can mock calls to find_spec originated from the hook during testing
|
| 100 |
+
_find_spec = importlib.machinery.PathFinder.find_spec
|
| 101 |
+
|
| 102 |
+
def find_spec(
|
| 103 |
+
self,
|
| 104 |
+
name: str,
|
| 105 |
+
path: Sequence[str | bytes] | None = None,
|
| 106 |
+
target: types.ModuleType | None = None,
|
| 107 |
+
) -> importlib.machinery.ModuleSpec | None:
|
| 108 |
+
if self._writing_pyc:
|
| 109 |
+
return None
|
| 110 |
+
state = self.config.stash[assertstate_key]
|
| 111 |
+
if self._early_rewrite_bailout(name, state):
|
| 112 |
+
return None
|
| 113 |
+
state.trace(f"find_module called for: {name}")
|
| 114 |
+
|
| 115 |
+
# Type ignored because mypy is confused about the `self` binding here.
|
| 116 |
+
spec = self._find_spec(name, path) # type: ignore
|
| 117 |
+
|
| 118 |
+
if (
|
| 119 |
+
# the import machinery could not find a file to import
|
| 120 |
+
spec is None
|
| 121 |
+
# this is a namespace package (without `__init__.py`)
|
| 122 |
+
# there's nothing to rewrite there
|
| 123 |
+
or spec.origin is None
|
| 124 |
+
# we can only rewrite source files
|
| 125 |
+
or not isinstance(spec.loader, importlib.machinery.SourceFileLoader)
|
| 126 |
+
# if the file doesn't exist, we can't rewrite it
|
| 127 |
+
or not os.path.exists(spec.origin)
|
| 128 |
+
):
|
| 129 |
+
return None
|
| 130 |
+
else:
|
| 131 |
+
fn = spec.origin
|
| 132 |
+
|
| 133 |
+
if not self._should_rewrite(name, fn, state):
|
| 134 |
+
return None
|
| 135 |
+
|
| 136 |
+
return importlib.util.spec_from_file_location(
|
| 137 |
+
name,
|
| 138 |
+
fn,
|
| 139 |
+
loader=self,
|
| 140 |
+
submodule_search_locations=spec.submodule_search_locations,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
def create_module(
|
| 144 |
+
self, spec: importlib.machinery.ModuleSpec
|
| 145 |
+
) -> types.ModuleType | None:
|
| 146 |
+
return None # default behaviour is fine
|
| 147 |
+
|
| 148 |
+
def exec_module(self, module: types.ModuleType) -> None:
|
| 149 |
+
assert module.__spec__ is not None
|
| 150 |
+
assert module.__spec__.origin is not None
|
| 151 |
+
fn = Path(module.__spec__.origin)
|
| 152 |
+
state = self.config.stash[assertstate_key]
|
| 153 |
+
|
| 154 |
+
self._rewritten_names[module.__name__] = fn
|
| 155 |
+
|
| 156 |
+
# The requested module looks like a test file, so rewrite it. This is
|
| 157 |
+
# the most magical part of the process: load the source, rewrite the
|
| 158 |
+
# asserts, and load the rewritten source. We also cache the rewritten
|
| 159 |
+
# module code in a special pyc. We must be aware of the possibility of
|
| 160 |
+
# concurrent pytest processes rewriting and loading pycs. To avoid
|
| 161 |
+
# tricky race conditions, we maintain the following invariant: The
|
| 162 |
+
# cached pyc is always a complete, valid pyc. Operations on it must be
|
| 163 |
+
# atomic. POSIX's atomic rename comes in handy.
|
| 164 |
+
write = not sys.dont_write_bytecode
|
| 165 |
+
cache_dir = get_cache_dir(fn)
|
| 166 |
+
if write:
|
| 167 |
+
ok = try_makedirs(cache_dir)
|
| 168 |
+
if not ok:
|
| 169 |
+
write = False
|
| 170 |
+
state.trace(f"read only directory: {cache_dir}")
|
| 171 |
+
|
| 172 |
+
cache_name = fn.name[:-3] + PYC_TAIL
|
| 173 |
+
pyc = cache_dir / cache_name
|
| 174 |
+
# Notice that even if we're in a read-only directory, I'm going
|
| 175 |
+
# to check for a cached pyc. This may not be optimal...
|
| 176 |
+
co = _read_pyc(fn, pyc, state.trace)
|
| 177 |
+
if co is None:
|
| 178 |
+
state.trace(f"rewriting {fn!r}")
|
| 179 |
+
source_stat, co = _rewrite_test(fn, self.config)
|
| 180 |
+
if write:
|
| 181 |
+
self._writing_pyc = True
|
| 182 |
+
try:
|
| 183 |
+
_write_pyc(state, co, source_stat, pyc)
|
| 184 |
+
finally:
|
| 185 |
+
self._writing_pyc = False
|
| 186 |
+
else:
|
| 187 |
+
state.trace(f"found cached rewritten pyc for {fn}")
|
| 188 |
+
exec(co, module.__dict__)
|
| 189 |
+
|
| 190 |
+
def _early_rewrite_bailout(self, name: str, state: AssertionState) -> bool:
|
| 191 |
+
"""A fast way to get out of rewriting modules.
|
| 192 |
+
|
| 193 |
+
Profiling has shown that the call to PathFinder.find_spec (inside of
|
| 194 |
+
the find_spec from this class) is a major slowdown, so, this method
|
| 195 |
+
tries to filter what we're sure won't be rewritten before getting to
|
| 196 |
+
it.
|
| 197 |
+
"""
|
| 198 |
+
if self.session is not None and not self._session_paths_checked:
|
| 199 |
+
self._session_paths_checked = True
|
| 200 |
+
for initial_path in self.session._initialpaths:
|
| 201 |
+
# Make something as c:/projects/my_project/path.py ->
|
| 202 |
+
# ['c:', 'projects', 'my_project', 'path.py']
|
| 203 |
+
parts = str(initial_path).split(os.sep)
|
| 204 |
+
# add 'path' to basenames to be checked.
|
| 205 |
+
self._basenames_to_check_rewrite.add(os.path.splitext(parts[-1])[0])
|
| 206 |
+
|
| 207 |
+
# Note: conftest already by default in _basenames_to_check_rewrite.
|
| 208 |
+
parts = name.split(".")
|
| 209 |
+
if parts[-1] in self._basenames_to_check_rewrite:
|
| 210 |
+
return False
|
| 211 |
+
|
| 212 |
+
# For matching the name it must be as if it was a filename.
|
| 213 |
+
path = PurePath(*parts).with_suffix(".py")
|
| 214 |
+
|
| 215 |
+
for pat in self.fnpats:
|
| 216 |
+
# if the pattern contains subdirectories ("tests/**.py" for example) we can't bail out based
|
| 217 |
+
# on the name alone because we need to match against the full path
|
| 218 |
+
if os.path.dirname(pat):
|
| 219 |
+
return False
|
| 220 |
+
if fnmatch_ex(pat, path):
|
| 221 |
+
return False
|
| 222 |
+
|
| 223 |
+
if self._is_marked_for_rewrite(name, state):
|
| 224 |
+
return False
|
| 225 |
+
|
| 226 |
+
state.trace(f"early skip of rewriting module: {name}")
|
| 227 |
+
return True
|
| 228 |
+
|
| 229 |
+
def _should_rewrite(self, name: str, fn: str, state: AssertionState) -> bool:
|
| 230 |
+
# always rewrite conftest files
|
| 231 |
+
if os.path.basename(fn) == "conftest.py":
|
| 232 |
+
state.trace(f"rewriting conftest file: {fn!r}")
|
| 233 |
+
return True
|
| 234 |
+
|
| 235 |
+
if self.session is not None:
|
| 236 |
+
if self.session.isinitpath(absolutepath(fn)):
|
| 237 |
+
state.trace(f"matched test file (was specified on cmdline): {fn!r}")
|
| 238 |
+
return True
|
| 239 |
+
|
| 240 |
+
# modules not passed explicitly on the command line are only
|
| 241 |
+
# rewritten if they match the naming convention for test files
|
| 242 |
+
fn_path = PurePath(fn)
|
| 243 |
+
for pat in self.fnpats:
|
| 244 |
+
if fnmatch_ex(pat, fn_path):
|
| 245 |
+
state.trace(f"matched test file {fn!r}")
|
| 246 |
+
return True
|
| 247 |
+
|
| 248 |
+
return self._is_marked_for_rewrite(name, state)
|
| 249 |
+
|
| 250 |
+
def _is_marked_for_rewrite(self, name: str, state: AssertionState) -> bool:
|
| 251 |
+
try:
|
| 252 |
+
return self._marked_for_rewrite_cache[name]
|
| 253 |
+
except KeyError:
|
| 254 |
+
for marked in self._must_rewrite:
|
| 255 |
+
if name == marked or name.startswith(marked + "."):
|
| 256 |
+
state.trace(f"matched marked file {name!r} (from {marked!r})")
|
| 257 |
+
self._marked_for_rewrite_cache[name] = True
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
self._marked_for_rewrite_cache[name] = False
|
| 261 |
+
return False
|
| 262 |
+
|
| 263 |
+
def mark_rewrite(self, *names: str) -> None:
|
| 264 |
+
"""Mark import names as needing to be rewritten.
|
| 265 |
+
|
| 266 |
+
The named module or package as well as any nested modules will
|
| 267 |
+
be rewritten on import.
|
| 268 |
+
"""
|
| 269 |
+
already_imported = (
|
| 270 |
+
set(names).intersection(sys.modules).difference(self._rewritten_names)
|
| 271 |
+
)
|
| 272 |
+
for name in already_imported:
|
| 273 |
+
mod = sys.modules[name]
|
| 274 |
+
if not AssertionRewriter.is_rewrite_disabled(
|
| 275 |
+
mod.__doc__ or ""
|
| 276 |
+
) and not isinstance(mod.__loader__, type(self)):
|
| 277 |
+
self._warn_already_imported(name)
|
| 278 |
+
self._must_rewrite.update(names)
|
| 279 |
+
self._marked_for_rewrite_cache.clear()
|
| 280 |
+
|
| 281 |
+
def _warn_already_imported(self, name: str) -> None:
|
| 282 |
+
from _pytest.warning_types import PytestAssertRewriteWarning
|
| 283 |
+
|
| 284 |
+
self.config.issue_config_time_warning(
|
| 285 |
+
PytestAssertRewriteWarning(
|
| 286 |
+
f"Module already imported so cannot be rewritten; {name}"
|
| 287 |
+
),
|
| 288 |
+
stacklevel=5,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
def get_data(self, pathname: str | bytes) -> bytes:
|
| 292 |
+
"""Optional PEP302 get_data API."""
|
| 293 |
+
with open(pathname, "rb") as f:
|
| 294 |
+
return f.read()
|
| 295 |
+
|
| 296 |
+
def get_resource_reader(self, name: str) -> TraversableResources:
|
| 297 |
+
return FileReader(types.SimpleNamespace(path=self._rewritten_names[name])) # type: ignore[arg-type]
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _write_pyc_fp(
|
| 301 |
+
fp: IO[bytes], source_stat: os.stat_result, co: types.CodeType
|
| 302 |
+
) -> None:
|
| 303 |
+
# Technically, we don't have to have the same pyc format as
|
| 304 |
+
# (C)Python, since these "pycs" should never be seen by builtin
|
| 305 |
+
# import. However, there's little reason to deviate.
|
| 306 |
+
fp.write(importlib.util.MAGIC_NUMBER)
|
| 307 |
+
# https://www.python.org/dev/peps/pep-0552/
|
| 308 |
+
flags = b"\x00\x00\x00\x00"
|
| 309 |
+
fp.write(flags)
|
| 310 |
+
# as of now, bytecode header expects 32-bit numbers for size and mtime (#4903)
|
| 311 |
+
mtime = int(source_stat.st_mtime) & 0xFFFFFFFF
|
| 312 |
+
size = source_stat.st_size & 0xFFFFFFFF
|
| 313 |
+
# "<LL" stands for 2 unsigned longs, little-endian.
|
| 314 |
+
fp.write(struct.pack("<LL", mtime, size))
|
| 315 |
+
fp.write(marshal.dumps(co))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _write_pyc(
|
| 319 |
+
state: AssertionState,
|
| 320 |
+
co: types.CodeType,
|
| 321 |
+
source_stat: os.stat_result,
|
| 322 |
+
pyc: Path,
|
| 323 |
+
) -> bool:
|
| 324 |
+
proc_pyc = f"{pyc}.{os.getpid()}"
|
| 325 |
+
try:
|
| 326 |
+
with open(proc_pyc, "wb") as fp:
|
| 327 |
+
_write_pyc_fp(fp, source_stat, co)
|
| 328 |
+
except OSError as e:
|
| 329 |
+
state.trace(f"error writing pyc file at {proc_pyc}: errno={e.errno}")
|
| 330 |
+
return False
|
| 331 |
+
|
| 332 |
+
try:
|
| 333 |
+
os.replace(proc_pyc, pyc)
|
| 334 |
+
except OSError as e:
|
| 335 |
+
state.trace(f"error writing pyc file at {pyc}: {e}")
|
| 336 |
+
# we ignore any failure to write the cache file
|
| 337 |
+
# there are many reasons, permission-denied, pycache dir being a
|
| 338 |
+
# file etc.
|
| 339 |
+
return False
|
| 340 |
+
return True
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def _rewrite_test(fn: Path, config: Config) -> tuple[os.stat_result, types.CodeType]:
|
| 344 |
+
"""Read and rewrite *fn* and return the code object."""
|
| 345 |
+
stat = os.stat(fn)
|
| 346 |
+
source = fn.read_bytes()
|
| 347 |
+
strfn = str(fn)
|
| 348 |
+
tree = ast.parse(source, filename=strfn)
|
| 349 |
+
rewrite_asserts(tree, source, strfn, config)
|
| 350 |
+
co = compile(tree, strfn, "exec", dont_inherit=True)
|
| 351 |
+
return stat, co
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _read_pyc(
|
| 355 |
+
source: Path, pyc: Path, trace: Callable[[str], None] = lambda x: None
|
| 356 |
+
) -> types.CodeType | None:
|
| 357 |
+
"""Possibly read a pytest pyc containing rewritten code.
|
| 358 |
+
|
| 359 |
+
Return rewritten code if successful or None if not.
|
| 360 |
+
"""
|
| 361 |
+
try:
|
| 362 |
+
fp = open(pyc, "rb")
|
| 363 |
+
except OSError:
|
| 364 |
+
return None
|
| 365 |
+
with fp:
|
| 366 |
+
try:
|
| 367 |
+
stat_result = os.stat(source)
|
| 368 |
+
mtime = int(stat_result.st_mtime)
|
| 369 |
+
size = stat_result.st_size
|
| 370 |
+
data = fp.read(16)
|
| 371 |
+
except OSError as e:
|
| 372 |
+
trace(f"_read_pyc({source}): OSError {e}")
|
| 373 |
+
return None
|
| 374 |
+
# Check for invalid or out of date pyc file.
|
| 375 |
+
if len(data) != (16):
|
| 376 |
+
trace(f"_read_pyc({source}): invalid pyc (too short)")
|
| 377 |
+
return None
|
| 378 |
+
if data[:4] != importlib.util.MAGIC_NUMBER:
|
| 379 |
+
trace(f"_read_pyc({source}): invalid pyc (bad magic number)")
|
| 380 |
+
return None
|
| 381 |
+
if data[4:8] != b"\x00\x00\x00\x00":
|
| 382 |
+
trace(f"_read_pyc({source}): invalid pyc (unsupported flags)")
|
| 383 |
+
return None
|
| 384 |
+
mtime_data = data[8:12]
|
| 385 |
+
if int.from_bytes(mtime_data, "little") != mtime & 0xFFFFFFFF:
|
| 386 |
+
trace(f"_read_pyc({source}): out of date")
|
| 387 |
+
return None
|
| 388 |
+
size_data = data[12:16]
|
| 389 |
+
if int.from_bytes(size_data, "little") != size & 0xFFFFFFFF:
|
| 390 |
+
trace(f"_read_pyc({source}): invalid pyc (incorrect size)")
|
| 391 |
+
return None
|
| 392 |
+
try:
|
| 393 |
+
co = marshal.load(fp)
|
| 394 |
+
except Exception as e:
|
| 395 |
+
trace(f"_read_pyc({source}): marshal.load error {e}")
|
| 396 |
+
return None
|
| 397 |
+
if not isinstance(co, types.CodeType):
|
| 398 |
+
trace(f"_read_pyc({source}): not a code object")
|
| 399 |
+
return None
|
| 400 |
+
return co
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def rewrite_asserts(
|
| 404 |
+
mod: ast.Module,
|
| 405 |
+
source: bytes,
|
| 406 |
+
module_path: str | None = None,
|
| 407 |
+
config: Config | None = None,
|
| 408 |
+
) -> None:
|
| 409 |
+
"""Rewrite the assert statements in mod."""
|
| 410 |
+
AssertionRewriter(module_path, config, source).run(mod)
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _saferepr(obj: object) -> str:
|
| 414 |
+
r"""Get a safe repr of an object for assertion error messages.
|
| 415 |
+
|
| 416 |
+
The assertion formatting (util.format_explanation()) requires
|
| 417 |
+
newlines to be escaped since they are a special character for it.
|
| 418 |
+
Normally assertion.util.format_explanation() does this but for a
|
| 419 |
+
custom repr it is possible to contain one of the special escape
|
| 420 |
+
sequences, especially '\n{' and '\n}' are likely to be present in
|
| 421 |
+
JSON reprs.
|
| 422 |
+
"""
|
| 423 |
+
if isinstance(obj, types.MethodType):
|
| 424 |
+
# for bound methods, skip redundant <bound method ...> information
|
| 425 |
+
return obj.__name__
|
| 426 |
+
|
| 427 |
+
maxsize = _get_maxsize_for_saferepr(util._config)
|
| 428 |
+
if not maxsize:
|
| 429 |
+
return saferepr_unlimited(obj).replace("\n", "\\n")
|
| 430 |
+
return saferepr(obj, maxsize=maxsize).replace("\n", "\\n")
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
def _get_maxsize_for_saferepr(config: Config | None) -> int | None:
|
| 434 |
+
"""Get `maxsize` configuration for saferepr based on the given config object."""
|
| 435 |
+
if config is None:
|
| 436 |
+
verbosity = 0
|
| 437 |
+
else:
|
| 438 |
+
verbosity = config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
|
| 439 |
+
if verbosity >= 2:
|
| 440 |
+
return None
|
| 441 |
+
if verbosity >= 1:
|
| 442 |
+
return DEFAULT_REPR_MAX_SIZE * 10
|
| 443 |
+
return DEFAULT_REPR_MAX_SIZE
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _format_assertmsg(obj: object) -> str:
|
| 447 |
+
r"""Format the custom assertion message given.
|
| 448 |
+
|
| 449 |
+
For strings this simply replaces newlines with '\n~' so that
|
| 450 |
+
util.format_explanation() will preserve them instead of escaping
|
| 451 |
+
newlines. For other objects saferepr() is used first.
|
| 452 |
+
"""
|
| 453 |
+
# reprlib appears to have a bug which means that if a string
|
| 454 |
+
# contains a newline it gets escaped, however if an object has a
|
| 455 |
+
# .__repr__() which contains newlines it does not get escaped.
|
| 456 |
+
# However in either case we want to preserve the newline.
|
| 457 |
+
replaces = [("\n", "\n~"), ("%", "%%")]
|
| 458 |
+
if not isinstance(obj, str):
|
| 459 |
+
obj = saferepr(obj, _get_maxsize_for_saferepr(util._config))
|
| 460 |
+
replaces.append(("\\n", "\n~"))
|
| 461 |
+
|
| 462 |
+
for r1, r2 in replaces:
|
| 463 |
+
obj = obj.replace(r1, r2)
|
| 464 |
+
|
| 465 |
+
return obj
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def _should_repr_global_name(obj: object) -> bool:
|
| 469 |
+
if callable(obj):
|
| 470 |
+
# For pytest fixtures the __repr__ method provides more information than the function name.
|
| 471 |
+
return isinstance(obj, FixtureFunctionDefinition)
|
| 472 |
+
|
| 473 |
+
try:
|
| 474 |
+
return not hasattr(obj, "__name__")
|
| 475 |
+
except Exception:
|
| 476 |
+
return True
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def _format_boolop(explanations: Iterable[str], is_or: bool) -> str:
|
| 480 |
+
explanation = "(" + ((is_or and " or ") or " and ").join(explanations) + ")"
|
| 481 |
+
return explanation.replace("%", "%%")
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def _call_reprcompare(
|
| 485 |
+
ops: Sequence[str],
|
| 486 |
+
results: Sequence[bool],
|
| 487 |
+
expls: Sequence[str],
|
| 488 |
+
each_obj: Sequence[object],
|
| 489 |
+
) -> str:
|
| 490 |
+
for i, res, expl in zip(range(len(ops)), results, expls, strict=True):
|
| 491 |
+
try:
|
| 492 |
+
done = not res
|
| 493 |
+
except Exception:
|
| 494 |
+
done = True
|
| 495 |
+
if done:
|
| 496 |
+
break
|
| 497 |
+
if util._reprcompare is not None:
|
| 498 |
+
custom = util._reprcompare(ops[i], each_obj[i], each_obj[i + 1])
|
| 499 |
+
if custom is not None:
|
| 500 |
+
return custom
|
| 501 |
+
return expl
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def _call_assertion_pass(lineno: int, orig: str, expl: str) -> None:
|
| 505 |
+
if util._assertion_pass is not None:
|
| 506 |
+
util._assertion_pass(lineno, orig, expl)
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def _check_if_assertion_pass_impl() -> bool:
|
| 510 |
+
"""Check if any plugins implement the pytest_assertion_pass hook
|
| 511 |
+
in order not to generate explanation unnecessarily (might be expensive)."""
|
| 512 |
+
return True if util._assertion_pass else False
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
UNARY_MAP = {ast.Not: "not %s", ast.Invert: "~%s", ast.USub: "-%s", ast.UAdd: "+%s"}
|
| 516 |
+
|
| 517 |
+
BINOP_MAP = {
|
| 518 |
+
ast.BitOr: "|",
|
| 519 |
+
ast.BitXor: "^",
|
| 520 |
+
ast.BitAnd: "&",
|
| 521 |
+
ast.LShift: "<<",
|
| 522 |
+
ast.RShift: ">>",
|
| 523 |
+
ast.Add: "+",
|
| 524 |
+
ast.Sub: "-",
|
| 525 |
+
ast.Mult: "*",
|
| 526 |
+
ast.Div: "/",
|
| 527 |
+
ast.FloorDiv: "//",
|
| 528 |
+
ast.Mod: "%%", # escaped for string formatting
|
| 529 |
+
ast.Eq: "==",
|
| 530 |
+
ast.NotEq: "!=",
|
| 531 |
+
ast.Lt: "<",
|
| 532 |
+
ast.LtE: "<=",
|
| 533 |
+
ast.Gt: ">",
|
| 534 |
+
ast.GtE: ">=",
|
| 535 |
+
ast.Pow: "**",
|
| 536 |
+
ast.Is: "is",
|
| 537 |
+
ast.IsNot: "is not",
|
| 538 |
+
ast.In: "in",
|
| 539 |
+
ast.NotIn: "not in",
|
| 540 |
+
ast.MatMult: "@",
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def traverse_node(node: ast.AST) -> Iterator[ast.AST]:
|
| 545 |
+
"""Recursively yield node and all its children in depth-first order."""
|
| 546 |
+
yield node
|
| 547 |
+
for child in ast.iter_child_nodes(node):
|
| 548 |
+
yield from traverse_node(child)
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
@functools.lru_cache(maxsize=1)
|
| 552 |
+
def _get_assertion_exprs(src: bytes) -> dict[int, str]:
|
| 553 |
+
"""Return a mapping from {lineno: "assertion test expression"}."""
|
| 554 |
+
ret: dict[int, str] = {}
|
| 555 |
+
|
| 556 |
+
depth = 0
|
| 557 |
+
lines: list[str] = []
|
| 558 |
+
assert_lineno: int | None = None
|
| 559 |
+
seen_lines: set[int] = set()
|
| 560 |
+
|
| 561 |
+
def _write_and_reset() -> None:
|
| 562 |
+
nonlocal depth, lines, assert_lineno, seen_lines
|
| 563 |
+
assert assert_lineno is not None
|
| 564 |
+
ret[assert_lineno] = "".join(lines).rstrip().rstrip("\\")
|
| 565 |
+
depth = 0
|
| 566 |
+
lines = []
|
| 567 |
+
assert_lineno = None
|
| 568 |
+
seen_lines = set()
|
| 569 |
+
|
| 570 |
+
tokens = tokenize.tokenize(io.BytesIO(src).readline)
|
| 571 |
+
for tp, source, (lineno, offset), _, line in tokens:
|
| 572 |
+
if tp == tokenize.NAME and source == "assert":
|
| 573 |
+
assert_lineno = lineno
|
| 574 |
+
elif assert_lineno is not None:
|
| 575 |
+
# keep track of depth for the assert-message `,` lookup
|
| 576 |
+
if tp == tokenize.OP and source in "([{":
|
| 577 |
+
depth += 1
|
| 578 |
+
elif tp == tokenize.OP and source in ")]}":
|
| 579 |
+
depth -= 1
|
| 580 |
+
|
| 581 |
+
if not lines:
|
| 582 |
+
lines.append(line[offset:])
|
| 583 |
+
seen_lines.add(lineno)
|
| 584 |
+
# a non-nested comma separates the expression from the message
|
| 585 |
+
elif depth == 0 and tp == tokenize.OP and source == ",":
|
| 586 |
+
# one line assert with message
|
| 587 |
+
if lineno in seen_lines and len(lines) == 1:
|
| 588 |
+
offset_in_trimmed = offset + len(lines[-1]) - len(line)
|
| 589 |
+
lines[-1] = lines[-1][:offset_in_trimmed]
|
| 590 |
+
# multi-line assert with message
|
| 591 |
+
elif lineno in seen_lines:
|
| 592 |
+
lines[-1] = lines[-1][:offset]
|
| 593 |
+
# multi line assert with escaped newline before message
|
| 594 |
+
else:
|
| 595 |
+
lines.append(line[:offset])
|
| 596 |
+
_write_and_reset()
|
| 597 |
+
elif tp in {tokenize.NEWLINE, tokenize.ENDMARKER}:
|
| 598 |
+
_write_and_reset()
|
| 599 |
+
elif lines and lineno not in seen_lines:
|
| 600 |
+
lines.append(line)
|
| 601 |
+
seen_lines.add(lineno)
|
| 602 |
+
|
| 603 |
+
return ret
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
class AssertionRewriter(ast.NodeVisitor):
|
| 607 |
+
"""Assertion rewriting implementation.
|
| 608 |
+
|
| 609 |
+
The main entrypoint is to call .run() with an ast.Module instance,
|
| 610 |
+
this will then find all the assert statements and rewrite them to
|
| 611 |
+
provide intermediate values and a detailed assertion error. See
|
| 612 |
+
http://pybites.blogspot.be/2011/07/behind-scenes-of-pytests-new-assertion.html
|
| 613 |
+
for an overview of how this works.
|
| 614 |
+
|
| 615 |
+
The entry point here is .run() which will iterate over all the
|
| 616 |
+
statements in an ast.Module and for each ast.Assert statement it
|
| 617 |
+
finds call .visit() with it. Then .visit_Assert() takes over and
|
| 618 |
+
is responsible for creating new ast statements to replace the
|
| 619 |
+
original assert statement: it rewrites the test of an assertion
|
| 620 |
+
to provide intermediate values and replace it with an if statement
|
| 621 |
+
which raises an assertion error with a detailed explanation in
|
| 622 |
+
case the expression is false and calls pytest_assertion_pass hook
|
| 623 |
+
if expression is true.
|
| 624 |
+
|
| 625 |
+
For this .visit_Assert() uses the visitor pattern to visit all the
|
| 626 |
+
AST nodes of the ast.Assert.test field, each visit call returning
|
| 627 |
+
an AST node and the corresponding explanation string. During this
|
| 628 |
+
state is kept in several instance attributes:
|
| 629 |
+
|
| 630 |
+
:statements: All the AST statements which will replace the assert
|
| 631 |
+
statement.
|
| 632 |
+
|
| 633 |
+
:variables: This is populated by .variable() with each variable
|
| 634 |
+
used by the statements so that they can all be set to None at
|
| 635 |
+
the end of the statements.
|
| 636 |
+
|
| 637 |
+
:variable_counter: Counter to create new unique variables needed
|
| 638 |
+
by statements. Variables are created using .variable() and
|
| 639 |
+
have the form of "@py_assert0".
|
| 640 |
+
|
| 641 |
+
:expl_stmts: The AST statements which will be executed to get
|
| 642 |
+
data from the assertion. This is the code which will construct
|
| 643 |
+
the detailed assertion message that is used in the AssertionError
|
| 644 |
+
or for the pytest_assertion_pass hook.
|
| 645 |
+
|
| 646 |
+
:explanation_specifiers: A dict filled by .explanation_param()
|
| 647 |
+
with %-formatting placeholders and their corresponding
|
| 648 |
+
expressions to use in the building of an assertion message.
|
| 649 |
+
This is used by .pop_format_context() to build a message.
|
| 650 |
+
|
| 651 |
+
:stack: A stack of the explanation_specifiers dicts maintained by
|
| 652 |
+
.push_format_context() and .pop_format_context() which allows
|
| 653 |
+
to build another %-formatted string while already building one.
|
| 654 |
+
|
| 655 |
+
:scope: A tuple containing the current scope used for variables_overwrite.
|
| 656 |
+
|
| 657 |
+
:variables_overwrite: A dict filled with references to variables
|
| 658 |
+
that change value within an assert. This happens when a variable is
|
| 659 |
+
reassigned with the walrus operator
|
| 660 |
+
|
| 661 |
+
This state, except the variables_overwrite, is reset on every new assert
|
| 662 |
+
statement visited and used by the other visitors.
|
| 663 |
+
"""
|
| 664 |
+
|
| 665 |
+
def __init__(
|
| 666 |
+
self, module_path: str | None, config: Config | None, source: bytes
|
| 667 |
+
) -> None:
|
| 668 |
+
super().__init__()
|
| 669 |
+
self.module_path = module_path
|
| 670 |
+
self.config = config
|
| 671 |
+
if config is not None:
|
| 672 |
+
self.enable_assertion_pass_hook = config.getini(
|
| 673 |
+
"enable_assertion_pass_hook"
|
| 674 |
+
)
|
| 675 |
+
else:
|
| 676 |
+
self.enable_assertion_pass_hook = False
|
| 677 |
+
self.source = source
|
| 678 |
+
self.scope: tuple[ast.AST, ...] = ()
|
| 679 |
+
self.variables_overwrite: defaultdict[tuple[ast.AST, ...], dict[str, str]] = (
|
| 680 |
+
defaultdict(dict)
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
def run(self, mod: ast.Module) -> None:
|
| 684 |
+
"""Find all assert statements in *mod* and rewrite them."""
|
| 685 |
+
if not mod.body:
|
| 686 |
+
# Nothing to do.
|
| 687 |
+
return
|
| 688 |
+
|
| 689 |
+
# We'll insert some special imports at the top of the module, but after any
|
| 690 |
+
# docstrings and __future__ imports, so first figure out where that is.
|
| 691 |
+
doc = getattr(mod, "docstring", None)
|
| 692 |
+
expect_docstring = doc is None
|
| 693 |
+
if doc is not None and self.is_rewrite_disabled(doc):
|
| 694 |
+
return
|
| 695 |
+
pos = 0
|
| 696 |
+
for item in mod.body:
|
| 697 |
+
match item:
|
| 698 |
+
case ast.Expr(value=ast.Constant(value=str() as doc)) if (
|
| 699 |
+
expect_docstring
|
| 700 |
+
):
|
| 701 |
+
if self.is_rewrite_disabled(doc):
|
| 702 |
+
return
|
| 703 |
+
expect_docstring = False
|
| 704 |
+
case ast.ImportFrom(level=0, module="__future__"):
|
| 705 |
+
pass
|
| 706 |
+
case _:
|
| 707 |
+
break
|
| 708 |
+
pos += 1
|
| 709 |
+
# Special case: for a decorated function, set the lineno to that of the
|
| 710 |
+
# first decorator, not the `def`. Issue #4984.
|
| 711 |
+
if isinstance(item, ast.FunctionDef) and item.decorator_list:
|
| 712 |
+
lineno = item.decorator_list[0].lineno
|
| 713 |
+
else:
|
| 714 |
+
lineno = item.lineno
|
| 715 |
+
# Now actually insert the special imports.
|
| 716 |
+
aliases = [
|
| 717 |
+
ast.alias("builtins", "@py_builtins", lineno=lineno, col_offset=0),
|
| 718 |
+
ast.alias(
|
| 719 |
+
"_pytest.assertion.rewrite",
|
| 720 |
+
"@pytest_ar",
|
| 721 |
+
lineno=lineno,
|
| 722 |
+
col_offset=0,
|
| 723 |
+
),
|
| 724 |
+
]
|
| 725 |
+
imports = [
|
| 726 |
+
ast.Import([alias], lineno=lineno, col_offset=0) for alias in aliases
|
| 727 |
+
]
|
| 728 |
+
mod.body[pos:pos] = imports
|
| 729 |
+
|
| 730 |
+
# Collect asserts.
|
| 731 |
+
self.scope = (mod,)
|
| 732 |
+
nodes: list[ast.AST | Sentinel] = [mod]
|
| 733 |
+
while nodes:
|
| 734 |
+
node = nodes.pop()
|
| 735 |
+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
|
| 736 |
+
self.scope = tuple((*self.scope, node))
|
| 737 |
+
nodes.append(_SCOPE_END_MARKER)
|
| 738 |
+
if node == _SCOPE_END_MARKER:
|
| 739 |
+
self.scope = self.scope[:-1]
|
| 740 |
+
continue
|
| 741 |
+
assert isinstance(node, ast.AST)
|
| 742 |
+
for name, field in ast.iter_fields(node):
|
| 743 |
+
if isinstance(field, list):
|
| 744 |
+
new: list[ast.AST] = []
|
| 745 |
+
for i, child in enumerate(field):
|
| 746 |
+
if isinstance(child, ast.Assert):
|
| 747 |
+
# Transform assert.
|
| 748 |
+
new.extend(self.visit(child))
|
| 749 |
+
else:
|
| 750 |
+
new.append(child)
|
| 751 |
+
if isinstance(child, ast.AST):
|
| 752 |
+
nodes.append(child)
|
| 753 |
+
setattr(node, name, new)
|
| 754 |
+
elif (
|
| 755 |
+
isinstance(field, ast.AST)
|
| 756 |
+
# Don't recurse into expressions as they can't contain
|
| 757 |
+
# asserts.
|
| 758 |
+
and not isinstance(field, ast.expr)
|
| 759 |
+
):
|
| 760 |
+
nodes.append(field)
|
| 761 |
+
|
| 762 |
+
@staticmethod
|
| 763 |
+
def is_rewrite_disabled(docstring: str) -> bool:
|
| 764 |
+
return "PYTEST_DONT_REWRITE" in docstring
|
| 765 |
+
|
| 766 |
+
def variable(self) -> str:
|
| 767 |
+
"""Get a new variable."""
|
| 768 |
+
# Use a character invalid in python identifiers to avoid clashing.
|
| 769 |
+
name = "@py_assert" + str(next(self.variable_counter))
|
| 770 |
+
self.variables.append(name)
|
| 771 |
+
return name
|
| 772 |
+
|
| 773 |
+
def assign(self, expr: ast.expr) -> ast.Name:
|
| 774 |
+
"""Give *expr* a name."""
|
| 775 |
+
name = self.variable()
|
| 776 |
+
self.statements.append(ast.Assign([ast.Name(name, ast.Store())], expr))
|
| 777 |
+
return ast.copy_location(ast.Name(name, ast.Load()), expr)
|
| 778 |
+
|
| 779 |
+
def display(self, expr: ast.expr) -> ast.expr:
|
| 780 |
+
"""Call saferepr on the expression."""
|
| 781 |
+
return self.helper("_saferepr", expr)
|
| 782 |
+
|
| 783 |
+
def helper(self, name: str, *args: ast.expr) -> ast.expr:
|
| 784 |
+
"""Call a helper in this module."""
|
| 785 |
+
py_name = ast.Name("@pytest_ar", ast.Load())
|
| 786 |
+
attr = ast.Attribute(py_name, name, ast.Load())
|
| 787 |
+
return ast.Call(attr, list(args), [])
|
| 788 |
+
|
| 789 |
+
def builtin(self, name: str) -> ast.Attribute:
|
| 790 |
+
"""Return the builtin called *name*."""
|
| 791 |
+
builtin_name = ast.Name("@py_builtins", ast.Load())
|
| 792 |
+
return ast.Attribute(builtin_name, name, ast.Load())
|
| 793 |
+
|
| 794 |
+
def explanation_param(self, expr: ast.expr) -> str:
|
| 795 |
+
"""Return a new named %-formatting placeholder for expr.
|
| 796 |
+
|
| 797 |
+
This creates a %-formatting placeholder for expr in the
|
| 798 |
+
current formatting context, e.g. ``%(py0)s``. The placeholder
|
| 799 |
+
and expr are placed in the current format context so that it
|
| 800 |
+
can be used on the next call to .pop_format_context().
|
| 801 |
+
"""
|
| 802 |
+
specifier = "py" + str(next(self.variable_counter))
|
| 803 |
+
self.explanation_specifiers[specifier] = expr
|
| 804 |
+
return "%(" + specifier + ")s"
|
| 805 |
+
|
| 806 |
+
def push_format_context(self) -> None:
|
| 807 |
+
"""Create a new formatting context.
|
| 808 |
+
|
| 809 |
+
The format context is used for when an explanation wants to
|
| 810 |
+
have a variable value formatted in the assertion message. In
|
| 811 |
+
this case the value required can be added using
|
| 812 |
+
.explanation_param(). Finally .pop_format_context() is used
|
| 813 |
+
to format a string of %-formatted values as added by
|
| 814 |
+
.explanation_param().
|
| 815 |
+
"""
|
| 816 |
+
self.explanation_specifiers: dict[str, ast.expr] = {}
|
| 817 |
+
self.stack.append(self.explanation_specifiers)
|
| 818 |
+
|
| 819 |
+
def pop_format_context(self, expl_expr: ast.expr) -> ast.Name:
|
| 820 |
+
"""Format the %-formatted string with current format context.
|
| 821 |
+
|
| 822 |
+
The expl_expr should be an str ast.expr instance constructed from
|
| 823 |
+
the %-placeholders created by .explanation_param(). This will
|
| 824 |
+
add the required code to format said string to .expl_stmts and
|
| 825 |
+
return the ast.Name instance of the formatted string.
|
| 826 |
+
"""
|
| 827 |
+
current = self.stack.pop()
|
| 828 |
+
if self.stack:
|
| 829 |
+
self.explanation_specifiers = self.stack[-1]
|
| 830 |
+
keys: list[ast.expr | None] = [ast.Constant(key) for key in current.keys()]
|
| 831 |
+
format_dict = ast.Dict(keys, list(current.values()))
|
| 832 |
+
form = ast.BinOp(expl_expr, ast.Mod(), format_dict)
|
| 833 |
+
name = "@py_format" + str(next(self.variable_counter))
|
| 834 |
+
if self.enable_assertion_pass_hook:
|
| 835 |
+
self.format_variables.append(name)
|
| 836 |
+
self.expl_stmts.append(ast.Assign([ast.Name(name, ast.Store())], form))
|
| 837 |
+
return ast.Name(name, ast.Load())
|
| 838 |
+
|
| 839 |
+
def generic_visit(self, node: ast.AST) -> tuple[ast.Name, str]:
|
| 840 |
+
"""Handle expressions we don't have custom code for."""
|
| 841 |
+
assert isinstance(node, ast.expr)
|
| 842 |
+
res = self.assign(node)
|
| 843 |
+
return res, self.explanation_param(self.display(res))
|
| 844 |
+
|
| 845 |
+
def visit_Assert(self, assert_: ast.Assert) -> list[ast.stmt]:
|
| 846 |
+
"""Return the AST statements to replace the ast.Assert instance.
|
| 847 |
+
|
| 848 |
+
This rewrites the test of an assertion to provide
|
| 849 |
+
intermediate values and replace it with an if statement which
|
| 850 |
+
raises an assertion error with a detailed explanation in case
|
| 851 |
+
the expression is false.
|
| 852 |
+
"""
|
| 853 |
+
if isinstance(assert_.test, ast.Tuple) and len(assert_.test.elts) >= 1:
|
| 854 |
+
import warnings
|
| 855 |
+
|
| 856 |
+
from _pytest.warning_types import PytestAssertRewriteWarning
|
| 857 |
+
|
| 858 |
+
# TODO: This assert should not be needed.
|
| 859 |
+
assert self.module_path is not None
|
| 860 |
+
warnings.warn_explicit(
|
| 861 |
+
PytestAssertRewriteWarning(
|
| 862 |
+
"assertion is always true, perhaps remove parentheses?"
|
| 863 |
+
),
|
| 864 |
+
category=None,
|
| 865 |
+
filename=self.module_path,
|
| 866 |
+
lineno=assert_.lineno,
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
self.statements: list[ast.stmt] = []
|
| 870 |
+
self.variables: list[str] = []
|
| 871 |
+
self.variable_counter = itertools.count()
|
| 872 |
+
|
| 873 |
+
if self.enable_assertion_pass_hook:
|
| 874 |
+
self.format_variables: list[str] = []
|
| 875 |
+
|
| 876 |
+
self.stack: list[dict[str, ast.expr]] = []
|
| 877 |
+
self.expl_stmts: list[ast.stmt] = []
|
| 878 |
+
self.push_format_context()
|
| 879 |
+
# Rewrite assert into a bunch of statements.
|
| 880 |
+
top_condition, explanation = self.visit(assert_.test)
|
| 881 |
+
|
| 882 |
+
negation = ast.UnaryOp(ast.Not(), top_condition)
|
| 883 |
+
|
| 884 |
+
if self.enable_assertion_pass_hook: # Experimental pytest_assertion_pass hook
|
| 885 |
+
msg = self.pop_format_context(ast.Constant(explanation))
|
| 886 |
+
|
| 887 |
+
# Failed
|
| 888 |
+
if assert_.msg:
|
| 889 |
+
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
| 890 |
+
gluestr = "\n>assert "
|
| 891 |
+
else:
|
| 892 |
+
assertmsg = ast.Constant("")
|
| 893 |
+
gluestr = "assert "
|
| 894 |
+
err_explanation = ast.BinOp(ast.Constant(gluestr), ast.Add(), msg)
|
| 895 |
+
err_msg = ast.BinOp(assertmsg, ast.Add(), err_explanation)
|
| 896 |
+
err_name = ast.Name("AssertionError", ast.Load())
|
| 897 |
+
fmt = self.helper("_format_explanation", err_msg)
|
| 898 |
+
exc = ast.Call(err_name, [fmt], [])
|
| 899 |
+
raise_ = ast.Raise(exc, None)
|
| 900 |
+
statements_fail = []
|
| 901 |
+
statements_fail.extend(self.expl_stmts)
|
| 902 |
+
statements_fail.append(raise_)
|
| 903 |
+
|
| 904 |
+
# Passed
|
| 905 |
+
fmt_pass = self.helper("_format_explanation", msg)
|
| 906 |
+
orig = _get_assertion_exprs(self.source)[assert_.lineno]
|
| 907 |
+
hook_call_pass = ast.Expr(
|
| 908 |
+
self.helper(
|
| 909 |
+
"_call_assertion_pass",
|
| 910 |
+
ast.Constant(assert_.lineno),
|
| 911 |
+
ast.Constant(orig),
|
| 912 |
+
fmt_pass,
|
| 913 |
+
)
|
| 914 |
+
)
|
| 915 |
+
# If any hooks implement assert_pass hook
|
| 916 |
+
hook_impl_test = ast.If(
|
| 917 |
+
self.helper("_check_if_assertion_pass_impl"),
|
| 918 |
+
[*self.expl_stmts, hook_call_pass],
|
| 919 |
+
[],
|
| 920 |
+
)
|
| 921 |
+
statements_pass: list[ast.stmt] = [hook_impl_test]
|
| 922 |
+
|
| 923 |
+
# Test for assertion condition
|
| 924 |
+
main_test = ast.If(negation, statements_fail, statements_pass)
|
| 925 |
+
self.statements.append(main_test)
|
| 926 |
+
if self.format_variables:
|
| 927 |
+
variables: list[ast.expr] = [
|
| 928 |
+
ast.Name(name, ast.Store()) for name in self.format_variables
|
| 929 |
+
]
|
| 930 |
+
clear_format = ast.Assign(variables, ast.Constant(None))
|
| 931 |
+
self.statements.append(clear_format)
|
| 932 |
+
|
| 933 |
+
else: # Original assertion rewriting
|
| 934 |
+
# Create failure message.
|
| 935 |
+
body = self.expl_stmts
|
| 936 |
+
self.statements.append(ast.If(negation, body, []))
|
| 937 |
+
if assert_.msg:
|
| 938 |
+
assertmsg = self.helper("_format_assertmsg", assert_.msg)
|
| 939 |
+
explanation = "\n>assert " + explanation
|
| 940 |
+
else:
|
| 941 |
+
assertmsg = ast.Constant("")
|
| 942 |
+
explanation = "assert " + explanation
|
| 943 |
+
template = ast.BinOp(assertmsg, ast.Add(), ast.Constant(explanation))
|
| 944 |
+
msg = self.pop_format_context(template)
|
| 945 |
+
fmt = self.helper("_format_explanation", msg)
|
| 946 |
+
err_name = ast.Name("AssertionError", ast.Load())
|
| 947 |
+
exc = ast.Call(err_name, [fmt], [])
|
| 948 |
+
raise_ = ast.Raise(exc, None)
|
| 949 |
+
|
| 950 |
+
body.append(raise_)
|
| 951 |
+
|
| 952 |
+
# Clear temporary variables by setting them to None.
|
| 953 |
+
if self.variables:
|
| 954 |
+
variables = [ast.Name(name, ast.Store()) for name in self.variables]
|
| 955 |
+
clear = ast.Assign(variables, ast.Constant(None))
|
| 956 |
+
self.statements.append(clear)
|
| 957 |
+
# Fix locations (line numbers/column offsets).
|
| 958 |
+
for stmt in self.statements:
|
| 959 |
+
for node in traverse_node(stmt):
|
| 960 |
+
if getattr(node, "lineno", None) is None:
|
| 961 |
+
# apply the assertion location to all generated ast nodes without source location
|
| 962 |
+
# and preserve the location of existing nodes or generated nodes with an correct location.
|
| 963 |
+
ast.copy_location(node, assert_)
|
| 964 |
+
return self.statements
|
| 965 |
+
|
| 966 |
+
def visit_NamedExpr(self, name: ast.NamedExpr) -> tuple[ast.NamedExpr, str]:
|
| 967 |
+
# This method handles the 'walrus operator' repr of the target
|
| 968 |
+
# name if it's a local variable or _should_repr_global_name()
|
| 969 |
+
# thinks it's acceptable.
|
| 970 |
+
locs = ast.Call(self.builtin("locals"), [], [])
|
| 971 |
+
target_id = name.target.id
|
| 972 |
+
inlocs = ast.Compare(ast.Constant(target_id), [ast.In()], [locs])
|
| 973 |
+
dorepr = self.helper("_should_repr_global_name", name)
|
| 974 |
+
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
| 975 |
+
expr = ast.IfExp(test, self.display(name), ast.Constant(target_id))
|
| 976 |
+
return name, self.explanation_param(expr)
|
| 977 |
+
|
| 978 |
+
def visit_Name(self, name: ast.Name) -> tuple[ast.Name, str]:
|
| 979 |
+
# Display the repr of the name if it's a local variable or
|
| 980 |
+
# _should_repr_global_name() thinks it's acceptable.
|
| 981 |
+
locs = ast.Call(self.builtin("locals"), [], [])
|
| 982 |
+
inlocs = ast.Compare(ast.Constant(name.id), [ast.In()], [locs])
|
| 983 |
+
dorepr = self.helper("_should_repr_global_name", name)
|
| 984 |
+
test = ast.BoolOp(ast.Or(), [inlocs, dorepr])
|
| 985 |
+
expr = ast.IfExp(test, self.display(name), ast.Constant(name.id))
|
| 986 |
+
return name, self.explanation_param(expr)
|
| 987 |
+
|
| 988 |
+
def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
|
| 989 |
+
res_var = self.variable()
|
| 990 |
+
expl_list = self.assign(ast.List([], ast.Load()))
|
| 991 |
+
app = ast.Attribute(expl_list, "append", ast.Load())
|
| 992 |
+
is_or = int(isinstance(boolop.op, ast.Or))
|
| 993 |
+
body = save = self.statements
|
| 994 |
+
fail_save = self.expl_stmts
|
| 995 |
+
levels = len(boolop.values) - 1
|
| 996 |
+
self.push_format_context()
|
| 997 |
+
# Process each operand, short-circuiting if needed.
|
| 998 |
+
for i, v in enumerate(boolop.values):
|
| 999 |
+
if i:
|
| 1000 |
+
fail_inner: list[ast.stmt] = []
|
| 1001 |
+
# cond is set in a prior loop iteration below
|
| 1002 |
+
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821
|
| 1003 |
+
self.expl_stmts = fail_inner
|
| 1004 |
+
match v:
|
| 1005 |
+
# Check if the left operand is an ast.NamedExpr and the value has already been visited
|
| 1006 |
+
case ast.Compare(
|
| 1007 |
+
left=ast.NamedExpr(target=ast.Name(id=target_id))
|
| 1008 |
+
) if target_id in [
|
| 1009 |
+
e.id for e in boolop.values[:i] if hasattr(e, "id")
|
| 1010 |
+
]:
|
| 1011 |
+
pytest_temp = self.variable()
|
| 1012 |
+
self.variables_overwrite[self.scope][target_id] = v.left # type:ignore[assignment]
|
| 1013 |
+
# mypy's false positive, we're checking that the 'target' attribute exists.
|
| 1014 |
+
v.left.target.id = pytest_temp # type:ignore[attr-defined]
|
| 1015 |
+
self.push_format_context()
|
| 1016 |
+
res, expl = self.visit(v)
|
| 1017 |
+
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
|
| 1018 |
+
expl_format = self.pop_format_context(ast.Constant(expl))
|
| 1019 |
+
call = ast.Call(app, [expl_format], [])
|
| 1020 |
+
self.expl_stmts.append(ast.Expr(call))
|
| 1021 |
+
if i < levels:
|
| 1022 |
+
cond: ast.expr = res
|
| 1023 |
+
if is_or:
|
| 1024 |
+
cond = ast.UnaryOp(ast.Not(), cond)
|
| 1025 |
+
inner: list[ast.stmt] = []
|
| 1026 |
+
self.statements.append(ast.If(cond, inner, []))
|
| 1027 |
+
self.statements = body = inner
|
| 1028 |
+
self.statements = save
|
| 1029 |
+
self.expl_stmts = fail_save
|
| 1030 |
+
expl_template = self.helper("_format_boolop", expl_list, ast.Constant(is_or))
|
| 1031 |
+
expl = self.pop_format_context(expl_template)
|
| 1032 |
+
return ast.Name(res_var, ast.Load()), self.explanation_param(expl)
|
| 1033 |
+
|
| 1034 |
+
def visit_UnaryOp(self, unary: ast.UnaryOp) -> tuple[ast.Name, str]:
|
| 1035 |
+
pattern = UNARY_MAP[unary.op.__class__]
|
| 1036 |
+
operand_res, operand_expl = self.visit(unary.operand)
|
| 1037 |
+
res = self.assign(ast.copy_location(ast.UnaryOp(unary.op, operand_res), unary))
|
| 1038 |
+
return res, pattern % (operand_expl,)
|
| 1039 |
+
|
| 1040 |
+
def visit_BinOp(self, binop: ast.BinOp) -> tuple[ast.Name, str]:
|
| 1041 |
+
symbol = BINOP_MAP[binop.op.__class__]
|
| 1042 |
+
left_expr, left_expl = self.visit(binop.left)
|
| 1043 |
+
right_expr, right_expl = self.visit(binop.right)
|
| 1044 |
+
explanation = f"({left_expl} {symbol} {right_expl})"
|
| 1045 |
+
res = self.assign(
|
| 1046 |
+
ast.copy_location(ast.BinOp(left_expr, binop.op, right_expr), binop)
|
| 1047 |
+
)
|
| 1048 |
+
return res, explanation
|
| 1049 |
+
|
| 1050 |
+
def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]:
|
| 1051 |
+
new_func, func_expl = self.visit(call.func)
|
| 1052 |
+
arg_expls = []
|
| 1053 |
+
new_args = []
|
| 1054 |
+
new_kwargs = []
|
| 1055 |
+
for arg in call.args:
|
| 1056 |
+
if isinstance(arg, ast.Name) and arg.id in self.variables_overwrite.get(
|
| 1057 |
+
self.scope, {}
|
| 1058 |
+
):
|
| 1059 |
+
arg = self.variables_overwrite[self.scope][arg.id] # type:ignore[assignment]
|
| 1060 |
+
res, expl = self.visit(arg)
|
| 1061 |
+
arg_expls.append(expl)
|
| 1062 |
+
new_args.append(res)
|
| 1063 |
+
for keyword in call.keywords:
|
| 1064 |
+
match keyword.value:
|
| 1065 |
+
case ast.Name(id=id) if id in self.variables_overwrite.get(
|
| 1066 |
+
self.scope, {}
|
| 1067 |
+
):
|
| 1068 |
+
keyword.value = self.variables_overwrite[self.scope][id] # type:ignore[assignment]
|
| 1069 |
+
res, expl = self.visit(keyword.value)
|
| 1070 |
+
new_kwargs.append(ast.keyword(keyword.arg, res))
|
| 1071 |
+
if keyword.arg:
|
| 1072 |
+
arg_expls.append(keyword.arg + "=" + expl)
|
| 1073 |
+
else: # **args have `arg` keywords with an .arg of None
|
| 1074 |
+
arg_expls.append("**" + expl)
|
| 1075 |
+
|
| 1076 |
+
expl = "{}({})".format(func_expl, ", ".join(arg_expls))
|
| 1077 |
+
new_call = ast.copy_location(ast.Call(new_func, new_args, new_kwargs), call)
|
| 1078 |
+
res = self.assign(new_call)
|
| 1079 |
+
res_expl = self.explanation_param(self.display(res))
|
| 1080 |
+
outer_expl = f"{res_expl}\n{{{res_expl} = {expl}\n}}"
|
| 1081 |
+
return res, outer_expl
|
| 1082 |
+
|
| 1083 |
+
def visit_Starred(self, starred: ast.Starred) -> tuple[ast.Starred, str]:
|
| 1084 |
+
# A Starred node can appear in a function call.
|
| 1085 |
+
res, expl = self.visit(starred.value)
|
| 1086 |
+
new_starred = ast.Starred(res, starred.ctx)
|
| 1087 |
+
return new_starred, "*" + expl
|
| 1088 |
+
|
| 1089 |
+
def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]:
|
| 1090 |
+
if not isinstance(attr.ctx, ast.Load):
|
| 1091 |
+
return self.generic_visit(attr)
|
| 1092 |
+
value, value_expl = self.visit(attr.value)
|
| 1093 |
+
res = self.assign(
|
| 1094 |
+
ast.copy_location(ast.Attribute(value, attr.attr, ast.Load()), attr)
|
| 1095 |
+
)
|
| 1096 |
+
res_expl = self.explanation_param(self.display(res))
|
| 1097 |
+
pat = "%s\n{%s = %s.%s\n}"
|
| 1098 |
+
expl = pat % (res_expl, res_expl, value_expl, attr.attr)
|
| 1099 |
+
return res, expl
|
| 1100 |
+
|
| 1101 |
+
def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
|
| 1102 |
+
self.push_format_context()
|
| 1103 |
+
# We first check if we have overwritten a variable in the previous assert
|
| 1104 |
+
match comp.left:
|
| 1105 |
+
case ast.Name(id=name_id) if name_id in self.variables_overwrite.get(
|
| 1106 |
+
self.scope, {}
|
| 1107 |
+
):
|
| 1108 |
+
comp.left = self.variables_overwrite[self.scope][name_id] # type: ignore[assignment]
|
| 1109 |
+
case ast.NamedExpr(target=ast.Name(id=target_id)):
|
| 1110 |
+
self.variables_overwrite[self.scope][target_id] = comp.left # type: ignore[assignment]
|
| 1111 |
+
left_res, left_expl = self.visit(comp.left)
|
| 1112 |
+
if isinstance(comp.left, ast.Compare | ast.BoolOp):
|
| 1113 |
+
left_expl = f"({left_expl})"
|
| 1114 |
+
res_variables = [self.variable() for i in range(len(comp.ops))]
|
| 1115 |
+
load_names: list[ast.expr] = [ast.Name(v, ast.Load()) for v in res_variables]
|
| 1116 |
+
store_names = [ast.Name(v, ast.Store()) for v in res_variables]
|
| 1117 |
+
it = zip(range(len(comp.ops)), comp.ops, comp.comparators, strict=True)
|
| 1118 |
+
expls: list[ast.expr] = []
|
| 1119 |
+
syms: list[ast.expr] = []
|
| 1120 |
+
results = [left_res]
|
| 1121 |
+
for i, op, next_operand in it:
|
| 1122 |
+
match (next_operand, left_res):
|
| 1123 |
+
case (
|
| 1124 |
+
ast.NamedExpr(target=ast.Name(id=target_id)),
|
| 1125 |
+
ast.Name(id=name_id),
|
| 1126 |
+
) if target_id == name_id:
|
| 1127 |
+
next_operand.target.id = self.variable()
|
| 1128 |
+
self.variables_overwrite[self.scope][name_id] = next_operand # type: ignore[assignment]
|
| 1129 |
+
|
| 1130 |
+
next_res, next_expl = self.visit(next_operand)
|
| 1131 |
+
if isinstance(next_operand, ast.Compare | ast.BoolOp):
|
| 1132 |
+
next_expl = f"({next_expl})"
|
| 1133 |
+
results.append(next_res)
|
| 1134 |
+
sym = BINOP_MAP[op.__class__]
|
| 1135 |
+
syms.append(ast.Constant(sym))
|
| 1136 |
+
expl = f"{left_expl} {sym} {next_expl}"
|
| 1137 |
+
expls.append(ast.Constant(expl))
|
| 1138 |
+
res_expr = ast.copy_location(ast.Compare(left_res, [op], [next_res]), comp)
|
| 1139 |
+
self.statements.append(ast.Assign([store_names[i]], res_expr))
|
| 1140 |
+
left_res, left_expl = next_res, next_expl
|
| 1141 |
+
# Use pytest.assertion.util._reprcompare if that's available.
|
| 1142 |
+
expl_call = self.helper(
|
| 1143 |
+
"_call_reprcompare",
|
| 1144 |
+
ast.Tuple(syms, ast.Load()),
|
| 1145 |
+
ast.Tuple(load_names, ast.Load()),
|
| 1146 |
+
ast.Tuple(expls, ast.Load()),
|
| 1147 |
+
ast.Tuple(results, ast.Load()),
|
| 1148 |
+
)
|
| 1149 |
+
if len(comp.ops) > 1:
|
| 1150 |
+
res: ast.expr = ast.BoolOp(ast.And(), load_names)
|
| 1151 |
+
else:
|
| 1152 |
+
res = load_names[0]
|
| 1153 |
+
|
| 1154 |
+
return res, self.explanation_param(self.pop_format_context(expl_call))
|
| 1155 |
+
|
| 1156 |
+
|
| 1157 |
+
def try_makedirs(cache_dir: Path) -> bool:
|
| 1158 |
+
"""Attempt to create the given directory and sub-directories exist.
|
| 1159 |
+
|
| 1160 |
+
Returns True if successful or if it already exists.
|
| 1161 |
+
"""
|
| 1162 |
+
try:
|
| 1163 |
+
os.makedirs(cache_dir, exist_ok=True)
|
| 1164 |
+
except (FileNotFoundError, NotADirectoryError, FileExistsError):
|
| 1165 |
+
# One of the path components was not a directory:
|
| 1166 |
+
# - we're in a zip file
|
| 1167 |
+
# - it is a file
|
| 1168 |
+
return False
|
| 1169 |
+
except PermissionError:
|
| 1170 |
+
return False
|
| 1171 |
+
except OSError as e:
|
| 1172 |
+
# as of now, EROFS doesn't have an equivalent OSError-subclass
|
| 1173 |
+
#
|
| 1174 |
+
# squashfuse_ll returns ENOSYS "OSError: [Errno 38] Function not
|
| 1175 |
+
# implemented" for a read-only error
|
| 1176 |
+
if e.errno in {errno.EROFS, errno.ENOSYS}:
|
| 1177 |
+
return False
|
| 1178 |
+
raise
|
| 1179 |
+
return True
|
| 1180 |
+
|
| 1181 |
+
|
| 1182 |
+
def get_cache_dir(file_path: Path) -> Path:
|
| 1183 |
+
"""Return the cache directory to write .pyc files for the given .py file path."""
|
| 1184 |
+
if sys.pycache_prefix:
|
| 1185 |
+
# given:
|
| 1186 |
+
# prefix = '/tmp/pycs'
|
| 1187 |
+
# path = '/home/user/proj/test_app.py'
|
| 1188 |
+
# we want:
|
| 1189 |
+
# '/tmp/pycs/home/user/proj'
|
| 1190 |
+
return Path(sys.pycache_prefix) / Path(*file_path.parts[1:-1])
|
| 1191 |
+
else:
|
| 1192 |
+
# classic pycache directory
|
| 1193 |
+
return file_path.parent / "__pycache__"
|
.venv/lib/python3.14/site-packages/_pytest/assertion/truncate.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.compat import running_on_ci
|
| 10 |
+
from _pytest.config import Config
|
| 11 |
+
from _pytest.nodes import Item
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
DEFAULT_MAX_LINES = 8
|
| 15 |
+
DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80
|
| 16 |
+
USAGE_MSG = "use '-vv' to show"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def truncate_if_required(explanation: list[str], item: Item) -> list[str]:
|
| 20 |
+
"""Truncate this assertion explanation if the given test item is eligible."""
|
| 21 |
+
should_truncate, max_lines, max_chars = _get_truncation_parameters(item)
|
| 22 |
+
if should_truncate:
|
| 23 |
+
return _truncate_explanation(
|
| 24 |
+
explanation,
|
| 25 |
+
max_lines=max_lines,
|
| 26 |
+
max_chars=max_chars,
|
| 27 |
+
)
|
| 28 |
+
return explanation
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]:
|
| 32 |
+
"""Return the truncation parameters related to the given item, as (should truncate, max lines, max chars)."""
|
| 33 |
+
# We do not need to truncate if one of conditions is met:
|
| 34 |
+
# 1. Verbosity level is 2 or more;
|
| 35 |
+
# 2. Test is being run in CI environment;
|
| 36 |
+
# 3. Both truncation_limit_lines and truncation_limit_chars
|
| 37 |
+
# .ini parameters are set to 0 explicitly.
|
| 38 |
+
max_lines = item.config.getini("truncation_limit_lines")
|
| 39 |
+
max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES)
|
| 40 |
+
|
| 41 |
+
max_chars = item.config.getini("truncation_limit_chars")
|
| 42 |
+
max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS)
|
| 43 |
+
|
| 44 |
+
verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
|
| 45 |
+
|
| 46 |
+
should_truncate = verbose < 2 and not running_on_ci()
|
| 47 |
+
should_truncate = should_truncate and (max_lines > 0 or max_chars > 0)
|
| 48 |
+
|
| 49 |
+
return should_truncate, max_lines, max_chars
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _truncate_explanation(
|
| 53 |
+
input_lines: list[str],
|
| 54 |
+
max_lines: int,
|
| 55 |
+
max_chars: int,
|
| 56 |
+
) -> list[str]:
|
| 57 |
+
"""Truncate given list of strings that makes up the assertion explanation.
|
| 58 |
+
|
| 59 |
+
Truncates to either max_lines, or max_chars - whichever the input reaches
|
| 60 |
+
first, taking the truncation explanation into account. The remaining lines
|
| 61 |
+
will be replaced by a usage message.
|
| 62 |
+
|
| 63 |
+
If max_chars=0, no truncation by character count is performed.
|
| 64 |
+
If max_lines=0, no truncation by line count is performed.
|
| 65 |
+
|
| 66 |
+
When this function is launched we know max_lines > 0 or max_chars > 0
|
| 67 |
+
because _get_truncation_parameters was called first.
|
| 68 |
+
"""
|
| 69 |
+
# The length of the truncation explanation depends on the number of lines
|
| 70 |
+
# removed but is at least 68 characters:
|
| 71 |
+
# The real value is
|
| 72 |
+
# 64 (for the base message:
|
| 73 |
+
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
|
| 74 |
+
# )
|
| 75 |
+
# + 1 (for plural)
|
| 76 |
+
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
|
| 77 |
+
# + 3 for the '...' added to the truncated line
|
| 78 |
+
# But if there's more than 100 lines it's very likely that we're going to
|
| 79 |
+
# truncate, so we don't need the exact value using log10.
|
| 80 |
+
tolerable_max_chars = (
|
| 81 |
+
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
|
| 82 |
+
)
|
| 83 |
+
# The truncation explanation add two lines to the output
|
| 84 |
+
if max_lines == 0 or len(input_lines) <= max_lines + 2:
|
| 85 |
+
if max_chars == 0 or sum(len(s) for s in input_lines) <= tolerable_max_chars:
|
| 86 |
+
return input_lines
|
| 87 |
+
truncated_explanation = input_lines
|
| 88 |
+
else:
|
| 89 |
+
# Truncate first to max_lines, and then truncate to max_chars if necessary
|
| 90 |
+
truncated_explanation = input_lines[:max_lines]
|
| 91 |
+
# We reevaluate the need to truncate chars following removal of some lines
|
| 92 |
+
need_to_truncate_char = (
|
| 93 |
+
max_chars > 0
|
| 94 |
+
and sum(len(e) for e in truncated_explanation) > tolerable_max_chars
|
| 95 |
+
)
|
| 96 |
+
if need_to_truncate_char:
|
| 97 |
+
truncated_explanation = _truncate_by_char_count(
|
| 98 |
+
truncated_explanation, max_chars
|
| 99 |
+
)
|
| 100 |
+
# Something was truncated, adding '...' at the end to show that
|
| 101 |
+
truncated_explanation[-1] += "..."
|
| 102 |
+
truncated_line_count = (
|
| 103 |
+
len(input_lines) - len(truncated_explanation) + int(need_to_truncate_char)
|
| 104 |
+
)
|
| 105 |
+
return [
|
| 106 |
+
*truncated_explanation,
|
| 107 |
+
"",
|
| 108 |
+
f"...Full output truncated ({truncated_line_count} line"
|
| 109 |
+
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _truncate_by_char_count(input_lines: list[str], max_chars: int) -> list[str]:
|
| 114 |
+
# Find point at which input length exceeds total allowed length
|
| 115 |
+
iterated_char_count = 0
|
| 116 |
+
for iterated_index, input_line in enumerate(input_lines):
|
| 117 |
+
if iterated_char_count + len(input_line) > max_chars:
|
| 118 |
+
break
|
| 119 |
+
iterated_char_count += len(input_line)
|
| 120 |
+
|
| 121 |
+
# Create truncated explanation with modified final line
|
| 122 |
+
truncated_result = input_lines[:iterated_index]
|
| 123 |
+
final_line = input_lines[iterated_index]
|
| 124 |
+
if final_line:
|
| 125 |
+
final_line_truncate_point = max_chars - iterated_char_count
|
| 126 |
+
final_line = final_line[:final_line_truncate_point]
|
| 127 |
+
truncated_result.append(final_line)
|
| 128 |
+
return truncated_result
|
.venv/lib/python3.14/site-packages/_pytest/assertion/util.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Utilities for assertion debugging."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
from collections.abc import Iterator
|
| 8 |
+
from collections.abc import Sequence
|
| 9 |
+
from typing import Literal
|
| 10 |
+
from unicodedata import normalize
|
| 11 |
+
|
| 12 |
+
from _pytest import outcomes
|
| 13 |
+
import _pytest._code
|
| 14 |
+
from _pytest._io.saferepr import saferepr
|
| 15 |
+
from _pytest._io.saferepr import saferepr_unlimited
|
| 16 |
+
from _pytest.assertion._compare_any import _compare_eq_any
|
| 17 |
+
from _pytest.assertion._compare_set import SET_COMPARISON_FUNCTIONS
|
| 18 |
+
from _pytest.assertion._guards import isset
|
| 19 |
+
from _pytest.assertion._guards import istext
|
| 20 |
+
from _pytest.assertion._typing import _AssertionTextDiffStyle
|
| 21 |
+
from _pytest.assertion._typing import _HighlightFunc
|
| 22 |
+
from _pytest.assertion.compare_text import _notin_text
|
| 23 |
+
from _pytest.assertion.highlight import dummy_highlighter as dummy_highlighter
|
| 24 |
+
from _pytest.config import Config
|
| 25 |
+
from _pytest.config import UsageError
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# The _reprcompare attribute on the util module is used by the new assertion
|
| 29 |
+
# interpretation code and assertion rewriter to detect this plugin was
|
| 30 |
+
# loaded and in turn call the hooks defined here as part of the
|
| 31 |
+
# DebugInterpreter.
|
| 32 |
+
_reprcompare: Callable[[str, object, object], str | None] | None = None
|
| 33 |
+
|
| 34 |
+
# Works similarly as _reprcompare attribute. Is populated with the hook call
|
| 35 |
+
# when pytest_runtest_setup is called.
|
| 36 |
+
_assertion_pass: Callable[[int, str, str], None] | None = None
|
| 37 |
+
|
| 38 |
+
# Config object which is assigned during pytest_runtest_protocol.
|
| 39 |
+
_config: Config | None = None
|
| 40 |
+
|
| 41 |
+
ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style"
|
| 42 |
+
ASSERTION_TEXT_DIFF_STYLE_NDIFF: Literal["ndiff"] = "ndiff"
|
| 43 |
+
ASSERTION_TEXT_DIFF_STYLE_BLOCK: Literal["block"] = "block"
|
| 44 |
+
ASSERTION_TEXT_DIFF_STYLE_CHOICES = (
|
| 45 |
+
ASSERTION_TEXT_DIFF_STYLE_NDIFF,
|
| 46 |
+
ASSERTION_TEXT_DIFF_STYLE_BLOCK,
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def get_assertion_text_diff_style(config: Config) -> _AssertionTextDiffStyle:
|
| 51 |
+
style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI))
|
| 52 |
+
match style:
|
| 53 |
+
case "ndiff" | "block":
|
| 54 |
+
return style
|
| 55 |
+
case _:
|
| 56 |
+
choices = ", ".join(
|
| 57 |
+
repr(choice) for choice in ASSERTION_TEXT_DIFF_STYLE_CHOICES
|
| 58 |
+
)
|
| 59 |
+
raise UsageError(
|
| 60 |
+
f"{ASSERTION_TEXT_DIFF_STYLE_INI} must be one of {choices}; got {style!r}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def validate_assertion_text_diff_style(config: Config) -> None:
|
| 65 |
+
get_assertion_text_diff_style(config)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def format_explanation(explanation: str) -> str:
|
| 69 |
+
r"""Format an explanation.
|
| 70 |
+
|
| 71 |
+
Normally all embedded newlines are escaped, however there are
|
| 72 |
+
three exceptions: \n{, \n} and \n~. The first two are intended
|
| 73 |
+
cover nested explanations, see function and attribute explanations
|
| 74 |
+
for examples (.visit_Call(), visit_Attribute()). The last one is
|
| 75 |
+
for when one explanation needs to span multiple lines, e.g. when
|
| 76 |
+
displaying diffs.
|
| 77 |
+
"""
|
| 78 |
+
lines = _split_explanation(explanation)
|
| 79 |
+
result = _format_lines(lines)
|
| 80 |
+
return "\n".join(result)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _split_explanation(explanation: str) -> list[str]:
|
| 84 |
+
r"""Return a list of individual lines in the explanation.
|
| 85 |
+
|
| 86 |
+
This will return a list of lines split on '\n{', '\n}' and '\n~'.
|
| 87 |
+
Any other newlines will be escaped and appear in the line as the
|
| 88 |
+
literal '\n' characters.
|
| 89 |
+
"""
|
| 90 |
+
raw_lines = (explanation or "").split("\n")
|
| 91 |
+
lines = [raw_lines[0]]
|
| 92 |
+
for values in raw_lines[1:]:
|
| 93 |
+
if values and values[0] in ["{", "}", "~", ">"]:
|
| 94 |
+
lines.append(values)
|
| 95 |
+
else:
|
| 96 |
+
lines[-1] += "\\n" + values
|
| 97 |
+
return lines
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _format_lines(lines: Sequence[str]) -> list[str]:
|
| 101 |
+
"""Format the individual lines.
|
| 102 |
+
|
| 103 |
+
This will replace the '{', '}' and '~' characters of our mini formatting
|
| 104 |
+
language with the proper 'where ...', 'and ...' and ' + ...' text, taking
|
| 105 |
+
care of indentation along the way.
|
| 106 |
+
|
| 107 |
+
Return a list of formatted lines.
|
| 108 |
+
"""
|
| 109 |
+
result = list(lines[:1])
|
| 110 |
+
stack = [0]
|
| 111 |
+
stackcnt = [0]
|
| 112 |
+
for line in lines[1:]:
|
| 113 |
+
if line.startswith("{"):
|
| 114 |
+
if stackcnt[-1]:
|
| 115 |
+
s = "and "
|
| 116 |
+
else:
|
| 117 |
+
s = "where "
|
| 118 |
+
stack.append(len(result))
|
| 119 |
+
stackcnt[-1] += 1
|
| 120 |
+
stackcnt.append(0)
|
| 121 |
+
result.append(" +" + " " * (len(stack) - 1) + s + line[1:])
|
| 122 |
+
elif line.startswith("}"):
|
| 123 |
+
stack.pop()
|
| 124 |
+
stackcnt.pop()
|
| 125 |
+
result[stack[-1]] += line[1:]
|
| 126 |
+
else:
|
| 127 |
+
assert line[0] in ["~", ">"]
|
| 128 |
+
stack[-1] += 1
|
| 129 |
+
indent = len(stack) if line.startswith("~") else len(stack) - 1
|
| 130 |
+
result.append(" " * indent + line[1:])
|
| 131 |
+
assert len(stack) == 1
|
| 132 |
+
return result
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def assertrepr_compare(
|
| 136 |
+
op: str,
|
| 137 |
+
left: object,
|
| 138 |
+
right: object,
|
| 139 |
+
*,
|
| 140 |
+
verbose: int,
|
| 141 |
+
highlighter: _HighlightFunc,
|
| 142 |
+
assertion_text_diff_style: _AssertionTextDiffStyle,
|
| 143 |
+
) -> Iterator[str]:
|
| 144 |
+
"""Yield specialised explanations for some operators/operands.
|
| 145 |
+
|
| 146 |
+
The first line yielded is always the summary (``left op right``);
|
| 147 |
+
subsequent lines are the detailed explanation. Yields nothing when no
|
| 148 |
+
specialised explanation applies, which lets consumers map an empty
|
| 149 |
+
iterator to "no explanation" without materialising anything.
|
| 150 |
+
|
| 151 |
+
The iterator is lazy on purpose: a streaming consumer can stop pulling
|
| 152 |
+
lines as soon as it has enough to show, so an enormous diff doesn't
|
| 153 |
+
have to be built in full just to be thrown away.
|
| 154 |
+
"""
|
| 155 |
+
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
|
| 156 |
+
# See issue #3246.
|
| 157 |
+
use_ascii = (
|
| 158 |
+
isinstance(left, str)
|
| 159 |
+
and isinstance(right, str)
|
| 160 |
+
and normalize("NFD", left) == normalize("NFD", right)
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if verbose > 1:
|
| 164 |
+
left_repr = saferepr_unlimited(left, use_ascii=use_ascii)
|
| 165 |
+
right_repr = saferepr_unlimited(right, use_ascii=use_ascii)
|
| 166 |
+
else:
|
| 167 |
+
# XXX: "15 chars indentation" is wrong
|
| 168 |
+
# ("E AssertionError: assert "); should use term width.
|
| 169 |
+
maxsize = (
|
| 170 |
+
80 - 15 - len(op) - 2
|
| 171 |
+
) // 2 # 15 chars indentation, 1 space around op
|
| 172 |
+
|
| 173 |
+
left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii)
|
| 174 |
+
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
|
| 175 |
+
|
| 176 |
+
summary = f"{left_repr} {op} {right_repr}"
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
if op == "==":
|
| 180 |
+
source = _compare_eq_any(
|
| 181 |
+
left,
|
| 182 |
+
right,
|
| 183 |
+
highlighter,
|
| 184 |
+
verbose,
|
| 185 |
+
assertion_text_diff_style,
|
| 186 |
+
)
|
| 187 |
+
elif op == "not in" and istext(left) and istext(right):
|
| 188 |
+
source = _notin_text(left, right, verbose)
|
| 189 |
+
elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right):
|
| 190 |
+
source = SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
|
| 191 |
+
else:
|
| 192 |
+
source = iter(())
|
| 193 |
+
|
| 194 |
+
# Only yield the summary if there is a detailed explanation.
|
| 195 |
+
# Make sure there's a separating empty line after the summary.
|
| 196 |
+
summary_yielded = False
|
| 197 |
+
for line in source:
|
| 198 |
+
if not summary_yielded:
|
| 199 |
+
yield summary
|
| 200 |
+
if line != "":
|
| 201 |
+
yield ""
|
| 202 |
+
summary_yielded = True
|
| 203 |
+
yield line
|
| 204 |
+
except outcomes.Exit:
|
| 205 |
+
raise
|
| 206 |
+
except Exception:
|
| 207 |
+
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
|
| 208 |
+
if not summary_yielded:
|
| 209 |
+
yield summary
|
| 210 |
+
yield ""
|
| 211 |
+
summary_yielded = True
|
| 212 |
+
yield (
|
| 213 |
+
f"(pytest_assertion plugin: representation of details failed: {repr_crash}."
|
| 214 |
+
)
|
| 215 |
+
yield " Probably an object has a faulty __repr__.)"
|
.venv/lib/python3.14/site-packages/_pytest/cacheprovider.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Implementation of the cache provider."""
|
| 3 |
+
|
| 4 |
+
# This plugin was not named "cache" to avoid conflicts with the external
|
| 5 |
+
# pytest-cache version.
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from collections.abc import Generator
|
| 9 |
+
from collections.abc import Iterable
|
| 10 |
+
import dataclasses
|
| 11 |
+
import errno
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import shutil
|
| 16 |
+
import tempfile
|
| 17 |
+
from typing import final
|
| 18 |
+
|
| 19 |
+
from .pathlib import resolve_from_str
|
| 20 |
+
from .pathlib import rm_rf
|
| 21 |
+
from .reports import CollectReport
|
| 22 |
+
from _pytest import nodes
|
| 23 |
+
from _pytest._io import TerminalWriter
|
| 24 |
+
from _pytest.config import Config
|
| 25 |
+
from _pytest.config import ExitCode
|
| 26 |
+
from _pytest.config import hookimpl
|
| 27 |
+
from _pytest.config.argparsing import Parser
|
| 28 |
+
from _pytest.deprecated import check_ispytest
|
| 29 |
+
from _pytest.fixtures import fixture
|
| 30 |
+
from _pytest.fixtures import FixtureRequest
|
| 31 |
+
from _pytest.main import Session
|
| 32 |
+
from _pytest.nodes import Directory
|
| 33 |
+
from _pytest.nodes import File
|
| 34 |
+
from _pytest.reports import TestReport
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
CACHEDIR_FILES: dict[str, bytes] = {
|
| 38 |
+
"README.md": b"""\
|
| 39 |
+
# pytest cache directory #
|
| 40 |
+
|
| 41 |
+
This directory contains data from the pytest's cache plugin,
|
| 42 |
+
which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
|
| 43 |
+
|
| 44 |
+
**Do not** commit this to version control.
|
| 45 |
+
|
| 46 |
+
See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
|
| 47 |
+
""",
|
| 48 |
+
".gitignore": b"# Created by pytest automatically.\n*\n",
|
| 49 |
+
"CACHEDIR.TAG": b"""\
|
| 50 |
+
Signature: 8a477f597d28d172789f06886806bc55
|
| 51 |
+
# This file is a cache directory tag created by pytest.
|
| 52 |
+
# For information about cache directory tags, see:
|
| 53 |
+
# https://bford.info/cachedir/spec.html
|
| 54 |
+
""",
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _make_cachedir(target: Path) -> None:
|
| 59 |
+
"""Create the pytest cache directory atomically with supporting files.
|
| 60 |
+
|
| 61 |
+
Creates a temporary directory with README.md, .gitignore, and CACHEDIR.TAG,
|
| 62 |
+
then atomically renames it to the target location. If another process wins
|
| 63 |
+
the race, the temporary directory is cleaned up.
|
| 64 |
+
"""
|
| 65 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
path = Path(tempfile.mkdtemp(prefix="pytest-cache-files-", dir=target.parent))
|
| 67 |
+
try:
|
| 68 |
+
# Reset permissions to the default, see #12308.
|
| 69 |
+
# Note: there's no way to get the current umask atomically, eek.
|
| 70 |
+
umask = os.umask(0o022)
|
| 71 |
+
os.umask(umask)
|
| 72 |
+
path.chmod(0o777 - umask)
|
| 73 |
+
|
| 74 |
+
for name, content in CACHEDIR_FILES.items():
|
| 75 |
+
path.joinpath(name).write_bytes(content)
|
| 76 |
+
|
| 77 |
+
path.rename(target)
|
| 78 |
+
except OSError as e:
|
| 79 |
+
# If 2 concurrent pytests both race to the rename, the loser
|
| 80 |
+
# gets "Directory not empty" from the rename. In this case,
|
| 81 |
+
# everything is handled so just continue after cleanup.
|
| 82 |
+
# On Windows, the error is a FileExistsError which translates to EEXIST.
|
| 83 |
+
if e.errno not in (errno.ENOTEMPTY, errno.EEXIST):
|
| 84 |
+
raise
|
| 85 |
+
finally:
|
| 86 |
+
shutil.rmtree(path, ignore_errors=True)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@final
|
| 90 |
+
@dataclasses.dataclass
|
| 91 |
+
class Cache:
|
| 92 |
+
"""Instance of the `cache` fixture."""
|
| 93 |
+
|
| 94 |
+
_cachedir: Path = dataclasses.field(repr=False)
|
| 95 |
+
_config: Config = dataclasses.field(repr=False)
|
| 96 |
+
|
| 97 |
+
# Sub-directory under cache-dir for directories created by `mkdir()`.
|
| 98 |
+
_CACHE_PREFIX_DIRS = "d"
|
| 99 |
+
|
| 100 |
+
# Sub-directory under cache-dir for values created by `set()`.
|
| 101 |
+
_CACHE_PREFIX_VALUES = "v"
|
| 102 |
+
|
| 103 |
+
def __init__(
|
| 104 |
+
self, cachedir: Path, config: Config, *, _ispytest: bool = False
|
| 105 |
+
) -> None:
|
| 106 |
+
check_ispytest(_ispytest)
|
| 107 |
+
self._cachedir = cachedir
|
| 108 |
+
self._config = config
|
| 109 |
+
|
| 110 |
+
@classmethod
|
| 111 |
+
def for_config(cls, config: Config, *, _ispytest: bool = False) -> Cache:
|
| 112 |
+
"""Create the Cache instance for a Config.
|
| 113 |
+
|
| 114 |
+
:meta private:
|
| 115 |
+
"""
|
| 116 |
+
check_ispytest(_ispytest)
|
| 117 |
+
cachedir = cls.cache_dir_from_config(config, _ispytest=True)
|
| 118 |
+
if config.getoption("cacheclear") and cachedir.is_dir():
|
| 119 |
+
cls.clear_cache(cachedir, _ispytest=True)
|
| 120 |
+
return cls(cachedir, config, _ispytest=True)
|
| 121 |
+
|
| 122 |
+
@classmethod
|
| 123 |
+
def clear_cache(cls, cachedir: Path, _ispytest: bool = False) -> None:
|
| 124 |
+
"""Clear the sub-directories used to hold cached directories and values.
|
| 125 |
+
|
| 126 |
+
:meta private:
|
| 127 |
+
"""
|
| 128 |
+
check_ispytest(_ispytest)
|
| 129 |
+
for prefix in (cls._CACHE_PREFIX_DIRS, cls._CACHE_PREFIX_VALUES):
|
| 130 |
+
d = cachedir / prefix
|
| 131 |
+
if d.is_dir():
|
| 132 |
+
rm_rf(d)
|
| 133 |
+
|
| 134 |
+
@staticmethod
|
| 135 |
+
def cache_dir_from_config(config: Config, *, _ispytest: bool = False) -> Path:
|
| 136 |
+
"""Get the path to the cache directory for a Config.
|
| 137 |
+
|
| 138 |
+
:meta private:
|
| 139 |
+
"""
|
| 140 |
+
check_ispytest(_ispytest)
|
| 141 |
+
return resolve_from_str(config.getini("cache_dir"), config.rootpath)
|
| 142 |
+
|
| 143 |
+
def warn(self, fmt: str, *, _ispytest: bool = False, **args: object) -> None:
|
| 144 |
+
"""Issue a cache warning.
|
| 145 |
+
|
| 146 |
+
:meta private:
|
| 147 |
+
"""
|
| 148 |
+
check_ispytest(_ispytest)
|
| 149 |
+
import warnings
|
| 150 |
+
|
| 151 |
+
from _pytest.warning_types import PytestCacheWarning
|
| 152 |
+
|
| 153 |
+
warnings.warn(
|
| 154 |
+
PytestCacheWarning(fmt.format(**args) if args else fmt),
|
| 155 |
+
self._config.hook,
|
| 156 |
+
stacklevel=3,
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
def _mkdir(self, path: Path) -> None:
|
| 160 |
+
self._ensure_cache_dir_and_supporting_files()
|
| 161 |
+
path.mkdir(exist_ok=True, parents=True)
|
| 162 |
+
|
| 163 |
+
def mkdir(self, name: str) -> Path:
|
| 164 |
+
"""Return a directory path object with the given name.
|
| 165 |
+
|
| 166 |
+
If the directory does not yet exist, it will be created. You can use
|
| 167 |
+
it to manage files to e.g. store/retrieve database dumps across test
|
| 168 |
+
sessions.
|
| 169 |
+
|
| 170 |
+
.. versionadded:: 7.0
|
| 171 |
+
|
| 172 |
+
:param name:
|
| 173 |
+
Must be a string not containing a ``/`` separator.
|
| 174 |
+
Make sure the name contains your plugin or application
|
| 175 |
+
identifiers to prevent clashes with other cache users.
|
| 176 |
+
"""
|
| 177 |
+
path = Path(name)
|
| 178 |
+
if len(path.parts) > 1:
|
| 179 |
+
raise ValueError("name is not allowed to contain path separators")
|
| 180 |
+
res = self._cachedir.joinpath(self._CACHE_PREFIX_DIRS, path)
|
| 181 |
+
self._mkdir(res)
|
| 182 |
+
return res
|
| 183 |
+
|
| 184 |
+
def _getvaluepath(self, key: str) -> Path:
|
| 185 |
+
return self._cachedir.joinpath(self._CACHE_PREFIX_VALUES, Path(key))
|
| 186 |
+
|
| 187 |
+
def get(self, key: str, default):
|
| 188 |
+
"""Return the cached value for the given key.
|
| 189 |
+
|
| 190 |
+
If no value was yet cached or the value cannot be read, the specified
|
| 191 |
+
default is returned.
|
| 192 |
+
|
| 193 |
+
:param key:
|
| 194 |
+
Must be a ``/`` separated value. Usually the first
|
| 195 |
+
name is the name of your plugin or your application.
|
| 196 |
+
:param default:
|
| 197 |
+
The value to return in case of a cache-miss or invalid cache value.
|
| 198 |
+
"""
|
| 199 |
+
path = self._getvaluepath(key)
|
| 200 |
+
try:
|
| 201 |
+
with path.open("r", encoding="UTF-8") as f:
|
| 202 |
+
return json.load(f)
|
| 203 |
+
except (ValueError, OSError):
|
| 204 |
+
return default
|
| 205 |
+
|
| 206 |
+
def set(self, key: str, value: object) -> None:
|
| 207 |
+
"""Save value for the given key.
|
| 208 |
+
|
| 209 |
+
:param key:
|
| 210 |
+
Must be a ``/`` separated value. Usually the first
|
| 211 |
+
name is the name of your plugin or your application.
|
| 212 |
+
:param value:
|
| 213 |
+
Must be of any combination of basic python types,
|
| 214 |
+
including nested types like lists of dictionaries.
|
| 215 |
+
"""
|
| 216 |
+
path = self._getvaluepath(key)
|
| 217 |
+
try:
|
| 218 |
+
self._mkdir(path.parent)
|
| 219 |
+
except OSError as exc:
|
| 220 |
+
self.warn(
|
| 221 |
+
f"could not create cache path {path}: {exc}",
|
| 222 |
+
_ispytest=True,
|
| 223 |
+
)
|
| 224 |
+
return
|
| 225 |
+
data = json.dumps(value, ensure_ascii=False, indent=2)
|
| 226 |
+
try:
|
| 227 |
+
f = path.open("w", encoding="UTF-8")
|
| 228 |
+
except OSError as exc:
|
| 229 |
+
self.warn(
|
| 230 |
+
f"cache could not write path {path}: {exc}",
|
| 231 |
+
_ispytest=True,
|
| 232 |
+
)
|
| 233 |
+
else:
|
| 234 |
+
with f:
|
| 235 |
+
f.write(data)
|
| 236 |
+
|
| 237 |
+
def _ensure_cache_dir_and_supporting_files(self) -> None:
|
| 238 |
+
"""Create the cache dir and its supporting files."""
|
| 239 |
+
if not self._cachedir.is_dir():
|
| 240 |
+
_make_cachedir(self._cachedir)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class LFPluginCollWrapper:
|
| 244 |
+
def __init__(self, lfplugin: LFPlugin) -> None:
|
| 245 |
+
self.lfplugin = lfplugin
|
| 246 |
+
self._collected_at_least_one_failure = False
|
| 247 |
+
|
| 248 |
+
@hookimpl(wrapper=True)
|
| 249 |
+
def pytest_make_collect_report(
|
| 250 |
+
self, collector: nodes.Collector
|
| 251 |
+
) -> Generator[None, CollectReport, CollectReport]:
|
| 252 |
+
res = yield
|
| 253 |
+
if isinstance(collector, Session | Directory):
|
| 254 |
+
# Sort any lf-paths to the beginning.
|
| 255 |
+
lf_paths = self.lfplugin._last_failed_paths
|
| 256 |
+
|
| 257 |
+
# Use stable sort to prioritize last failed.
|
| 258 |
+
def sort_key(node: nodes.Item | nodes.Collector) -> bool:
|
| 259 |
+
return node.path in lf_paths
|
| 260 |
+
|
| 261 |
+
res.result = sorted(
|
| 262 |
+
res.result,
|
| 263 |
+
key=sort_key,
|
| 264 |
+
reverse=True,
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
elif isinstance(collector, File):
|
| 268 |
+
if collector.path in self.lfplugin._last_failed_paths:
|
| 269 |
+
result = res.result
|
| 270 |
+
lastfailed = self.lfplugin.lastfailed
|
| 271 |
+
|
| 272 |
+
# Only filter with known failures.
|
| 273 |
+
if not self._collected_at_least_one_failure:
|
| 274 |
+
if not any(x.nodeid in lastfailed for x in result):
|
| 275 |
+
return res
|
| 276 |
+
self.lfplugin.config.pluginmanager.register(
|
| 277 |
+
LFPluginCollSkipfiles(self.lfplugin), "lfplugin-collskip"
|
| 278 |
+
)
|
| 279 |
+
self._collected_at_least_one_failure = True
|
| 280 |
+
|
| 281 |
+
session = collector.session
|
| 282 |
+
result[:] = [
|
| 283 |
+
x
|
| 284 |
+
for x in result
|
| 285 |
+
if x.nodeid in lastfailed
|
| 286 |
+
# Include any passed arguments (not trivial to filter).
|
| 287 |
+
or session.isinitpath(x.path)
|
| 288 |
+
# Keep all sub-collectors.
|
| 289 |
+
or isinstance(x, nodes.Collector)
|
| 290 |
+
]
|
| 291 |
+
|
| 292 |
+
return res
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
class LFPluginCollSkipfiles:
|
| 296 |
+
def __init__(self, lfplugin: LFPlugin) -> None:
|
| 297 |
+
self.lfplugin = lfplugin
|
| 298 |
+
|
| 299 |
+
@hookimpl
|
| 300 |
+
def pytest_make_collect_report(
|
| 301 |
+
self, collector: nodes.Collector
|
| 302 |
+
) -> CollectReport | None:
|
| 303 |
+
if isinstance(collector, File):
|
| 304 |
+
if collector.path not in self.lfplugin._last_failed_paths:
|
| 305 |
+
self.lfplugin._skipped_files += 1
|
| 306 |
+
|
| 307 |
+
return CollectReport(
|
| 308 |
+
collector.nodeid, "passed", longrepr=None, result=[]
|
| 309 |
+
)
|
| 310 |
+
return None
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
class LFPlugin:
|
| 314 |
+
"""Plugin which implements the --lf (run last-failing) option."""
|
| 315 |
+
|
| 316 |
+
def __init__(self, config: Config) -> None:
|
| 317 |
+
self.config = config
|
| 318 |
+
active_keys = "lf", "failedfirst"
|
| 319 |
+
self.active = any(config.getoption(key) for key in active_keys)
|
| 320 |
+
assert config.cache
|
| 321 |
+
self.lastfailed: dict[str, bool] = config.cache.get("cache/lastfailed", {})
|
| 322 |
+
self._previously_failed_count: int | None = None
|
| 323 |
+
self._report_status: str | None = None
|
| 324 |
+
self._skipped_files = 0 # count skipped files during collection due to --lf
|
| 325 |
+
|
| 326 |
+
if config.getoption("lf"):
|
| 327 |
+
self._last_failed_paths = self.get_last_failed_paths()
|
| 328 |
+
config.pluginmanager.register(
|
| 329 |
+
LFPluginCollWrapper(self), "lfplugin-collwrapper"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
def get_last_failed_paths(self) -> set[Path]:
|
| 333 |
+
"""Return a set with all Paths of the previously failed nodeids and
|
| 334 |
+
their parents."""
|
| 335 |
+
rootpath = self.config.rootpath
|
| 336 |
+
result = set()
|
| 337 |
+
for nodeid in self.lastfailed:
|
| 338 |
+
path = rootpath / nodeid.split("::")[0]
|
| 339 |
+
result.add(path)
|
| 340 |
+
result.update(path.parents)
|
| 341 |
+
return {x for x in result if x.exists()}
|
| 342 |
+
|
| 343 |
+
def pytest_report_collectionfinish(self) -> str | None:
|
| 344 |
+
if self.active and self.config.get_verbosity() >= 0:
|
| 345 |
+
return f"run-last-failure: {self._report_status}"
|
| 346 |
+
return None
|
| 347 |
+
|
| 348 |
+
def pytest_runtest_logreport(self, report: TestReport) -> None:
|
| 349 |
+
if (report.when == "call" and report.passed) or report.skipped:
|
| 350 |
+
self.lastfailed.pop(report.nodeid, None)
|
| 351 |
+
elif report.failed:
|
| 352 |
+
self.lastfailed[report.nodeid] = True
|
| 353 |
+
|
| 354 |
+
def pytest_collectreport(self, report: CollectReport) -> None:
|
| 355 |
+
passed = report.outcome in ("passed", "skipped")
|
| 356 |
+
if passed:
|
| 357 |
+
if report.nodeid in self.lastfailed:
|
| 358 |
+
self.lastfailed.pop(report.nodeid)
|
| 359 |
+
self.lastfailed.update((item.nodeid, True) for item in report.result)
|
| 360 |
+
else:
|
| 361 |
+
self.lastfailed[report.nodeid] = True
|
| 362 |
+
|
| 363 |
+
@hookimpl(wrapper=True, tryfirst=True)
|
| 364 |
+
def pytest_collection_modifyitems(
|
| 365 |
+
self, config: Config, items: list[nodes.Item]
|
| 366 |
+
) -> Generator[None]:
|
| 367 |
+
res = yield
|
| 368 |
+
|
| 369 |
+
if not self.active:
|
| 370 |
+
return res
|
| 371 |
+
|
| 372 |
+
if self.lastfailed:
|
| 373 |
+
previously_failed = []
|
| 374 |
+
previously_passed = []
|
| 375 |
+
for item in items:
|
| 376 |
+
if item.nodeid in self.lastfailed:
|
| 377 |
+
previously_failed.append(item)
|
| 378 |
+
else:
|
| 379 |
+
previously_passed.append(item)
|
| 380 |
+
self._previously_failed_count = len(previously_failed)
|
| 381 |
+
|
| 382 |
+
if not previously_failed:
|
| 383 |
+
# Running a subset of all tests with recorded failures
|
| 384 |
+
# only outside of it.
|
| 385 |
+
self._report_status = (
|
| 386 |
+
f"{len(self.lastfailed)} known failures not in selected tests"
|
| 387 |
+
)
|
| 388 |
+
else:
|
| 389 |
+
if self.config.getoption("lf"):
|
| 390 |
+
items[:] = previously_failed
|
| 391 |
+
config.hook.pytest_deselected(items=previously_passed)
|
| 392 |
+
else: # --failedfirst
|
| 393 |
+
items[:] = previously_failed + previously_passed
|
| 394 |
+
|
| 395 |
+
noun = "failure" if self._previously_failed_count == 1 else "failures"
|
| 396 |
+
suffix = " first" if self.config.getoption("failedfirst") else ""
|
| 397 |
+
self._report_status = (
|
| 398 |
+
f"rerun previous {self._previously_failed_count} {noun}{suffix}"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
if self._skipped_files > 0:
|
| 402 |
+
files_noun = "file" if self._skipped_files == 1 else "files"
|
| 403 |
+
self._report_status += f" (skipped {self._skipped_files} {files_noun})"
|
| 404 |
+
else:
|
| 405 |
+
self._report_status = "no previously failed tests, "
|
| 406 |
+
if self.config.getoption("last_failed_no_failures") == "none":
|
| 407 |
+
self._report_status += "deselecting all items."
|
| 408 |
+
config.hook.pytest_deselected(items=items[:])
|
| 409 |
+
items[:] = []
|
| 410 |
+
else:
|
| 411 |
+
self._report_status += "not deselecting items."
|
| 412 |
+
|
| 413 |
+
return res
|
| 414 |
+
|
| 415 |
+
def pytest_sessionfinish(self, session: Session) -> None:
|
| 416 |
+
config = self.config
|
| 417 |
+
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
|
| 418 |
+
return
|
| 419 |
+
|
| 420 |
+
assert config.cache is not None
|
| 421 |
+
saved_lastfailed = config.cache.get("cache/lastfailed", {})
|
| 422 |
+
if saved_lastfailed != self.lastfailed:
|
| 423 |
+
config.cache.set("cache/lastfailed", self.lastfailed)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
class NFPlugin:
|
| 427 |
+
"""Plugin which implements the --nf (run new-first) option."""
|
| 428 |
+
|
| 429 |
+
def __init__(self, config: Config) -> None:
|
| 430 |
+
self.config = config
|
| 431 |
+
self.active = config.option.newfirst
|
| 432 |
+
assert config.cache is not None
|
| 433 |
+
self.cached_nodeids = set(config.cache.get("cache/nodeids", []))
|
| 434 |
+
|
| 435 |
+
@hookimpl(wrapper=True, tryfirst=True)
|
| 436 |
+
def pytest_collection_modifyitems(self, items: list[nodes.Item]) -> Generator[None]:
|
| 437 |
+
res = yield
|
| 438 |
+
|
| 439 |
+
if self.active:
|
| 440 |
+
new_items: dict[str, nodes.Item] = {}
|
| 441 |
+
other_items: dict[str, nodes.Item] = {}
|
| 442 |
+
for item in items:
|
| 443 |
+
if item.nodeid not in self.cached_nodeids:
|
| 444 |
+
new_items[item.nodeid] = item
|
| 445 |
+
else:
|
| 446 |
+
other_items[item.nodeid] = item
|
| 447 |
+
|
| 448 |
+
items[:] = self._get_increasing_order(
|
| 449 |
+
new_items.values()
|
| 450 |
+
) + self._get_increasing_order(other_items.values())
|
| 451 |
+
self.cached_nodeids.update(new_items)
|
| 452 |
+
else:
|
| 453 |
+
self.cached_nodeids.update(item.nodeid for item in items)
|
| 454 |
+
|
| 455 |
+
return res
|
| 456 |
+
|
| 457 |
+
def _get_increasing_order(self, items: Iterable[nodes.Item]) -> list[nodes.Item]:
|
| 458 |
+
return sorted(items, key=lambda item: item.path.stat().st_mtime, reverse=True)
|
| 459 |
+
|
| 460 |
+
def pytest_sessionfinish(self) -> None:
|
| 461 |
+
config = self.config
|
| 462 |
+
if config.getoption("cacheshow") or hasattr(config, "workerinput"):
|
| 463 |
+
return
|
| 464 |
+
|
| 465 |
+
if config.getoption("collectonly"):
|
| 466 |
+
return
|
| 467 |
+
|
| 468 |
+
assert config.cache is not None
|
| 469 |
+
config.cache.set("cache/nodeids", sorted(self.cached_nodeids))
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 473 |
+
"""Add command-line options for cache functionality.
|
| 474 |
+
|
| 475 |
+
:param parser: Parser object to add command-line options to.
|
| 476 |
+
"""
|
| 477 |
+
group = parser.getgroup("general")
|
| 478 |
+
group.addoption(
|
| 479 |
+
"--lf",
|
| 480 |
+
"--last-failed",
|
| 481 |
+
action="store_true",
|
| 482 |
+
dest="lf",
|
| 483 |
+
help="Rerun only the tests that failed at the last run (or all if none failed)",
|
| 484 |
+
)
|
| 485 |
+
group.addoption(
|
| 486 |
+
"--ff",
|
| 487 |
+
"--failed-first",
|
| 488 |
+
action="store_true",
|
| 489 |
+
dest="failedfirst",
|
| 490 |
+
help="Run all tests, but run the last failures first. "
|
| 491 |
+
"This may re-order tests and thus lead to "
|
| 492 |
+
"repeated fixture setup/teardown.",
|
| 493 |
+
)
|
| 494 |
+
group.addoption(
|
| 495 |
+
"--nf",
|
| 496 |
+
"--new-first",
|
| 497 |
+
action="store_true",
|
| 498 |
+
dest="newfirst",
|
| 499 |
+
help="Run tests from new files first, then the rest of the tests "
|
| 500 |
+
"sorted by file mtime",
|
| 501 |
+
)
|
| 502 |
+
group.addoption(
|
| 503 |
+
"--cache-show",
|
| 504 |
+
action="append",
|
| 505 |
+
nargs="?",
|
| 506 |
+
dest="cacheshow",
|
| 507 |
+
help=(
|
| 508 |
+
"Show cache contents, don't perform collection or tests. "
|
| 509 |
+
"Optional argument: glob (default: '*')."
|
| 510 |
+
),
|
| 511 |
+
)
|
| 512 |
+
group.addoption(
|
| 513 |
+
"--cache-clear",
|
| 514 |
+
action="store_true",
|
| 515 |
+
dest="cacheclear",
|
| 516 |
+
help="Remove all cache contents at start of test run",
|
| 517 |
+
)
|
| 518 |
+
cache_dir_default = ".pytest_cache"
|
| 519 |
+
if "TOX_ENV_DIR" in os.environ:
|
| 520 |
+
cache_dir_default = os.path.join(os.environ["TOX_ENV_DIR"], cache_dir_default)
|
| 521 |
+
parser.addini("cache_dir", default=cache_dir_default, help="Cache directory path")
|
| 522 |
+
group.addoption(
|
| 523 |
+
"--lfnf",
|
| 524 |
+
"--last-failed-no-failures",
|
| 525 |
+
action="store",
|
| 526 |
+
dest="last_failed_no_failures",
|
| 527 |
+
choices=("all", "none"),
|
| 528 |
+
default="all",
|
| 529 |
+
help="With ``--lf``, determines whether to execute tests when there "
|
| 530 |
+
"are no previously (known) failures or when no "
|
| 531 |
+
"cached ``lastfailed`` data was found. "
|
| 532 |
+
"``all`` (the default) runs the full test suite again. "
|
| 533 |
+
"``none`` just emits a message about no known failures and exits successfully.",
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
|
| 538 |
+
if config.option.cacheshow and not config.option.help:
|
| 539 |
+
from _pytest.main import wrap_session
|
| 540 |
+
|
| 541 |
+
return wrap_session(config, cacheshow)
|
| 542 |
+
return None
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
@hookimpl(tryfirst=True)
|
| 546 |
+
def pytest_configure(config: Config) -> None:
|
| 547 |
+
"""Configure cache system and register related plugins.
|
| 548 |
+
|
| 549 |
+
Creates the Cache instance and registers the last-failed (LFPlugin)
|
| 550 |
+
and new-first (NFPlugin) plugins with the plugin manager.
|
| 551 |
+
|
| 552 |
+
:param config: pytest configuration object.
|
| 553 |
+
"""
|
| 554 |
+
config.cache = Cache.for_config(config, _ispytest=True)
|
| 555 |
+
config.pluginmanager.register(LFPlugin(config), "lfplugin")
|
| 556 |
+
config.pluginmanager.register(NFPlugin(config), "nfplugin")
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
@fixture
|
| 560 |
+
def cache(request: FixtureRequest) -> Cache:
|
| 561 |
+
"""Return a cache object that can persist state between testing sessions.
|
| 562 |
+
|
| 563 |
+
cache.get(key, default)
|
| 564 |
+
cache.set(key, value)
|
| 565 |
+
|
| 566 |
+
Keys must be ``/`` separated strings, where the first part is usually the
|
| 567 |
+
name of your plugin or application to avoid clashes with other cache users.
|
| 568 |
+
|
| 569 |
+
Values can be any object handled by the json stdlib module.
|
| 570 |
+
"""
|
| 571 |
+
assert request.config.cache is not None
|
| 572 |
+
return request.config.cache
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def pytest_report_header(config: Config) -> str | None:
|
| 576 |
+
"""Display cachedir with --cache-show and if non-default."""
|
| 577 |
+
if config.option.verbose > 0 or config.getini("cache_dir") != ".pytest_cache":
|
| 578 |
+
assert config.cache is not None
|
| 579 |
+
cachedir = config.cache._cachedir
|
| 580 |
+
# TODO: evaluate generating upward relative paths
|
| 581 |
+
# starting with .., ../.. if sensible
|
| 582 |
+
|
| 583 |
+
try:
|
| 584 |
+
displaypath = cachedir.relative_to(config.rootpath)
|
| 585 |
+
except ValueError:
|
| 586 |
+
displaypath = cachedir
|
| 587 |
+
return f"cachedir: {displaypath}"
|
| 588 |
+
return None
|
| 589 |
+
|
| 590 |
+
|
| 591 |
+
def cacheshow(config: Config, session: Session) -> int:
|
| 592 |
+
"""Display cache contents when --cache-show is used.
|
| 593 |
+
|
| 594 |
+
Shows cached values and directories matching the specified glob pattern
|
| 595 |
+
(default: '*'). Displays cache location, cached test results, and
|
| 596 |
+
any cached directories created by plugins.
|
| 597 |
+
|
| 598 |
+
:param config: pytest configuration object.
|
| 599 |
+
:param session: pytest session object.
|
| 600 |
+
:returns: Exit code (0 for success).
|
| 601 |
+
"""
|
| 602 |
+
from pprint import pformat
|
| 603 |
+
|
| 604 |
+
assert config.cache is not None
|
| 605 |
+
|
| 606 |
+
tw = TerminalWriter()
|
| 607 |
+
tw.line("cachedir: " + str(config.cache._cachedir))
|
| 608 |
+
if not config.cache._cachedir.is_dir():
|
| 609 |
+
tw.line("cache is empty")
|
| 610 |
+
return 0
|
| 611 |
+
|
| 612 |
+
glob = config.option.cacheshow[0]
|
| 613 |
+
if glob is None:
|
| 614 |
+
glob = "*"
|
| 615 |
+
|
| 616 |
+
dummy = object()
|
| 617 |
+
basedir = config.cache._cachedir
|
| 618 |
+
vdir = basedir / Cache._CACHE_PREFIX_VALUES
|
| 619 |
+
tw.sep("-", f"cache values for {glob!r}")
|
| 620 |
+
for valpath in sorted(x for x in vdir.rglob(glob) if x.is_file()):
|
| 621 |
+
key = str(valpath.relative_to(vdir))
|
| 622 |
+
val = config.cache.get(key, dummy)
|
| 623 |
+
if val is dummy:
|
| 624 |
+
tw.line(f"{key} contains unreadable content, will be ignored")
|
| 625 |
+
else:
|
| 626 |
+
tw.line(f"{key} contains:")
|
| 627 |
+
for line in pformat(val).splitlines():
|
| 628 |
+
tw.line(" " + line)
|
| 629 |
+
|
| 630 |
+
ddir = basedir / Cache._CACHE_PREFIX_DIRS
|
| 631 |
+
if ddir.is_dir():
|
| 632 |
+
contents = sorted(ddir.rglob(glob))
|
| 633 |
+
tw.sep("-", f"cache directories for {glob!r}")
|
| 634 |
+
for p in contents:
|
| 635 |
+
# if p.is_dir():
|
| 636 |
+
# print("%s/" % p.relative_to(basedir))
|
| 637 |
+
if p.is_file():
|
| 638 |
+
key = str(p.relative_to(basedir))
|
| 639 |
+
tw.line(f"{key} is a file of length {p.stat().st_size}")
|
| 640 |
+
return 0
|
.venv/lib/python3.14/site-packages/_pytest/capture.py
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from collections.abc import Generator
|
| 9 |
+
from collections.abc import Iterable
|
| 10 |
+
from collections.abc import Iterator
|
| 11 |
+
import contextlib
|
| 12 |
+
import io
|
| 13 |
+
from io import UnsupportedOperation
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from tempfile import TemporaryFile
|
| 17 |
+
from types import TracebackType
|
| 18 |
+
from typing import Any
|
| 19 |
+
from typing import AnyStr
|
| 20 |
+
from typing import BinaryIO
|
| 21 |
+
from typing import cast
|
| 22 |
+
from typing import Final
|
| 23 |
+
from typing import final
|
| 24 |
+
from typing import Generic
|
| 25 |
+
from typing import Literal
|
| 26 |
+
from typing import NamedTuple
|
| 27 |
+
from typing import TextIO
|
| 28 |
+
from typing import TYPE_CHECKING
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
if TYPE_CHECKING:
|
| 32 |
+
from typing_extensions import Self
|
| 33 |
+
|
| 34 |
+
from _pytest.config import Config
|
| 35 |
+
from _pytest.config import hookimpl
|
| 36 |
+
from _pytest.config.argparsing import Parser
|
| 37 |
+
from _pytest.deprecated import check_ispytest
|
| 38 |
+
from _pytest.fixtures import fixture
|
| 39 |
+
from _pytest.fixtures import SubRequest
|
| 40 |
+
from _pytest.nodes import Collector
|
| 41 |
+
from _pytest.nodes import File
|
| 42 |
+
from _pytest.nodes import Item
|
| 43 |
+
from _pytest.reports import CollectReport
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
_CaptureMethod = Literal["fd", "sys", "no", "tee-sys"]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def pytest_addoption(parser: Parser) -> None:
|
| 50 |
+
group = parser.getgroup("general")
|
| 51 |
+
group.addoption(
|
| 52 |
+
"--capture",
|
| 53 |
+
action="store",
|
| 54 |
+
default="fd",
|
| 55 |
+
metavar="method",
|
| 56 |
+
choices=["fd", "sys", "no", "tee-sys"],
|
| 57 |
+
help="Per-test capturing method: one of fd|sys|no|tee-sys",
|
| 58 |
+
)
|
| 59 |
+
group._addoption( # private to use reserved lower-case short option
|
| 60 |
+
"-s",
|
| 61 |
+
action="store_const",
|
| 62 |
+
const="no",
|
| 63 |
+
dest="capture",
|
| 64 |
+
help="Shortcut for --capture=no",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _colorama_workaround() -> None:
|
| 69 |
+
"""Ensure colorama is imported so that it attaches to the correct stdio
|
| 70 |
+
handles on Windows.
|
| 71 |
+
|
| 72 |
+
colorama uses the terminal on import time. So if something does the
|
| 73 |
+
first import of colorama while I/O capture is active, colorama will
|
| 74 |
+
fail in various ways.
|
| 75 |
+
"""
|
| 76 |
+
if sys.platform.startswith("win32"):
|
| 77 |
+
try:
|
| 78 |
+
import colorama # noqa: F401
|
| 79 |
+
except ImportError:
|
| 80 |
+
pass
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _readline_workaround() -> None:
|
| 84 |
+
"""Ensure readline is imported early so it attaches to the correct stdio handles.
|
| 85 |
+
|
| 86 |
+
This isn't a problem with the default GNU readline implementation, but in
|
| 87 |
+
some configurations, Python uses libedit instead (on macOS, and for prebuilt
|
| 88 |
+
binaries such as used by uv).
|
| 89 |
+
|
| 90 |
+
In theory this is only needed if readline.backend == "libedit", but the
|
| 91 |
+
workaround consists of importing readline here, so we already worked around
|
| 92 |
+
the issue by the time we could check if we need to.
|
| 93 |
+
"""
|
| 94 |
+
try:
|
| 95 |
+
import readline # noqa: F401
|
| 96 |
+
except ImportError:
|
| 97 |
+
pass
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _windowsconsoleio_workaround(stream: TextIO) -> None:
|
| 101 |
+
"""Workaround for Windows Unicode console handling.
|
| 102 |
+
|
| 103 |
+
Python 3.6 implemented Unicode console handling for Windows. This works
|
| 104 |
+
by reading/writing to the raw console handle using
|
| 105 |
+
``{Read,Write}ConsoleW``.
|
| 106 |
+
|
| 107 |
+
The problem is that we are going to ``dup2`` over the stdio file
|
| 108 |
+
descriptors when doing ``FDCapture`` and this will ``CloseHandle`` the
|
| 109 |
+
handles used by Python to write to the console. Though there is still some
|
| 110 |
+
weirdness and the console handle seems to only be closed randomly and not
|
| 111 |
+
on the first call to ``CloseHandle``, or maybe it gets reopened with the
|
| 112 |
+
same handle value when we suspend capturing.
|
| 113 |
+
|
| 114 |
+
The workaround in this case will reopen stdio with a different fd which
|
| 115 |
+
also means a different handle by replicating the logic in
|
| 116 |
+
"Py_lifecycle.c:initstdio/create_stdio".
|
| 117 |
+
|
| 118 |
+
:param stream:
|
| 119 |
+
In practice ``sys.stdout`` or ``sys.stderr``, but given
|
| 120 |
+
here as parameter for unittesting purposes.
|
| 121 |
+
|
| 122 |
+
See https://github.com/pytest-dev/py/issues/103.
|
| 123 |
+
"""
|
| 124 |
+
if not sys.platform.startswith("win32") or hasattr(sys, "pypy_version_info"):
|
| 125 |
+
return
|
| 126 |
+
|
| 127 |
+
# Bail out if ``stream`` doesn't seem like a proper ``io`` stream (#2666).
|
| 128 |
+
if not hasattr(stream, "buffer"): # type: ignore[unreachable,unused-ignore]
|
| 129 |
+
return
|
| 130 |
+
|
| 131 |
+
raw_stdout = stream.buffer.raw if hasattr(stream.buffer, "raw") else stream.buffer
|
| 132 |
+
|
| 133 |
+
if not isinstance(raw_stdout, io._WindowsConsoleIO): # type: ignore[attr-defined,unused-ignore]
|
| 134 |
+
return
|
| 135 |
+
|
| 136 |
+
def _reopen_stdio(f, mode):
|
| 137 |
+
if not hasattr(stream.buffer, "raw") and mode[0] == "w":
|
| 138 |
+
buffering = 0
|
| 139 |
+
else:
|
| 140 |
+
buffering = -1
|
| 141 |
+
|
| 142 |
+
return io.TextIOWrapper(
|
| 143 |
+
open(os.dup(f.fileno()), mode, buffering),
|
| 144 |
+
f.encoding,
|
| 145 |
+
f.errors,
|
| 146 |
+
f.newlines,
|
| 147 |
+
f.line_buffering,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
sys.stdin = _reopen_stdio(sys.stdin, "rb")
|
| 151 |
+
sys.stdout = _reopen_stdio(sys.stdout, "wb")
|
| 152 |
+
sys.stderr = _reopen_stdio(sys.stderr, "wb")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@hookimpl(wrapper=True)
|
| 156 |
+
def pytest_load_initial_conftests(early_config: Config) -> Generator[None]:
|
| 157 |
+
ns = early_config.known_args_namespace
|
| 158 |
+
if ns.capture == "fd":
|
| 159 |
+
_windowsconsoleio_workaround(sys.stdout)
|
| 160 |
+
_colorama_workaround()
|
| 161 |
+
_readline_workaround()
|
| 162 |
+
pluginmanager = early_config.pluginmanager
|
| 163 |
+
capman = CaptureManager(ns.capture)
|
| 164 |
+
pluginmanager.register(capman, "capturemanager")
|
| 165 |
+
|
| 166 |
+
# Make sure that capturemanager is properly reset at final shutdown.
|
| 167 |
+
early_config.add_cleanup(capman.stop_global_capturing)
|
| 168 |
+
|
| 169 |
+
# Finally trigger conftest loading but while capturing (issue #93).
|
| 170 |
+
capman.start_global_capturing()
|
| 171 |
+
try:
|
| 172 |
+
try:
|
| 173 |
+
yield
|
| 174 |
+
finally:
|
| 175 |
+
capman.suspend_global_capture()
|
| 176 |
+
except BaseException:
|
| 177 |
+
out, err = capman.read_global_capture()
|
| 178 |
+
sys.stdout.write(out)
|
| 179 |
+
sys.stderr.write(err)
|
| 180 |
+
raise
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# IO Helpers.
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class EncodedFile(io.TextIOWrapper):
|
| 187 |
+
__slots__ = ()
|
| 188 |
+
|
| 189 |
+
@property
|
| 190 |
+
def name(self) -> str:
|
| 191 |
+
# Ensure that file.name is a string. Workaround for a Python bug
|
| 192 |
+
# fixed in >=3.7.4: https://bugs.python.org/issue36015
|
| 193 |
+
return repr(self.buffer)
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def mode(self) -> str:
|
| 197 |
+
# TextIOWrapper doesn't expose a mode, but at least some of our
|
| 198 |
+
# tests check it.
|
| 199 |
+
assert hasattr(self.buffer, "mode")
|
| 200 |
+
return cast(str, self.buffer.mode.replace("b", ""))
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class CaptureIO(io.TextIOWrapper):
|
| 204 |
+
def __init__(self) -> None:
|
| 205 |
+
super().__init__(io.BytesIO(), encoding="UTF-8", newline="", write_through=True)
|
| 206 |
+
|
| 207 |
+
def getvalue(self) -> str:
|
| 208 |
+
assert isinstance(self.buffer, io.BytesIO)
|
| 209 |
+
return self.buffer.getvalue().decode("UTF-8")
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class TeeCaptureIO(CaptureIO):
|
| 213 |
+
def __init__(self, other: TextIO) -> None:
|
| 214 |
+
self._other = other
|
| 215 |
+
super().__init__()
|
| 216 |
+
|
| 217 |
+
def write(self, s: str) -> int:
|
| 218 |
+
super().write(s)
|
| 219 |
+
return self._other.write(s)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class DontReadFromInput(TextIO):
|
| 223 |
+
@property
|
| 224 |
+
def encoding(self) -> str:
|
| 225 |
+
assert sys.__stdin__ is not None
|
| 226 |
+
return sys.__stdin__.encoding
|
| 227 |
+
|
| 228 |
+
def read(self, size: int = -1) -> str:
|
| 229 |
+
raise OSError(
|
| 230 |
+
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
readline = read
|
| 234 |
+
|
| 235 |
+
def __next__(self) -> str:
|
| 236 |
+
return self.readline()
|
| 237 |
+
|
| 238 |
+
def readlines(self, hint: int | None = -1) -> list[str]:
|
| 239 |
+
raise OSError(
|
| 240 |
+
"pytest: reading from stdin while output is captured! Consider using `-s`."
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
def __iter__(self) -> Iterator[str]:
|
| 244 |
+
return self
|
| 245 |
+
|
| 246 |
+
def fileno(self) -> int:
|
| 247 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no fileno()")
|
| 248 |
+
|
| 249 |
+
def flush(self) -> None:
|
| 250 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no flush()")
|
| 251 |
+
|
| 252 |
+
def isatty(self) -> bool:
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
def close(self) -> None:
|
| 256 |
+
pass
|
| 257 |
+
|
| 258 |
+
def readable(self) -> bool:
|
| 259 |
+
return False
|
| 260 |
+
|
| 261 |
+
def seek(self, offset: int, whence: int = 0) -> int:
|
| 262 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no seek(int)")
|
| 263 |
+
|
| 264 |
+
def seekable(self) -> bool:
|
| 265 |
+
return False
|
| 266 |
+
|
| 267 |
+
def tell(self) -> int:
|
| 268 |
+
raise UnsupportedOperation("redirected stdin is pseudofile, has no tell()")
|
| 269 |
+
|
| 270 |
+
def truncate(self, size: int | None = None) -> int:
|
| 271 |
+
raise UnsupportedOperation("cannot truncate stdin")
|
| 272 |
+
|
| 273 |
+
def write(self, data: str) -> int:
|
| 274 |
+
raise UnsupportedOperation("cannot write to stdin")
|
| 275 |
+
|
| 276 |
+
def writelines(self, lines: Iterable[str]) -> None:
|
| 277 |
+
raise UnsupportedOperation("Cannot write to stdin")
|
| 278 |
+
|
| 279 |
+
def writable(self) -> bool:
|
| 280 |
+
return False
|
| 281 |
+
|
| 282 |
+
def __enter__(self) -> Self:
|
| 283 |
+
return self
|
| 284 |
+
|
| 285 |
+
def __exit__(
|
| 286 |
+
self,
|
| 287 |
+
type: type[BaseException] | None,
|
| 288 |
+
value: BaseException | None,
|
| 289 |
+
traceback: TracebackType | None,
|
| 290 |
+
) -> None:
|
| 291 |
+
pass
|
| 292 |
+
|
| 293 |
+
@property
|
| 294 |
+
def buffer(self) -> BinaryIO:
|
| 295 |
+
# The str/bytes doesn't actually matter in this type, so OK to fake.
|
| 296 |
+
return self # type: ignore[return-value]
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# Capture classes.
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class CaptureBase(abc.ABC, Generic[AnyStr]):
|
| 303 |
+
EMPTY_BUFFER: AnyStr
|
| 304 |
+
|
| 305 |
+
@abc.abstractmethod
|
| 306 |
+
def __init__(self, fd: int) -> None:
|
| 307 |
+
raise NotImplementedError()
|
| 308 |
+
|
| 309 |
+
@abc.abstractmethod
|
| 310 |
+
def start(self) -> None:
|
| 311 |
+
raise NotImplementedError()
|
| 312 |
+
|
| 313 |
+
@abc.abstractmethod
|
| 314 |
+
def done(self) -> None:
|
| 315 |
+
raise NotImplementedError()
|
| 316 |
+
|
| 317 |
+
@abc.abstractmethod
|
| 318 |
+
def suspend(self) -> None:
|
| 319 |
+
raise NotImplementedError()
|
| 320 |
+
|
| 321 |
+
@abc.abstractmethod
|
| 322 |
+
def resume(self) -> None:
|
| 323 |
+
raise NotImplementedError()
|
| 324 |
+
|
| 325 |
+
@abc.abstractmethod
|
| 326 |
+
def writeorg(self, data: AnyStr) -> None:
|
| 327 |
+
raise NotImplementedError()
|
| 328 |
+
|
| 329 |
+
@abc.abstractmethod
|
| 330 |
+
def snap(self) -> AnyStr:
|
| 331 |
+
raise NotImplementedError()
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
patchsysdict = {0: "stdin", 1: "stdout", 2: "stderr"}
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
class NoCapture(CaptureBase[str]):
|
| 338 |
+
EMPTY_BUFFER = ""
|
| 339 |
+
|
| 340 |
+
def __init__(self, fd: int) -> None:
|
| 341 |
+
pass
|
| 342 |
+
|
| 343 |
+
def start(self) -> None:
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
def done(self) -> None:
|
| 347 |
+
pass
|
| 348 |
+
|
| 349 |
+
def suspend(self) -> None:
|
| 350 |
+
pass
|
| 351 |
+
|
| 352 |
+
def resume(self) -> None:
|
| 353 |
+
pass
|
| 354 |
+
|
| 355 |
+
def snap(self) -> str:
|
| 356 |
+
return ""
|
| 357 |
+
|
| 358 |
+
def writeorg(self, data: str) -> None:
|
| 359 |
+
pass
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
class SysCaptureBase(CaptureBase[AnyStr]):
|
| 363 |
+
def __init__(
|
| 364 |
+
self, fd: int, tmpfile: TextIO | None = None, *, tee: bool = False
|
| 365 |
+
) -> None:
|
| 366 |
+
name = patchsysdict[fd]
|
| 367 |
+
self._old: TextIO = getattr(sys, name)
|
| 368 |
+
self.name = name
|
| 369 |
+
if tmpfile is None:
|
| 370 |
+
if name == "stdin":
|
| 371 |
+
tmpfile = DontReadFromInput()
|
| 372 |
+
else:
|
| 373 |
+
tmpfile = CaptureIO() if not tee else TeeCaptureIO(self._old)
|
| 374 |
+
self.tmpfile = tmpfile
|
| 375 |
+
self._state = "initialized"
|
| 376 |
+
|
| 377 |
+
def repr(self, class_name: str) -> str:
|
| 378 |
+
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
| 379 |
+
class_name,
|
| 380 |
+
self.name,
|
| 381 |
+
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
|
| 382 |
+
self._state,
|
| 383 |
+
self.tmpfile,
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
def __repr__(self) -> str:
|
| 387 |
+
return "<{} {} _old={} _state={!r} tmpfile={!r}>".format(
|
| 388 |
+
self.__class__.__name__,
|
| 389 |
+
self.name,
|
| 390 |
+
(hasattr(self, "_old") and repr(self._old)) or "<UNSET>",
|
| 391 |
+
self._state,
|
| 392 |
+
self.tmpfile,
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
| 396 |
+
assert self._state in states, (
|
| 397 |
+
"cannot {} in state {!r}: expected one of {}".format(
|
| 398 |
+
op, self._state, ", ".join(states)
|
| 399 |
+
)
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
def start(self) -> None:
|
| 403 |
+
self._assert_state("start", ("initialized",))
|
| 404 |
+
setattr(sys, self.name, self.tmpfile)
|
| 405 |
+
self._state = "started"
|
| 406 |
+
|
| 407 |
+
def done(self) -> None:
|
| 408 |
+
self._assert_state("done", ("initialized", "started", "suspended", "done"))
|
| 409 |
+
if self._state == "done":
|
| 410 |
+
return
|
| 411 |
+
setattr(sys, self.name, self._old)
|
| 412 |
+
del self._old
|
| 413 |
+
self.tmpfile.close()
|
| 414 |
+
self._state = "done"
|
| 415 |
+
|
| 416 |
+
def suspend(self) -> None:
|
| 417 |
+
self._assert_state("suspend", ("started", "suspended"))
|
| 418 |
+
setattr(sys, self.name, self._old)
|
| 419 |
+
self._state = "suspended"
|
| 420 |
+
|
| 421 |
+
def resume(self) -> None:
|
| 422 |
+
self._assert_state("resume", ("started", "suspended"))
|
| 423 |
+
if self._state == "started":
|
| 424 |
+
return
|
| 425 |
+
setattr(sys, self.name, self.tmpfile)
|
| 426 |
+
self._state = "started"
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
class SysCaptureBinary(SysCaptureBase[bytes]):
|
| 430 |
+
EMPTY_BUFFER = b""
|
| 431 |
+
|
| 432 |
+
def snap(self) -> bytes:
|
| 433 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 434 |
+
self.tmpfile.seek(0)
|
| 435 |
+
res = self.tmpfile.buffer.read()
|
| 436 |
+
self.tmpfile.seek(0)
|
| 437 |
+
self.tmpfile.truncate()
|
| 438 |
+
return res
|
| 439 |
+
|
| 440 |
+
def writeorg(self, data: bytes) -> None:
|
| 441 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 442 |
+
self._old.flush()
|
| 443 |
+
self._old.buffer.write(data)
|
| 444 |
+
self._old.buffer.flush()
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
class SysCapture(SysCaptureBase[str]):
|
| 448 |
+
EMPTY_BUFFER = ""
|
| 449 |
+
|
| 450 |
+
def snap(self) -> str:
|
| 451 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 452 |
+
assert isinstance(self.tmpfile, CaptureIO)
|
| 453 |
+
res = self.tmpfile.getvalue()
|
| 454 |
+
self.tmpfile.seek(0)
|
| 455 |
+
self.tmpfile.truncate()
|
| 456 |
+
return res
|
| 457 |
+
|
| 458 |
+
def writeorg(self, data: str) -> None:
|
| 459 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 460 |
+
self._old.write(data)
|
| 461 |
+
self._old.flush()
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
class FDCaptureBase(CaptureBase[AnyStr]):
|
| 465 |
+
def __init__(self, targetfd: int) -> None:
|
| 466 |
+
self.targetfd = targetfd
|
| 467 |
+
|
| 468 |
+
try:
|
| 469 |
+
os.fstat(targetfd)
|
| 470 |
+
except OSError:
|
| 471 |
+
# FD capturing is conceptually simple -- create a temporary file,
|
| 472 |
+
# redirect the FD to it, redirect back when done. But when the
|
| 473 |
+
# target FD is invalid it throws a wrench into this lovely scheme.
|
| 474 |
+
#
|
| 475 |
+
# Tests themselves shouldn't care if the FD is valid, FD capturing
|
| 476 |
+
# should work regardless of external circumstances. So falling back
|
| 477 |
+
# to just sys capturing is not a good option.
|
| 478 |
+
#
|
| 479 |
+
# Further complications are the need to support suspend() and the
|
| 480 |
+
# possibility of FD reuse (e.g. the tmpfile getting the very same
|
| 481 |
+
# target FD). The following approach is robust, I believe.
|
| 482 |
+
self.targetfd_invalid: int | None = os.open(os.devnull, os.O_RDWR)
|
| 483 |
+
os.dup2(self.targetfd_invalid, targetfd)
|
| 484 |
+
else:
|
| 485 |
+
self.targetfd_invalid = None
|
| 486 |
+
self.targetfd_save = os.dup(targetfd)
|
| 487 |
+
|
| 488 |
+
if targetfd == 0:
|
| 489 |
+
self.tmpfile = open(os.devnull, encoding="utf-8")
|
| 490 |
+
self.syscapture: CaptureBase[str] = SysCapture(targetfd)
|
| 491 |
+
else:
|
| 492 |
+
self.tmpfile = EncodedFile(
|
| 493 |
+
TemporaryFile(buffering=0),
|
| 494 |
+
encoding="utf-8",
|
| 495 |
+
errors="replace",
|
| 496 |
+
newline="",
|
| 497 |
+
write_through=True,
|
| 498 |
+
)
|
| 499 |
+
if targetfd in patchsysdict:
|
| 500 |
+
self.syscapture = SysCapture(targetfd, self.tmpfile)
|
| 501 |
+
else:
|
| 502 |
+
self.syscapture = NoCapture(targetfd)
|
| 503 |
+
|
| 504 |
+
self._state = "initialized"
|
| 505 |
+
|
| 506 |
+
def __repr__(self) -> str:
|
| 507 |
+
return (
|
| 508 |
+
f"<{self.__class__.__name__} {self.targetfd} oldfd={self.targetfd_save} "
|
| 509 |
+
f"_state={self._state!r} tmpfile={self.tmpfile!r}>"
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
def _assert_state(self, op: str, states: tuple[str, ...]) -> None:
|
| 513 |
+
assert self._state in states, (
|
| 514 |
+
"cannot {} in state {!r}: expected one of {}".format(
|
| 515 |
+
op, self._state, ", ".join(states)
|
| 516 |
+
)
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
def start(self) -> None:
|
| 520 |
+
"""Start capturing on targetfd using memorized tmpfile."""
|
| 521 |
+
self._assert_state("start", ("initialized",))
|
| 522 |
+
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
| 523 |
+
self.syscapture.start()
|
| 524 |
+
self._state = "started"
|
| 525 |
+
|
| 526 |
+
def done(self) -> None:
|
| 527 |
+
"""Stop capturing, restore streams, return original capture file,
|
| 528 |
+
seeked to position zero."""
|
| 529 |
+
self._assert_state("done", ("initialized", "started", "suspended", "done"))
|
| 530 |
+
if self._state == "done":
|
| 531 |
+
return
|
| 532 |
+
os.dup2(self.targetfd_save, self.targetfd)
|
| 533 |
+
os.close(self.targetfd_save)
|
| 534 |
+
if self.targetfd_invalid is not None:
|
| 535 |
+
if self.targetfd_invalid != self.targetfd:
|
| 536 |
+
os.close(self.targetfd)
|
| 537 |
+
os.close(self.targetfd_invalid)
|
| 538 |
+
self.syscapture.done()
|
| 539 |
+
self.tmpfile.close()
|
| 540 |
+
self._state = "done"
|
| 541 |
+
|
| 542 |
+
def suspend(self) -> None:
|
| 543 |
+
self._assert_state("suspend", ("started", "suspended"))
|
| 544 |
+
if self._state == "suspended":
|
| 545 |
+
return
|
| 546 |
+
self.syscapture.suspend()
|
| 547 |
+
os.dup2(self.targetfd_save, self.targetfd)
|
| 548 |
+
self._state = "suspended"
|
| 549 |
+
|
| 550 |
+
def resume(self) -> None:
|
| 551 |
+
self._assert_state("resume", ("started", "suspended"))
|
| 552 |
+
if self._state == "started":
|
| 553 |
+
return
|
| 554 |
+
self.syscapture.resume()
|
| 555 |
+
os.dup2(self.tmpfile.fileno(), self.targetfd)
|
| 556 |
+
self._state = "started"
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
class FDCaptureBinary(FDCaptureBase[bytes]):
|
| 560 |
+
"""Capture IO to/from a given OS-level file descriptor.
|
| 561 |
+
|
| 562 |
+
snap() produces `bytes`.
|
| 563 |
+
"""
|
| 564 |
+
|
| 565 |
+
EMPTY_BUFFER = b""
|
| 566 |
+
|
| 567 |
+
def snap(self) -> bytes:
|
| 568 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 569 |
+
self.tmpfile.seek(0)
|
| 570 |
+
res = self.tmpfile.buffer.read()
|
| 571 |
+
self.tmpfile.seek(0)
|
| 572 |
+
self.tmpfile.truncate()
|
| 573 |
+
return res # type: ignore[return-value]
|
| 574 |
+
|
| 575 |
+
def writeorg(self, data: bytes) -> None:
|
| 576 |
+
"""Write to original file descriptor."""
|
| 577 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 578 |
+
os.write(self.targetfd_save, data)
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
class FDCapture(FDCaptureBase[str]):
|
| 582 |
+
"""Capture IO to/from a given OS-level file descriptor.
|
| 583 |
+
|
| 584 |
+
snap() produces text.
|
| 585 |
+
"""
|
| 586 |
+
|
| 587 |
+
EMPTY_BUFFER = ""
|
| 588 |
+
|
| 589 |
+
def snap(self) -> str:
|
| 590 |
+
self._assert_state("snap", ("started", "suspended"))
|
| 591 |
+
self.tmpfile.seek(0)
|
| 592 |
+
res = self.tmpfile.read()
|
| 593 |
+
self.tmpfile.seek(0)
|
| 594 |
+
self.tmpfile.truncate()
|
| 595 |
+
return res
|
| 596 |
+
|
| 597 |
+
def writeorg(self, data: str) -> None:
|
| 598 |
+
"""Write to original file descriptor."""
|
| 599 |
+
self._assert_state("writeorg", ("started", "suspended"))
|
| 600 |
+
# XXX use encoding of original stream
|
| 601 |
+
os.write(self.targetfd_save, data.encode("utf-8"))
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
# MultiCapture
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
# Generic NamedTuple only supported since Python 3.11.
|
| 608 |
+
if sys.version_info >= (3, 11) or TYPE_CHECKING:
|
| 609 |
+
|
| 610 |
+
@final
|
| 611 |
+
class CaptureResult(NamedTuple, Generic[AnyStr]):
|
| 612 |
+
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
|
| 613 |
+
|
| 614 |
+
out: AnyStr
|
| 615 |
+
err: AnyStr
|
| 616 |
+
|
| 617 |
+
else:
|
| 618 |
+
|
| 619 |
+
class CaptureResult(
|
| 620 |
+
collections.namedtuple("CaptureResult", ["out", "err"]), # noqa: PYI024
|
| 621 |
+
Generic[AnyStr],
|
| 622 |
+
):
|
| 623 |
+
"""The result of :method:`caplog.readouterr() <pytest.CaptureFixture.readouterr>`."""
|
| 624 |
+
|
| 625 |
+
__slots__ = ()
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
class MultiCapture(Generic[AnyStr]):
|
| 629 |
+
_state = None
|
| 630 |
+
_in_suspended = False
|
| 631 |
+
|
| 632 |
+
def __init__(
|
| 633 |
+
self,
|
| 634 |
+
in_: CaptureBase[AnyStr] | None,
|
| 635 |
+
out: CaptureBase[AnyStr] | None,
|
| 636 |
+
err: CaptureBase[AnyStr] | None,
|
| 637 |
+
) -> None:
|
| 638 |
+
self.in_: CaptureBase[AnyStr] | None = in_
|
| 639 |
+
self.out: CaptureBase[AnyStr] | None = out
|
| 640 |
+
self.err: CaptureBase[AnyStr] | None = err
|
| 641 |
+
|
| 642 |
+
def __repr__(self) -> str:
|
| 643 |
+
return (
|
| 644 |
+
f"<MultiCapture out={self.out!r} err={self.err!r} in_={self.in_!r} "
|
| 645 |
+
f"_state={self._state!r} _in_suspended={self._in_suspended!r}>"
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
def start_capturing(self) -> None:
|
| 649 |
+
self._state = "started"
|
| 650 |
+
if self.in_:
|
| 651 |
+
self.in_.start()
|
| 652 |
+
if self.out:
|
| 653 |
+
self.out.start()
|
| 654 |
+
if self.err:
|
| 655 |
+
self.err.start()
|
| 656 |
+
|
| 657 |
+
def pop_outerr_to_orig(self) -> tuple[AnyStr, AnyStr]:
|
| 658 |
+
"""Pop current snapshot out/err capture and flush to orig streams."""
|
| 659 |
+
out, err = self.readouterr()
|
| 660 |
+
if out:
|
| 661 |
+
assert self.out is not None
|
| 662 |
+
self.out.writeorg(out)
|
| 663 |
+
if err:
|
| 664 |
+
assert self.err is not None
|
| 665 |
+
self.err.writeorg(err)
|
| 666 |
+
return out, err
|
| 667 |
+
|
| 668 |
+
def suspend_capturing(self, in_: bool = False) -> None:
|
| 669 |
+
self._state = "suspended"
|
| 670 |
+
if self.out:
|
| 671 |
+
self.out.suspend()
|
| 672 |
+
if self.err:
|
| 673 |
+
self.err.suspend()
|
| 674 |
+
if in_ and self.in_:
|
| 675 |
+
self.in_.suspend()
|
| 676 |
+
self._in_suspended = True
|
| 677 |
+
|
| 678 |
+
def resume_capturing(self) -> None:
|
| 679 |
+
self._state = "started"
|
| 680 |
+
if self.out:
|
| 681 |
+
self.out.resume()
|
| 682 |
+
if self.err:
|
| 683 |
+
self.err.resume()
|
| 684 |
+
if self._in_suspended:
|
| 685 |
+
assert self.in_ is not None
|
| 686 |
+
self.in_.resume()
|
| 687 |
+
self._in_suspended = False
|
| 688 |
+
|
| 689 |
+
def stop_capturing(self) -> None:
|
| 690 |
+
"""Stop capturing and reset capturing streams."""
|
| 691 |
+
if self._state == "stopped":
|
| 692 |
+
raise ValueError("was already stopped")
|
| 693 |
+
self._state = "stopped"
|
| 694 |
+
if self.out:
|
| 695 |
+
self.out.done()
|
| 696 |
+
if self.err:
|
| 697 |
+
self.err.done()
|
| 698 |
+
if self.in_:
|
| 699 |
+
self.in_.done()
|
| 700 |
+
|
| 701 |
+
def is_started(self) -> bool:
|
| 702 |
+
"""Whether actively capturing -- not suspended or stopped."""
|
| 703 |
+
return self._state == "started"
|
| 704 |
+
|
| 705 |
+
def readouterr(self) -> CaptureResult[AnyStr]:
|
| 706 |
+
out = self.out.snap() if self.out else ""
|
| 707 |
+
err = self.err.snap() if self.err else ""
|
| 708 |
+
# TODO: This type error is real, need to fix.
|
| 709 |
+
return CaptureResult(out, err) # type: ignore[arg-type]
|
| 710 |
+
|
| 711 |
+
|
| 712 |
+
def _get_multicapture(method: _CaptureMethod) -> MultiCapture[str]:
|
| 713 |
+
if method == "fd":
|
| 714 |
+
return MultiCapture(in_=FDCapture(0), out=FDCapture(1), err=FDCapture(2))
|
| 715 |
+
elif method == "sys":
|
| 716 |
+
return MultiCapture(in_=SysCapture(0), out=SysCapture(1), err=SysCapture(2))
|
| 717 |
+
elif method == "no":
|
| 718 |
+
return MultiCapture(in_=None, out=None, err=None)
|
| 719 |
+
elif method == "tee-sys":
|
| 720 |
+
return MultiCapture(
|
| 721 |
+
in_=None, out=SysCapture(1, tee=True), err=SysCapture(2, tee=True)
|
| 722 |
+
)
|
| 723 |
+
raise ValueError(f"unknown capturing method: {method!r}")
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
# CaptureManager and CaptureFixture
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
class CaptureManager:
|
| 730 |
+
"""The capture plugin.
|
| 731 |
+
|
| 732 |
+
Manages that the appropriate capture method is enabled/disabled during
|
| 733 |
+
collection and each test phase (setup, call, teardown). After each of
|
| 734 |
+
those points, the captured output is obtained and attached to the
|
| 735 |
+
collection/runtest report.
|
| 736 |
+
|
| 737 |
+
There are two levels of capture:
|
| 738 |
+
|
| 739 |
+
* global: enabled by default and can be suppressed by the ``-s``
|
| 740 |
+
option. This is always enabled/disabled during collection and each test
|
| 741 |
+
phase.
|
| 742 |
+
|
| 743 |
+
* fixture: when a test function or one of its fixture depend on the
|
| 744 |
+
``capsys`` or ``capfd`` fixtures. In this case special handling is
|
| 745 |
+
needed to ensure the fixtures take precedence over the global capture.
|
| 746 |
+
"""
|
| 747 |
+
|
| 748 |
+
def __init__(self, method: _CaptureMethod) -> None:
|
| 749 |
+
self._method: Final = method
|
| 750 |
+
self._global_capturing: MultiCapture[str] | None = None
|
| 751 |
+
self._capture_fixture: CaptureFixture[Any] | None = None
|
| 752 |
+
|
| 753 |
+
def __repr__(self) -> str:
|
| 754 |
+
return (
|
| 755 |
+
f"<CaptureManager _method={self._method!r} _global_capturing={self._global_capturing!r} "
|
| 756 |
+
f"_capture_fixture={self._capture_fixture!r}>"
|
| 757 |
+
)
|
| 758 |
+
|
| 759 |
+
def is_capturing(self) -> str | bool:
|
| 760 |
+
if self.is_globally_capturing():
|
| 761 |
+
return "global"
|
| 762 |
+
if self._capture_fixture:
|
| 763 |
+
return f"fixture {self._capture_fixture.request.fixturename}"
|
| 764 |
+
return False
|
| 765 |
+
|
| 766 |
+
# Global capturing control
|
| 767 |
+
|
| 768 |
+
def is_globally_capturing(self) -> bool:
|
| 769 |
+
return self._method != "no"
|
| 770 |
+
|
| 771 |
+
def start_global_capturing(self) -> None:
|
| 772 |
+
assert self._global_capturing is None
|
| 773 |
+
self._global_capturing = _get_multicapture(self._method)
|
| 774 |
+
self._global_capturing.start_capturing()
|
| 775 |
+
|
| 776 |
+
def stop_global_capturing(self) -> None:
|
| 777 |
+
if self._global_capturing is not None:
|
| 778 |
+
self._global_capturing.pop_outerr_to_orig()
|
| 779 |
+
self._global_capturing.stop_capturing()
|
| 780 |
+
self._global_capturing = None
|
| 781 |
+
|
| 782 |
+
def resume_global_capture(self) -> None:
|
| 783 |
+
# During teardown of the python process, and on rare occasions, capture
|
| 784 |
+
# attributes can be `None` while trying to resume global capture.
|
| 785 |
+
if self._global_capturing is not None:
|
| 786 |
+
self._global_capturing.resume_capturing()
|
| 787 |
+
|
| 788 |
+
def suspend_global_capture(self, in_: bool = False) -> None:
|
| 789 |
+
if self._global_capturing is not None:
|
| 790 |
+
self._global_capturing.suspend_capturing(in_=in_)
|
| 791 |
+
|
| 792 |
+
def suspend(self, in_: bool = False) -> None:
|
| 793 |
+
# Need to undo local capsys-et-al if it exists before disabling global capture.
|
| 794 |
+
self.suspend_fixture()
|
| 795 |
+
self.suspend_global_capture(in_)
|
| 796 |
+
|
| 797 |
+
def resume(self) -> None:
|
| 798 |
+
self.resume_global_capture()
|
| 799 |
+
self.resume_fixture()
|
| 800 |
+
|
| 801 |
+
def read_global_capture(self) -> CaptureResult[str]:
|
| 802 |
+
assert self._global_capturing is not None
|
| 803 |
+
return self._global_capturing.readouterr()
|
| 804 |
+
|
| 805 |
+
# Fixture Control
|
| 806 |
+
|
| 807 |
+
def set_fixture(self, capture_fixture: CaptureFixture[Any]) -> None:
|
| 808 |
+
if self._capture_fixture:
|
| 809 |
+
current_fixture = self._capture_fixture.request.fixturename
|
| 810 |
+
requested_fixture = capture_fixture.request.fixturename
|
| 811 |
+
capture_fixture.request.raiseerror(
|
| 812 |
+
f"cannot use {requested_fixture} and {current_fixture} at the same time"
|
| 813 |
+
)
|
| 814 |
+
self._capture_fixture = capture_fixture
|
| 815 |
+
|
| 816 |
+
def unset_fixture(self) -> None:
|
| 817 |
+
self._capture_fixture = None
|
| 818 |
+
|
| 819 |
+
def activate_fixture(self) -> None:
|
| 820 |
+
"""If the current item is using ``capsys`` or ``capfd``, activate
|
| 821 |
+
them so they take precedence over the global capture."""
|
| 822 |
+
if self._capture_fixture:
|
| 823 |
+
self._capture_fixture._start()
|
| 824 |
+
|
| 825 |
+
def deactivate_fixture(self) -> None:
|
| 826 |
+
"""Deactivate the ``capsys`` or ``capfd`` fixture of this item, if any."""
|
| 827 |
+
if self._capture_fixture:
|
| 828 |
+
self._capture_fixture.close()
|
| 829 |
+
|
| 830 |
+
def suspend_fixture(self) -> None:
|
| 831 |
+
if self._capture_fixture:
|
| 832 |
+
self._capture_fixture._suspend()
|
| 833 |
+
|
| 834 |
+
def resume_fixture(self) -> None:
|
| 835 |
+
if self._capture_fixture:
|
| 836 |
+
self._capture_fixture._resume()
|
| 837 |
+
|
| 838 |
+
# Helper context managers
|
| 839 |
+
|
| 840 |
+
@contextlib.contextmanager
|
| 841 |
+
def global_and_fixture_disabled(self) -> Generator[None]:
|
| 842 |
+
"""Context manager to temporarily disable global and current fixture capturing."""
|
| 843 |
+
do_fixture = self._capture_fixture and self._capture_fixture._is_started()
|
| 844 |
+
if do_fixture:
|
| 845 |
+
self.suspend_fixture()
|
| 846 |
+
do_global = self._global_capturing and self._global_capturing.is_started()
|
| 847 |
+
if do_global:
|
| 848 |
+
self.suspend_global_capture()
|
| 849 |
+
try:
|
| 850 |
+
yield
|
| 851 |
+
finally:
|
| 852 |
+
if do_global:
|
| 853 |
+
self.resume_global_capture()
|
| 854 |
+
if do_fixture:
|
| 855 |
+
self.resume_fixture()
|
| 856 |
+
|
| 857 |
+
@contextlib.contextmanager
|
| 858 |
+
def item_capture(self, when: str, item: Item) -> Generator[None]:
|
| 859 |
+
self.resume_global_capture()
|
| 860 |
+
self.activate_fixture()
|
| 861 |
+
try:
|
| 862 |
+
yield
|
| 863 |
+
finally:
|
| 864 |
+
self.deactivate_fixture()
|
| 865 |
+
self.suspend_global_capture(in_=False)
|
| 866 |
+
|
| 867 |
+
out, err = self.read_global_capture()
|
| 868 |
+
item.add_report_section(when, "stdout", out)
|
| 869 |
+
item.add_report_section(when, "stderr", err)
|
| 870 |
+
|
| 871 |
+
# Hooks
|
| 872 |
+
|
| 873 |
+
@hookimpl(wrapper=True)
|
| 874 |
+
def pytest_make_collect_report(
|
| 875 |
+
self, collector: Collector
|
| 876 |
+
) -> Generator[None, CollectReport, CollectReport]:
|
| 877 |
+
if isinstance(collector, File):
|
| 878 |
+
self.resume_global_capture()
|
| 879 |
+
try:
|
| 880 |
+
rep = yield
|
| 881 |
+
finally:
|
| 882 |
+
self.suspend_global_capture()
|
| 883 |
+
out, err = self.read_global_capture()
|
| 884 |
+
if out:
|
| 885 |
+
rep.sections.append(("Captured stdout", out))
|
| 886 |
+
if err:
|
| 887 |
+
rep.sections.append(("Captured stderr", err))
|
| 888 |
+
else:
|
| 889 |
+
rep = yield
|
| 890 |
+
return rep
|
| 891 |
+
|
| 892 |
+
@hookimpl(wrapper=True)
|
| 893 |
+
def pytest_runtest_setup(self, item: Item) -> Generator[None]:
|
| 894 |
+
with self.item_capture("setup", item):
|
| 895 |
+
return (yield)
|
| 896 |
+
|
| 897 |
+
@hookimpl(wrapper=True)
|
| 898 |
+
def pytest_runtest_call(self, item: Item) -> Generator[None]:
|
| 899 |
+
with self.item_capture("call", item):
|
| 900 |
+
return (yield)
|
| 901 |
+
|
| 902 |
+
@hookimpl(wrapper=True)
|
| 903 |
+
def pytest_runtest_teardown(self, item: Item) -> Generator[None]:
|
| 904 |
+
with self.item_capture("teardown", item):
|
| 905 |
+
return (yield)
|
| 906 |
+
|
| 907 |
+
@hookimpl(tryfirst=True)
|
| 908 |
+
def pytest_keyboard_interrupt(self) -> None:
|
| 909 |
+
self.stop_global_capturing()
|
| 910 |
+
|
| 911 |
+
@hookimpl(tryfirst=True)
|
| 912 |
+
def pytest_internalerror(self) -> None:
|
| 913 |
+
self.stop_global_capturing()
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
class CaptureFixture(Generic[AnyStr]):
|
| 917 |
+
"""Object returned by the :fixture:`capsys`, :fixture:`capsysbinary`,
|
| 918 |
+
:fixture:`capfd` and :fixture:`capfdbinary` fixtures."""
|
| 919 |
+
|
| 920 |
+
def __init__(
|
| 921 |
+
self,
|
| 922 |
+
captureclass: type[CaptureBase[AnyStr]],
|
| 923 |
+
request: SubRequest,
|
| 924 |
+
*,
|
| 925 |
+
config: dict[str, Any] | None = None,
|
| 926 |
+
_ispytest: bool = False,
|
| 927 |
+
) -> None:
|
| 928 |
+
check_ispytest(_ispytest)
|
| 929 |
+
self.captureclass: type[CaptureBase[AnyStr]] = captureclass
|
| 930 |
+
self.request = request
|
| 931 |
+
self._config = config if config else {}
|
| 932 |
+
self._capture: MultiCapture[AnyStr] | None = None
|
| 933 |
+
self._captured_out: AnyStr = self.captureclass.EMPTY_BUFFER
|
| 934 |
+
self._captured_err: AnyStr = self.captureclass.EMPTY_BUFFER
|
| 935 |
+
|
| 936 |
+
def _start(self) -> None:
|
| 937 |
+
if self._capture is None:
|
| 938 |
+
self._capture = MultiCapture(
|
| 939 |
+
in_=None,
|
| 940 |
+
out=self.captureclass(1, **self._config),
|
| 941 |
+
err=self.captureclass(2, **self._config),
|
| 942 |
+
)
|
| 943 |
+
self._capture.start_capturing()
|
| 944 |
+
|
| 945 |
+
def close(self) -> None:
|
| 946 |
+
if self._capture is not None:
|
| 947 |
+
if self._config.get("tee"):
|
| 948 |
+
# When tee is enabled, output was already written to the
|
| 949 |
+
# original stream in real-time by TeeCaptureIO. Using
|
| 950 |
+
# pop_outerr_to_orig() would write it a second time via
|
| 951 |
+
# writeorg(), causing doubled output (see #13784).
|
| 952 |
+
out, err = self._capture.readouterr()
|
| 953 |
+
else:
|
| 954 |
+
out, err = self._capture.pop_outerr_to_orig()
|
| 955 |
+
self._captured_out += out
|
| 956 |
+
self._captured_err += err
|
| 957 |
+
self._capture.stop_capturing()
|
| 958 |
+
self._capture = None
|
| 959 |
+
|
| 960 |
+
def readouterr(self) -> CaptureResult[AnyStr]:
|
| 961 |
+
"""Read and return the captured output so far, resetting the internal
|
| 962 |
+
buffer.
|
| 963 |
+
|
| 964 |
+
:returns:
|
| 965 |
+
The captured content as a namedtuple with ``out`` and ``err``
|
| 966 |
+
string attributes.
|
| 967 |
+
"""
|
| 968 |
+
captured_out, captured_err = self._captured_out, self._captured_err
|
| 969 |
+
if self._capture is not None:
|
| 970 |
+
out, err = self._capture.readouterr()
|
| 971 |
+
captured_out += out
|
| 972 |
+
captured_err += err
|
| 973 |
+
self._captured_out = self.captureclass.EMPTY_BUFFER
|
| 974 |
+
self._captured_err = self.captureclass.EMPTY_BUFFER
|
| 975 |
+
return CaptureResult(captured_out, captured_err)
|
| 976 |
+
|
| 977 |
+
def _suspend(self) -> None:
|
| 978 |
+
"""Suspend this fixture's own capturing temporarily."""
|
| 979 |
+
if self._capture is not None:
|
| 980 |
+
self._capture.suspend_capturing()
|
| 981 |
+
|
| 982 |
+
def _resume(self) -> None:
|
| 983 |
+
"""Resume this fixture's own capturing temporarily."""
|
| 984 |
+
if self._capture is not None:
|
| 985 |
+
self._capture.resume_capturing()
|
| 986 |
+
|
| 987 |
+
def _is_started(self) -> bool:
|
| 988 |
+
"""Whether actively capturing -- not disabled or closed."""
|
| 989 |
+
if self._capture is not None:
|
| 990 |
+
return self._capture.is_started()
|
| 991 |
+
return False
|
| 992 |
+
|
| 993 |
+
@contextlib.contextmanager
|
| 994 |
+
def disabled(self) -> Generator[None]:
|
| 995 |
+
"""Temporarily disable capturing while inside the ``with`` block."""
|
| 996 |
+
capmanager: CaptureManager = self.request.config.pluginmanager.getplugin(
|
| 997 |
+
"capturemanager"
|
| 998 |
+
)
|
| 999 |
+
with capmanager.global_and_fixture_disabled():
|
| 1000 |
+
yield
|
| 1001 |
+
|
| 1002 |
+
|
| 1003 |
+
# The fixtures.
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
+
@fixture
|
| 1007 |
+
def capsys(request: SubRequest) -> Generator[CaptureFixture[str]]:
|
| 1008 |
+
r"""Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
| 1009 |
+
|
| 1010 |
+
The captured output is made available via ``capsys.readouterr()`` method
|
| 1011 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1012 |
+
``out`` and ``err`` will be ``text`` objects.
|
| 1013 |
+
|
| 1014 |
+
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
| 1015 |
+
|
| 1016 |
+
Example:
|
| 1017 |
+
|
| 1018 |
+
.. code-block:: python
|
| 1019 |
+
|
| 1020 |
+
def test_output(capsys):
|
| 1021 |
+
print("hello")
|
| 1022 |
+
captured = capsys.readouterr()
|
| 1023 |
+
assert captured.out == "hello\n"
|
| 1024 |
+
"""
|
| 1025 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1026 |
+
capture_fixture = CaptureFixture(SysCapture, request, _ispytest=True)
|
| 1027 |
+
capman.set_fixture(capture_fixture)
|
| 1028 |
+
capture_fixture._start()
|
| 1029 |
+
yield capture_fixture
|
| 1030 |
+
capture_fixture.close()
|
| 1031 |
+
capman.unset_fixture()
|
| 1032 |
+
|
| 1033 |
+
|
| 1034 |
+
@fixture
|
| 1035 |
+
def capteesys(request: SubRequest) -> Generator[CaptureFixture[str]]:
|
| 1036 |
+
r"""Enable simultaneous text capturing and pass-through of writes
|
| 1037 |
+
to ``sys.stdout`` and ``sys.stderr`` as defined by ``--capture=``.
|
| 1038 |
+
|
| 1039 |
+
|
| 1040 |
+
The captured output is made available via ``capteesys.readouterr()`` method
|
| 1041 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1042 |
+
``out`` and ``err`` will be ``text`` objects.
|
| 1043 |
+
|
| 1044 |
+
The output is also passed-through, allowing it to be "live-printed",
|
| 1045 |
+
reported, or both as defined by ``--capture=``.
|
| 1046 |
+
|
| 1047 |
+
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
| 1048 |
+
|
| 1049 |
+
Example:
|
| 1050 |
+
|
| 1051 |
+
.. code-block:: python
|
| 1052 |
+
|
| 1053 |
+
def test_output(capteesys):
|
| 1054 |
+
print("hello")
|
| 1055 |
+
captured = capteesys.readouterr()
|
| 1056 |
+
assert captured.out == "hello\n"
|
| 1057 |
+
"""
|
| 1058 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1059 |
+
capture_fixture = CaptureFixture(
|
| 1060 |
+
SysCapture, request, config=dict(tee=True), _ispytest=True
|
| 1061 |
+
)
|
| 1062 |
+
capman.set_fixture(capture_fixture)
|
| 1063 |
+
capture_fixture._start()
|
| 1064 |
+
yield capture_fixture
|
| 1065 |
+
capture_fixture.close()
|
| 1066 |
+
capman.unset_fixture()
|
| 1067 |
+
|
| 1068 |
+
|
| 1069 |
+
@fixture
|
| 1070 |
+
def capsysbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
|
| 1071 |
+
r"""Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``.
|
| 1072 |
+
|
| 1073 |
+
The captured output is made available via ``capsysbinary.readouterr()``
|
| 1074 |
+
method calls, which return a ``(out, err)`` namedtuple.
|
| 1075 |
+
``out`` and ``err`` will be ``bytes`` objects.
|
| 1076 |
+
|
| 1077 |
+
Returns an instance of :class:`CaptureFixture[bytes] <pytest.CaptureFixture>`.
|
| 1078 |
+
|
| 1079 |
+
Example:
|
| 1080 |
+
|
| 1081 |
+
.. code-block:: python
|
| 1082 |
+
|
| 1083 |
+
def test_output(capsysbinary):
|
| 1084 |
+
print("hello")
|
| 1085 |
+
captured = capsysbinary.readouterr()
|
| 1086 |
+
assert captured.out == b"hello\n"
|
| 1087 |
+
"""
|
| 1088 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1089 |
+
capture_fixture = CaptureFixture(SysCaptureBinary, request, _ispytest=True)
|
| 1090 |
+
capman.set_fixture(capture_fixture)
|
| 1091 |
+
capture_fixture._start()
|
| 1092 |
+
yield capture_fixture
|
| 1093 |
+
capture_fixture.close()
|
| 1094 |
+
capman.unset_fixture()
|
| 1095 |
+
|
| 1096 |
+
|
| 1097 |
+
@fixture
|
| 1098 |
+
def capfd(request: SubRequest) -> Generator[CaptureFixture[str]]:
|
| 1099 |
+
r"""Enable text capturing of writes to file descriptors ``1`` and ``2``.
|
| 1100 |
+
|
| 1101 |
+
The captured output is made available via ``capfd.readouterr()`` method
|
| 1102 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1103 |
+
``out`` and ``err`` will be ``text`` objects.
|
| 1104 |
+
|
| 1105 |
+
Returns an instance of :class:`CaptureFixture[str] <pytest.CaptureFixture>`.
|
| 1106 |
+
|
| 1107 |
+
Example:
|
| 1108 |
+
|
| 1109 |
+
.. code-block:: python
|
| 1110 |
+
|
| 1111 |
+
def test_system_echo(capfd):
|
| 1112 |
+
os.system('echo "hello"')
|
| 1113 |
+
captured = capfd.readouterr()
|
| 1114 |
+
assert captured.out == "hello\n"
|
| 1115 |
+
"""
|
| 1116 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1117 |
+
capture_fixture = CaptureFixture(FDCapture, request, _ispytest=True)
|
| 1118 |
+
capman.set_fixture(capture_fixture)
|
| 1119 |
+
capture_fixture._start()
|
| 1120 |
+
yield capture_fixture
|
| 1121 |
+
capture_fixture.close()
|
| 1122 |
+
capman.unset_fixture()
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
@fixture
|
| 1126 |
+
def capfdbinary(request: SubRequest) -> Generator[CaptureFixture[bytes]]:
|
| 1127 |
+
r"""Enable bytes capturing of writes to file descriptors ``1`` and ``2``.
|
| 1128 |
+
|
| 1129 |
+
The captured output is made available via ``capfd.readouterr()`` method
|
| 1130 |
+
calls, which return a ``(out, err)`` namedtuple.
|
| 1131 |
+
``out`` and ``err`` will be ``byte`` objects.
|
| 1132 |
+
|
| 1133 |
+
Returns an instance of :class:`CaptureFixture[bytes] <pytest.CaptureFixture>`.
|
| 1134 |
+
|
| 1135 |
+
Example:
|
| 1136 |
+
|
| 1137 |
+
.. code-block:: python
|
| 1138 |
+
|
| 1139 |
+
def test_system_echo(capfdbinary):
|
| 1140 |
+
os.system('echo "hello"')
|
| 1141 |
+
captured = capfdbinary.readouterr()
|
| 1142 |
+
assert captured.out == b"hello\n"
|
| 1143 |
+
|
| 1144 |
+
"""
|
| 1145 |
+
capman: CaptureManager = request.config.pluginmanager.getplugin("capturemanager")
|
| 1146 |
+
capture_fixture = CaptureFixture(FDCaptureBinary, request, _ispytest=True)
|
| 1147 |
+
capman.set_fixture(capture_fixture)
|
| 1148 |
+
capture_fixture._start()
|
| 1149 |
+
yield capture_fixture
|
| 1150 |
+
capture_fixture.close()
|
| 1151 |
+
capman.unset_fixture()
|
.venv/lib/python3.14/site-packages/_pytest/compat.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Python version compatibility code and random general utilities."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from collections.abc import Callable
|
| 7 |
+
import enum
|
| 8 |
+
import functools
|
| 9 |
+
import inspect
|
| 10 |
+
from inspect import Parameter
|
| 11 |
+
from inspect import Signature
|
| 12 |
+
import os
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import sys
|
| 15 |
+
from typing import Any
|
| 16 |
+
from typing import Final
|
| 17 |
+
from typing import NoReturn
|
| 18 |
+
from typing import TYPE_CHECKING
|
| 19 |
+
|
| 20 |
+
import py
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
if sys.version_info >= (3, 14):
|
| 24 |
+
from annotationlib import Format
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
#: constant to prepare valuing pylib path replacements/lazy proxies later on
|
| 28 |
+
# intended for removal in pytest 8.0 or 9.0
|
| 29 |
+
|
| 30 |
+
# fmt: off
|
| 31 |
+
# intentional space to create a fake difference for the verification
|
| 32 |
+
LEGACY_PATH = py.path. local
|
| 33 |
+
# fmt: on
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def legacy_path(path: str | os.PathLike[str]) -> LEGACY_PATH:
|
| 37 |
+
"""Internal wrapper to prepare lazy proxies for legacy_path instances"""
|
| 38 |
+
return LEGACY_PATH(path)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# fmt: off
|
| 42 |
+
# Singleton type for NOTSET, as described in:
|
| 43 |
+
# https://www.python.org/dev/peps/pep-0484/#support-for-singleton-types-in-unions
|
| 44 |
+
class NotSetType(enum.Enum):
|
| 45 |
+
token = 0
|
| 46 |
+
NOTSET: Final = NotSetType.token
|
| 47 |
+
# fmt: on
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def iscoroutinefunction(func: object) -> bool:
|
| 51 |
+
"""Return True if func is a coroutine function (a function defined with async
|
| 52 |
+
def syntax, and doesn't contain yield), or a function decorated with
|
| 53 |
+
@asyncio.coroutine.
|
| 54 |
+
|
| 55 |
+
Note: copied and modified from Python 3.5's builtin coroutines.py to avoid
|
| 56 |
+
importing asyncio directly, which in turns also initializes the "logging"
|
| 57 |
+
module as a side-effect (see issue #8).
|
| 58 |
+
"""
|
| 59 |
+
return inspect.iscoroutinefunction(func) or getattr(func, "_is_coroutine", False)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def is_async_function(func: object) -> bool:
|
| 63 |
+
"""Return True if the given function seems to be an async function or
|
| 64 |
+
an async generator."""
|
| 65 |
+
return iscoroutinefunction(func) or inspect.isasyncgenfunction(func)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def signature(obj: Callable[..., Any]) -> Signature:
|
| 69 |
+
"""Return signature without evaluating annotations."""
|
| 70 |
+
if sys.version_info >= (3, 14):
|
| 71 |
+
return inspect.signature(obj, annotation_format=Format.STRING)
|
| 72 |
+
return inspect.signature(obj)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def getlocation(function, curdir: str | os.PathLike[str] | None = None) -> str:
|
| 76 |
+
function = get_real_func(function)
|
| 77 |
+
fn = Path(inspect.getfile(function))
|
| 78 |
+
lineno = function.__code__.co_firstlineno
|
| 79 |
+
if curdir is not None:
|
| 80 |
+
try:
|
| 81 |
+
relfn = fn.relative_to(curdir)
|
| 82 |
+
except ValueError:
|
| 83 |
+
pass
|
| 84 |
+
else:
|
| 85 |
+
return f"{relfn}:{lineno + 1}"
|
| 86 |
+
return f"{fn}:{lineno + 1}"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def num_mock_patch_args(function) -> int:
|
| 90 |
+
"""Return number of arguments used up by mock arguments (if any)."""
|
| 91 |
+
patchings = getattr(function, "patchings", None)
|
| 92 |
+
if not patchings:
|
| 93 |
+
return 0
|
| 94 |
+
|
| 95 |
+
mock_sentinel = getattr(sys.modules.get("mock"), "DEFAULT", object())
|
| 96 |
+
ut_mock_sentinel = getattr(sys.modules.get("unittest.mock"), "DEFAULT", object())
|
| 97 |
+
|
| 98 |
+
return len(
|
| 99 |
+
[
|
| 100 |
+
p
|
| 101 |
+
for p in patchings
|
| 102 |
+
if not p.attribute_name
|
| 103 |
+
and (p.new is mock_sentinel or p.new is ut_mock_sentinel)
|
| 104 |
+
]
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def getfuncargnames(
|
| 109 |
+
function: Callable[..., object],
|
| 110 |
+
*,
|
| 111 |
+
name: str = "",
|
| 112 |
+
cls: type | None = None,
|
| 113 |
+
) -> tuple[str, ...]:
|
| 114 |
+
"""Return the names of a function's mandatory arguments.
|
| 115 |
+
|
| 116 |
+
Should return the names of all function arguments that:
|
| 117 |
+
* Aren't bound to an instance or type as in instance or class methods.
|
| 118 |
+
* Don't have default values.
|
| 119 |
+
* Aren't bound with functools.partial.
|
| 120 |
+
* Aren't replaced with mocks.
|
| 121 |
+
|
| 122 |
+
The cls arguments indicate that the function should be treated as a bound
|
| 123 |
+
method even though it's not unless the function is a static method.
|
| 124 |
+
|
| 125 |
+
The name parameter should be the original name in which the function was collected.
|
| 126 |
+
"""
|
| 127 |
+
# TODO(RonnyPfannschmidt): This function should be refactored when we
|
| 128 |
+
# revisit fixtures. The fixture mechanism should ask the node for
|
| 129 |
+
# the fixture names, and not try to obtain directly from the
|
| 130 |
+
# function object well after collection has occurred.
|
| 131 |
+
|
| 132 |
+
# The parameters attribute of a Signature object contains an
|
| 133 |
+
# ordered mapping of parameter names to Parameter instances. This
|
| 134 |
+
# creates a tuple of the names of the parameters that don't have
|
| 135 |
+
# defaults.
|
| 136 |
+
try:
|
| 137 |
+
parameters = signature(function).parameters.values()
|
| 138 |
+
except (ValueError, TypeError) as e:
|
| 139 |
+
from _pytest.outcomes import fail
|
| 140 |
+
|
| 141 |
+
fail(
|
| 142 |
+
f"Could not determine arguments of {function!r}: {e}",
|
| 143 |
+
pytrace=False,
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
arg_names = tuple(
|
| 147 |
+
p.name
|
| 148 |
+
for p in parameters
|
| 149 |
+
if (
|
| 150 |
+
p.kind is Parameter.POSITIONAL_OR_KEYWORD
|
| 151 |
+
or p.kind is Parameter.KEYWORD_ONLY
|
| 152 |
+
)
|
| 153 |
+
and p.default is Parameter.empty
|
| 154 |
+
)
|
| 155 |
+
if not name:
|
| 156 |
+
name = function.__name__
|
| 157 |
+
|
| 158 |
+
# If this function should be treated as a bound method even though
|
| 159 |
+
# it's passed as an unbound method or function, and its first parameter
|
| 160 |
+
# wasn't defined as positional only, remove the first parameter name.
|
| 161 |
+
if not any(p.kind is Parameter.POSITIONAL_ONLY for p in parameters) and (
|
| 162 |
+
# Not using `getattr` because we don't want to resolve the staticmethod.
|
| 163 |
+
# Not using `cls.__dict__` because we want to check the entire MRO.
|
| 164 |
+
cls
|
| 165 |
+
and not isinstance(
|
| 166 |
+
inspect.getattr_static(cls, name, default=None), staticmethod
|
| 167 |
+
)
|
| 168 |
+
):
|
| 169 |
+
arg_names = arg_names[1:]
|
| 170 |
+
# Remove any names that will be replaced with mocks.
|
| 171 |
+
if hasattr(function, "__wrapped__"):
|
| 172 |
+
arg_names = arg_names[num_mock_patch_args(function) :]
|
| 173 |
+
return arg_names
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def get_default_arg_names(function: Callable[..., Any]) -> tuple[str, ...]:
|
| 177 |
+
# Note: this code intentionally mirrors the code at the beginning of
|
| 178 |
+
# getfuncargnames, to get the arguments which were excluded from its result
|
| 179 |
+
# because they had default values.
|
| 180 |
+
return tuple(
|
| 181 |
+
p.name
|
| 182 |
+
for p in signature(function).parameters.values()
|
| 183 |
+
if p.kind in (Parameter.POSITIONAL_OR_KEYWORD, Parameter.KEYWORD_ONLY)
|
| 184 |
+
and p.default is not Parameter.empty
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
_non_printable_ascii_translate_table = {
|
| 189 |
+
i: f"\\x{i:02x}" for i in range(128) if i not in range(32, 127)
|
| 190 |
+
}
|
| 191 |
+
_non_printable_ascii_translate_table.update(
|
| 192 |
+
{ord("\t"): "\\t", ord("\r"): "\\r", ord("\n"): "\\n"}
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def ascii_escaped(val: bytes | str) -> str:
|
| 197 |
+
r"""If val is pure ASCII, return it as an str, otherwise, escape
|
| 198 |
+
bytes objects into a sequence of escaped bytes:
|
| 199 |
+
|
| 200 |
+
b'\xc3\xb4\xc5\xd6' -> r'\xc3\xb4\xc5\xd6'
|
| 201 |
+
|
| 202 |
+
and escapes strings into a sequence of escaped unicode ids, e.g.:
|
| 203 |
+
|
| 204 |
+
r'4\nV\U00043efa\x0eMXWB\x1e\u3028\u15fd\xcd\U0007d944'
|
| 205 |
+
|
| 206 |
+
Note:
|
| 207 |
+
The obvious "v.decode('unicode-escape')" will return
|
| 208 |
+
valid UTF-8 unicode if it finds them in bytes, but we
|
| 209 |
+
want to return escaped bytes for any byte, even if they match
|
| 210 |
+
a UTF-8 string.
|
| 211 |
+
"""
|
| 212 |
+
if isinstance(val, bytes):
|
| 213 |
+
ret = val.decode("ascii", "backslashreplace")
|
| 214 |
+
else:
|
| 215 |
+
ret = val.encode("unicode_escape").decode("ascii")
|
| 216 |
+
return ret.translate(_non_printable_ascii_translate_table)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def get_real_func(obj):
|
| 220 |
+
"""Get the real function object of the (possibly) wrapped object by
|
| 221 |
+
:func:`functools.wraps`, or :func:`functools.partial`."""
|
| 222 |
+
obj = inspect.unwrap(obj)
|
| 223 |
+
|
| 224 |
+
if isinstance(obj, functools.partial):
|
| 225 |
+
obj = obj.func
|
| 226 |
+
return obj
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def getimfunc(func):
|
| 230 |
+
try:
|
| 231 |
+
return func.__func__
|
| 232 |
+
except AttributeError:
|
| 233 |
+
return func
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def safe_getattr(object: Any, name: str, default: Any) -> Any:
|
| 237 |
+
"""Like getattr but return default upon any Exception or any OutcomeException.
|
| 238 |
+
|
| 239 |
+
Attribute access can potentially fail for 'evil' Python objects.
|
| 240 |
+
See issue #214.
|
| 241 |
+
It catches OutcomeException because of #2490 (issue #580), new outcomes
|
| 242 |
+
are derived from BaseException instead of Exception (for more details
|
| 243 |
+
check #2707).
|
| 244 |
+
"""
|
| 245 |
+
from _pytest.outcomes import TEST_OUTCOME
|
| 246 |
+
|
| 247 |
+
try:
|
| 248 |
+
return getattr(object, name, default)
|
| 249 |
+
except TEST_OUTCOME:
|
| 250 |
+
return default
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def safe_isclass(obj: object) -> bool:
|
| 254 |
+
"""Ignore any exception via isinstance on Python 3."""
|
| 255 |
+
try:
|
| 256 |
+
return inspect.isclass(obj)
|
| 257 |
+
except Exception:
|
| 258 |
+
return False
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def get_user_id() -> int | None:
|
| 262 |
+
"""Return the current process's real user id or None if it could not be
|
| 263 |
+
determined.
|
| 264 |
+
|
| 265 |
+
:return: The user id or None if it could not be determined.
|
| 266 |
+
"""
|
| 267 |
+
# mypy follows the version and platform checking expectation of PEP 484:
|
| 268 |
+
# https://mypy.readthedocs.io/en/stable/common_issues.html?highlight=platform#python-version-and-system-platform-checks
|
| 269 |
+
# Containment checks are too complex for mypy v1.5.0 and cause failure.
|
| 270 |
+
if sys.platform == "win32" or sys.platform == "emscripten":
|
| 271 |
+
# win32 does not have a getuid() function.
|
| 272 |
+
# Emscripten has a return 0 stub.
|
| 273 |
+
return None
|
| 274 |
+
else:
|
| 275 |
+
# On other platforms, a return value of -1 is assumed to indicate that
|
| 276 |
+
# the current process's real user id could not be determined.
|
| 277 |
+
ERROR = -1
|
| 278 |
+
uid = os.getuid()
|
| 279 |
+
return uid if uid != ERROR else None
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
if sys.version_info >= (3, 11):
|
| 283 |
+
from typing import assert_never
|
| 284 |
+
else:
|
| 285 |
+
|
| 286 |
+
def assert_never(value: NoReturn) -> NoReturn:
|
| 287 |
+
assert False, f"Unhandled value: {value} ({type(value).__name__})"
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
class CallableBool:
|
| 291 |
+
"""
|
| 292 |
+
A bool-like object that can also be called, returning its true/false value.
|
| 293 |
+
|
| 294 |
+
Used for backwards compatibility in cases where something was supposed to be a method
|
| 295 |
+
but was implemented as a simple attribute by mistake (see `TerminalReporter.isatty`).
|
| 296 |
+
|
| 297 |
+
Do not use in new code.
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
def __init__(self, value: bool) -> None:
|
| 301 |
+
self._value = value
|
| 302 |
+
|
| 303 |
+
def __bool__(self) -> bool:
|
| 304 |
+
return self._value
|
| 305 |
+
|
| 306 |
+
def __call__(self) -> bool:
|
| 307 |
+
return self._value
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def running_on_ci() -> bool:
|
| 311 |
+
"""Check if we're currently running on a CI system."""
|
| 312 |
+
# Only enable CI mode if one of these env variables is defined and non-empty.
|
| 313 |
+
# Note: review `regendoc` tox env in case this list is changed.
|
| 314 |
+
env_vars = ["CI", "BUILD_NUMBER"]
|
| 315 |
+
return any(os.environ.get(var) for var in env_vars)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
if sys.version_info >= (3, 13):
|
| 319 |
+
from warnings import deprecated as deprecated
|
| 320 |
+
else:
|
| 321 |
+
if TYPE_CHECKING:
|
| 322 |
+
from typing_extensions import deprecated as deprecated
|
| 323 |
+
else:
|
| 324 |
+
|
| 325 |
+
def deprecated(msg, /, *, category=None, stacklevel=1):
|
| 326 |
+
def decorator(func):
|
| 327 |
+
return func
|
| 328 |
+
|
| 329 |
+
return decorator
|
.venv/lib/python3.14/site-packages/_pytest/config/__init__.py
ADDED
|
@@ -0,0 +1,2246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
"""Command line options, config-file and conftest.py processing."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import builtins
|
| 8 |
+
import collections.abc
|
| 9 |
+
from collections.abc import Callable
|
| 10 |
+
from collections.abc import Generator
|
| 11 |
+
from collections.abc import Iterable
|
| 12 |
+
from collections.abc import Iterator
|
| 13 |
+
from collections.abc import Mapping
|
| 14 |
+
from collections.abc import MutableMapping
|
| 15 |
+
from collections.abc import Sequence
|
| 16 |
+
import contextlib
|
| 17 |
+
import copy
|
| 18 |
+
import dataclasses
|
| 19 |
+
import enum
|
| 20 |
+
from functools import lru_cache
|
| 21 |
+
import glob
|
| 22 |
+
import importlib
|
| 23 |
+
import importlib.metadata
|
| 24 |
+
import inspect
|
| 25 |
+
import os
|
| 26 |
+
import pathlib
|
| 27 |
+
import re
|
| 28 |
+
import shlex
|
| 29 |
+
import sys
|
| 30 |
+
from textwrap import dedent
|
| 31 |
+
import types
|
| 32 |
+
from types import FunctionType
|
| 33 |
+
from typing import Any
|
| 34 |
+
from typing import cast
|
| 35 |
+
from typing import Final
|
| 36 |
+
from typing import final
|
| 37 |
+
from typing import IO
|
| 38 |
+
from typing import TextIO
|
| 39 |
+
from typing import TYPE_CHECKING
|
| 40 |
+
import warnings
|
| 41 |
+
|
| 42 |
+
from pluggy import HookimplMarker
|
| 43 |
+
from pluggy import HookimplOpts
|
| 44 |
+
from pluggy import HookspecMarker
|
| 45 |
+
from pluggy import HookspecOpts
|
| 46 |
+
from pluggy import PluginManager
|
| 47 |
+
|
| 48 |
+
from .exceptions import PrintHelp as PrintHelp
|
| 49 |
+
from .exceptions import UsageError as UsageError
|
| 50 |
+
from .findpaths import ConfigDict
|
| 51 |
+
from .findpaths import ConfigValue
|
| 52 |
+
from .findpaths import determine_setup
|
| 53 |
+
from .findpaths import parse_override_ini
|
| 54 |
+
from _pytest import __version__
|
| 55 |
+
import _pytest._code
|
| 56 |
+
from _pytest._code import ExceptionInfo
|
| 57 |
+
from _pytest._code import filter_traceback
|
| 58 |
+
from _pytest._code.code import TracebackStyle
|
| 59 |
+
from _pytest._io import TerminalWriter
|
| 60 |
+
from _pytest.compat import assert_never
|
| 61 |
+
from _pytest.compat import deprecated
|
| 62 |
+
from _pytest.compat import NOTSET
|
| 63 |
+
from _pytest.config.argparsing import Argument
|
| 64 |
+
from _pytest.config.argparsing import FILE_OR_DIR
|
| 65 |
+
from _pytest.config.argparsing import Parser
|
| 66 |
+
import _pytest.deprecated
|
| 67 |
+
import _pytest.hookspec
|
| 68 |
+
from _pytest.outcomes import fail
|
| 69 |
+
from _pytest.outcomes import Skipped
|
| 70 |
+
from _pytest.pathlib import absolutepath
|
| 71 |
+
from _pytest.pathlib import bestrelpath
|
| 72 |
+
from _pytest.pathlib import import_path
|
| 73 |
+
from _pytest.pathlib import ImportMode
|
| 74 |
+
from _pytest.pathlib import resolve_package_path
|
| 75 |
+
from _pytest.pathlib import safe_exists
|
| 76 |
+
from _pytest.stash import Stash
|
| 77 |
+
from _pytest.warning_types import PytestConfigWarning
|
| 78 |
+
from _pytest.warning_types import warn_explicit_for
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if TYPE_CHECKING:
|
| 82 |
+
from _pytest.assertion.rewrite import AssertionRewritingHook
|
| 83 |
+
from _pytest.cacheprovider import Cache
|
| 84 |
+
from _pytest.terminal import TerminalReporter
|
| 85 |
+
|
| 86 |
+
_PluggyPlugin = object
|
| 87 |
+
"""A type to represent plugin objects.
|
| 88 |
+
|
| 89 |
+
Plugins can be any namespace, so we can't narrow it down much, but we use an
|
| 90 |
+
alias to make the intent clear.
|
| 91 |
+
|
| 92 |
+
Ideally this type would be provided by pluggy itself.
|
| 93 |
+
"""
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
hookimpl = HookimplMarker("pytest")
|
| 97 |
+
hookspec = HookspecMarker("pytest")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@final
|
| 101 |
+
class ExitCode(enum.IntEnum):
|
| 102 |
+
"""Encodes the valid exit codes by pytest.
|
| 103 |
+
|
| 104 |
+
Currently users and plugins may supply other exit codes as well.
|
| 105 |
+
|
| 106 |
+
.. versionadded:: 5.0
|
| 107 |
+
"""
|
| 108 |
+
|
| 109 |
+
#: Tests passed.
|
| 110 |
+
OK = 0
|
| 111 |
+
#: Tests failed.
|
| 112 |
+
TESTS_FAILED = 1
|
| 113 |
+
#: pytest was interrupted.
|
| 114 |
+
INTERRUPTED = 2
|
| 115 |
+
#: An internal error got in the way.
|
| 116 |
+
INTERNAL_ERROR = 3
|
| 117 |
+
#: pytest was misused.
|
| 118 |
+
USAGE_ERROR = 4
|
| 119 |
+
#: pytest couldn't find tests.
|
| 120 |
+
NO_TESTS_COLLECTED = 5
|
| 121 |
+
#: All tests pass, but maximum number of warnings exceeded.
|
| 122 |
+
MAX_WARNINGS_ERROR = 6
|
| 123 |
+
|
| 124 |
+
__module__ = "pytest"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class ConftestImportFailure(Exception):
|
| 128 |
+
def __init__(
|
| 129 |
+
self,
|
| 130 |
+
path: pathlib.Path,
|
| 131 |
+
*,
|
| 132 |
+
cause: Exception,
|
| 133 |
+
) -> None:
|
| 134 |
+
self.path = path
|
| 135 |
+
self.cause = cause
|
| 136 |
+
|
| 137 |
+
def __str__(self) -> str:
|
| 138 |
+
return f"{type(self.cause).__name__}: {self.cause} (from {self.path})"
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def filter_traceback_for_conftest_import_failure(
|
| 142 |
+
entry: _pytest._code.TracebackEntry,
|
| 143 |
+
) -> bool:
|
| 144 |
+
"""Filter tracebacks entries which point to pytest internals or importlib.
|
| 145 |
+
|
| 146 |
+
Make a special case for importlib because we use it to import test modules and conftest files
|
| 147 |
+
in _pytest.pathlib.import_path.
|
| 148 |
+
"""
|
| 149 |
+
return filter_traceback(entry) and "importlib" not in str(entry.path).split(os.sep)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def print_conftest_import_error(e: ConftestImportFailure, file: TextIO) -> None:
|
| 153 |
+
exc_info = ExceptionInfo.from_exception(e.cause)
|
| 154 |
+
tw = TerminalWriter(file)
|
| 155 |
+
tw.line(f"ImportError while loading conftest '{e.path}'.", red=True)
|
| 156 |
+
exc_info.traceback = exc_info.traceback.filter(
|
| 157 |
+
filter_traceback_for_conftest_import_failure
|
| 158 |
+
)
|
| 159 |
+
exc_repr = (
|
| 160 |
+
exc_info.getrepr(style="short", chain=False)
|
| 161 |
+
if exc_info.traceback
|
| 162 |
+
else exc_info.exconly()
|
| 163 |
+
)
|
| 164 |
+
formatted_tb = str(exc_repr)
|
| 165 |
+
for line in formatted_tb.splitlines():
|
| 166 |
+
tw.line(line.rstrip(), red=True)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def print_usage_error(e: UsageError, file: TextIO) -> None:
|
| 170 |
+
tw = TerminalWriter(file)
|
| 171 |
+
for msg in e.args:
|
| 172 |
+
tw.line(f"ERROR: {msg}\n", red=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _get_prog_name(argv: Sequence[str]) -> str:
|
| 176 |
+
"""Determine the CLI program name from the argument vector.
|
| 177 |
+
|
| 178 |
+
:param argv: The argument vector (typically ``sys.argv``).
|
| 179 |
+
:returns: ``"python -m pytest"`` when invoked via ``python -m``,
|
| 180 |
+
``"pytest"`` otherwise.
|
| 181 |
+
"""
|
| 182 |
+
argv0 = argv[0] if argv else ""
|
| 183 |
+
if os.path.basename(argv0) == "__main__.py":
|
| 184 |
+
return "python -m pytest"
|
| 185 |
+
return "pytest"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def main(
|
| 189 |
+
args: list[str] | os.PathLike[str] | None = None,
|
| 190 |
+
plugins: Sequence[str | _PluggyPlugin] | None = None,
|
| 191 |
+
) -> int | ExitCode:
|
| 192 |
+
"""Perform an in-process test run.
|
| 193 |
+
|
| 194 |
+
:param args:
|
| 195 |
+
List of command line arguments. If `None` or not given, defaults to reading
|
| 196 |
+
arguments directly from the process command line (:data:`sys.argv`).
|
| 197 |
+
:param plugins: List of plugin objects to be auto-registered during initialization.
|
| 198 |
+
|
| 199 |
+
:returns: An exit code.
|
| 200 |
+
"""
|
| 201 |
+
return _main(args=args, plugins=plugins, prog="pytest.main()")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _main(
|
| 205 |
+
*,
|
| 206 |
+
args: list[str] | os.PathLike[str] | None = None,
|
| 207 |
+
plugins: Sequence[str | _PluggyPlugin] | None = None,
|
| 208 |
+
prog: str,
|
| 209 |
+
) -> int | ExitCode:
|
| 210 |
+
# Handle a single `--version`/`-V` argument early to avoid starting up the entire pytest infrastructure.
|
| 211 |
+
new_args = sys.argv[1:] if args is None else args
|
| 212 |
+
if (
|
| 213 |
+
isinstance(new_args, Sequence)
|
| 214 |
+
and (new_args.count("--version") + new_args.count("-V")) == 1
|
| 215 |
+
):
|
| 216 |
+
sys.stdout.write(f"pytest {__version__}\n")
|
| 217 |
+
return ExitCode.OK
|
| 218 |
+
|
| 219 |
+
old_pytest_version = os.environ.get("PYTEST_VERSION")
|
| 220 |
+
try:
|
| 221 |
+
os.environ["PYTEST_VERSION"] = __version__
|
| 222 |
+
try:
|
| 223 |
+
config = _prepareconfig(new_args, plugins, prog=prog)
|
| 224 |
+
except ConftestImportFailure as e:
|
| 225 |
+
print_conftest_import_error(e, file=sys.stderr)
|
| 226 |
+
return ExitCode.USAGE_ERROR
|
| 227 |
+
|
| 228 |
+
try:
|
| 229 |
+
ret: ExitCode | int = config.hook.pytest_cmdline_main(config=config)
|
| 230 |
+
try:
|
| 231 |
+
return ExitCode(ret)
|
| 232 |
+
except ValueError:
|
| 233 |
+
return ret
|
| 234 |
+
finally:
|
| 235 |
+
config._ensure_unconfigure()
|
| 236 |
+
except UsageError as e:
|
| 237 |
+
print_usage_error(e, file=sys.stderr)
|
| 238 |
+
return ExitCode.USAGE_ERROR
|
| 239 |
+
finally:
|
| 240 |
+
if old_pytest_version is None:
|
| 241 |
+
os.environ.pop("PYTEST_VERSION", None)
|
| 242 |
+
else:
|
| 243 |
+
os.environ["PYTEST_VERSION"] = old_pytest_version
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _console_main() -> int:
|
| 247 |
+
"""The CLI entry point of pytest (internal).
|
| 248 |
+
|
| 249 |
+
This is the real implementation used by entry points and ``__main__.py``.
|
| 250 |
+
"""
|
| 251 |
+
# https://docs.python.org/3/library/signal.html#note-on-sigpipe
|
| 252 |
+
try:
|
| 253 |
+
code = _main(prog=_get_prog_name(sys.argv))
|
| 254 |
+
sys.stdout.flush()
|
| 255 |
+
return code
|
| 256 |
+
except BrokenPipeError:
|
| 257 |
+
# Python flushes standard streams on exit; redirect remaining output
|
| 258 |
+
# to devnull to avoid another BrokenPipeError at shutdown
|
| 259 |
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
| 260 |
+
os.dup2(devnull, sys.stdout.fileno())
|
| 261 |
+
return 1 # Python exits with error code 1 on EPIPE
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def console_main() -> int:
|
| 265 |
+
"""The CLI entry point of pytest.
|
| 266 |
+
|
| 267 |
+
.. deprecated:: 9.1
|
| 268 |
+
This function is slated for removal in pytest 10.
|
| 269 |
+
It is not meant for programmable use; use :func:`pytest.main` instead.
|
| 270 |
+
"""
|
| 271 |
+
import warnings
|
| 272 |
+
|
| 273 |
+
from _pytest.deprecated import CONSOLE_MAIN
|
| 274 |
+
|
| 275 |
+
warnings.warn(CONSOLE_MAIN, stacklevel=2)
|
| 276 |
+
return _console_main()
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
class cmdline: # compatibility namespace
|
| 280 |
+
main = staticmethod(main)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def filename_arg(path: str, optname: str) -> str:
|
| 284 |
+
"""Argparse type validator for filename arguments.
|
| 285 |
+
|
| 286 |
+
:path: Path of filename.
|
| 287 |
+
:optname: Name of the option.
|
| 288 |
+
"""
|
| 289 |
+
if os.path.isdir(path):
|
| 290 |
+
raise UsageError(f"{optname} must be a filename, given: {path}")
|
| 291 |
+
return path
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def directory_arg(path: str, optname: str) -> str:
|
| 295 |
+
"""Argparse type validator for directory arguments.
|
| 296 |
+
|
| 297 |
+
:path: Path of directory.
|
| 298 |
+
:optname: Name of the option.
|
| 299 |
+
"""
|
| 300 |
+
if not os.path.isdir(path):
|
| 301 |
+
raise UsageError(f"{optname} must be a directory, given: {path}")
|
| 302 |
+
return path
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# Plugins that cannot be disabled via "-p no:X" currently.
|
| 306 |
+
essential_plugins = (
|
| 307 |
+
"mark",
|
| 308 |
+
"main",
|
| 309 |
+
"runner",
|
| 310 |
+
"fixtures",
|
| 311 |
+
"helpconfig", # Provides -p.
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
default_plugins = (
|
| 315 |
+
*essential_plugins,
|
| 316 |
+
"python",
|
| 317 |
+
"terminal",
|
| 318 |
+
"debugging",
|
| 319 |
+
"unittest",
|
| 320 |
+
"capture",
|
| 321 |
+
"skipping",
|
| 322 |
+
"legacypath",
|
| 323 |
+
"tmpdir",
|
| 324 |
+
"monkeypatch",
|
| 325 |
+
"recwarn",
|
| 326 |
+
"pastebin",
|
| 327 |
+
"assertion",
|
| 328 |
+
"junitxml",
|
| 329 |
+
"doctest",
|
| 330 |
+
"cacheprovider",
|
| 331 |
+
"setuponly",
|
| 332 |
+
"setupplan",
|
| 333 |
+
"stepwise",
|
| 334 |
+
"unraisableexception",
|
| 335 |
+
"threadexception",
|
| 336 |
+
"warnings",
|
| 337 |
+
"logging",
|
| 338 |
+
"reports",
|
| 339 |
+
"faulthandler",
|
| 340 |
+
"subtests",
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
builtin_plugins = {
|
| 344 |
+
*default_plugins,
|
| 345 |
+
"pytester",
|
| 346 |
+
"pytester_assertions",
|
| 347 |
+
"terminalprogress",
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def get_config(
|
| 352 |
+
args: Iterable[str] | None = None,
|
| 353 |
+
plugins: Sequence[str | _PluggyPlugin] | None = None,
|
| 354 |
+
*,
|
| 355 |
+
prog: str | None = None,
|
| 356 |
+
) -> Config:
|
| 357 |
+
# Subsequent calls to main will create a fresh instance.
|
| 358 |
+
pluginmanager = PytestPluginManager()
|
| 359 |
+
invocation_params = Config.InvocationParams(
|
| 360 |
+
args=args or (),
|
| 361 |
+
plugins=plugins,
|
| 362 |
+
dir=pathlib.Path.cwd(),
|
| 363 |
+
)
|
| 364 |
+
config = Config(pluginmanager, invocation_params=invocation_params, prog=prog)
|
| 365 |
+
|
| 366 |
+
if invocation_params.args:
|
| 367 |
+
# Handle any "-p no:plugin" args.
|
| 368 |
+
pluginmanager.consider_preparse(invocation_params.args, exclude_only=True)
|
| 369 |
+
|
| 370 |
+
for spec in default_plugins:
|
| 371 |
+
pluginmanager.import_plugin(spec)
|
| 372 |
+
|
| 373 |
+
return config
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def get_plugin_manager() -> PytestPluginManager:
|
| 377 |
+
"""Obtain a new instance of the
|
| 378 |
+
:py:class:`pytest.PytestPluginManager`, with default plugins
|
| 379 |
+
already loaded.
|
| 380 |
+
|
| 381 |
+
This function can be used by integration with other tools, like hooking
|
| 382 |
+
into pytest to run tests into an IDE.
|
| 383 |
+
"""
|
| 384 |
+
return get_config().pluginmanager
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
def _prepareconfig(
|
| 388 |
+
args: list[str] | os.PathLike[str],
|
| 389 |
+
plugins: Sequence[str | _PluggyPlugin] | None = None,
|
| 390 |
+
*,
|
| 391 |
+
prog: str | None = None,
|
| 392 |
+
) -> Config:
|
| 393 |
+
if isinstance(args, os.PathLike):
|
| 394 |
+
args = [os.fspath(args)]
|
| 395 |
+
elif not isinstance(args, list):
|
| 396 |
+
msg = ( # type:ignore[unreachable]
|
| 397 |
+
"`args` parameter expected to be a list of strings, got: {!r} (type: {})"
|
| 398 |
+
)
|
| 399 |
+
raise TypeError(msg.format(args, type(args)))
|
| 400 |
+
|
| 401 |
+
initial_config = get_config(args, plugins, prog=prog)
|
| 402 |
+
pluginmanager = initial_config.pluginmanager
|
| 403 |
+
try:
|
| 404 |
+
if plugins:
|
| 405 |
+
for plugin in plugins:
|
| 406 |
+
if isinstance(plugin, str):
|
| 407 |
+
pluginmanager.consider_pluginarg(plugin)
|
| 408 |
+
else:
|
| 409 |
+
pluginmanager.register(plugin)
|
| 410 |
+
config: Config = pluginmanager.hook.pytest_cmdline_parse(
|
| 411 |
+
pluginmanager=pluginmanager, args=args
|
| 412 |
+
)
|
| 413 |
+
return config
|
| 414 |
+
except BaseException:
|
| 415 |
+
initial_config._ensure_unconfigure()
|
| 416 |
+
raise
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _get_directory(path: pathlib.Path) -> pathlib.Path:
|
| 420 |
+
"""Get the directory of a path - itself if already a directory."""
|
| 421 |
+
if path.is_file():
|
| 422 |
+
return path.parent
|
| 423 |
+
else:
|
| 424 |
+
return path
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def _get_legacy_hook_marks(
|
| 428 |
+
method: Any,
|
| 429 |
+
hook_type: str,
|
| 430 |
+
opt_names: tuple[str, ...],
|
| 431 |
+
) -> dict[str, bool]:
|
| 432 |
+
if TYPE_CHECKING:
|
| 433 |
+
# abuse typeguard from importlib to avoid massive method type union that's lacking an alias
|
| 434 |
+
assert inspect.isroutine(method)
|
| 435 |
+
known_marks: set[str] = {m.name for m in getattr(method, "pytestmark", [])}
|
| 436 |
+
must_warn: list[str] = []
|
| 437 |
+
opts: dict[str, bool] = {}
|
| 438 |
+
for opt_name in opt_names:
|
| 439 |
+
opt_attr = getattr(method, opt_name, AttributeError)
|
| 440 |
+
if opt_attr is not AttributeError:
|
| 441 |
+
must_warn.append(f"{opt_name}={opt_attr}")
|
| 442 |
+
opts[opt_name] = True
|
| 443 |
+
elif opt_name in known_marks:
|
| 444 |
+
must_warn.append(f"{opt_name}=True")
|
| 445 |
+
opts[opt_name] = True
|
| 446 |
+
else:
|
| 447 |
+
opts[opt_name] = False
|
| 448 |
+
if must_warn:
|
| 449 |
+
hook_opts = ", ".join(must_warn)
|
| 450 |
+
message = _pytest.deprecated.HOOK_LEGACY_MARKING.format(
|
| 451 |
+
type=hook_type,
|
| 452 |
+
fullname=method.__qualname__,
|
| 453 |
+
hook_opts=hook_opts,
|
| 454 |
+
)
|
| 455 |
+
warn_explicit_for(cast(FunctionType, method), message)
|
| 456 |
+
return opts
|
| 457 |
+
|
| 458 |
+
|
| 459 |
+
@final
|
| 460 |
+
class PytestPluginManager(PluginManager):
|
| 461 |
+
"""A :py:class:`pluggy.PluginManager <pluggy.PluginManager>` with
|
| 462 |
+
additional pytest-specific functionality:
|
| 463 |
+
|
| 464 |
+
* Loading plugins from the command line, ``PYTEST_PLUGINS`` env variable and
|
| 465 |
+
``pytest_plugins`` global variables found in plugins being loaded.
|
| 466 |
+
* ``conftest.py`` loading during start-up.
|
| 467 |
+
"""
|
| 468 |
+
|
| 469 |
+
def __init__(self) -> None:
|
| 470 |
+
from _pytest.assertion import DummyRewriteHook
|
| 471 |
+
from _pytest.assertion import RewriteHook
|
| 472 |
+
|
| 473 |
+
super().__init__("pytest")
|
| 474 |
+
|
| 475 |
+
# -- State related to local conftest plugins.
|
| 476 |
+
# All loaded conftest modules.
|
| 477 |
+
self._conftest_plugins: set[types.ModuleType] = set()
|
| 478 |
+
# All conftest modules applicable for a directory.
|
| 479 |
+
# This includes the directory's own conftest modules as well
|
| 480 |
+
# as those of its parent directories.
|
| 481 |
+
self._dirpath2confmods: dict[pathlib.Path, list[types.ModuleType]] = {}
|
| 482 |
+
# Cutoff directory above which conftests are no longer discovered.
|
| 483 |
+
self._confcutdir: pathlib.Path | None = None
|
| 484 |
+
# If set, conftest loading is skipped.
|
| 485 |
+
self._noconftest = False
|
| 486 |
+
|
| 487 |
+
# _getconftestmodules()'s call to _get_directory() causes a stat
|
| 488 |
+
# storm when it's called potentially thousands of times in a test
|
| 489 |
+
# session (#9478), often with the same path, so cache it.
|
| 490 |
+
self._get_directory = lru_cache(256)(_get_directory)
|
| 491 |
+
|
| 492 |
+
# plugins that were explicitly skipped with pytest.skip
|
| 493 |
+
# list of (module name, skip reason)
|
| 494 |
+
# previously we would issue a warning when a plugin was skipped, but
|
| 495 |
+
# since we refactored warnings as first citizens of Config, they are
|
| 496 |
+
# just stored here to be used later.
|
| 497 |
+
self.skipped_plugins: list[tuple[str, str]] = []
|
| 498 |
+
|
| 499 |
+
self.add_hookspecs(_pytest.hookspec)
|
| 500 |
+
self.register(self)
|
| 501 |
+
if os.environ.get("PYTEST_DEBUG"):
|
| 502 |
+
err: IO[str] = sys.stderr
|
| 503 |
+
encoding: str = getattr(err, "encoding", "utf8")
|
| 504 |
+
try:
|
| 505 |
+
err = open(
|
| 506 |
+
os.dup(err.fileno()),
|
| 507 |
+
mode=err.mode,
|
| 508 |
+
buffering=1,
|
| 509 |
+
encoding=encoding,
|
| 510 |
+
)
|
| 511 |
+
except Exception:
|
| 512 |
+
pass
|
| 513 |
+
self.trace.root.setwriter(err.write)
|
| 514 |
+
self.enable_tracing()
|
| 515 |
+
|
| 516 |
+
# Config._consider_importhook will set a real object if required.
|
| 517 |
+
self.rewrite_hook: RewriteHook = DummyRewriteHook()
|
| 518 |
+
# Used to know when we are importing conftests after the pytest_configure stage.
|
| 519 |
+
self._configured = False
|
| 520 |
+
|
| 521 |
+
def parse_hookimpl_opts(
|
| 522 |
+
self, plugin: _PluggyPlugin, name: str
|
| 523 |
+
) -> HookimplOpts | None:
|
| 524 |
+
""":meta private:"""
|
| 525 |
+
# pytest hooks are always prefixed with "pytest_",
|
| 526 |
+
# so we avoid accessing possibly non-readable attributes
|
| 527 |
+
# (see issue #1073).
|
| 528 |
+
if not name.startswith("pytest_"):
|
| 529 |
+
return None
|
| 530 |
+
# Ignore names which cannot be hooks.
|
| 531 |
+
if name == "pytest_plugins":
|
| 532 |
+
return None
|
| 533 |
+
|
| 534 |
+
opts = super().parse_hookimpl_opts(plugin, name)
|
| 535 |
+
if opts is not None:
|
| 536 |
+
return opts
|
| 537 |
+
|
| 538 |
+
method = getattr(plugin, name)
|
| 539 |
+
# Consider only actual functions for hooks (#3775).
|
| 540 |
+
if not inspect.isroutine(method):
|
| 541 |
+
return None
|
| 542 |
+
# Collect unmarked hooks as long as they have the `pytest_' prefix.
|
| 543 |
+
legacy = _get_legacy_hook_marks(
|
| 544 |
+
method, "impl", ("tryfirst", "trylast", "optionalhook", "hookwrapper")
|
| 545 |
+
)
|
| 546 |
+
return cast(HookimplOpts, legacy)
|
| 547 |
+
|
| 548 |
+
def parse_hookspec_opts(self, module_or_class, name: str) -> HookspecOpts | None:
|
| 549 |
+
""":meta private:"""
|
| 550 |
+
opts = super().parse_hookspec_opts(module_or_class, name)
|
| 551 |
+
if opts is None:
|
| 552 |
+
method = getattr(module_or_class, name)
|
| 553 |
+
if name.startswith("pytest_"):
|
| 554 |
+
legacy = _get_legacy_hook_marks(
|
| 555 |
+
method, "spec", ("firstresult", "historic")
|
| 556 |
+
)
|
| 557 |
+
opts = cast(HookspecOpts, legacy)
|
| 558 |
+
return opts
|
| 559 |
+
|
| 560 |
+
def register(self, plugin: _PluggyPlugin, name: str | None = None) -> str | None:
|
| 561 |
+
if name in _pytest.deprecated.DEPRECATED_EXTERNAL_PLUGINS:
|
| 562 |
+
warnings.warn(
|
| 563 |
+
PytestConfigWarning(
|
| 564 |
+
"{} plugin has been merged into the core, "
|
| 565 |
+
"please remove it from your requirements.".format(
|
| 566 |
+
name.replace("_", "-")
|
| 567 |
+
)
|
| 568 |
+
)
|
| 569 |
+
)
|
| 570 |
+
return None
|
| 571 |
+
plugin_name = super().register(plugin, name)
|
| 572 |
+
if plugin_name is not None:
|
| 573 |
+
self.hook.pytest_plugin_registered.call_historic(
|
| 574 |
+
kwargs=dict(
|
| 575 |
+
plugin=plugin,
|
| 576 |
+
plugin_name=plugin_name,
|
| 577 |
+
manager=self,
|
| 578 |
+
)
|
| 579 |
+
)
|
| 580 |
+
|
| 581 |
+
if isinstance(plugin, types.ModuleType):
|
| 582 |
+
self.consider_module(plugin)
|
| 583 |
+
return plugin_name
|
| 584 |
+
|
| 585 |
+
def getplugin(self, name: str):
|
| 586 |
+
# Support deprecated naming because plugins (xdist e.g.) use it.
|
| 587 |
+
plugin: _PluggyPlugin | None = self.get_plugin(name)
|
| 588 |
+
return plugin
|
| 589 |
+
|
| 590 |
+
def hasplugin(self, name: str) -> bool:
|
| 591 |
+
"""Return whether a plugin with the given name is registered."""
|
| 592 |
+
return bool(self.get_plugin(name))
|
| 593 |
+
|
| 594 |
+
def pytest_configure(self, config: Config) -> None:
|
| 595 |
+
""":meta private:"""
|
| 596 |
+
# XXX now that the pluginmanager exposes hookimpl(tryfirst...)
|
| 597 |
+
# we should remove tryfirst/trylast as markers.
|
| 598 |
+
config.addinivalue_line(
|
| 599 |
+
"markers",
|
| 600 |
+
"tryfirst: mark a hook implementation function such that the "
|
| 601 |
+
"plugin machinery will try to call it first/as early as possible. "
|
| 602 |
+
"DEPRECATED, use @pytest.hookimpl(tryfirst=True) instead.",
|
| 603 |
+
)
|
| 604 |
+
config.addinivalue_line(
|
| 605 |
+
"markers",
|
| 606 |
+
"trylast: mark a hook implementation function such that the "
|
| 607 |
+
"plugin machinery will try to call it last/as late as possible. "
|
| 608 |
+
"DEPRECATED, use @pytest.hookimpl(trylast=True) instead.",
|
| 609 |
+
)
|
| 610 |
+
self._configured = True
|
| 611 |
+
|
| 612 |
+
#
|
| 613 |
+
# Internal API for local conftest plugin handling.
|
| 614 |
+
#
|
| 615 |
+
def _set_initial_conftests(
|
| 616 |
+
self,
|
| 617 |
+
args: Sequence[str | pathlib.Path],
|
| 618 |
+
pyargs: bool,
|
| 619 |
+
noconftest: bool,
|
| 620 |
+
rootpath: pathlib.Path,
|
| 621 |
+
confcutdir: pathlib.Path | None,
|
| 622 |
+
invocation_dir: pathlib.Path,
|
| 623 |
+
importmode: ImportMode | str,
|
| 624 |
+
*,
|
| 625 |
+
consider_namespace_packages: bool,
|
| 626 |
+
) -> None:
|
| 627 |
+
"""Load initial conftest files given a preparsed "namespace".
|
| 628 |
+
|
| 629 |
+
As conftest files may add their own command line options which have
|
| 630 |
+
arguments ('--my-opt somepath') we might get some false positives.
|
| 631 |
+
All builtin and 3rd party plugins will have been loaded, however, so
|
| 632 |
+
common options will not confuse our logic here.
|
| 633 |
+
"""
|
| 634 |
+
self._confcutdir = (
|
| 635 |
+
absolutepath(invocation_dir / confcutdir) if confcutdir else None
|
| 636 |
+
)
|
| 637 |
+
self._noconftest = noconftest
|
| 638 |
+
self._using_pyargs = pyargs
|
| 639 |
+
|
| 640 |
+
anchors = []
|
| 641 |
+
for initial_path in args:
|
| 642 |
+
path = str(initial_path)
|
| 643 |
+
# remove node-id syntax
|
| 644 |
+
i = path.find("::")
|
| 645 |
+
if i != -1:
|
| 646 |
+
path = path[:i]
|
| 647 |
+
anchor = absolutepath(invocation_dir / path)
|
| 648 |
+
# Ensure we do not break if what appears to be an anchor
|
| 649 |
+
# is in fact a very long option (#10169, #11394).
|
| 650 |
+
if not safe_exists(anchor):
|
| 651 |
+
continue
|
| 652 |
+
|
| 653 |
+
anchors.append(anchor)
|
| 654 |
+
# Let's also consider test* subdirs.
|
| 655 |
+
if anchor.is_dir():
|
| 656 |
+
anchors.extend(x for x in anchor.glob("test*") if x.is_dir())
|
| 657 |
+
if not anchors:
|
| 658 |
+
anchors.append(invocation_dir)
|
| 659 |
+
anchors.extend(x for x in invocation_dir.glob("test*") if x.is_dir())
|
| 660 |
+
|
| 661 |
+
for anchor in anchors:
|
| 662 |
+
self._loadconftestmodules(
|
| 663 |
+
anchor,
|
| 664 |
+
importmode,
|
| 665 |
+
rootpath,
|
| 666 |
+
consider_namespace_packages=consider_namespace_packages,
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
def _is_in_confcutdir(self, path: pathlib.Path) -> bool:
|
| 670 |
+
"""Whether to consider the given path to load conftests from."""
|
| 671 |
+
if self._confcutdir is None:
|
| 672 |
+
return True
|
| 673 |
+
# The semantics here are literally:
|
| 674 |
+
# Do not load a conftest if it is found upwards from confcut dir.
|
| 675 |
+
# But this is *not* the same as:
|
| 676 |
+
# Load only conftests from confcutdir or below.
|
| 677 |
+
# At first glance they might seem the same thing, however we do support use cases where
|
| 678 |
+
# we want to load conftests that are not found in confcutdir or below, but are found
|
| 679 |
+
# in completely different directory hierarchies like packages installed
|
| 680 |
+
# in out-of-source trees.
|
| 681 |
+
# (see #9767 for a regression where the logic was inverted).
|
| 682 |
+
return path not in self._confcutdir.parents
|
| 683 |
+
|
| 684 |
+
def _loadconftestmodules(
|
| 685 |
+
self,
|
| 686 |
+
path: pathlib.Path,
|
| 687 |
+
importmode: str | ImportMode,
|
| 688 |
+
rootpath: pathlib.Path,
|
| 689 |
+
*,
|
| 690 |
+
consider_namespace_packages: bool,
|
| 691 |
+
) -> None:
|
| 692 |
+
if self._noconftest:
|
| 693 |
+
return
|
| 694 |
+
|
| 695 |
+
directory = self._get_directory(path)
|
| 696 |
+
|
| 697 |
+
# Optimization: avoid repeated searches in the same directory.
|
| 698 |
+
# Assumes always called with same importmode and rootpath.
|
| 699 |
+
if directory in self._dirpath2confmods:
|
| 700 |
+
return
|
| 701 |
+
|
| 702 |
+
clist = []
|
| 703 |
+
for parent in reversed((directory, *directory.parents)):
|
| 704 |
+
if self._is_in_confcutdir(parent):
|
| 705 |
+
conftestpath = parent / "conftest.py"
|
| 706 |
+
if conftestpath.is_file():
|
| 707 |
+
mod = self._importconftest(
|
| 708 |
+
conftestpath,
|
| 709 |
+
importmode,
|
| 710 |
+
rootpath,
|
| 711 |
+
consider_namespace_packages=consider_namespace_packages,
|
| 712 |
+
)
|
| 713 |
+
clist.append(mod)
|
| 714 |
+
self._dirpath2confmods[directory] = clist
|
| 715 |
+
|
| 716 |
+
def _getconftestmodules(self, path: pathlib.Path) -> Sequence[types.ModuleType]:
|
| 717 |
+
directory = self._get_directory(path)
|
| 718 |
+
return self._dirpath2confmods.get(directory, ())
|
| 719 |
+
|
| 720 |
+
def _rget_with_confmod(
|
| 721 |
+
self,
|
| 722 |
+
name: str,
|
| 723 |
+
path: pathlib.Path,
|
| 724 |
+
) -> tuple[types.ModuleType, Any]:
|
| 725 |
+
modules = self._getconftestmodules(path)
|
| 726 |
+
for mod in reversed(modules):
|
| 727 |
+
try:
|
| 728 |
+
return mod, getattr(mod, name)
|
| 729 |
+
except AttributeError:
|
| 730 |
+
continue
|
| 731 |
+
raise KeyError(name)
|
| 732 |
+
|
| 733 |
+
def _importconftest(
|
| 734 |
+
self,
|
| 735 |
+
conftestpath: pathlib.Path,
|
| 736 |
+
importmode: str | ImportMode,
|
| 737 |
+
rootpath: pathlib.Path,
|
| 738 |
+
*,
|
| 739 |
+
consider_namespace_packages: bool,
|
| 740 |
+
) -> types.ModuleType:
|
| 741 |
+
conftestpath_plugin_name = str(conftestpath)
|
| 742 |
+
existing = self.get_plugin(conftestpath_plugin_name)
|
| 743 |
+
if existing is not None:
|
| 744 |
+
return cast(types.ModuleType, existing)
|
| 745 |
+
|
| 746 |
+
# conftest.py files there are not in a Python package all have module
|
| 747 |
+
# name "conftest", and thus conflict with each other. Clear the existing
|
| 748 |
+
# before loading the new one, otherwise the existing one will be
|
| 749 |
+
# returned from the module cache.
|
| 750 |
+
pkgpath = resolve_package_path(conftestpath)
|
| 751 |
+
if pkgpath is None:
|
| 752 |
+
try:
|
| 753 |
+
del sys.modules[conftestpath.stem]
|
| 754 |
+
except KeyError:
|
| 755 |
+
pass
|
| 756 |
+
|
| 757 |
+
try:
|
| 758 |
+
mod = import_path(
|
| 759 |
+
conftestpath,
|
| 760 |
+
mode=importmode,
|
| 761 |
+
root=rootpath,
|
| 762 |
+
consider_namespace_packages=consider_namespace_packages,
|
| 763 |
+
)
|
| 764 |
+
except Exception as e:
|
| 765 |
+
assert e.__traceback__ is not None
|
| 766 |
+
raise ConftestImportFailure(conftestpath, cause=e) from e
|
| 767 |
+
|
| 768 |
+
self._check_non_top_pytest_plugins(mod, conftestpath)
|
| 769 |
+
|
| 770 |
+
self._conftest_plugins.add(mod)
|
| 771 |
+
dirpath = conftestpath.parent
|
| 772 |
+
if dirpath in self._dirpath2confmods:
|
| 773 |
+
for path, mods in self._dirpath2confmods.items():
|
| 774 |
+
if dirpath in path.parents or path == dirpath:
|
| 775 |
+
if mod in mods:
|
| 776 |
+
raise AssertionError(
|
| 777 |
+
f"While trying to load conftest path {conftestpath!s}, "
|
| 778 |
+
f"found that the module {mod} is already loaded with path {mod.__file__}. "
|
| 779 |
+
"This is not supposed to happen. Please report this issue to pytest."
|
| 780 |
+
)
|
| 781 |
+
mods.append(mod)
|
| 782 |
+
self.trace(f"loading conftestmodule {mod!r}")
|
| 783 |
+
self.consider_conftest(mod, registration_name=conftestpath_plugin_name)
|
| 784 |
+
return mod
|
| 785 |
+
|
| 786 |
+
def _check_non_top_pytest_plugins(
|
| 787 |
+
self,
|
| 788 |
+
mod: types.ModuleType,
|
| 789 |
+
conftestpath: pathlib.Path,
|
| 790 |
+
) -> None:
|
| 791 |
+
if (
|
| 792 |
+
hasattr(mod, "pytest_plugins")
|
| 793 |
+
and self._configured
|
| 794 |
+
and not self._using_pyargs
|
| 795 |
+
):
|
| 796 |
+
msg = (
|
| 797 |
+
"Defining 'pytest_plugins' in a non-top-level conftest is no longer supported:\n"
|
| 798 |
+
"It affects the entire test suite instead of just below the conftest as expected.\n"
|
| 799 |
+
" {}\n"
|
| 800 |
+
"Please move it to a top level conftest file at the rootdir:\n"
|
| 801 |
+
" {}\n"
|
| 802 |
+
"For more information, visit:\n"
|
| 803 |
+
" https://docs.pytest.org/en/stable/deprecations.html#pytest-plugins-in-non-top-level-conftest-files"
|
| 804 |
+
)
|
| 805 |
+
fail(msg.format(conftestpath, self._confcutdir), pytrace=False)
|
| 806 |
+
|
| 807 |
+
#
|
| 808 |
+
# API for bootstrapping plugin loading
|
| 809 |
+
#
|
| 810 |
+
#
|
| 811 |
+
|
| 812 |
+
def consider_preparse(
|
| 813 |
+
self, args: Sequence[str], *, exclude_only: bool = False
|
| 814 |
+
) -> None:
|
| 815 |
+
""":meta private:"""
|
| 816 |
+
i = 0
|
| 817 |
+
n = len(args)
|
| 818 |
+
while i < n:
|
| 819 |
+
opt = args[i]
|
| 820 |
+
i += 1
|
| 821 |
+
if isinstance(opt, str):
|
| 822 |
+
if opt == "-p":
|
| 823 |
+
try:
|
| 824 |
+
parg = args[i]
|
| 825 |
+
except IndexError:
|
| 826 |
+
return
|
| 827 |
+
i += 1
|
| 828 |
+
elif opt.startswith("-p"):
|
| 829 |
+
parg = opt[2:]
|
| 830 |
+
else:
|
| 831 |
+
continue
|
| 832 |
+
parg = parg.strip()
|
| 833 |
+
if exclude_only and not parg.startswith("no:"):
|
| 834 |
+
continue
|
| 835 |
+
self.consider_pluginarg(parg)
|
| 836 |
+
|
| 837 |
+
def consider_pluginarg(self, arg: str) -> None:
|
| 838 |
+
""":meta private:"""
|
| 839 |
+
if arg.startswith("no:"):
|
| 840 |
+
name = arg[3:]
|
| 841 |
+
if name in essential_plugins:
|
| 842 |
+
raise UsageError(f"plugin {name} cannot be disabled")
|
| 843 |
+
|
| 844 |
+
if name.endswith("conftest.py"):
|
| 845 |
+
raise UsageError(
|
| 846 |
+
f"Blocking conftest files using -p is not supported: -p no:{name}\n"
|
| 847 |
+
"conftest.py files are not plugins and cannot be disabled via -p.\n"
|
| 848 |
+
)
|
| 849 |
+
|
| 850 |
+
# PR #4304: remove stepwise if cacheprovider is blocked.
|
| 851 |
+
if name == "cacheprovider":
|
| 852 |
+
self.set_blocked("stepwise")
|
| 853 |
+
self.set_blocked("pytest_stepwise")
|
| 854 |
+
|
| 855 |
+
self.set_blocked(name)
|
| 856 |
+
if not name.startswith("pytest_"):
|
| 857 |
+
self.set_blocked("pytest_" + name)
|
| 858 |
+
else:
|
| 859 |
+
name = arg
|
| 860 |
+
# Unblock the plugin.
|
| 861 |
+
self.unblock(name)
|
| 862 |
+
if not name.startswith("pytest_"):
|
| 863 |
+
self.unblock("pytest_" + name)
|
| 864 |
+
self.import_plugin(arg, consider_entry_points=True)
|
| 865 |
+
|
| 866 |
+
def consider_conftest(
|
| 867 |
+
self, conftestmodule: types.ModuleType, registration_name: str
|
| 868 |
+
) -> None:
|
| 869 |
+
""":meta private:"""
|
| 870 |
+
self.register(conftestmodule, name=registration_name)
|
| 871 |
+
|
| 872 |
+
def consider_env(self) -> None:
|
| 873 |
+
""":meta private:"""
|
| 874 |
+
self._import_plugin_specs(os.environ.get("PYTEST_PLUGINS"))
|
| 875 |
+
|
| 876 |
+
def consider_module(self, mod: types.ModuleType) -> None:
|
| 877 |
+
""":meta private:"""
|
| 878 |
+
self._import_plugin_specs(getattr(mod, "pytest_plugins", []))
|
| 879 |
+
|
| 880 |
+
def _import_plugin_specs(
|
| 881 |
+
self, spec: None | types.ModuleType | str | Sequence[str]
|
| 882 |
+
) -> None:
|
| 883 |
+
plugins = _get_plugin_specs_as_list(spec)
|
| 884 |
+
for import_spec in plugins:
|
| 885 |
+
self.import_plugin(import_spec)
|
| 886 |
+
|
| 887 |
+
def import_plugin(self, modname: str, consider_entry_points: bool = False) -> None:
|
| 888 |
+
"""Import a plugin with ``modname``.
|
| 889 |
+
|
| 890 |
+
If ``consider_entry_points`` is True, entry point names are also
|
| 891 |
+
considered to find a plugin.
|
| 892 |
+
"""
|
| 893 |
+
# Most often modname refers to builtin modules, e.g. "pytester",
|
| 894 |
+
# "terminal" or "capture". Those plugins are registered under their
|
| 895 |
+
# basename for historic purposes but must be imported with the
|
| 896 |
+
# _pytest prefix.
|
| 897 |
+
assert isinstance(modname, str), (
|
| 898 |
+
f"module name as text required, got {modname!r}"
|
| 899 |
+
)
|
| 900 |
+
if self.is_blocked(modname) or self.get_plugin(modname) is not None:
|
| 901 |
+
return
|
| 902 |
+
|
| 903 |
+
importspec = "_pytest." + modname if modname in builtin_plugins else modname
|
| 904 |
+
self.rewrite_hook.mark_rewrite(importspec)
|
| 905 |
+
|
| 906 |
+
if consider_entry_points:
|
| 907 |
+
loaded = self.load_setuptools_entrypoints("pytest11", name=modname)
|
| 908 |
+
if loaded:
|
| 909 |
+
return
|
| 910 |
+
|
| 911 |
+
try:
|
| 912 |
+
if sys.version_info >= (3, 11):
|
| 913 |
+
mod = importlib.import_module(importspec)
|
| 914 |
+
else:
|
| 915 |
+
# On Python 3.10, import_module breaks
|
| 916 |
+
# testing/test_config.py::test_disable_plugin_autoload.
|
| 917 |
+
__import__(importspec)
|
| 918 |
+
mod = sys.modules[importspec]
|
| 919 |
+
except ImportError as e:
|
| 920 |
+
raise ImportError(
|
| 921 |
+
f'Error importing plugin "{modname}": {e.args[0]}'
|
| 922 |
+
).with_traceback(e.__traceback__) from e
|
| 923 |
+
|
| 924 |
+
except Skipped as e:
|
| 925 |
+
self.skipped_plugins.append((modname, e.msg or ""))
|
| 926 |
+
else:
|
| 927 |
+
self.register(mod, modname)
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
def _get_plugin_specs_as_list(
|
| 931 |
+
specs: None | types.ModuleType | str | Sequence[str],
|
| 932 |
+
) -> list[str]:
|
| 933 |
+
"""Parse a plugins specification into a list of plugin names."""
|
| 934 |
+
# None means empty.
|
| 935 |
+
if specs is None:
|
| 936 |
+
return []
|
| 937 |
+
# Workaround for #3899 - a submodule which happens to be called "pytest_plugins".
|
| 938 |
+
if isinstance(specs, types.ModuleType):
|
| 939 |
+
return []
|
| 940 |
+
# Comma-separated list.
|
| 941 |
+
if isinstance(specs, str):
|
| 942 |
+
return specs.split(",") if specs else []
|
| 943 |
+
# Direct specification.
|
| 944 |
+
if isinstance(specs, collections.abc.Sequence):
|
| 945 |
+
return list(specs)
|
| 946 |
+
raise UsageError(
|
| 947 |
+
f"Plugins may be specified as a sequence or a ','-separated string of plugin names. Got: {specs!r}"
|
| 948 |
+
)
|
| 949 |
+
|
| 950 |
+
|
| 951 |
+
def _iter_rewritable_modules(package_files: Iterable[str]) -> Iterator[str]:
|
| 952 |
+
"""Given an iterable of file names in a source distribution, return the "names" that should
|
| 953 |
+
be marked for assertion rewrite.
|
| 954 |
+
|
| 955 |
+
For example the package "pytest_mock/__init__.py" should be added as "pytest_mock" in
|
| 956 |
+
the assertion rewrite mechanism.
|
| 957 |
+
|
| 958 |
+
This function has to deal with dist-info based distributions and egg based distributions
|
| 959 |
+
(which are still very much in use for "editable" installs).
|
| 960 |
+
|
| 961 |
+
Here are the file names as seen in a dist-info based distribution:
|
| 962 |
+
|
| 963 |
+
pytest_mock/__init__.py
|
| 964 |
+
pytest_mock/_version.py
|
| 965 |
+
pytest_mock/plugin.py
|
| 966 |
+
pytest_mock.egg-info/PKG-INFO
|
| 967 |
+
|
| 968 |
+
Here are the file names as seen in an egg based distribution:
|
| 969 |
+
|
| 970 |
+
src/pytest_mock/__init__.py
|
| 971 |
+
src/pytest_mock/_version.py
|
| 972 |
+
src/pytest_mock/plugin.py
|
| 973 |
+
src/pytest_mock.egg-info/PKG-INFO
|
| 974 |
+
LICENSE
|
| 975 |
+
setup.py
|
| 976 |
+
|
| 977 |
+
We have to take in account those two distribution flavors in order to determine which
|
| 978 |
+
names should be considered for assertion rewriting.
|
| 979 |
+
|
| 980 |
+
More information:
|
| 981 |
+
https://github.com/pytest-dev/pytest-mock/issues/167
|
| 982 |
+
"""
|
| 983 |
+
package_files = list(package_files)
|
| 984 |
+
seen_some = False
|
| 985 |
+
for fn in package_files:
|
| 986 |
+
is_simple_module = "/" not in fn and fn.endswith(".py")
|
| 987 |
+
is_package = fn.count("/") == 1 and fn.endswith("__init__.py")
|
| 988 |
+
if is_simple_module:
|
| 989 |
+
module_name, _ = os.path.splitext(fn)
|
| 990 |
+
# we ignore "setup.py" at the root of the distribution
|
| 991 |
+
# as well as editable installation finder modules made by setuptools
|
| 992 |
+
if module_name != "setup" and not module_name.startswith("__editable__"):
|
| 993 |
+
seen_some = True
|
| 994 |
+
yield module_name
|
| 995 |
+
elif is_package:
|
| 996 |
+
package_name = os.path.dirname(fn)
|
| 997 |
+
seen_some = True
|
| 998 |
+
yield package_name
|
| 999 |
+
|
| 1000 |
+
if not seen_some:
|
| 1001 |
+
# At this point we did not find any packages or modules suitable for assertion
|
| 1002 |
+
# rewriting, so we try again by stripping the first path component (to account for
|
| 1003 |
+
# "src" based source trees for example).
|
| 1004 |
+
# This approach lets us have the common case continue to be fast, as egg-distributions
|
| 1005 |
+
# are rarer.
|
| 1006 |
+
new_package_files = []
|
| 1007 |
+
for fn in package_files:
|
| 1008 |
+
parts = fn.split("/")
|
| 1009 |
+
new_fn = "/".join(parts[1:])
|
| 1010 |
+
if new_fn:
|
| 1011 |
+
new_package_files.append(new_fn)
|
| 1012 |
+
if new_package_files:
|
| 1013 |
+
yield from _iter_rewritable_modules(new_package_files)
|
| 1014 |
+
|
| 1015 |
+
|
| 1016 |
+
class _DeprecatedInicfgProxy(MutableMapping[str, Any]):
|
| 1017 |
+
"""Compatibility proxy for the deprecated Config.inicfg."""
|
| 1018 |
+
|
| 1019 |
+
__slots__ = ("_config",)
|
| 1020 |
+
|
| 1021 |
+
def __init__(self, config: Config) -> None:
|
| 1022 |
+
self._config = config
|
| 1023 |
+
|
| 1024 |
+
def __getitem__(self, key: str) -> Any:
|
| 1025 |
+
return self._config._inicfg[key].value
|
| 1026 |
+
|
| 1027 |
+
def __setitem__(self, key: str, value: Any) -> None:
|
| 1028 |
+
self._config._inicfg[key] = ConfigValue(value, origin="override", mode="toml")
|
| 1029 |
+
|
| 1030 |
+
def __delitem__(self, key: str) -> None:
|
| 1031 |
+
del self._config._inicfg[key]
|
| 1032 |
+
|
| 1033 |
+
def __iter__(self) -> Iterator[str]:
|
| 1034 |
+
return iter(self._config._inicfg)
|
| 1035 |
+
|
| 1036 |
+
def __len__(self) -> int:
|
| 1037 |
+
return len(self._config._inicfg)
|
| 1038 |
+
|
| 1039 |
+
|
| 1040 |
+
@final
|
| 1041 |
+
class Config:
|
| 1042 |
+
"""Access to configuration values, pluginmanager and plugin hooks.
|
| 1043 |
+
|
| 1044 |
+
:param PytestPluginManager pluginmanager:
|
| 1045 |
+
A pytest PluginManager.
|
| 1046 |
+
|
| 1047 |
+
:param InvocationParams invocation_params:
|
| 1048 |
+
Object containing parameters regarding the :func:`pytest.main`
|
| 1049 |
+
invocation.
|
| 1050 |
+
"""
|
| 1051 |
+
|
| 1052 |
+
@final
|
| 1053 |
+
@dataclasses.dataclass(frozen=True)
|
| 1054 |
+
class InvocationParams:
|
| 1055 |
+
"""Holds parameters passed during :func:`pytest.main`.
|
| 1056 |
+
|
| 1057 |
+
The object attributes are read-only.
|
| 1058 |
+
|
| 1059 |
+
.. versionadded:: 5.1
|
| 1060 |
+
|
| 1061 |
+
.. note::
|
| 1062 |
+
|
| 1063 |
+
Note that the environment variable ``PYTEST_ADDOPTS`` and the ``addopts``
|
| 1064 |
+
configuration option are handled by pytest, not being included in the ``args`` attribute.
|
| 1065 |
+
|
| 1066 |
+
Plugins accessing ``InvocationParams`` must be aware of that.
|
| 1067 |
+
"""
|
| 1068 |
+
|
| 1069 |
+
args: tuple[str, ...]
|
| 1070 |
+
"""The command-line arguments as passed to :func:`pytest.main`."""
|
| 1071 |
+
plugins: Sequence[str | _PluggyPlugin] | None
|
| 1072 |
+
"""Extra plugins, might be `None`."""
|
| 1073 |
+
dir: pathlib.Path
|
| 1074 |
+
"""The directory from which :func:`pytest.main` was invoked."""
|
| 1075 |
+
|
| 1076 |
+
def __init__(
|
| 1077 |
+
self,
|
| 1078 |
+
*,
|
| 1079 |
+
args: Iterable[str],
|
| 1080 |
+
plugins: Sequence[str | _PluggyPlugin] | None,
|
| 1081 |
+
dir: pathlib.Path,
|
| 1082 |
+
) -> None:
|
| 1083 |
+
object.__setattr__(self, "args", tuple(args))
|
| 1084 |
+
object.__setattr__(self, "plugins", plugins)
|
| 1085 |
+
object.__setattr__(self, "dir", dir)
|
| 1086 |
+
|
| 1087 |
+
class ArgsSource(enum.Enum):
|
| 1088 |
+
"""Indicates the source of the test arguments.
|
| 1089 |
+
|
| 1090 |
+
.. versionadded:: 7.2
|
| 1091 |
+
"""
|
| 1092 |
+
|
| 1093 |
+
#: Command line arguments.
|
| 1094 |
+
ARGS = enum.auto()
|
| 1095 |
+
#: Invocation directory.
|
| 1096 |
+
INVOCATION_DIR = enum.auto()
|
| 1097 |
+
INCOVATION_DIR = INVOCATION_DIR # backwards compatibility alias
|
| 1098 |
+
#: 'testpaths' configuration value.
|
| 1099 |
+
TESTPATHS = enum.auto()
|
| 1100 |
+
|
| 1101 |
+
# Set by cacheprovider plugin.
|
| 1102 |
+
cache: Cache
|
| 1103 |
+
|
| 1104 |
+
def __init__(
|
| 1105 |
+
self,
|
| 1106 |
+
pluginmanager: PytestPluginManager,
|
| 1107 |
+
*,
|
| 1108 |
+
invocation_params: InvocationParams | None = None,
|
| 1109 |
+
prog: str | None = None,
|
| 1110 |
+
) -> None:
|
| 1111 |
+
if invocation_params is None:
|
| 1112 |
+
invocation_params = self.InvocationParams(
|
| 1113 |
+
args=(), plugins=None, dir=pathlib.Path.cwd()
|
| 1114 |
+
)
|
| 1115 |
+
|
| 1116 |
+
self.option = argparse.Namespace()
|
| 1117 |
+
"""Access to command line option as attributes.
|
| 1118 |
+
|
| 1119 |
+
:type: argparse.Namespace
|
| 1120 |
+
"""
|
| 1121 |
+
|
| 1122 |
+
self.invocation_params = invocation_params
|
| 1123 |
+
"""The parameters with which pytest was invoked.
|
| 1124 |
+
|
| 1125 |
+
:type: InvocationParams
|
| 1126 |
+
"""
|
| 1127 |
+
|
| 1128 |
+
self._parser = Parser(
|
| 1129 |
+
usage=f"%(prog)s [options] [{FILE_OR_DIR}] [{FILE_OR_DIR}] [...]",
|
| 1130 |
+
processopt=self._processopt,
|
| 1131 |
+
prog=prog,
|
| 1132 |
+
_ispytest=True,
|
| 1133 |
+
)
|
| 1134 |
+
self.pluginmanager = pluginmanager
|
| 1135 |
+
"""The plugin manager handles plugin registration and hook invocation.
|
| 1136 |
+
|
| 1137 |
+
:type: PytestPluginManager
|
| 1138 |
+
"""
|
| 1139 |
+
|
| 1140 |
+
self.stash = Stash()
|
| 1141 |
+
"""A place where plugins can store information on the config for their
|
| 1142 |
+
own use.
|
| 1143 |
+
|
| 1144 |
+
:type: Stash
|
| 1145 |
+
"""
|
| 1146 |
+
# Deprecated alias. Was never public. Can be removed in a few releases.
|
| 1147 |
+
self._store = self.stash
|
| 1148 |
+
|
| 1149 |
+
self.trace = self.pluginmanager.trace.root.get("config")
|
| 1150 |
+
self.hook = self.pluginmanager.hook
|
| 1151 |
+
self._inicache: dict[str, Any] = {}
|
| 1152 |
+
self._inicfg: ConfigDict = {}
|
| 1153 |
+
self._cleanup_stack = contextlib.ExitStack()
|
| 1154 |
+
self.pluginmanager.register(self, "pytestconfig")
|
| 1155 |
+
self._configured = False
|
| 1156 |
+
self.hook.pytest_addoption.call_historic(
|
| 1157 |
+
kwargs=dict(parser=self._parser, pluginmanager=self.pluginmanager)
|
| 1158 |
+
)
|
| 1159 |
+
self.args_source = Config.ArgsSource.ARGS
|
| 1160 |
+
self.args: list[str] = []
|
| 1161 |
+
|
| 1162 |
+
if TYPE_CHECKING:
|
| 1163 |
+
|
| 1164 |
+
@deprecated(
|
| 1165 |
+
"config.inicfg is deprecated, use config.getini() to access configuration values instead.",
|
| 1166 |
+
)
|
| 1167 |
+
@property
|
| 1168 |
+
def inicfg(self) -> _DeprecatedInicfgProxy:
|
| 1169 |
+
raise NotImplementedError()
|
| 1170 |
+
else:
|
| 1171 |
+
|
| 1172 |
+
@property
|
| 1173 |
+
def inicfg(self) -> _DeprecatedInicfgProxy:
|
| 1174 |
+
warnings.warn(
|
| 1175 |
+
_pytest.deprecated.CONFIG_INICFG,
|
| 1176 |
+
stacklevel=2,
|
| 1177 |
+
)
|
| 1178 |
+
return _DeprecatedInicfgProxy(self)
|
| 1179 |
+
|
| 1180 |
+
@property
|
| 1181 |
+
def rootpath(self) -> pathlib.Path:
|
| 1182 |
+
"""The path to the :ref:`rootdir <rootdir>`.
|
| 1183 |
+
|
| 1184 |
+
.. versionadded:: 6.1
|
| 1185 |
+
"""
|
| 1186 |
+
return self._rootpath
|
| 1187 |
+
|
| 1188 |
+
@property
|
| 1189 |
+
def inipath(self) -> pathlib.Path | None:
|
| 1190 |
+
"""The path to the :ref:`configfile <configfiles>`.
|
| 1191 |
+
|
| 1192 |
+
.. versionadded:: 6.1
|
| 1193 |
+
"""
|
| 1194 |
+
return self._inipath
|
| 1195 |
+
|
| 1196 |
+
def add_cleanup(self, func: Callable[[], None]) -> None:
|
| 1197 |
+
"""Add a function to be called when the config object gets out of
|
| 1198 |
+
use (usually coinciding with pytest_unconfigure).
|
| 1199 |
+
"""
|
| 1200 |
+
self._cleanup_stack.callback(func)
|
| 1201 |
+
|
| 1202 |
+
def _do_configure(self) -> None:
|
| 1203 |
+
assert not self._configured
|
| 1204 |
+
self._configured = True
|
| 1205 |
+
self.hook.pytest_configure.call_historic(kwargs=dict(config=self))
|
| 1206 |
+
|
| 1207 |
+
def _ensure_unconfigure(self) -> None:
|
| 1208 |
+
try:
|
| 1209 |
+
if self._configured:
|
| 1210 |
+
self._configured = False
|
| 1211 |
+
try:
|
| 1212 |
+
self.hook.pytest_unconfigure(config=self)
|
| 1213 |
+
finally:
|
| 1214 |
+
self.hook.pytest_configure._call_history = []
|
| 1215 |
+
finally:
|
| 1216 |
+
try:
|
| 1217 |
+
self._cleanup_stack.close()
|
| 1218 |
+
finally:
|
| 1219 |
+
self._cleanup_stack = contextlib.ExitStack()
|
| 1220 |
+
|
| 1221 |
+
def get_terminal_writer(self) -> TerminalWriter:
|
| 1222 |
+
terminalreporter: TerminalReporter | None = self.pluginmanager.get_plugin(
|
| 1223 |
+
"terminalreporter"
|
| 1224 |
+
)
|
| 1225 |
+
assert terminalreporter is not None
|
| 1226 |
+
return terminalreporter._tw
|
| 1227 |
+
|
| 1228 |
+
def pytest_cmdline_parse(
|
| 1229 |
+
self, pluginmanager: PytestPluginManager, args: list[str]
|
| 1230 |
+
) -> Config:
|
| 1231 |
+
try:
|
| 1232 |
+
self.parse(args)
|
| 1233 |
+
except UsageError:
|
| 1234 |
+
# Handle `--version --version` and `--help` here in a minimal fashion.
|
| 1235 |
+
# This gets done via helpconfig normally, but its
|
| 1236 |
+
# pytest_cmdline_main is not called in case of errors.
|
| 1237 |
+
if getattr(self.option, "version", False) or "--version" in args:
|
| 1238 |
+
from _pytest.helpconfig import show_version_verbose
|
| 1239 |
+
|
| 1240 |
+
# Note that `--version` (single argument) is handled early by `Config.main()`, so the only
|
| 1241 |
+
# way we are reaching this point is via `--version --version`.
|
| 1242 |
+
show_version_verbose(self)
|
| 1243 |
+
elif (
|
| 1244 |
+
getattr(self.option, "help", False) or "--help" in args or "-h" in args
|
| 1245 |
+
):
|
| 1246 |
+
self._parser.optparser.print_help()
|
| 1247 |
+
sys.stdout.write(
|
| 1248 |
+
"\nNOTE: displaying only minimal help due to UsageError.\n\n"
|
| 1249 |
+
)
|
| 1250 |
+
|
| 1251 |
+
raise
|
| 1252 |
+
|
| 1253 |
+
return self
|
| 1254 |
+
|
| 1255 |
+
def notify_exception(
|
| 1256 |
+
self,
|
| 1257 |
+
excinfo: ExceptionInfo[BaseException],
|
| 1258 |
+
option: argparse.Namespace | None = None,
|
| 1259 |
+
) -> None:
|
| 1260 |
+
if option and getattr(option, "fulltrace", False):
|
| 1261 |
+
style: TracebackStyle = "long"
|
| 1262 |
+
else:
|
| 1263 |
+
style = "native"
|
| 1264 |
+
excrepr = excinfo.getrepr(
|
| 1265 |
+
funcargs=True, showlocals=getattr(option, "showlocals", False), style=style
|
| 1266 |
+
)
|
| 1267 |
+
res = self.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo)
|
| 1268 |
+
if not any(res):
|
| 1269 |
+
for line in str(excrepr).split("\n"):
|
| 1270 |
+
sys.stderr.write(f"INTERNALERROR> {line}\n")
|
| 1271 |
+
sys.stderr.flush()
|
| 1272 |
+
|
| 1273 |
+
def cwd_relative_nodeid(self, nodeid: str) -> str:
|
| 1274 |
+
# nodeid's are relative to the rootpath, compute relative to cwd.
|
| 1275 |
+
if self.invocation_params.dir != self.rootpath:
|
| 1276 |
+
base_path_part, *nodeid_part = nodeid.split("::")
|
| 1277 |
+
# Only process path part
|
| 1278 |
+
fullpath = self.rootpath / base_path_part
|
| 1279 |
+
relative_path = bestrelpath(self.invocation_params.dir, fullpath)
|
| 1280 |
+
|
| 1281 |
+
nodeid = "::".join([relative_path, *nodeid_part])
|
| 1282 |
+
return nodeid
|
| 1283 |
+
|
| 1284 |
+
@classmethod
|
| 1285 |
+
def fromdictargs(cls, option_dict: Mapping[str, Any], args: list[str]) -> Config:
|
| 1286 |
+
"""Constructor usable for subprocesses."""
|
| 1287 |
+
config = get_config(args)
|
| 1288 |
+
config.option.__dict__.update(option_dict)
|
| 1289 |
+
config.parse(args, addopts=False)
|
| 1290 |
+
for x in config.option.plugins:
|
| 1291 |
+
config.pluginmanager.consider_pluginarg(x)
|
| 1292 |
+
return config
|
| 1293 |
+
|
| 1294 |
+
def _processopt(self, opt: Argument) -> None:
|
| 1295 |
+
if not hasattr(self.option, opt.dest):
|
| 1296 |
+
setattr(self.option, opt.dest, opt.default)
|
| 1297 |
+
|
| 1298 |
+
@hookimpl(trylast=True)
|
| 1299 |
+
def pytest_load_initial_conftests(self, early_config: Config) -> None:
|
| 1300 |
+
# We haven't fully parsed the command line arguments yet, so
|
| 1301 |
+
# early_config.args it not set yet. But we need it for
|
| 1302 |
+
# discovering the initial conftests. So "pre-run" the logic here.
|
| 1303 |
+
# It will be done for real in `parse()`.
|
| 1304 |
+
args, _args_source = early_config._decide_args(
|
| 1305 |
+
args=early_config.known_args_namespace.file_or_dir,
|
| 1306 |
+
pyargs=early_config.known_args_namespace.pyargs,
|
| 1307 |
+
testpaths=early_config.getini("testpaths"),
|
| 1308 |
+
invocation_dir=early_config.invocation_params.dir,
|
| 1309 |
+
rootpath=early_config.rootpath,
|
| 1310 |
+
warn=False,
|
| 1311 |
+
)
|
| 1312 |
+
self.pluginmanager._set_initial_conftests(
|
| 1313 |
+
args=args,
|
| 1314 |
+
pyargs=early_config.known_args_namespace.pyargs,
|
| 1315 |
+
noconftest=early_config.known_args_namespace.noconftest,
|
| 1316 |
+
rootpath=early_config.rootpath,
|
| 1317 |
+
confcutdir=early_config.known_args_namespace.confcutdir,
|
| 1318 |
+
invocation_dir=early_config.invocation_params.dir,
|
| 1319 |
+
importmode=early_config.known_args_namespace.importmode,
|
| 1320 |
+
consider_namespace_packages=early_config.getini(
|
| 1321 |
+
"consider_namespace_packages"
|
| 1322 |
+
),
|
| 1323 |
+
)
|
| 1324 |
+
|
| 1325 |
+
def _consider_importhook(self) -> None:
|
| 1326 |
+
"""Install the PEP 302 import hook if using assertion rewriting.
|
| 1327 |
+
|
| 1328 |
+
Needs to parse the --assert=<mode> option from the commandline
|
| 1329 |
+
and find all the installed plugins to mark them for rewriting
|
| 1330 |
+
by the importhook.
|
| 1331 |
+
"""
|
| 1332 |
+
mode = getattr(self.known_args_namespace, "assertmode", "plain")
|
| 1333 |
+
|
| 1334 |
+
disable_autoload = getattr(
|
| 1335 |
+
self.known_args_namespace, "disable_plugin_autoload", False
|
| 1336 |
+
) or bool(os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD"))
|
| 1337 |
+
if mode == "rewrite":
|
| 1338 |
+
import _pytest.assertion
|
| 1339 |
+
|
| 1340 |
+
try:
|
| 1341 |
+
hook = _pytest.assertion.install_importhook(self)
|
| 1342 |
+
except SystemError:
|
| 1343 |
+
mode = "plain"
|
| 1344 |
+
else:
|
| 1345 |
+
self._mark_plugins_for_rewrite(hook, disable_autoload)
|
| 1346 |
+
self._warn_about_missing_assertion(mode)
|
| 1347 |
+
|
| 1348 |
+
def _mark_plugins_for_rewrite(
|
| 1349 |
+
self, hook: AssertionRewritingHook, disable_autoload: bool
|
| 1350 |
+
) -> None:
|
| 1351 |
+
"""Given an importhook, mark for rewrite any top-level
|
| 1352 |
+
modules or packages in the distribution package for
|
| 1353 |
+
all pytest plugins."""
|
| 1354 |
+
self.pluginmanager.rewrite_hook = hook
|
| 1355 |
+
|
| 1356 |
+
if disable_autoload:
|
| 1357 |
+
# We don't autoload from distribution package entry points,
|
| 1358 |
+
# no need to continue.
|
| 1359 |
+
return
|
| 1360 |
+
|
| 1361 |
+
package_files = (
|
| 1362 |
+
str(file)
|
| 1363 |
+
for dist in importlib.metadata.distributions()
|
| 1364 |
+
if any(ep.group == "pytest11" for ep in dist.entry_points)
|
| 1365 |
+
for file in dist.files or []
|
| 1366 |
+
)
|
| 1367 |
+
|
| 1368 |
+
for name in _iter_rewritable_modules(package_files):
|
| 1369 |
+
hook.mark_rewrite(name)
|
| 1370 |
+
|
| 1371 |
+
def _configure_python_path(self) -> None:
|
| 1372 |
+
# `pythonpath = a b` will set `sys.path` to `[a, b, x, y, z, ...]`
|
| 1373 |
+
for path in reversed(self.getini("pythonpath")):
|
| 1374 |
+
sys.path.insert(0, str(path))
|
| 1375 |
+
self.add_cleanup(self._unconfigure_python_path)
|
| 1376 |
+
|
| 1377 |
+
def _unconfigure_python_path(self) -> None:
|
| 1378 |
+
for path in self.getini("pythonpath"):
|
| 1379 |
+
path_str = str(path)
|
| 1380 |
+
if path_str in sys.path:
|
| 1381 |
+
sys.path.remove(path_str)
|
| 1382 |
+
|
| 1383 |
+
def _validate_args(self, args: list[str], via: str) -> list[str]:
|
| 1384 |
+
"""Validate known args."""
|
| 1385 |
+
self._parser.extra_info["config source"] = via
|
| 1386 |
+
try:
|
| 1387 |
+
self._parser.parse_known_and_unknown_args(
|
| 1388 |
+
args, namespace=copy.copy(self.option)
|
| 1389 |
+
)
|
| 1390 |
+
finally:
|
| 1391 |
+
self._parser.extra_info.pop("config source", None)
|
| 1392 |
+
|
| 1393 |
+
return args
|
| 1394 |
+
|
| 1395 |
+
def _decide_args(
|
| 1396 |
+
self,
|
| 1397 |
+
*,
|
| 1398 |
+
args: list[str],
|
| 1399 |
+
pyargs: bool,
|
| 1400 |
+
testpaths: list[str],
|
| 1401 |
+
invocation_dir: pathlib.Path,
|
| 1402 |
+
rootpath: pathlib.Path,
|
| 1403 |
+
warn: bool,
|
| 1404 |
+
) -> tuple[list[str], ArgsSource]:
|
| 1405 |
+
"""Decide the args (initial paths/nodeids) to use given the relevant inputs.
|
| 1406 |
+
|
| 1407 |
+
:param warn: Whether can issue warnings.
|
| 1408 |
+
|
| 1409 |
+
:returns: The args and the args source. Guaranteed to be non-empty.
|
| 1410 |
+
"""
|
| 1411 |
+
if args:
|
| 1412 |
+
source = Config.ArgsSource.ARGS
|
| 1413 |
+
result = args
|
| 1414 |
+
else:
|
| 1415 |
+
if invocation_dir == rootpath:
|
| 1416 |
+
source = Config.ArgsSource.TESTPATHS
|
| 1417 |
+
if pyargs:
|
| 1418 |
+
result = testpaths
|
| 1419 |
+
else:
|
| 1420 |
+
result = []
|
| 1421 |
+
for path in testpaths:
|
| 1422 |
+
result.extend(sorted(glob.iglob(path, recursive=True)))
|
| 1423 |
+
if testpaths and not result:
|
| 1424 |
+
if warn:
|
| 1425 |
+
warning_text = (
|
| 1426 |
+
"No files were found in testpaths; "
|
| 1427 |
+
"consider removing or adjusting your testpaths configuration. "
|
| 1428 |
+
"Searching recursively from the current directory instead."
|
| 1429 |
+
)
|
| 1430 |
+
self.issue_config_time_warning(
|
| 1431 |
+
PytestConfigWarning(warning_text), stacklevel=3
|
| 1432 |
+
)
|
| 1433 |
+
else:
|
| 1434 |
+
result = []
|
| 1435 |
+
if not result:
|
| 1436 |
+
source = Config.ArgsSource.INVOCATION_DIR
|
| 1437 |
+
result = [str(invocation_dir)]
|
| 1438 |
+
return result, source
|
| 1439 |
+
|
| 1440 |
+
@hookimpl(wrapper=True)
|
| 1441 |
+
def pytest_collection(self) -> Generator[None, object, object]:
|
| 1442 |
+
# Validate invalid configuration keys after collection is done so we
|
| 1443 |
+
# take in account options added by late-loading conftest files.
|
| 1444 |
+
try:
|
| 1445 |
+
return (yield)
|
| 1446 |
+
finally:
|
| 1447 |
+
self._validate_config_options()
|
| 1448 |
+
|
| 1449 |
+
def _checkversion(self) -> None:
|
| 1450 |
+
import pytest
|
| 1451 |
+
|
| 1452 |
+
minver = self.getini("minversion")
|
| 1453 |
+
if minver:
|
| 1454 |
+
# Imported lazily to improve start-up time.
|
| 1455 |
+
from packaging.version import Version
|
| 1456 |
+
|
| 1457 |
+
if Version(minver) > Version(pytest.__version__):
|
| 1458 |
+
raise pytest.UsageError(
|
| 1459 |
+
f"{self.inipath}: 'minversion' requires pytest-{minver}, actual pytest-{pytest.__version__}'"
|
| 1460 |
+
)
|
| 1461 |
+
|
| 1462 |
+
def _validate_config_options(self) -> None:
|
| 1463 |
+
for key in sorted(self._get_unknown_ini_keys()):
|
| 1464 |
+
self._warn_or_fail_if_strict(f"Unknown config option: {key}\n")
|
| 1465 |
+
|
| 1466 |
+
def _validate_plugins(self) -> None:
|
| 1467 |
+
required_plugins = sorted(self.getini("required_plugins"))
|
| 1468 |
+
if not required_plugins:
|
| 1469 |
+
return
|
| 1470 |
+
|
| 1471 |
+
# Imported lazily to improve start-up time.
|
| 1472 |
+
from packaging.requirements import InvalidRequirement
|
| 1473 |
+
from packaging.requirements import Requirement
|
| 1474 |
+
from packaging.version import Version
|
| 1475 |
+
|
| 1476 |
+
plugin_info = self.pluginmanager.list_plugin_distinfo()
|
| 1477 |
+
plugin_dist_info = {dist.project_name: dist.version for _, dist in plugin_info}
|
| 1478 |
+
|
| 1479 |
+
missing_plugins = []
|
| 1480 |
+
for required_plugin in required_plugins:
|
| 1481 |
+
try:
|
| 1482 |
+
req = Requirement(required_plugin)
|
| 1483 |
+
except InvalidRequirement:
|
| 1484 |
+
missing_plugins.append(required_plugin)
|
| 1485 |
+
continue
|
| 1486 |
+
|
| 1487 |
+
if req.name not in plugin_dist_info:
|
| 1488 |
+
missing_plugins.append(required_plugin)
|
| 1489 |
+
elif not req.specifier.contains(
|
| 1490 |
+
Version(plugin_dist_info[req.name]), prereleases=True
|
| 1491 |
+
):
|
| 1492 |
+
missing_plugins.append(required_plugin)
|
| 1493 |
+
|
| 1494 |
+
if missing_plugins:
|
| 1495 |
+
raise UsageError(
|
| 1496 |
+
"Missing required plugins: {}".format(", ".join(missing_plugins)),
|
| 1497 |
+
)
|
| 1498 |
+
|
| 1499 |
+
def _warn_or_fail_if_strict(self, message: str) -> None:
|
| 1500 |
+
strict_config = self.getini("strict_config")
|
| 1501 |
+
if strict_config is None:
|
| 1502 |
+
strict_config = self.getini("strict")
|
| 1503 |
+
if strict_config:
|
| 1504 |
+
raise UsageError(message)
|
| 1505 |
+
|
| 1506 |
+
self.issue_config_time_warning(PytestConfigWarning(message), stacklevel=3)
|
| 1507 |
+
|
| 1508 |
+
def _get_unknown_ini_keys(self) -> set[str]:
|
| 1509 |
+
known_keys = self._parser._inidict.keys() | self._parser._ini_aliases.keys()
|
| 1510 |
+
return self._inicfg.keys() - known_keys
|
| 1511 |
+
|
| 1512 |
+
def parse(self, args: list[str], addopts: bool = True) -> None:
|
| 1513 |
+
# Parse given cmdline arguments into this config object.
|
| 1514 |
+
assert self.args == [], (
|
| 1515 |
+
"can only parse cmdline args at most once per Config object"
|
| 1516 |
+
)
|
| 1517 |
+
|
| 1518 |
+
self.hook.pytest_addhooks.call_historic(
|
| 1519 |
+
kwargs=dict(pluginmanager=self.pluginmanager)
|
| 1520 |
+
)
|
| 1521 |
+
|
| 1522 |
+
if addopts:
|
| 1523 |
+
env_addopts = os.environ.get("PYTEST_ADDOPTS", "")
|
| 1524 |
+
if len(env_addopts):
|
| 1525 |
+
args[:] = (
|
| 1526 |
+
self._validate_args(shlex.split(env_addopts), "via PYTEST_ADDOPTS")
|
| 1527 |
+
+ args
|
| 1528 |
+
)
|
| 1529 |
+
|
| 1530 |
+
# At this point, self.option contains only defaults from the _processopt
|
| 1531 |
+
# callback.
|
| 1532 |
+
ns = self._parser.parse_known_args(args, namespace=copy.copy(self.option))
|
| 1533 |
+
rootpath, inipath, inicfg, ignored_config_files = determine_setup(
|
| 1534 |
+
inifile=ns.inifilename,
|
| 1535 |
+
override_ini=ns.override_ini,
|
| 1536 |
+
args=ns.file_or_dir,
|
| 1537 |
+
rootdir_cmd_arg=ns.rootdir or None,
|
| 1538 |
+
invocation_dir=self.invocation_params.dir,
|
| 1539 |
+
)
|
| 1540 |
+
self._rootpath = rootpath
|
| 1541 |
+
self._inipath = inipath
|
| 1542 |
+
self._ignored_config_files = ignored_config_files
|
| 1543 |
+
self._inicfg = inicfg
|
| 1544 |
+
self._parser.extra_info["rootdir"] = str(self.rootpath)
|
| 1545 |
+
self._parser.extra_info["inifile"] = str(self.inipath)
|
| 1546 |
+
|
| 1547 |
+
self._parser.addini("addopts", "Extra command line options", "args")
|
| 1548 |
+
self._parser.addini("minversion", "Minimally required pytest version")
|
| 1549 |
+
self._parser.addini(
|
| 1550 |
+
"pythonpath", type="paths", help="Add paths to sys.path", default=[]
|
| 1551 |
+
)
|
| 1552 |
+
self._parser.addini(
|
| 1553 |
+
"required_plugins",
|
| 1554 |
+
"Plugins that must be present for pytest to run",
|
| 1555 |
+
type="args",
|
| 1556 |
+
default=[],
|
| 1557 |
+
)
|
| 1558 |
+
|
| 1559 |
+
if addopts:
|
| 1560 |
+
args[:] = (
|
| 1561 |
+
self._validate_args(self.getini("addopts"), "via addopts config") + args
|
| 1562 |
+
)
|
| 1563 |
+
|
| 1564 |
+
self.known_args_namespace = self._parser.parse_known_args(
|
| 1565 |
+
args, namespace=copy.copy(self.option)
|
| 1566 |
+
)
|
| 1567 |
+
if addopts:
|
| 1568 |
+
# addopts may have added overrides (especially via OverrideIniAction).
|
| 1569 |
+
# The thing can be endlessly circular but we only do one level (#14442).
|
| 1570 |
+
if overrides := parse_override_ini(self.known_args_namespace.override_ini):
|
| 1571 |
+
self._inicfg.update(overrides)
|
| 1572 |
+
self._inicache.clear()
|
| 1573 |
+
self._checkversion()
|
| 1574 |
+
self._consider_importhook()
|
| 1575 |
+
self._configure_python_path()
|
| 1576 |
+
self.pluginmanager.consider_preparse(args, exclude_only=False)
|
| 1577 |
+
if (
|
| 1578 |
+
not os.environ.get("PYTEST_DISABLE_PLUGIN_AUTOLOAD")
|
| 1579 |
+
and not self.known_args_namespace.disable_plugin_autoload
|
| 1580 |
+
):
|
| 1581 |
+
# Autoloading from distribution package entry point has
|
| 1582 |
+
# not been disabled.
|
| 1583 |
+
self.pluginmanager.load_setuptools_entrypoints("pytest11")
|
| 1584 |
+
# Otherwise only plugins explicitly specified in PYTEST_PLUGINS
|
| 1585 |
+
# are going to be loaded.
|
| 1586 |
+
self.pluginmanager.consider_env()
|
| 1587 |
+
|
| 1588 |
+
# Parse again, now including options added in pytest_addoption
|
| 1589 |
+
# by third-party plugins loaded above. This way they're available
|
| 1590 |
+
# on early_config in the pytest_load_initial_conftests hook call below.
|
| 1591 |
+
self.known_args_namespace = self._parser.parse_known_args(
|
| 1592 |
+
args, namespace=copy.copy(self.option)
|
| 1593 |
+
)
|
| 1594 |
+
|
| 1595 |
+
self._validate_plugins()
|
| 1596 |
+
self._warn_about_skipped_plugins()
|
| 1597 |
+
|
| 1598 |
+
if self.known_args_namespace.confcutdir is None:
|
| 1599 |
+
if self.inipath is not None:
|
| 1600 |
+
confcutdir = str(self.inipath.parent)
|
| 1601 |
+
else:
|
| 1602 |
+
confcutdir = str(self.rootpath)
|
| 1603 |
+
self.known_args_namespace.confcutdir = confcutdir
|
| 1604 |
+
try:
|
| 1605 |
+
self.hook.pytest_load_initial_conftests(
|
| 1606 |
+
early_config=self, args=args, parser=self._parser
|
| 1607 |
+
)
|
| 1608 |
+
except ConftestImportFailure as e:
|
| 1609 |
+
if self.known_args_namespace.help or self.known_args_namespace.version:
|
| 1610 |
+
# we don't want to prevent --help/--version to work
|
| 1611 |
+
# so just let it pass and print a warning at the end
|
| 1612 |
+
self.issue_config_time_warning(
|
| 1613 |
+
PytestConfigWarning(f"could not load initial conftests: {e.path}"),
|
| 1614 |
+
stacklevel=2,
|
| 1615 |
+
)
|
| 1616 |
+
else:
|
| 1617 |
+
raise
|
| 1618 |
+
|
| 1619 |
+
try:
|
| 1620 |
+
self._parser.parse(args, namespace=self.option)
|
| 1621 |
+
except PrintHelp:
|
| 1622 |
+
return
|
| 1623 |
+
|
| 1624 |
+
self.args, self.args_source = self._decide_args(
|
| 1625 |
+
args=getattr(self.option, FILE_OR_DIR),
|
| 1626 |
+
pyargs=self.option.pyargs,
|
| 1627 |
+
testpaths=self.getini("testpaths"),
|
| 1628 |
+
invocation_dir=self.invocation_params.dir,
|
| 1629 |
+
rootpath=self.rootpath,
|
| 1630 |
+
warn=True,
|
| 1631 |
+
)
|
| 1632 |
+
|
| 1633 |
+
def issue_config_time_warning(self, warning: Warning, stacklevel: int) -> None:
|
| 1634 |
+
"""Issue and handle a warning during the "configure" stage.
|
| 1635 |
+
|
| 1636 |
+
During ``pytest_configure`` we can't capture warnings using the ``catch_warnings_for_item``
|
| 1637 |
+
function because it is not possible to have hook wrappers around ``pytest_configure``.
|
| 1638 |
+
|
| 1639 |
+
This function is mainly intended for plugins that need to issue warnings during
|
| 1640 |
+
``pytest_configure`` (or similar stages).
|
| 1641 |
+
|
| 1642 |
+
:param warning: The warning instance.
|
| 1643 |
+
:param stacklevel: stacklevel forwarded to warnings.warn.
|
| 1644 |
+
"""
|
| 1645 |
+
if self.pluginmanager.is_blocked("warnings"):
|
| 1646 |
+
return
|
| 1647 |
+
|
| 1648 |
+
cmdline_filters = self.known_args_namespace.pythonwarnings or []
|
| 1649 |
+
config_filters = self.getini("filterwarnings")
|
| 1650 |
+
|
| 1651 |
+
with warnings.catch_warnings(record=True) as records:
|
| 1652 |
+
warnings.simplefilter("always", type(warning))
|
| 1653 |
+
apply_warning_filters(config_filters, cmdline_filters)
|
| 1654 |
+
warnings.warn(warning, stacklevel=stacklevel)
|
| 1655 |
+
|
| 1656 |
+
if records:
|
| 1657 |
+
frame = sys._getframe(stacklevel - 1)
|
| 1658 |
+
location = frame.f_code.co_filename, frame.f_lineno, frame.f_code.co_name
|
| 1659 |
+
self.hook.pytest_warning_recorded.call_historic(
|
| 1660 |
+
kwargs=dict(
|
| 1661 |
+
warning_message=records[0],
|
| 1662 |
+
when="config",
|
| 1663 |
+
nodeid="",
|
| 1664 |
+
location=location,
|
| 1665 |
+
)
|
| 1666 |
+
)
|
| 1667 |
+
|
| 1668 |
+
def addinivalue_line(self, name: str, line: str) -> None:
|
| 1669 |
+
"""Add a line to a configuration option. The option must have been
|
| 1670 |
+
declared but might not yet be set in which case the line becomes
|
| 1671 |
+
the first line in its value."""
|
| 1672 |
+
x = self.getini(name)
|
| 1673 |
+
assert isinstance(x, list)
|
| 1674 |
+
x.append(line) # modifies the cached list inline
|
| 1675 |
+
|
| 1676 |
+
def getini(self, name: str) -> Any:
|
| 1677 |
+
"""Return configuration value the an :ref:`configuration file <configfiles>`.
|
| 1678 |
+
|
| 1679 |
+
If a configuration value is not defined in a
|
| 1680 |
+
:ref:`configuration file <configfiles>`, then the ``default`` value
|
| 1681 |
+
provided while registering the configuration through
|
| 1682 |
+
:func:`parser.addini <pytest.Parser.addini>` will be returned.
|
| 1683 |
+
Please note that you can even provide ``None`` as a valid
|
| 1684 |
+
default value.
|
| 1685 |
+
|
| 1686 |
+
If ``default`` is not provided while registering using
|
| 1687 |
+
:func:`parser.addini <pytest.Parser.addini>`, then a default value
|
| 1688 |
+
based on the ``type`` parameter passed to
|
| 1689 |
+
:func:`parser.addini <pytest.Parser.addini>` will be returned.
|
| 1690 |
+
The default values based on ``type`` are:
|
| 1691 |
+
``paths``, ``pathlist``, ``args`` and ``linelist`` : empty list ``[]``
|
| 1692 |
+
``bool`` : ``False``
|
| 1693 |
+
``string`` : empty string ``""``
|
| 1694 |
+
``int`` : ``0``
|
| 1695 |
+
``float`` : ``0.0``
|
| 1696 |
+
|
| 1697 |
+
If neither the ``default`` nor the ``type`` parameter is passed
|
| 1698 |
+
while registering the configuration through
|
| 1699 |
+
:func:`parser.addini <pytest.Parser.addini>`, then the configuration
|
| 1700 |
+
is treated as a string and a default empty string '' is returned.
|
| 1701 |
+
|
| 1702 |
+
If the specified name hasn't been registered through a prior
|
| 1703 |
+
:func:`parser.addini <pytest.Parser.addini>` call (usually from a
|
| 1704 |
+
plugin), a ValueError is raised.
|
| 1705 |
+
"""
|
| 1706 |
+
canonical_name = self._parser._ini_aliases.get(name, name)
|
| 1707 |
+
try:
|
| 1708 |
+
return self._inicache[canonical_name]
|
| 1709 |
+
except KeyError:
|
| 1710 |
+
pass
|
| 1711 |
+
self._inicache[canonical_name] = val = self._getini(canonical_name)
|
| 1712 |
+
return val
|
| 1713 |
+
|
| 1714 |
+
# Meant for easy monkeypatching by legacypath plugin.
|
| 1715 |
+
# Can be inlined back (with no cover removed) once legacypath is gone.
|
| 1716 |
+
def _getini_unknown_type(self, name: str, type: str, value: object):
|
| 1717 |
+
msg = (
|
| 1718 |
+
f"Option {name} has unknown configuration type {type} with value {value!r}"
|
| 1719 |
+
)
|
| 1720 |
+
raise ValueError(msg) # pragma: no cover
|
| 1721 |
+
|
| 1722 |
+
def _getini(self, name: str):
|
| 1723 |
+
# If this is an alias, resolve to canonical name.
|
| 1724 |
+
canonical_name = self._parser._ini_aliases.get(name, name)
|
| 1725 |
+
|
| 1726 |
+
try:
|
| 1727 |
+
_description, type, default = self._parser._inidict[canonical_name]
|
| 1728 |
+
except KeyError as e:
|
| 1729 |
+
raise ValueError(f"unknown configuration value: {name!r}") from e
|
| 1730 |
+
|
| 1731 |
+
# Collect all possible values (canonical name + aliases) from _inicfg.
|
| 1732 |
+
# Each candidate is (ConfigValue, is_canonical).
|
| 1733 |
+
candidates = []
|
| 1734 |
+
if canonical_name in self._inicfg:
|
| 1735 |
+
candidates.append((self._inicfg[canonical_name], True))
|
| 1736 |
+
for alias, target in self._parser._ini_aliases.items():
|
| 1737 |
+
if target == canonical_name and alias in self._inicfg:
|
| 1738 |
+
candidates.append((self._inicfg[alias], False))
|
| 1739 |
+
|
| 1740 |
+
if not candidates:
|
| 1741 |
+
return default
|
| 1742 |
+
|
| 1743 |
+
# Pick the best candidate based on precedence:
|
| 1744 |
+
# 1. CLI override takes precedence over file, then
|
| 1745 |
+
# 2. Canonical name takes precedence over alias.
|
| 1746 |
+
selected = max(candidates, key=lambda x: (x[0].origin == "override", x[1]))[0]
|
| 1747 |
+
value = selected.value
|
| 1748 |
+
mode = selected.mode
|
| 1749 |
+
|
| 1750 |
+
if mode == "ini":
|
| 1751 |
+
# In ini mode, values are always str | list[str].
|
| 1752 |
+
assert isinstance(value, (str, list))
|
| 1753 |
+
return self._getini_ini(name, canonical_name, type, value, default)
|
| 1754 |
+
elif mode == "toml":
|
| 1755 |
+
return self._getini_toml(name, canonical_name, type, value, default)
|
| 1756 |
+
else:
|
| 1757 |
+
assert_never(mode)
|
| 1758 |
+
|
| 1759 |
+
def _getini_ini(
|
| 1760 |
+
self,
|
| 1761 |
+
name: str,
|
| 1762 |
+
canonical_name: str,
|
| 1763 |
+
type: str,
|
| 1764 |
+
value: str | list[str],
|
| 1765 |
+
default: Any,
|
| 1766 |
+
):
|
| 1767 |
+
"""Handle config values read in INI mode.
|
| 1768 |
+
|
| 1769 |
+
In INI mode, values are stored as str or list[str] only, and coerced
|
| 1770 |
+
from string based on the registered type.
|
| 1771 |
+
"""
|
| 1772 |
+
# Note: some coercions are only required if we are reading from .ini
|
| 1773 |
+
# files, because the file format doesn't contain type information, but
|
| 1774 |
+
# when reading from toml (in ini mode) we will get either str or list of
|
| 1775 |
+
# str values (see load_config_dict_from_file). For example:
|
| 1776 |
+
#
|
| 1777 |
+
# ini:
|
| 1778 |
+
# a_line_list = "tests acceptance"
|
| 1779 |
+
#
|
| 1780 |
+
# in this case, we need to split the string to obtain a list of strings.
|
| 1781 |
+
#
|
| 1782 |
+
# toml (ini mode):
|
| 1783 |
+
# a_line_list = ["tests", "acceptance"]
|
| 1784 |
+
#
|
| 1785 |
+
# in this case, we already have a list ready to use.
|
| 1786 |
+
if type == "paths":
|
| 1787 |
+
dp = (
|
| 1788 |
+
self.inipath.parent
|
| 1789 |
+
if self.inipath is not None
|
| 1790 |
+
else self.invocation_params.dir
|
| 1791 |
+
)
|
| 1792 |
+
input_values = shlex.split(value) if isinstance(value, str) else value
|
| 1793 |
+
return [dp / x for x in input_values]
|
| 1794 |
+
elif type == "args":
|
| 1795 |
+
return shlex.split(value) if isinstance(value, str) else value
|
| 1796 |
+
elif type == "linelist":
|
| 1797 |
+
if isinstance(value, str):
|
| 1798 |
+
return [t for t in map(lambda x: x.strip(), value.split("\n")) if t]
|
| 1799 |
+
else:
|
| 1800 |
+
return value
|
| 1801 |
+
elif type == "bool":
|
| 1802 |
+
return _strtobool(str(value).strip())
|
| 1803 |
+
elif type == "string":
|
| 1804 |
+
return value
|
| 1805 |
+
elif type == "int":
|
| 1806 |
+
if not isinstance(value, str):
|
| 1807 |
+
raise TypeError(
|
| 1808 |
+
f"Expected an int string for option {name} of type integer, but got: {value!r}"
|
| 1809 |
+
) from None
|
| 1810 |
+
return int(value)
|
| 1811 |
+
elif type == "float":
|
| 1812 |
+
if not isinstance(value, str):
|
| 1813 |
+
raise TypeError(
|
| 1814 |
+
f"Expected a float string for option {name} of type float, but got: {value!r}"
|
| 1815 |
+
) from None
|
| 1816 |
+
return float(value)
|
| 1817 |
+
else:
|
| 1818 |
+
return self._getini_unknown_type(name, type, value)
|
| 1819 |
+
|
| 1820 |
+
def _getini_toml(
|
| 1821 |
+
self,
|
| 1822 |
+
name: str,
|
| 1823 |
+
canonical_name: str,
|
| 1824 |
+
type: str,
|
| 1825 |
+
value: object,
|
| 1826 |
+
default: Any,
|
| 1827 |
+
):
|
| 1828 |
+
"""Handle TOML config values with strict type validation and no coercion.
|
| 1829 |
+
|
| 1830 |
+
In TOML mode, values already have native types from TOML parsing.
|
| 1831 |
+
We validate types match expectations exactly, including list items.
|
| 1832 |
+
"""
|
| 1833 |
+
value_type = builtins.type(value).__name__
|
| 1834 |
+
if type == "paths":
|
| 1835 |
+
# Expect a list of strings.
|
| 1836 |
+
if not isinstance(value, list):
|
| 1837 |
+
raise TypeError(
|
| 1838 |
+
f"{self.inipath}: config option '{name}' expects a list for type 'paths', "
|
| 1839 |
+
f"got {value_type}: {value!r}"
|
| 1840 |
+
)
|
| 1841 |
+
for i, item in enumerate(value):
|
| 1842 |
+
if not isinstance(item, str):
|
| 1843 |
+
item_type = builtins.type(item).__name__
|
| 1844 |
+
raise TypeError(
|
| 1845 |
+
f"{self.inipath}: config option '{name}' expects a list of strings, "
|
| 1846 |
+
f"but item at index {i} is {item_type}: {item!r}"
|
| 1847 |
+
)
|
| 1848 |
+
dp = (
|
| 1849 |
+
self.inipath.parent
|
| 1850 |
+
if self.inipath is not None
|
| 1851 |
+
else self.invocation_params.dir
|
| 1852 |
+
)
|
| 1853 |
+
return [dp / x for x in value]
|
| 1854 |
+
elif type in {"args", "linelist"}:
|
| 1855 |
+
# Expect a list of strings.
|
| 1856 |
+
if not isinstance(value, list):
|
| 1857 |
+
raise TypeError(
|
| 1858 |
+
f"{self.inipath}: config option '{name}' expects a list for type '{type}', "
|
| 1859 |
+
f"got {value_type}: {value!r}"
|
| 1860 |
+
)
|
| 1861 |
+
for i, item in enumerate(value):
|
| 1862 |
+
if not isinstance(item, str):
|
| 1863 |
+
item_type = builtins.type(item).__name__
|
| 1864 |
+
raise TypeError(
|
| 1865 |
+
f"{self.inipath}: config option '{name}' expects a list of strings, "
|
| 1866 |
+
f"but item at index {i} is {item_type}: {item!r}"
|
| 1867 |
+
)
|
| 1868 |
+
return list(value)
|
| 1869 |
+
elif type == "bool":
|
| 1870 |
+
# Expect a boolean.
|
| 1871 |
+
if not isinstance(value, bool):
|
| 1872 |
+
raise TypeError(
|
| 1873 |
+
f"{self.inipath}: config option '{name}' expects a bool, "
|
| 1874 |
+
f"got {value_type}: {value!r}"
|
| 1875 |
+
)
|
| 1876 |
+
return value
|
| 1877 |
+
elif type == "int":
|
| 1878 |
+
# Expect an integer (but not bool, which is a subclass of int).
|
| 1879 |
+
if not isinstance(value, int) or isinstance(value, bool):
|
| 1880 |
+
raise TypeError(
|
| 1881 |
+
f"{self.inipath}: config option '{name}' expects an int, "
|
| 1882 |
+
f"got {value_type}: {value!r}"
|
| 1883 |
+
)
|
| 1884 |
+
return value
|
| 1885 |
+
elif type == "float":
|
| 1886 |
+
# Expect a float or integer only.
|
| 1887 |
+
if not isinstance(value, (float, int)) or isinstance(value, bool):
|
| 1888 |
+
raise TypeError(
|
| 1889 |
+
f"{self.inipath}: config option '{name}' expects a float, "
|
| 1890 |
+
f"got {value_type}: {value!r}"
|
| 1891 |
+
)
|
| 1892 |
+
return value
|
| 1893 |
+
elif type == "string":
|
| 1894 |
+
# Expect a string.
|
| 1895 |
+
if not isinstance(value, str):
|
| 1896 |
+
raise TypeError(
|
| 1897 |
+
f"{self.inipath}: config option '{name}' expects a string, "
|
| 1898 |
+
f"got {value_type}: {value!r}"
|
| 1899 |
+
)
|
| 1900 |
+
return value
|
| 1901 |
+
else:
|
| 1902 |
+
return self._getini_unknown_type(name, type, value)
|
| 1903 |
+
|
| 1904 |
+
def _getconftest_pathlist(
|
| 1905 |
+
self, name: str, path: pathlib.Path
|
| 1906 |
+
) -> list[pathlib.Path] | None:
|
| 1907 |
+
try:
|
| 1908 |
+
mod, relroots = self.pluginmanager._rget_with_confmod(name, path)
|
| 1909 |
+
except KeyError:
|
| 1910 |
+
return None
|
| 1911 |
+
assert mod.__file__ is not None
|
| 1912 |
+
modpath = pathlib.Path(mod.__file__).parent
|
| 1913 |
+
values: list[pathlib.Path] = []
|
| 1914 |
+
for relroot in relroots:
|
| 1915 |
+
if isinstance(relroot, os.PathLike):
|
| 1916 |
+
relroot = pathlib.Path(relroot)
|
| 1917 |
+
else:
|
| 1918 |
+
relroot = relroot.replace("/", os.sep)
|
| 1919 |
+
relroot = absolutepath(modpath / relroot)
|
| 1920 |
+
values.append(relroot)
|
| 1921 |
+
return values
|
| 1922 |
+
|
| 1923 |
+
def getoption(self, name: str, default: Any = NOTSET, skip: bool = False):
|
| 1924 |
+
"""Return command line option value.
|
| 1925 |
+
|
| 1926 |
+
:param name: Name of the option. You may also specify
|
| 1927 |
+
the literal ``--OPT`` option instead of the "dest" option name.
|
| 1928 |
+
:param default: Fallback value if no option of that name is **declared** via :hook:`pytest_addoption`.
|
| 1929 |
+
Note this parameter will be ignored when the option is **declared** even if the option's value is ``None``.
|
| 1930 |
+
:param skip: If ``True``, raise :func:`pytest.skip` if option is undeclared or has a ``None`` value.
|
| 1931 |
+
Note that even if ``True``, if a default was specified it will be returned instead of a skip.
|
| 1932 |
+
"""
|
| 1933 |
+
name = self._parser._opt2dest.get(name, name)
|
| 1934 |
+
try:
|
| 1935 |
+
val = getattr(self.option, name)
|
| 1936 |
+
if val is None and skip:
|
| 1937 |
+
raise AttributeError(name)
|
| 1938 |
+
return val
|
| 1939 |
+
except AttributeError as e:
|
| 1940 |
+
if default is not NOTSET:
|
| 1941 |
+
return default
|
| 1942 |
+
if skip:
|
| 1943 |
+
import pytest
|
| 1944 |
+
|
| 1945 |
+
pytest.skip(f"no {name!r} option found")
|
| 1946 |
+
raise ValueError(f"no option named {name!r}") from e
|
| 1947 |
+
|
| 1948 |
+
def getvalue(self, name: str, path=None):
|
| 1949 |
+
"""Deprecated, use getoption() instead."""
|
| 1950 |
+
return self.getoption(name)
|
| 1951 |
+
|
| 1952 |
+
def getvalueorskip(self, name: str, path=None):
|
| 1953 |
+
"""Deprecated, use getoption(skip=True) instead."""
|
| 1954 |
+
return self.getoption(name, skip=True)
|
| 1955 |
+
|
| 1956 |
+
#: Verbosity type for failed assertions (see :confval:`verbosity_assertions`).
|
| 1957 |
+
VERBOSITY_ASSERTIONS: Final = "assertions"
|
| 1958 |
+
#: Verbosity type for test case execution (see :confval:`verbosity_test_cases`).
|
| 1959 |
+
VERBOSITY_TEST_CASES: Final = "test_cases"
|
| 1960 |
+
#: Verbosity type for failed subtests (see :confval:`verbosity_subtests`).
|
| 1961 |
+
VERBOSITY_SUBTESTS: Final = "subtests"
|
| 1962 |
+
|
| 1963 |
+
_VERBOSITY_INI_DEFAULT: Final = "auto"
|
| 1964 |
+
|
| 1965 |
+
def get_verbosity(self, verbosity_type: str | None = None) -> int:
|
| 1966 |
+
r"""Retrieve the verbosity level for a fine-grained verbosity type.
|
| 1967 |
+
|
| 1968 |
+
:param verbosity_type: Verbosity type to get level for. If a level is
|
| 1969 |
+
configured for the given type, that value will be returned. If the
|
| 1970 |
+
given type is not a known verbosity type, the global verbosity
|
| 1971 |
+
level will be returned. If the given type is None (default), the
|
| 1972 |
+
global verbosity level will be returned.
|
| 1973 |
+
|
| 1974 |
+
To configure a level for a fine-grained verbosity type, the
|
| 1975 |
+
configuration file should have a setting for the configuration name
|
| 1976 |
+
and a numeric value for the verbosity level. A special value of "auto"
|
| 1977 |
+
can be used to explicitly use the global verbosity level.
|
| 1978 |
+
|
| 1979 |
+
Example:
|
| 1980 |
+
|
| 1981 |
+
.. tab:: toml
|
| 1982 |
+
|
| 1983 |
+
.. code-block:: toml
|
| 1984 |
+
|
| 1985 |
+
[tool.pytest]
|
| 1986 |
+
verbosity_assertions = 2
|
| 1987 |
+
|
| 1988 |
+
.. tab:: ini
|
| 1989 |
+
|
| 1990 |
+
.. code-block:: ini
|
| 1991 |
+
|
| 1992 |
+
[pytest]
|
| 1993 |
+
verbosity_assertions = 2
|
| 1994 |
+
|
| 1995 |
+
.. code-block:: console
|
| 1996 |
+
|
| 1997 |
+
pytest -v
|
| 1998 |
+
|
| 1999 |
+
.. code-block:: python
|
| 2000 |
+
|
| 2001 |
+
print(config.get_verbosity()) # 1
|
| 2002 |
+
print(config.get_verbosity(Config.VERBOSITY_ASSERTIONS)) # 2
|
| 2003 |
+
"""
|
| 2004 |
+
global_level = self.getoption("verbose", default=0)
|
| 2005 |
+
assert isinstance(global_level, int)
|
| 2006 |
+
if verbosity_type is None:
|
| 2007 |
+
return global_level
|
| 2008 |
+
|
| 2009 |
+
ini_name = Config._verbosity_ini_name(verbosity_type)
|
| 2010 |
+
if ini_name not in self._parser._inidict:
|
| 2011 |
+
return global_level
|
| 2012 |
+
|
| 2013 |
+
level = self.getini(ini_name)
|
| 2014 |
+
if level == Config._VERBOSITY_INI_DEFAULT:
|
| 2015 |
+
return global_level
|
| 2016 |
+
|
| 2017 |
+
return int(level)
|
| 2018 |
+
|
| 2019 |
+
@staticmethod
|
| 2020 |
+
def _verbosity_ini_name(verbosity_type: str) -> str:
|
| 2021 |
+
return f"verbosity_{verbosity_type}"
|
| 2022 |
+
|
| 2023 |
+
@staticmethod
|
| 2024 |
+
def _add_verbosity_ini(parser: Parser, verbosity_type: str, help: str) -> None:
|
| 2025 |
+
"""Add a output verbosity configuration option for the given output type.
|
| 2026 |
+
|
| 2027 |
+
:param parser: Parser for command line arguments and config-file values.
|
| 2028 |
+
:param verbosity_type: Fine-grained verbosity category.
|
| 2029 |
+
:param help: Description of the output this type controls.
|
| 2030 |
+
|
| 2031 |
+
The value should be retrieved via a call to
|
| 2032 |
+
:py:func:`config.get_verbosity(type) <pytest.Config.get_verbosity>`.
|
| 2033 |
+
"""
|
| 2034 |
+
parser.addini(
|
| 2035 |
+
Config._verbosity_ini_name(verbosity_type),
|
| 2036 |
+
help=help,
|
| 2037 |
+
type="string",
|
| 2038 |
+
default=Config._VERBOSITY_INI_DEFAULT,
|
| 2039 |
+
)
|
| 2040 |
+
|
| 2041 |
+
def _warn_about_missing_assertion(self, mode: str) -> None:
|
| 2042 |
+
if not _assertion_supported():
|
| 2043 |
+
if mode == "plain":
|
| 2044 |
+
warning_text = (
|
| 2045 |
+
"ASSERTIONS ARE NOT EXECUTED"
|
| 2046 |
+
" and FAILING TESTS WILL PASS. Are you"
|
| 2047 |
+
" using python -O?"
|
| 2048 |
+
)
|
| 2049 |
+
else:
|
| 2050 |
+
warning_text = (
|
| 2051 |
+
"assertions not in test modules or"
|
| 2052 |
+
" plugins will be ignored"
|
| 2053 |
+
" because assert statements are not executed "
|
| 2054 |
+
"by the underlying Python interpreter "
|
| 2055 |
+
"(are you using python -O?)\n"
|
| 2056 |
+
)
|
| 2057 |
+
self.issue_config_time_warning(
|
| 2058 |
+
PytestConfigWarning(warning_text),
|
| 2059 |
+
stacklevel=3,
|
| 2060 |
+
)
|
| 2061 |
+
|
| 2062 |
+
def _warn_about_skipped_plugins(self) -> None:
|
| 2063 |
+
for module_name, msg in self.pluginmanager.skipped_plugins:
|
| 2064 |
+
self.issue_config_time_warning(
|
| 2065 |
+
PytestConfigWarning(f"skipped plugin {module_name!r}: {msg}"),
|
| 2066 |
+
stacklevel=2,
|
| 2067 |
+
)
|
| 2068 |
+
|
| 2069 |
+
|
| 2070 |
+
def _assertion_supported() -> bool:
|
| 2071 |
+
try:
|
| 2072 |
+
assert False
|
| 2073 |
+
except AssertionError:
|
| 2074 |
+
return True
|
| 2075 |
+
else:
|
| 2076 |
+
return False # type: ignore[unreachable]
|
| 2077 |
+
|
| 2078 |
+
|
| 2079 |
+
def create_terminal_writer(
|
| 2080 |
+
config: Config, file: TextIO | None = None
|
| 2081 |
+
) -> TerminalWriter:
|
| 2082 |
+
"""Create a TerminalWriter instance configured according to the options
|
| 2083 |
+
in the config object.
|
| 2084 |
+
|
| 2085 |
+
Every code which requires a TerminalWriter object and has access to a
|
| 2086 |
+
config object should use this function.
|
| 2087 |
+
"""
|
| 2088 |
+
tw = TerminalWriter(file=file)
|
| 2089 |
+
|
| 2090 |
+
if config.option.color == "yes":
|
| 2091 |
+
tw.hasmarkup = True
|
| 2092 |
+
elif config.option.color == "no":
|
| 2093 |
+
tw.hasmarkup = False
|
| 2094 |
+
|
| 2095 |
+
if config.option.code_highlight == "yes":
|
| 2096 |
+
tw.code_highlight = True
|
| 2097 |
+
elif config.option.code_highlight == "no":
|
| 2098 |
+
tw.code_highlight = False
|
| 2099 |
+
|
| 2100 |
+
return tw
|
| 2101 |
+
|
| 2102 |
+
|
| 2103 |
+
def _strtobool(val: str) -> bool:
|
| 2104 |
+
"""Convert a string representation of truth to True or False.
|
| 2105 |
+
|
| 2106 |
+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
|
| 2107 |
+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
|
| 2108 |
+
'val' is anything else.
|
| 2109 |
+
|
| 2110 |
+
.. note:: Copied from distutils.util.
|
| 2111 |
+
"""
|
| 2112 |
+
val = val.lower()
|
| 2113 |
+
if val in ("y", "yes", "t", "true", "on", "1"):
|
| 2114 |
+
return True
|
| 2115 |
+
elif val in ("n", "no", "f", "false", "off", "0"):
|
| 2116 |
+
return False
|
| 2117 |
+
else:
|
| 2118 |
+
raise ValueError(f"invalid truth value {val!r}")
|
| 2119 |
+
|
| 2120 |
+
|
| 2121 |
+
@lru_cache(maxsize=50)
|
| 2122 |
+
def parse_warning_filter(
|
| 2123 |
+
arg: str, *, escape: bool
|
| 2124 |
+
) -> tuple[warnings._ActionKind, str, type[Warning], str, int]:
|
| 2125 |
+
"""Parse a warnings filter string.
|
| 2126 |
+
|
| 2127 |
+
This is copied from warnings._setoption with the following changes:
|
| 2128 |
+
|
| 2129 |
+
* Does not apply the filter.
|
| 2130 |
+
* Escaping is optional.
|
| 2131 |
+
* Raises UsageError so we get nice error messages on failure.
|
| 2132 |
+
"""
|
| 2133 |
+
__tracebackhide__ = True
|
| 2134 |
+
error_template = dedent(
|
| 2135 |
+
f"""\
|
| 2136 |
+
while parsing the following warning configuration:
|
| 2137 |
+
|
| 2138 |
+
{arg}
|
| 2139 |
+
|
| 2140 |
+
This error occurred:
|
| 2141 |
+
|
| 2142 |
+
{{error}}
|
| 2143 |
+
"""
|
| 2144 |
+
)
|
| 2145 |
+
|
| 2146 |
+
parts = arg.split(":")
|
| 2147 |
+
if len(parts) > 5:
|
| 2148 |
+
doc_url = (
|
| 2149 |
+
"https://docs.python.org/3/library/warnings.html#describing-warning-filters"
|
| 2150 |
+
)
|
| 2151 |
+
error = dedent(
|
| 2152 |
+
f"""\
|
| 2153 |
+
Too many fields ({len(parts)}), expected at most 5 separated by colons:
|
| 2154 |
+
|
| 2155 |
+
action:message:category:module:line
|
| 2156 |
+
|
| 2157 |
+
For more information please consult: {doc_url}
|
| 2158 |
+
"""
|
| 2159 |
+
)
|
| 2160 |
+
raise UsageError(error_template.format(error=error))
|
| 2161 |
+
|
| 2162 |
+
while len(parts) < 5:
|
| 2163 |
+
parts.append("")
|
| 2164 |
+
action_, message, category_, module, lineno_ = (s.strip() for s in parts)
|
| 2165 |
+
try:
|
| 2166 |
+
action: warnings._ActionKind = warnings._getaction(action_) # type: ignore[attr-defined]
|
| 2167 |
+
except warnings._OptionError as e:
|
| 2168 |
+
raise UsageError(error_template.format(error=str(e))) from None
|
| 2169 |
+
try:
|
| 2170 |
+
category: type[Warning] = _resolve_warning_category(category_)
|
| 2171 |
+
except ImportError:
|
| 2172 |
+
raise
|
| 2173 |
+
except Exception:
|
| 2174 |
+
exc_info = ExceptionInfo.from_current()
|
| 2175 |
+
exception_text = exc_info.getrepr(style="native")
|
| 2176 |
+
raise UsageError(error_template.format(error=exception_text)) from None
|
| 2177 |
+
if message and escape:
|
| 2178 |
+
message = re.escape(message)
|
| 2179 |
+
if module and escape:
|
| 2180 |
+
module = re.escape(module) + r"\Z"
|
| 2181 |
+
if lineno_:
|
| 2182 |
+
try:
|
| 2183 |
+
lineno = int(lineno_)
|
| 2184 |
+
if lineno < 0:
|
| 2185 |
+
raise ValueError("number is negative")
|
| 2186 |
+
except ValueError as e:
|
| 2187 |
+
raise UsageError(
|
| 2188 |
+
error_template.format(error=f"invalid lineno {lineno_!r}: {e}")
|
| 2189 |
+
) from None
|
| 2190 |
+
else:
|
| 2191 |
+
lineno = 0
|
| 2192 |
+
try:
|
| 2193 |
+
re.compile(message)
|
| 2194 |
+
re.compile(module)
|
| 2195 |
+
except re.error as e:
|
| 2196 |
+
raise UsageError(
|
| 2197 |
+
error_template.format(error=f"Invalid regex {e.pattern!r}: {e}")
|
| 2198 |
+
) from None
|
| 2199 |
+
return action, message, category, module, lineno
|
| 2200 |
+
|
| 2201 |
+
|
| 2202 |
+
def _resolve_warning_category(category: str) -> type[Warning]:
|
| 2203 |
+
"""
|
| 2204 |
+
Copied from warnings._getcategory, but changed so it lets exceptions (specially ImportErrors)
|
| 2205 |
+
propagate so we can get access to their tracebacks (#9218).
|
| 2206 |
+
"""
|
| 2207 |
+
__tracebackhide__ = True
|
| 2208 |
+
if not category:
|
| 2209 |
+
return Warning
|
| 2210 |
+
|
| 2211 |
+
if "." not in category:
|
| 2212 |
+
import builtins as m
|
| 2213 |
+
|
| 2214 |
+
klass = category
|
| 2215 |
+
else:
|
| 2216 |
+
module, _, klass = category.rpartition(".")
|
| 2217 |
+
m = importlib.import_module(module)
|
| 2218 |
+
cat = getattr(m, klass)
|
| 2219 |
+
if not issubclass(cat, Warning):
|
| 2220 |
+
raise UsageError(f"{cat} is not a Warning subclass")
|
| 2221 |
+
return cast(type[Warning], cat)
|
| 2222 |
+
|
| 2223 |
+
|
| 2224 |
+
def apply_warning_filters(
|
| 2225 |
+
config_filters: Iterable[str], cmdline_filters: Iterable[str]
|
| 2226 |
+
) -> None:
|
| 2227 |
+
"""Applies pytest-configured filters to the warnings module"""
|
| 2228 |
+
# Filters should have this precedence: cmdline options, config.
|
| 2229 |
+
# Filters should be applied in the inverse order of precedence.
|
| 2230 |
+
for arg in config_filters:
|
| 2231 |
+
try:
|
| 2232 |
+
warnings.filterwarnings(*parse_warning_filter(arg, escape=False))
|
| 2233 |
+
except ImportError as e:
|
| 2234 |
+
warnings.warn(
|
| 2235 |
+
f"Failed to import filter module '{e.name}': {arg}", PytestConfigWarning
|
| 2236 |
+
)
|
| 2237 |
+
continue
|
| 2238 |
+
|
| 2239 |
+
for arg in cmdline_filters:
|
| 2240 |
+
try:
|
| 2241 |
+
warnings.filterwarnings(*parse_warning_filter(arg, escape=True))
|
| 2242 |
+
except ImportError as e:
|
| 2243 |
+
warnings.warn(
|
| 2244 |
+
f"Failed to import filter module '{e.name}': {arg}", PytestConfigWarning
|
| 2245 |
+
)
|
| 2246 |
+
continue
|
.venv/lib/python3.14/site-packages/_pytest/config/argparsing.py
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# mypy: allow-untyped-defs
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from collections.abc import Sequence
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
import textwrap
|
| 10 |
+
from typing import Any
|
| 11 |
+
from typing import final
|
| 12 |
+
from typing import Literal
|
| 13 |
+
from typing import NoReturn
|
| 14 |
+
|
| 15 |
+
from .exceptions import UsageError
|
| 16 |
+
import _pytest._io
|
| 17 |
+
from _pytest.compat import NOTSET
|
| 18 |
+
from _pytest.deprecated import check_ispytest
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
FILE_OR_DIR = "file_or_dir"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@final
|
| 25 |
+
class Parser:
|
| 26 |
+
"""Parser for command line arguments and config-file values.
|
| 27 |
+
|
| 28 |
+
:ivar extra_info: Dict of generic param -> value to display in case
|
| 29 |
+
there's an error processing the command line arguments.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
usage: str | None = None,
|
| 35 |
+
processopt: Callable[[Argument], None] | None = None,
|
| 36 |
+
*,
|
| 37 |
+
prog: str | None = None,
|
| 38 |
+
_ispytest: bool = False,
|
| 39 |
+
) -> None:
|
| 40 |
+
check_ispytest(_ispytest)
|
| 41 |
+
|
| 42 |
+
from _pytest._argcomplete import filescompleter
|
| 43 |
+
|
| 44 |
+
self._processopt = processopt
|
| 45 |
+
self.extra_info: dict[str, Any] = {}
|
| 46 |
+
self.optparser = PytestArgumentParser(usage, self.extra_info, prog=prog)
|
| 47 |
+
anonymous_arggroup = self.optparser.add_argument_group("Custom options")
|
| 48 |
+
self._anonymous = OptionGroup(
|
| 49 |
+
anonymous_arggroup, "_anonymous", self, _ispytest=True
|
| 50 |
+
)
|
| 51 |
+
self._groups = [self._anonymous]
|
| 52 |
+
# Maps option strings -> dest, e.g. "-V" and "--version" to "version".
|
| 53 |
+
self._opt2dest: dict[str, str] = {}
|
| 54 |
+
file_or_dir_arg = self.optparser.add_argument(FILE_OR_DIR, nargs="*")
|
| 55 |
+
file_or_dir_arg.completer = filescompleter # type: ignore
|
| 56 |
+
|
| 57 |
+
self._inidict: dict[str, tuple[str, str, Any]] = {}
|
| 58 |
+
# Maps alias -> canonical name.
|
| 59 |
+
self._ini_aliases: dict[str, str] = {}
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def prog(self) -> str:
|
| 63 |
+
return self.optparser.prog
|
| 64 |
+
|
| 65 |
+
@prog.setter
|
| 66 |
+
def prog(self, value: str) -> None:
|
| 67 |
+
self.optparser.prog = value
|
| 68 |
+
|
| 69 |
+
def processoption(self, option: Argument) -> None:
|
| 70 |
+
if self._processopt:
|
| 71 |
+
if option.dest:
|
| 72 |
+
self._processopt(option)
|
| 73 |
+
|
| 74 |
+
def getgroup(
|
| 75 |
+
self, name: str, description: str = "", after: str | None = None
|
| 76 |
+
) -> OptionGroup:
|
| 77 |
+
"""Get (or create) a named option Group.
|
| 78 |
+
|
| 79 |
+
:param name: Name of the option group.
|
| 80 |
+
:param description: Long description for --help output.
|
| 81 |
+
:param after: Name of another group, used for ordering --help output.
|
| 82 |
+
:returns: The option group.
|
| 83 |
+
|
| 84 |
+
The returned group object has an ``addoption`` method with the same
|
| 85 |
+
signature as :func:`parser.addoption <pytest.Parser.addoption>` but
|
| 86 |
+
will be shown in the respective group in the output of
|
| 87 |
+
``pytest --help``.
|
| 88 |
+
"""
|
| 89 |
+
for group in self._groups:
|
| 90 |
+
if group.name == name:
|
| 91 |
+
return group
|
| 92 |
+
|
| 93 |
+
arggroup = self.optparser.add_argument_group(description or name)
|
| 94 |
+
group = OptionGroup(arggroup, name, self, _ispytest=True)
|
| 95 |
+
i = 0
|
| 96 |
+
for i, grp in enumerate(self._groups):
|
| 97 |
+
if grp.name == after:
|
| 98 |
+
break
|
| 99 |
+
self._groups.insert(i + 1, group)
|
| 100 |
+
# argparse doesn't provide a way to control `--help` order, so must
|
| 101 |
+
# access its internals ☹.
|
| 102 |
+
self.optparser._action_groups.insert(i + 1, self.optparser._action_groups.pop())
|
| 103 |
+
return group
|
| 104 |
+
|
| 105 |
+
def addoption(self, *opts: str, **attrs: Any) -> None:
|
| 106 |
+
"""Register a command line option.
|
| 107 |
+
|
| 108 |
+
:param opts:
|
| 109 |
+
Option names, can be short or long options.
|
| 110 |
+
:param attrs:
|
| 111 |
+
Same attributes as the argparse library's :meth:`add_argument()
|
| 112 |
+
<argparse.ArgumentParser.add_argument>` function accepts.
|
| 113 |
+
|
| 114 |
+
After command line parsing, options are available on the pytest config
|
| 115 |
+
object via ``config.option.NAME`` where ``NAME`` is usually set
|
| 116 |
+
by passing a ``dest`` attribute, for example
|
| 117 |
+
``addoption("--long", dest="NAME", ...)``.
|
| 118 |
+
"""
|
| 119 |
+
self._anonymous.addoption(*opts, **attrs)
|
| 120 |
+
|
| 121 |
+
def parse(
|
| 122 |
+
self,
|
| 123 |
+
args: Sequence[str | os.PathLike[str]],
|
| 124 |
+
namespace: argparse.Namespace | None = None,
|
| 125 |
+
) -> argparse.Namespace:
|
| 126 |
+
"""Parse the arguments.
|
| 127 |
+
|
| 128 |
+
Unlike ``parse_known_args`` and ``parse_known_and_unknown_args``,
|
| 129 |
+
raises PrintHelp on `--help` and UsageError on unknown flags
|
| 130 |
+
|
| 131 |
+
:meta private:
|
| 132 |
+
"""
|
| 133 |
+
from _pytest._argcomplete import try_argcomplete
|
| 134 |
+
|
| 135 |
+
try_argcomplete(self.optparser)
|
| 136 |
+
strargs = [os.fspath(x) for x in args]
|
| 137 |
+
if namespace is None:
|
| 138 |
+
namespace = argparse.Namespace()
|
| 139 |
+
try:
|
| 140 |
+
namespace._raise_print_help = True
|
| 141 |
+
return self.optparser.parse_intermixed_args(strargs, namespace=namespace)
|
| 142 |
+
finally:
|
| 143 |
+
del namespace._raise_print_help
|
| 144 |
+
|
| 145 |
+
def parse_known_args(
|
| 146 |
+
self,
|
| 147 |
+
args: Sequence[str | os.PathLike[str]],
|
| 148 |
+
namespace: argparse.Namespace | None = None,
|
| 149 |
+
) -> argparse.Namespace:
|
| 150 |
+
"""Parse the known arguments at this point.
|
| 151 |
+
|
| 152 |
+
:returns: An argparse namespace object.
|
| 153 |
+
"""
|
| 154 |
+
return self.parse_known_and_unknown_args(args, namespace=namespace)[0]
|
| 155 |
+
|
| 156 |
+
def parse_known_and_unknown_args(
|
| 157 |
+
self,
|
| 158 |
+
args: Sequence[str | os.PathLike[str]],
|
| 159 |
+
namespace: argparse.Namespace | None = None,
|
| 160 |
+
) -> tuple[argparse.Namespace, list[str]]:
|
| 161 |
+
"""Parse the known arguments at this point, and also return the
|
| 162 |
+
remaining unknown flag arguments.
|
| 163 |
+
|
| 164 |
+
:returns:
|
| 165 |
+
A tuple containing an argparse namespace object for the known
|
| 166 |
+
arguments, and a list of unknown flag arguments.
|
| 167 |
+
"""
|
| 168 |
+
strargs = [os.fspath(x) for x in args]
|
| 169 |
+
if sys.version_info < (3, 12, 8) or (3, 13) <= sys.version_info < (3, 13, 1):
|
| 170 |
+
# Older argparse have a bugged parse_known_intermixed_args.
|
| 171 |
+
namespace, unknown = self.optparser.parse_known_args(strargs, namespace)
|
| 172 |
+
assert namespace is not None
|
| 173 |
+
file_or_dir = getattr(namespace, FILE_OR_DIR)
|
| 174 |
+
unknown_flags: list[str] = []
|
| 175 |
+
for arg in unknown:
|
| 176 |
+
(unknown_flags if arg.startswith("-") else file_or_dir).append(arg)
|
| 177 |
+
return namespace, unknown_flags
|
| 178 |
+
else:
|
| 179 |
+
return self.optparser.parse_known_intermixed_args(strargs, namespace)
|
| 180 |
+
|
| 181 |
+
def addini(
|
| 182 |
+
self,
|
| 183 |
+
name: str,
|
| 184 |
+
help: str,
|
| 185 |
+
type: Literal[
|
| 186 |
+
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
|
| 187 |
+
]
|
| 188 |
+
| None = None,
|
| 189 |
+
default: Any = NOTSET,
|
| 190 |
+
*,
|
| 191 |
+
aliases: Sequence[str] = (),
|
| 192 |
+
) -> None:
|
| 193 |
+
"""Register a configuration file option.
|
| 194 |
+
|
| 195 |
+
:param name:
|
| 196 |
+
Name of the configuration.
|
| 197 |
+
:param type:
|
| 198 |
+
Type of the configuration. Can be:
|
| 199 |
+
|
| 200 |
+
* ``string``: a string
|
| 201 |
+
* ``bool``: a boolean
|
| 202 |
+
* ``args``: a list of strings, separated as in a shell
|
| 203 |
+
* ``linelist``: a list of strings, separated by line breaks
|
| 204 |
+
* ``paths``: a list of :class:`pathlib.Path`, separated as in a shell
|
| 205 |
+
* ``pathlist``: a list of ``py.path``, separated as in a shell
|
| 206 |
+
* ``int``: an integer
|
| 207 |
+
* ``float``: a floating-point number
|
| 208 |
+
|
| 209 |
+
.. versionadded:: 8.4
|
| 210 |
+
|
| 211 |
+
The ``float`` and ``int`` types.
|
| 212 |
+
|
| 213 |
+
For ``paths`` and ``pathlist`` types, they are considered relative to the config-file.
|
| 214 |
+
In case the execution is happening without a config-file defined,
|
| 215 |
+
they will be considered relative to the current working directory (for example with ``--override-ini``).
|
| 216 |
+
|
| 217 |
+
.. versionadded:: 7.0
|
| 218 |
+
The ``paths`` variable type.
|
| 219 |
+
|
| 220 |
+
.. versionadded:: 8.1
|
| 221 |
+
Use the current working directory to resolve ``paths`` and ``pathlist`` in the absence of a config-file.
|
| 222 |
+
|
| 223 |
+
Defaults to ``string`` if ``None`` or not passed.
|
| 224 |
+
:param default:
|
| 225 |
+
Default value if no config-file option exists but is queried.
|
| 226 |
+
:param aliases:
|
| 227 |
+
Additional names by which this option can be referenced.
|
| 228 |
+
Aliases resolve to the canonical name.
|
| 229 |
+
|
| 230 |
+
.. versionadded:: 9.0
|
| 231 |
+
The ``aliases`` parameter.
|
| 232 |
+
|
| 233 |
+
The value of configuration keys can be retrieved via a call to
|
| 234 |
+
:py:func:`config.getini(name) <pytest.Config.getini>`.
|
| 235 |
+
"""
|
| 236 |
+
assert type in (
|
| 237 |
+
None,
|
| 238 |
+
"string",
|
| 239 |
+
"paths",
|
| 240 |
+
"pathlist",
|
| 241 |
+
"args",
|
| 242 |
+
"linelist",
|
| 243 |
+
"bool",
|
| 244 |
+
"int",
|
| 245 |
+
"float",
|
| 246 |
+
)
|
| 247 |
+
if type is None:
|
| 248 |
+
type = "string"
|
| 249 |
+
if default is NOTSET:
|
| 250 |
+
default = get_ini_default_for_type(type)
|
| 251 |
+
|
| 252 |
+
self._inidict[name] = (help, type, default)
|
| 253 |
+
|
| 254 |
+
for alias in aliases:
|
| 255 |
+
if alias in self._inidict:
|
| 256 |
+
raise ValueError(
|
| 257 |
+
f"alias {alias!r} conflicts with existing configuration option"
|
| 258 |
+
)
|
| 259 |
+
if (already := self._ini_aliases.get(alias)) is not None:
|
| 260 |
+
raise ValueError(f"{alias!r} is already an alias of {already!r}")
|
| 261 |
+
self._ini_aliases[alias] = name
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def get_ini_default_for_type(
|
| 265 |
+
type: Literal[
|
| 266 |
+
"string", "paths", "pathlist", "args", "linelist", "bool", "int", "float"
|
| 267 |
+
],
|
| 268 |
+
) -> Any:
|
| 269 |
+
"""
|
| 270 |
+
Used by addini to get the default value for a given config option type, when
|
| 271 |
+
default is not supplied.
|
| 272 |
+
"""
|
| 273 |
+
if type in ("paths", "pathlist", "args", "linelist"):
|
| 274 |
+
return []
|
| 275 |
+
elif type == "bool":
|
| 276 |
+
return False
|
| 277 |
+
elif type == "int":
|
| 278 |
+
return 0
|
| 279 |
+
elif type == "float":
|
| 280 |
+
return 0.0
|
| 281 |
+
else:
|
| 282 |
+
return ""
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
class Argument:
|
| 286 |
+
"""An option defined in an OptionGroup."""
|
| 287 |
+
|
| 288 |
+
def __init__(self, action: argparse.Action) -> None:
|
| 289 |
+
self._action = action
|
| 290 |
+
|
| 291 |
+
def attrs(self) -> dict[str, Any]:
|
| 292 |
+
return self._action.__dict__
|
| 293 |
+
|
| 294 |
+
def names(self) -> Sequence[str]:
|
| 295 |
+
return self._action.option_strings
|
| 296 |
+
|
| 297 |
+
@property
|
| 298 |
+
def dest(self) -> str:
|
| 299 |
+
return self._action.dest
|
| 300 |
+
|
| 301 |
+
@property
|
| 302 |
+
def default(self) -> Any:
|
| 303 |
+
return self._action.default
|
| 304 |
+
|
| 305 |
+
@property
|
| 306 |
+
def type(self) -> Any | None:
|
| 307 |
+
return self._action.type
|
| 308 |
+
|
| 309 |
+
def __repr__(self) -> str:
|
| 310 |
+
action = getattr(self, "_action", None)
|
| 311 |
+
if action is None:
|
| 312 |
+
return "Argument(<uninitialized>)"
|
| 313 |
+
args: list[str] = []
|
| 314 |
+
args += ["opts: " + repr(self.names())]
|
| 315 |
+
args += ["dest: " + repr(self.dest)]
|
| 316 |
+
if action.type:
|
| 317 |
+
args += ["type: " + repr(self.type)]
|
| 318 |
+
args += ["default: " + repr(self.default)]
|
| 319 |
+
return "Argument({})".format(", ".join(args))
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
class OptionGroup:
|
| 323 |
+
"""A group of options shown in its own section."""
|
| 324 |
+
|
| 325 |
+
def __init__(
|
| 326 |
+
self,
|
| 327 |
+
arggroup: argparse._ArgumentGroup,
|
| 328 |
+
name: str,
|
| 329 |
+
parser: Parser | None,
|
| 330 |
+
_ispytest: bool = False,
|
| 331 |
+
) -> None:
|
| 332 |
+
check_ispytest(_ispytest)
|
| 333 |
+
self._arggroup = arggroup
|
| 334 |
+
self.name = name
|
| 335 |
+
self.options: list[Argument] = []
|
| 336 |
+
self.parser = parser
|
| 337 |
+
|
| 338 |
+
def addoption(self, *opts: str, **attrs: Any) -> None:
|
| 339 |
+
"""Add an option to this group.
|
| 340 |
+
|
| 341 |
+
If a shortened version of a long option is specified, it will
|
| 342 |
+
be suppressed in the help. ``addoption('--twowords', '--two-words')``
|
| 343 |
+
results in help showing ``--two-words`` only, but ``--twowords`` gets
|
| 344 |
+
accepted **and** the automatic destination is in ``args.twowords``.
|
| 345 |
+
|
| 346 |
+
:param opts:
|
| 347 |
+
Option names, can be short or long options.
|
| 348 |
+
Note that lower-case short options (e.g. `-x`) are reserved.
|
| 349 |
+
:param attrs:
|
| 350 |
+
Same attributes as the argparse library's :meth:`add_argument()
|
| 351 |
+
<argparse.ArgumentParser.add_argument>` function accepts.
|
| 352 |
+
"""
|
| 353 |
+
conflict = set(opts).intersection(
|
| 354 |
+
name for opt in self.options for name in opt.names()
|
| 355 |
+
)
|
| 356 |
+
if conflict:
|
| 357 |
+
raise ValueError(f"option names {conflict} already added")
|
| 358 |
+
self._addoption_inner(opts, attrs, allow_reserved=False)
|
| 359 |
+
|
| 360 |
+
def _addoption(self, *opts: str, **attrs: Any) -> None:
|
| 361 |
+
"""Like addoption(), but also allows registering short lower case options (e.g. -x),
|
| 362 |
+
which are reserved for pytest core."""
|
| 363 |
+
self._addoption_inner(opts, attrs, allow_reserved=True)
|
| 364 |
+
|
| 365 |
+
def _addoption_inner(
|
| 366 |
+
self, opts: tuple[str, ...], attrs: dict[str, Any], allow_reserved: bool
|
| 367 |
+
) -> None:
|
| 368 |
+
if not allow_reserved:
|
| 369 |
+
for opt in opts:
|
| 370 |
+
if len(opt) >= 2 and opt[0] == "-" and opt[1].islower():
|
| 371 |
+
raise ValueError("lowercase short options are reserved")
|
| 372 |
+
|
| 373 |
+
action = self._arggroup.add_argument(*opts, **attrs)
|
| 374 |
+
option = Argument(action)
|
| 375 |
+
self.options.append(option)
|
| 376 |
+
if self.parser:
|
| 377 |
+
for name in option.names():
|
| 378 |
+
self.parser._opt2dest[name] = option.dest
|
| 379 |
+
self.parser.processoption(option)
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
class PytestArgumentParser(argparse.ArgumentParser):
|
| 383 |
+
def __init__(
|
| 384 |
+
self,
|
| 385 |
+
usage: str | None,
|
| 386 |
+
extra_info: dict[str, str],
|
| 387 |
+
*,
|
| 388 |
+
prog: str | None = None,
|
| 389 |
+
) -> None:
|
| 390 |
+
super().__init__(
|
| 391 |
+
usage=usage,
|
| 392 |
+
prog=prog,
|
| 393 |
+
add_help=False,
|
| 394 |
+
formatter_class=DropShorterLongHelpFormatter,
|
| 395 |
+
allow_abbrev=False,
|
| 396 |
+
fromfile_prefix_chars="@",
|
| 397 |
+
)
|
| 398 |
+
# extra_info is a dict of (param -> value) to display if there's
|
| 399 |
+
# an usage error to provide more contextual information to the user.
|
| 400 |
+
self.extra_info = extra_info
|
| 401 |
+
|
| 402 |
+
def error(self, message: str) -> NoReturn:
|
| 403 |
+
"""Transform argparse error message into UsageError."""
|
| 404 |
+
# TODO(py313): Replace with `exit_on_error=False`. Note that while it
|
| 405 |
+
# was added in Python 3.9, it was broken until 3.13 (cpython#121018).
|
| 406 |
+
msg = f"{self.prog}: error: {message}"
|
| 407 |
+
if self.extra_info:
|
| 408 |
+
msg += "\n" + "\n".join(
|
| 409 |
+
f" {k}: {v}" for k, v in sorted(self.extra_info.items())
|
| 410 |
+
)
|
| 411 |
+
raise UsageError(self.format_usage() + msg)
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
class DropShorterLongHelpFormatter(argparse.HelpFormatter):
|
| 415 |
+
"""Shorten help for long options that differ only in extra hyphens.
|
| 416 |
+
|
| 417 |
+
- Collapse **long** options that are the same except for extra hyphens.
|
| 418 |
+
- Shortcut if there are only two options and one of them is a short one.
|
| 419 |
+
"""
|
| 420 |
+
|
| 421 |
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
| 422 |
+
# Use more accurate terminal width.
|
| 423 |
+
if "width" not in kwargs:
|
| 424 |
+
kwargs["width"] = _pytest._io.get_terminal_width()
|
| 425 |
+
super().__init__(*args, **kwargs)
|
| 426 |
+
|
| 427 |
+
def _format_action_invocation(self, action: argparse.Action) -> str:
|
| 428 |
+
orgstr = super()._format_action_invocation(action)
|
| 429 |
+
if orgstr and orgstr[0] != "-": # only optional arguments
|
| 430 |
+
return orgstr
|
| 431 |
+
options = orgstr.split(", ")
|
| 432 |
+
if len(options) == 2 and (len(options[0]) == 2 or len(options[1]) == 2):
|
| 433 |
+
# a shortcut for '-h, --help' or '--abc', '-a'
|
| 434 |
+
return orgstr
|
| 435 |
+
return_list = []
|
| 436 |
+
short_long: dict[str, str] = {}
|
| 437 |
+
for option in options:
|
| 438 |
+
if len(option) == 2 or option[2] == " ":
|
| 439 |
+
continue
|
| 440 |
+
assert option.startswith("--"), (
|
| 441 |
+
f'long optional argument without "--": [{option}]'
|
| 442 |
+
)
|
| 443 |
+
xxoption = option[2:]
|
| 444 |
+
shortened = xxoption.replace("-", "")
|
| 445 |
+
if shortened not in short_long or len(short_long[shortened]) < len(
|
| 446 |
+
xxoption
|
| 447 |
+
):
|
| 448 |
+
short_long[shortened] = xxoption
|
| 449 |
+
# now short_long has been filled out to the longest with dashes
|
| 450 |
+
# **and** we keep the right option ordering from add_argument
|
| 451 |
+
for option in options:
|
| 452 |
+
if len(option) == 2 or option[2] == " ":
|
| 453 |
+
return_list.append(option)
|
| 454 |
+
if option[2:] == short_long.get(option.replace("-", "")):
|
| 455 |
+
return_list.append(option.replace(" ", "=", 1))
|
| 456 |
+
return ", ".join(return_list)
|
| 457 |
+
|
| 458 |
+
def _split_lines(self, text: str, width: int) -> list[str]:
|
| 459 |
+
"""Wrap lines after splitting on original newlines.
|
| 460 |
+
|
| 461 |
+
This allows to have explicit line breaks in the help text.
|
| 462 |
+
"""
|
| 463 |
+
lines = []
|
| 464 |
+
for line in text.splitlines():
|
| 465 |
+
lines.extend(textwrap.wrap(line.strip(), width))
|
| 466 |
+
return lines
|
| 467 |
+
|
| 468 |
+
|
| 469 |
+
class OverrideIniAction(argparse.Action):
|
| 470 |
+
"""Custom argparse action that makes a CLI flag equivalent to overriding an
|
| 471 |
+
option, in addition to behaving like `store_true`.
|
| 472 |
+
|
| 473 |
+
This can simplify things since code only needs to inspect the config option
|
| 474 |
+
and not consider the CLI flag.
|
| 475 |
+
"""
|
| 476 |
+
|
| 477 |
+
def __init__(
|
| 478 |
+
self,
|
| 479 |
+
option_strings: Sequence[str],
|
| 480 |
+
dest: str,
|
| 481 |
+
nargs: int | str | None = None,
|
| 482 |
+
*args,
|
| 483 |
+
ini_option: str,
|
| 484 |
+
ini_value: str,
|
| 485 |
+
**kwargs,
|
| 486 |
+
) -> None:
|
| 487 |
+
super().__init__(option_strings, dest, 0, *args, **kwargs)
|
| 488 |
+
self.ini_option = ini_option
|
| 489 |
+
self.ini_value = ini_value
|
| 490 |
+
|
| 491 |
+
def __call__(
|
| 492 |
+
self,
|
| 493 |
+
parser: argparse.ArgumentParser,
|
| 494 |
+
namespace: argparse.Namespace,
|
| 495 |
+
*args,
|
| 496 |
+
**kwargs,
|
| 497 |
+
) -> None:
|
| 498 |
+
setattr(namespace, self.dest, True)
|
| 499 |
+
current_overrides = getattr(namespace, "override_ini", None)
|
| 500 |
+
if current_overrides is None:
|
| 501 |
+
current_overrides = []
|
| 502 |
+
current_overrides.append(f"{self.ini_option}={self.ini_value}")
|
| 503 |
+
setattr(namespace, "override_ini", current_overrides)
|
.venv/lib/python3.14/site-packages/_pytest/config/exceptions.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import final
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@final
|
| 7 |
+
class UsageError(Exception):
|
| 8 |
+
"""Error in pytest usage or invocation."""
|
| 9 |
+
|
| 10 |
+
__module__ = "pytest"
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PrintHelp(Exception):
|
| 14 |
+
"""Raised when pytest should print its help to skip the rest of the
|
| 15 |
+
argument parsing and validation."""
|
.venv/lib/python3.14/site-packages/_pytest/config/findpaths.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from collections.abc import Sequence
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from dataclasses import KW_ONLY
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import sys
|
| 10 |
+
from typing import Literal
|
| 11 |
+
from typing import TypeAlias
|
| 12 |
+
|
| 13 |
+
import iniconfig
|
| 14 |
+
|
| 15 |
+
from .exceptions import UsageError
|
| 16 |
+
from _pytest.outcomes import fail
|
| 17 |
+
from _pytest.pathlib import absolutepath
|
| 18 |
+
from _pytest.pathlib import commonpath
|
| 19 |
+
from _pytest.pathlib import safe_exists
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class ConfigValue:
|
| 24 |
+
"""Represents a configuration value with its origin and parsing mode.
|
| 25 |
+
|
| 26 |
+
This allows tracking whether a value came from a configuration file
|
| 27 |
+
or from a CLI override (--override-ini), which is important for
|
| 28 |
+
determining precedence when dealing with ini option aliases.
|
| 29 |
+
|
| 30 |
+
The mode tracks the parsing mode/data model used for the value:
|
| 31 |
+
- "ini": from INI files or [tool.pytest.ini_options], where the only
|
| 32 |
+
supported value types are `str` or `list[str]`.
|
| 33 |
+
- "toml": from TOML files (not in INI mode), where native TOML types
|
| 34 |
+
are preserved.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
value: object
|
| 38 |
+
_: KW_ONLY
|
| 39 |
+
origin: Literal["file", "override"]
|
| 40 |
+
mode: Literal["ini", "toml"]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
ConfigDict: TypeAlias = dict[str, ConfigValue]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _parse_ini_config(path: Path) -> iniconfig.IniConfig:
|
| 47 |
+
"""Parse the given generic '.ini' file using legacy IniConfig parser, returning
|
| 48 |
+
the parsed object.
|
| 49 |
+
|
| 50 |
+
Raise UsageError if the file cannot be parsed.
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
return iniconfig.IniConfig(str(path))
|
| 54 |
+
except iniconfig.ParseError as exc:
|
| 55 |
+
raise UsageError(str(exc)) from exc
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def load_config_dict_from_file(
|
| 59 |
+
filepath: Path,
|
| 60 |
+
) -> ConfigDict | None:
|
| 61 |
+
"""Load pytest configuration from the given file path, if supported.
|
| 62 |
+
|
| 63 |
+
Return None if the file does not contain valid pytest configuration.
|
| 64 |
+
"""
|
| 65 |
+
# Configuration from ini files are obtained from the [pytest] section, if present.
|
| 66 |
+
if filepath.suffix == ".ini":
|
| 67 |
+
iniconfig = _parse_ini_config(filepath)
|
| 68 |
+
|
| 69 |
+
if "pytest" in iniconfig:
|
| 70 |
+
return {
|
| 71 |
+
k: ConfigValue(v, origin="file", mode="ini")
|
| 72 |
+
for k, v in iniconfig["pytest"].items()
|
| 73 |
+
}
|
| 74 |
+
else:
|
| 75 |
+
# "pytest.ini" files are always the source of configuration, even if empty.
|
| 76 |
+
if filepath.name in {"pytest.ini", ".pytest.ini"}:
|
| 77 |
+
return {}
|
| 78 |
+
|
| 79 |
+
# '.cfg' files are considered if they contain a "[tool:pytest]" section.
|
| 80 |
+
elif filepath.suffix == ".cfg":
|
| 81 |
+
iniconfig = _parse_ini_config(filepath)
|
| 82 |
+
|
| 83 |
+
if "tool:pytest" in iniconfig.sections:
|
| 84 |
+
return {
|
| 85 |
+
k: ConfigValue(v, origin="file", mode="ini")
|
| 86 |
+
for k, v in iniconfig["tool:pytest"].items()
|
| 87 |
+
}
|
| 88 |
+
elif "pytest" in iniconfig.sections:
|
| 89 |
+
# If a setup.cfg contains a "[pytest]" section, we raise a failure to indicate users that
|
| 90 |
+
# plain "[pytest]" sections in setup.cfg files is no longer supported (#3086).
|
| 91 |
+
fail(CFG_PYTEST_SECTION.format(filename="setup.cfg"), pytrace=False)
|
| 92 |
+
|
| 93 |
+
# '.toml' files are considered if they contain a [tool.pytest] table (toml mode)
|
| 94 |
+
# or [tool.pytest.ini_options] table (ini mode) for pyproject.toml,
|
| 95 |
+
# or [pytest] table (toml mode) for pytest.toml/.pytest.toml.
|
| 96 |
+
elif filepath.suffix == ".toml":
|
| 97 |
+
if sys.version_info >= (3, 11):
|
| 98 |
+
import tomllib
|
| 99 |
+
else:
|
| 100 |
+
import tomli as tomllib
|
| 101 |
+
|
| 102 |
+
toml_text = filepath.read_text(encoding="utf-8")
|
| 103 |
+
try:
|
| 104 |
+
config = tomllib.loads(toml_text)
|
| 105 |
+
except tomllib.TOMLDecodeError as exc:
|
| 106 |
+
raise UsageError(f"{filepath}: {exc}") from exc
|
| 107 |
+
|
| 108 |
+
# pytest.toml and .pytest.toml use [pytest] table directly.
|
| 109 |
+
if filepath.name in ("pytest.toml", ".pytest.toml"):
|
| 110 |
+
pytest_config = config.get("pytest", {})
|
| 111 |
+
if pytest_config:
|
| 112 |
+
# TOML mode - preserve native TOML types.
|
| 113 |
+
return {
|
| 114 |
+
k: ConfigValue(v, origin="file", mode="toml")
|
| 115 |
+
for k, v in pytest_config.items()
|
| 116 |
+
}
|
| 117 |
+
# "pytest.toml" files are always the source of configuration, even if empty.
|
| 118 |
+
return {}
|
| 119 |
+
|
| 120 |
+
# pyproject.toml uses [tool.pytest] or [tool.pytest.ini_options].
|
| 121 |
+
else:
|
| 122 |
+
tool_pytest = config.get("tool", {}).get("pytest", {})
|
| 123 |
+
|
| 124 |
+
# Check for toml mode config: [tool.pytest] with content outside of ini_options.
|
| 125 |
+
toml_config = {k: v for k, v in tool_pytest.items() if k != "ini_options"}
|
| 126 |
+
# Check for ini mode config: [tool.pytest.ini_options].
|
| 127 |
+
ini_config = tool_pytest.get("ini_options", None)
|
| 128 |
+
|
| 129 |
+
if toml_config and ini_config:
|
| 130 |
+
raise UsageError(
|
| 131 |
+
f"{filepath}: Cannot use both [tool.pytest] (native TOML types) and "
|
| 132 |
+
"[tool.pytest.ini_options] (string-based INI format) simultaneously. "
|
| 133 |
+
"Please use [tool.pytest] with native TOML types (recommended) "
|
| 134 |
+
"or [tool.pytest.ini_options] for backwards compatibility."
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
if toml_config:
|
| 138 |
+
# TOML mode - preserve native TOML types.
|
| 139 |
+
return {
|
| 140 |
+
k: ConfigValue(v, origin="file", mode="toml")
|
| 141 |
+
for k, v in toml_config.items()
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
elif ini_config is not None:
|
| 145 |
+
# INI mode - TOML supports richer data types than INI files, but we need to
|
| 146 |
+
# convert all scalar values to str for compatibility with the INI system.
|
| 147 |
+
def make_scalar(v: object) -> str | list[str]:
|
| 148 |
+
return v if isinstance(v, list) else str(v)
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
k: ConfigValue(make_scalar(v), origin="file", mode="ini")
|
| 152 |
+
for k, v in ini_config.items()
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def locate_config(
|
| 159 |
+
invocation_dir: Path,
|
| 160 |
+
args: Iterable[Path],
|
| 161 |
+
) -> tuple[Path | None, Path | None, ConfigDict, Sequence[str]]:
|
| 162 |
+
"""Search in the list of arguments for a valid ini-file for pytest,
|
| 163 |
+
and return a tuple of (rootdir, inifile, cfg-dict, ignored-config-files), where
|
| 164 |
+
ignored-config-files is a list of config basenames found that contain
|
| 165 |
+
pytest configuration but were ignored."""
|
| 166 |
+
config_names = [
|
| 167 |
+
"pytest.toml",
|
| 168 |
+
".pytest.toml",
|
| 169 |
+
"pytest.ini",
|
| 170 |
+
".pytest.ini",
|
| 171 |
+
"pyproject.toml",
|
| 172 |
+
"tox.ini",
|
| 173 |
+
"setup.cfg",
|
| 174 |
+
]
|
| 175 |
+
args = [x for x in args if not str(x).startswith("-")]
|
| 176 |
+
if not args:
|
| 177 |
+
args = [invocation_dir]
|
| 178 |
+
found_pyproject_toml: Path | None = None
|
| 179 |
+
ignored_config_files: list[str] = []
|
| 180 |
+
|
| 181 |
+
for arg in args:
|
| 182 |
+
argpath = absolutepath(arg)
|
| 183 |
+
for base in (argpath, *argpath.parents):
|
| 184 |
+
for config_name in config_names:
|
| 185 |
+
p = base / config_name
|
| 186 |
+
if p.is_file():
|
| 187 |
+
if p.name == "pyproject.toml" and found_pyproject_toml is None:
|
| 188 |
+
found_pyproject_toml = p
|
| 189 |
+
ini_config = load_config_dict_from_file(p)
|
| 190 |
+
if ini_config is not None:
|
| 191 |
+
index = config_names.index(config_name)
|
| 192 |
+
for remainder in config_names[index + 1 :]:
|
| 193 |
+
p2 = base / remainder
|
| 194 |
+
if (
|
| 195 |
+
p2.is_file()
|
| 196 |
+
and load_config_dict_from_file(p2) is not None
|
| 197 |
+
):
|
| 198 |
+
ignored_config_files.append(remainder)
|
| 199 |
+
return base, p, ini_config, ignored_config_files
|
| 200 |
+
if found_pyproject_toml is not None:
|
| 201 |
+
return found_pyproject_toml.parent, found_pyproject_toml, {}, []
|
| 202 |
+
return None, None, {}, []
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def get_common_ancestor(
|
| 206 |
+
invocation_dir: Path,
|
| 207 |
+
paths: Iterable[Path],
|
| 208 |
+
) -> Path:
|
| 209 |
+
common_ancestor: Path | None = None
|
| 210 |
+
for path in paths:
|
| 211 |
+
if not path.exists():
|
| 212 |
+
continue
|
| 213 |
+
if common_ancestor is None:
|
| 214 |
+
common_ancestor = path
|
| 215 |
+
else:
|
| 216 |
+
if common_ancestor in path.parents or path == common_ancestor:
|
| 217 |
+
continue
|
| 218 |
+
elif path in common_ancestor.parents:
|
| 219 |
+
common_ancestor = path
|
| 220 |
+
else:
|
| 221 |
+
shared = commonpath(path, common_ancestor)
|
| 222 |
+
if shared is not None:
|
| 223 |
+
common_ancestor = shared
|
| 224 |
+
if common_ancestor is None:
|
| 225 |
+
common_ancestor = invocation_dir
|
| 226 |
+
elif common_ancestor.is_file():
|
| 227 |
+
common_ancestor = common_ancestor.parent
|
| 228 |
+
return common_ancestor
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def get_dirs_from_args(args: Iterable[str]) -> list[Path]:
|
| 232 |
+
def is_option(x: str) -> bool:
|
| 233 |
+
return x.startswith("-")
|
| 234 |
+
|
| 235 |
+
def get_file_part_from_node_id(x: str) -> str:
|
| 236 |
+
return x.split("::", maxsplit=1)[0]
|
| 237 |
+
|
| 238 |
+
def get_dir_from_path(path: Path) -> Path:
|
| 239 |
+
if path.is_dir():
|
| 240 |
+
return path
|
| 241 |
+
return path.parent
|
| 242 |
+
|
| 243 |
+
# These look like paths but may not exist
|
| 244 |
+
possible_paths = (
|
| 245 |
+
absolutepath(get_file_part_from_node_id(arg))
|
| 246 |
+
for arg in args
|
| 247 |
+
if not is_option(arg)
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return [get_dir_from_path(path) for path in possible_paths if safe_exists(path)]
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def parse_override_ini(override_ini: Sequence[str] | None) -> ConfigDict:
|
| 254 |
+
"""Parse the -o/--override-ini command line arguments and return the overrides.
|
| 255 |
+
|
| 256 |
+
:raises UsageError:
|
| 257 |
+
If one of the values is malformed.
|
| 258 |
+
"""
|
| 259 |
+
overrides = {}
|
| 260 |
+
# override_ini is a list of "ini=value" options.
|
| 261 |
+
# Always use the last item if multiple values are set for same ini-name,
|
| 262 |
+
# e.g. -o foo=bar1 -o foo=bar2 will set foo to bar2.
|
| 263 |
+
for ini_config in override_ini or ():
|
| 264 |
+
try:
|
| 265 |
+
key, user_ini_value = ini_config.split("=", 1)
|
| 266 |
+
except ValueError as e:
|
| 267 |
+
raise UsageError(
|
| 268 |
+
f"-o/--override-ini expects option=value style (got: {ini_config!r})."
|
| 269 |
+
) from e
|
| 270 |
+
else:
|
| 271 |
+
overrides[key] = ConfigValue(user_ini_value, origin="override", mode="ini")
|
| 272 |
+
return overrides
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
CFG_PYTEST_SECTION = "[pytest] section in {filename} files is no longer supported, change to [tool:pytest] instead."
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def determine_setup(
|
| 279 |
+
*,
|
| 280 |
+
inifile: str | None,
|
| 281 |
+
override_ini: Sequence[str] | None,
|
| 282 |
+
args: Sequence[str],
|
| 283 |
+
rootdir_cmd_arg: str | None,
|
| 284 |
+
invocation_dir: Path,
|
| 285 |
+
) -> tuple[Path, Path | None, ConfigDict, Sequence[str]]:
|
| 286 |
+
"""Determine the rootdir, inifile and ini configuration values from the
|
| 287 |
+
command line arguments.
|
| 288 |
+
|
| 289 |
+
:param inifile:
|
| 290 |
+
The `--inifile` command line argument, if given.
|
| 291 |
+
:param override_ini:
|
| 292 |
+
The -o/--override-ini command line arguments, if given.
|
| 293 |
+
:param args:
|
| 294 |
+
The free command line arguments.
|
| 295 |
+
:param rootdir_cmd_arg:
|
| 296 |
+
The `--rootdir` command line argument, if given.
|
| 297 |
+
:param invocation_dir:
|
| 298 |
+
The working directory when pytest was invoked.
|
| 299 |
+
|
| 300 |
+
:raises UsageError:
|
| 301 |
+
"""
|
| 302 |
+
rootdir = None
|
| 303 |
+
dirs = get_dirs_from_args(args)
|
| 304 |
+
ignored_config_files: Sequence[str] = []
|
| 305 |
+
|
| 306 |
+
if inifile:
|
| 307 |
+
inipath_ = absolutepath(inifile)
|
| 308 |
+
inipath: Path | None = inipath_
|
| 309 |
+
inicfg = load_config_dict_from_file(inipath_) or {}
|
| 310 |
+
if rootdir_cmd_arg is None:
|
| 311 |
+
rootdir = inipath_.parent
|
| 312 |
+
else:
|
| 313 |
+
ancestor = get_common_ancestor(invocation_dir, dirs)
|
| 314 |
+
rootdir, inipath, inicfg, ignored_config_files = locate_config(
|
| 315 |
+
invocation_dir, [ancestor]
|
| 316 |
+
)
|
| 317 |
+
if rootdir is None and rootdir_cmd_arg is None:
|
| 318 |
+
for possible_rootdir in (ancestor, *ancestor.parents):
|
| 319 |
+
if (possible_rootdir / "setup.py").is_file():
|
| 320 |
+
rootdir = possible_rootdir
|
| 321 |
+
break
|
| 322 |
+
else:
|
| 323 |
+
if dirs != [ancestor]:
|
| 324 |
+
rootdir, inipath, inicfg, _ = locate_config(invocation_dir, dirs)
|
| 325 |
+
if rootdir is None:
|
| 326 |
+
rootdir = get_common_ancestor(
|
| 327 |
+
invocation_dir, [invocation_dir, ancestor]
|
| 328 |
+
)
|
| 329 |
+
if is_fs_root(rootdir):
|
| 330 |
+
rootdir = ancestor
|
| 331 |
+
if rootdir_cmd_arg:
|
| 332 |
+
rootdir = absolutepath(os.path.expandvars(rootdir_cmd_arg))
|
| 333 |
+
if not rootdir.is_dir():
|
| 334 |
+
raise UsageError(
|
| 335 |
+
f"Directory '{rootdir}' not found. Check your '--rootdir' option."
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
ini_overrides = parse_override_ini(override_ini)
|
| 339 |
+
inicfg.update(ini_overrides)
|
| 340 |
+
|
| 341 |
+
assert rootdir is not None
|
| 342 |
+
return rootdir, inipath, inicfg, ignored_config_files
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def is_fs_root(p: Path) -> bool:
|
| 346 |
+
r"""
|
| 347 |
+
Return True if the given path is pointing to the root of the
|
| 348 |
+
file system ("/" on Unix and "C:\\" on Windows for example).
|
| 349 |
+
"""
|
| 350 |
+
return os.path.splitdrive(str(p))[1] == os.sep
|
.venv/lib/python3.14/site-packages/_pytest/debugging.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
from collections.abc import Callable
|
| 9 |
+
from collections.abc import Generator
|
| 10 |
+
import functools
|
| 11 |
+
import importlib
|
| 12 |
+
import sys
|
| 13 |
+
import types
|
| 14 |
+
from typing import Any
|
| 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 |
+
mod = importlib.import_module(modname)
|
| 127 |
+
|
| 128 |
+
# Handle --pdbcls=pdb:pdb.Pdb (useful e.g. with pdbpp).
|
| 129 |
+
parts = classname.split(".")
|
| 130 |
+
pdb_cls = getattr(mod, parts[0])
|
| 131 |
+
for part in parts[1:]:
|
| 132 |
+
pdb_cls = getattr(pdb_cls, part)
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
value = ":".join((modname, classname))
|
| 135 |
+
raise UsageError(
|
| 136 |
+
f"--pdbcls: could not import {value!r}: {exc}"
|
| 137 |
+
) from exc
|
| 138 |
+
else:
|
| 139 |
+
import pdb
|
| 140 |
+
|
| 141 |
+
pdb_cls = pdb.Pdb
|
| 142 |
+
|
| 143 |
+
wrapped_cls = cls._get_pdb_wrapper_class(pdb_cls, capman)
|
| 144 |
+
cls._wrapped_pdb_cls = (usepdb_cls, wrapped_cls)
|
| 145 |
+
return wrapped_cls
|
| 146 |
+
|
| 147 |
+
@classmethod
|
| 148 |
+
def _get_pdb_wrapper_class(cls, pdb_cls, capman: CaptureManager | None):
|
| 149 |
+
import _pytest.config
|
| 150 |
+
|
| 151 |
+
class PytestPdbWrapper(pdb_cls):
|
| 152 |
+
_pytest_capman = capman
|
| 153 |
+
_continued = False
|
| 154 |
+
|
| 155 |
+
def do_debug(self, arg):
|
| 156 |
+
cls._recursive_debug += 1
|
| 157 |
+
ret = super().do_debug(arg)
|
| 158 |
+
cls._recursive_debug -= 1
|
| 159 |
+
return ret
|
| 160 |
+
|
| 161 |
+
if hasattr(pdb_cls, "do_debug"):
|
| 162 |
+
do_debug.__doc__ = pdb_cls.do_debug.__doc__
|
| 163 |
+
|
| 164 |
+
def do_continue(self, arg):
|
| 165 |
+
ret = super().do_continue(arg)
|
| 166 |
+
if cls._recursive_debug == 0:
|
| 167 |
+
assert cls._config is not None
|
| 168 |
+
tw = _pytest.config.create_terminal_writer(cls._config)
|
| 169 |
+
tw.line()
|
| 170 |
+
|
| 171 |
+
capman = self._pytest_capman
|
| 172 |
+
capturing = pytestPDB._is_capturing(capman)
|
| 173 |
+
if capturing:
|
| 174 |
+
if capturing == "global":
|
| 175 |
+
tw.sep(">", "PDB continue (IO-capturing resumed)")
|
| 176 |
+
else:
|
| 177 |
+
tw.sep(
|
| 178 |
+
">",
|
| 179 |
+
f"PDB continue (IO-capturing resumed for {capturing})",
|
| 180 |
+
)
|
| 181 |
+
assert capman is not None
|
| 182 |
+
capman.resume()
|
| 183 |
+
else:
|
| 184 |
+
tw.sep(">", "PDB continue")
|
| 185 |
+
assert cls._pluginmanager is not None
|
| 186 |
+
cls._pluginmanager.hook.pytest_leave_pdb(config=cls._config, pdb=self)
|
| 187 |
+
self._continued = True
|
| 188 |
+
return ret
|
| 189 |
+
|
| 190 |
+
if hasattr(pdb_cls, "do_continue"):
|
| 191 |
+
do_continue.__doc__ = pdb_cls.do_continue.__doc__
|
| 192 |
+
|
| 193 |
+
do_c = do_cont = do_continue
|
| 194 |
+
|
| 195 |
+
def do_quit(self, arg):
|
| 196 |
+
# Raise Exit outcome when quit command is used in pdb.
|
| 197 |
+
#
|
| 198 |
+
# This is a bit of a hack - it would be better if BdbQuit
|
| 199 |
+
# could be handled, but this would require to wrap the
|
| 200 |
+
# whole pytest run, and adjust the report etc.
|
| 201 |
+
ret = super().do_quit(arg)
|
| 202 |
+
|
| 203 |
+
if cls._recursive_debug == 0:
|
| 204 |
+
outcomes.exit("Quitting debugger")
|
| 205 |
+
|
| 206 |
+
return ret
|
| 207 |
+
|
| 208 |
+
if hasattr(pdb_cls, "do_quit"):
|
| 209 |
+
do_quit.__doc__ = pdb_cls.do_quit.__doc__
|
| 210 |
+
|
| 211 |
+
do_q = do_quit
|
| 212 |
+
do_exit = do_quit
|
| 213 |
+
|
| 214 |
+
def setup(self, f, tb):
|
| 215 |
+
"""Suspend on setup().
|
| 216 |
+
|
| 217 |
+
Needed after do_continue resumed, and entering another
|
| 218 |
+
breakpoint again.
|
| 219 |
+
"""
|
| 220 |
+
ret = super().setup(f, tb)
|
| 221 |
+
if not ret and self._continued:
|
| 222 |
+
# pdb.setup() returns True if the command wants to exit
|
| 223 |
+
# from the interaction: do not suspend capturing then.
|
| 224 |
+
if self._pytest_capman:
|
| 225 |
+
self._pytest_capman.suspend_global_capture(in_=True)
|
| 226 |
+
return ret
|
| 227 |
+
|
| 228 |
+
def get_stack(self, f, t):
|
| 229 |
+
stack, i = super().get_stack(f, t)
|
| 230 |
+
if f is None:
|
| 231 |
+
# Find last non-hidden frame.
|
| 232 |
+
i = max(0, len(stack) - 1)
|
| 233 |
+
while i and stack[i][0].f_locals.get("__tracebackhide__", False):
|
| 234 |
+
i -= 1
|
| 235 |
+
return stack, i
|
| 236 |
+
|
| 237 |
+
return PytestPdbWrapper
|
| 238 |
+
|
| 239 |
+
@classmethod
|
| 240 |
+
def _init_pdb(cls, method, *args, **kwargs):
|
| 241 |
+
"""Initialize PDB debugging, dropping any IO capturing."""
|
| 242 |
+
import _pytest.config
|
| 243 |
+
|
| 244 |
+
if cls._pluginmanager is None:
|
| 245 |
+
capman: CaptureManager | None = None
|
| 246 |
+
else:
|
| 247 |
+
capman = cls._pluginmanager.getplugin("capturemanager")
|
| 248 |
+
if capman:
|
| 249 |
+
capman.suspend(in_=True)
|
| 250 |
+
|
| 251 |
+
if cls._config:
|
| 252 |
+
tw = _pytest.config.create_terminal_writer(cls._config)
|
| 253 |
+
tw.line()
|
| 254 |
+
|
| 255 |
+
if cls._recursive_debug == 0:
|
| 256 |
+
# Handle header similar to pdb.set_trace in py37+.
|
| 257 |
+
header = kwargs.pop("header", None)
|
| 258 |
+
if header is not None:
|
| 259 |
+
tw.sep(">", header)
|
| 260 |
+
else:
|
| 261 |
+
capturing = cls._is_capturing(capman)
|
| 262 |
+
if capturing == "global":
|
| 263 |
+
tw.sep(">", f"PDB {method} (IO-capturing turned off)")
|
| 264 |
+
elif capturing:
|
| 265 |
+
tw.sep(
|
| 266 |
+
">",
|
| 267 |
+
f"PDB {method} (IO-capturing turned off for {capturing})",
|
| 268 |
+
)
|
| 269 |
+
else:
|
| 270 |
+
tw.sep(">", f"PDB {method}")
|
| 271 |
+
|
| 272 |
+
_pdb = cls._import_pdb_cls(capman)(**kwargs)
|
| 273 |
+
|
| 274 |
+
if cls._pluginmanager:
|
| 275 |
+
cls._pluginmanager.hook.pytest_enter_pdb(config=cls._config, pdb=_pdb)
|
| 276 |
+
return _pdb
|
| 277 |
+
|
| 278 |
+
@classmethod
|
| 279 |
+
def set_trace(cls, *args, **kwargs) -> None:
|
| 280 |
+
"""Invoke debugging via ``Pdb.set_trace``, dropping any IO capturing."""
|
| 281 |
+
frame = sys._getframe().f_back
|
| 282 |
+
_pdb = cls._init_pdb("set_trace", *args, **kwargs)
|
| 283 |
+
_pdb.set_trace(frame)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
class PdbInvoke:
|
| 287 |
+
def pytest_exception_interact(
|
| 288 |
+
self, node: Node, call: CallInfo[Any], report: BaseReport
|
| 289 |
+
) -> None:
|
| 290 |
+
capman = node.config.pluginmanager.getplugin("capturemanager")
|
| 291 |
+
if capman:
|
| 292 |
+
capman.suspend_global_capture(in_=True)
|
| 293 |
+
out, err = capman.read_global_capture()
|
| 294 |
+
sys.stdout.write(out)
|
| 295 |
+
sys.stdout.write(err)
|
| 296 |
+
assert call.excinfo is not None
|
| 297 |
+
_enter_pdb(node, call.excinfo, report)
|
| 298 |
+
|
| 299 |
+
def pytest_internalerror(self, excinfo: ExceptionInfo[BaseException]) -> None:
|
| 300 |
+
exc_or_tb = _postmortem_exc_or_tb(excinfo)
|
| 301 |
+
post_mortem(exc_or_tb)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
class PdbTrace:
|
| 305 |
+
@hookimpl(wrapper=True)
|
| 306 |
+
def pytest_pyfunc_call(self, pyfuncitem) -> Generator[None, object, object]:
|
| 307 |
+
wrap_pytest_function_for_tracing(pyfuncitem)
|
| 308 |
+
return (yield)
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def wrap_pytest_function_for_tracing(pyfuncitem) -> None:
|
| 312 |
+
"""Change the Python function object of the given Function item by a
|
| 313 |
+
wrapper which actually enters pdb before calling the python function
|
| 314 |
+
itself, effectively leaving the user in the pdb prompt in the first
|
| 315 |
+
statement of the function."""
|
| 316 |
+
_pdb = pytestPDB._init_pdb("runcall")
|
| 317 |
+
testfunction = pyfuncitem.obj
|
| 318 |
+
|
| 319 |
+
# we can't just return `partial(pdb.runcall, testfunction)` because (on
|
| 320 |
+
# python < 3.7.4) runcall's first param is `func`, which means we'd get
|
| 321 |
+
# an exception if one of the kwargs to testfunction was called `func`.
|
| 322 |
+
@functools.wraps(testfunction)
|
| 323 |
+
def wrapper(*args, **kwargs) -> None:
|
| 324 |
+
func = functools.partial(testfunction, *args, **kwargs)
|
| 325 |
+
_pdb.runcall(func)
|
| 326 |
+
|
| 327 |
+
pyfuncitem.obj = wrapper
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def maybe_wrap_pytest_function_for_tracing(pyfuncitem) -> None:
|
| 331 |
+
"""Wrap the given pytestfunct item for tracing support if --trace was given in
|
| 332 |
+
the command line."""
|
| 333 |
+
if pyfuncitem.config.getvalue("trace"):
|
| 334 |
+
wrap_pytest_function_for_tracing(pyfuncitem)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _enter_pdb(
|
| 338 |
+
node: Node, excinfo: ExceptionInfo[BaseException], rep: BaseReport
|
| 339 |
+
) -> BaseReport:
|
| 340 |
+
# XXX we reuse the TerminalReporter's terminalwriter
|
| 341 |
+
# because this seems to avoid some encoding related troubles
|
| 342 |
+
# for not completely clear reasons.
|
| 343 |
+
tw = node.config.pluginmanager.getplugin("terminalreporter")._tw
|
| 344 |
+
tw.line()
|
| 345 |
+
|
| 346 |
+
showcapture = node.config.option.showcapture
|
| 347 |
+
|
| 348 |
+
for sectionname, content in (
|
| 349 |
+
("stdout", rep.capstdout),
|
| 350 |
+
("stderr", rep.capstderr),
|
| 351 |
+
("log", rep.caplog),
|
| 352 |
+
):
|
| 353 |
+
if showcapture in (sectionname, "all") and content:
|
| 354 |
+
tw.sep(">", "captured " + sectionname)
|
| 355 |
+
if content[-1:] == "\n":
|
| 356 |
+
content = content[:-1]
|
| 357 |
+
tw.line(content)
|
| 358 |
+
|
| 359 |
+
tw.sep(">", "traceback")
|
| 360 |
+
rep.toterminal(tw)
|
| 361 |
+
tw.sep(">", "entering PDB")
|
| 362 |
+
tb_or_exc = _postmortem_exc_or_tb(excinfo)
|
| 363 |
+
rep._pdbshown = True # type: ignore[attr-defined]
|
| 364 |
+
post_mortem(tb_or_exc)
|
| 365 |
+
return rep
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def _postmortem_exc_or_tb(
|
| 369 |
+
excinfo: ExceptionInfo[BaseException],
|
| 370 |
+
) -> types.TracebackType | BaseException:
|
| 371 |
+
from doctest import UnexpectedException
|
| 372 |
+
|
| 373 |
+
get_exc = sys.version_info >= (3, 13)
|
| 374 |
+
if isinstance(excinfo.value, UnexpectedException):
|
| 375 |
+
# A doctest.UnexpectedException is not useful for post_mortem.
|
| 376 |
+
# Use the underlying exception instead:
|
| 377 |
+
underlying_exc = excinfo.value
|
| 378 |
+
if get_exc:
|
| 379 |
+
return underlying_exc.exc_info[1]
|
| 380 |
+
|
| 381 |
+
return underlying_exc.exc_info[2]
|
| 382 |
+
elif isinstance(excinfo.value, ConftestImportFailure):
|
| 383 |
+
# A config.ConftestImportFailure is not useful for post_mortem.
|
| 384 |
+
# Use the underlying exception instead:
|
| 385 |
+
cause = excinfo.value.cause
|
| 386 |
+
if get_exc:
|
| 387 |
+
return cause
|
| 388 |
+
|
| 389 |
+
assert cause.__traceback__ is not None
|
| 390 |
+
return cause.__traceback__
|
| 391 |
+
else:
|
| 392 |
+
assert excinfo._excinfo is not None
|
| 393 |
+
if get_exc:
|
| 394 |
+
return excinfo._excinfo[1]
|
| 395 |
+
|
| 396 |
+
return excinfo._excinfo[2]
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def post_mortem(tb_or_exc: types.TracebackType | BaseException) -> None:
|
| 400 |
+
p = pytestPDB._init_pdb("post_mortem")
|
| 401 |
+
p.reset()
|
| 402 |
+
p.interaction(None, tb_or_exc)
|
| 403 |
+
if p.quitting:
|
| 404 |
+
outcomes.exit("Quitting debugger")
|
.venv/lib/python3.14/site-packages/_pytest/deprecated.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 PytestRemovedIn10Warning
|
| 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 |
+
"pytest_subtests",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# This could have been removed pytest 8, but it's harmless and common, so no rush to remove.
|
| 32 |
+
YIELD_FIXTURE = PytestDeprecationWarning(
|
| 33 |
+
"@pytest.yield_fixture is deprecated.\n"
|
| 34 |
+
"Use @pytest.fixture instead; they are the same."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
CLASS_FIXTURE_INSTANCE_METHOD = PytestRemovedIn10Warning(
|
| 38 |
+
"Class-scoped fixture defined as instance method is deprecated.\n"
|
| 39 |
+
"Instance attributes set in this fixture will NOT be visible to test methods,\n"
|
| 40 |
+
"as each test gets a new instance while the fixture runs only once per class.\n"
|
| 41 |
+
"Use @classmethod decorator and set attributes on cls instead.\n"
|
| 42 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#class-scoped-fixture-as-instance-method"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
# This deprecation is never really meant to be removed.
|
| 46 |
+
PRIVATE = PytestDeprecationWarning("A private pytest class or function was used.")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
HOOK_LEGACY_MARKING = UnformattedWarning(
|
| 50 |
+
PytestRemovedIn10Warning,
|
| 51 |
+
"The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n"
|
| 52 |
+
"Please use the pytest.hook{type}({hook_opts}) decorator instead\n"
|
| 53 |
+
" to configure the hooks.\n"
|
| 54 |
+
" See https://docs.pytest.org/en/latest/deprecations.html"
|
| 55 |
+
"#configuring-hook-specs-impls-using-markers",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
MONKEYPATCH_LEGACY_NAMESPACE_PACKAGES = PytestRemovedIn10Warning(
|
| 59 |
+
"monkeypatch.syspath_prepend() called with pkg_resources legacy namespace packages detected.\n"
|
| 60 |
+
"Legacy namespace packages (using pkg_resources.declare_namespace) are deprecated.\n"
|
| 61 |
+
"Please use native namespace packages (PEP 420) instead.\n"
|
| 62 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
PARAMETRIZE_NON_COLLECTION_ITERABLE = UnformattedWarning(
|
| 66 |
+
PytestRemovedIn10Warning,
|
| 67 |
+
"Passing a non-Collection iterable to parametrize is deprecated.\n"
|
| 68 |
+
"Test: {nodeid}, argvalues type: {type_name}\n"
|
| 69 |
+
"Please convert to a list or tuple.\n"
|
| 70 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
CONSOLE_MAIN = PytestRemovedIn10Warning(
|
| 74 |
+
"pytest.console_main() is deprecated and will be removed in pytest 10.\n"
|
| 75 |
+
"It was never intended for programmatic use; use pytest.main() instead.\n"
|
| 76 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#console-main"
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
CONFIG_INICFG = PytestRemovedIn10Warning(
|
| 80 |
+
"config.inicfg is deprecated, use config.getini() to access configuration values instead.\n"
|
| 81 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#config-inicfg"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
FIXTURE_GETFIXTUREVALUE_DURING_TEARDOWN = UnformattedWarning(
|
| 85 |
+
PytestRemovedIn10Warning,
|
| 86 |
+
'Calling request.getfixturevalue("{argname}") during teardown is deprecated.\n'
|
| 87 |
+
"Please request the fixture before teardown begins, either by declaring it in the fixture signature "
|
| 88 |
+
"or by calling request.getfixturevalue() before the fixture yields.\n"
|
| 89 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#dynamic-fixture-request-during-teardown",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
PASTEBIN = PytestRemovedIn10Warning(
|
| 93 |
+
"The --pastebin option is deprecated. "
|
| 94 |
+
"The functionality is now available in an external plugin package, pytest-pastebin.\n"
|
| 95 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#the-pastebin-option"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
# You want to make some `__init__` or function "private".
|
| 99 |
+
#
|
| 100 |
+
# def my_private_function(some, args):
|
| 101 |
+
# ...
|
| 102 |
+
#
|
| 103 |
+
# Do this:
|
| 104 |
+
#
|
| 105 |
+
# def my_private_function(some, args, *, _ispytest: bool = False):
|
| 106 |
+
# check_ispytest(_ispytest)
|
| 107 |
+
# ...
|
| 108 |
+
#
|
| 109 |
+
# Change all internal/allowed calls to
|
| 110 |
+
#
|
| 111 |
+
# my_private_function(some, args, _ispytest=True)
|
| 112 |
+
#
|
| 113 |
+
# All other calls will get the default _ispytest=False and trigger
|
| 114 |
+
# the warning (possibly error in the future).
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
FIXTURE_BASEID_DEPRECATED = PytestRemovedIn10Warning(
|
| 118 |
+
"Passing baseid to FixtureDef is deprecated. Pass node instead for fixture scoping."
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
FIXTURE_NODEID_DEPRECATED = PytestRemovedIn10Warning(
|
| 122 |
+
"Passing nodeid to _register_fixture is deprecated. "
|
| 123 |
+
"Pass node instead for fixture scoping."
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
FIXTUREDEF_HAS_LOCATION_DEPRECATED = PytestRemovedIn10Warning(
|
| 127 |
+
"FixtureDef.has_location is deprecated and will be removed in pytest 10. "
|
| 128 |
+
"See https://docs.pytest.org/en/stable/deprecations.html#fixturedef-has-location-deprecated"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
PARSEFACTORIES_NODEID_DEPRECATED = PytestRemovedIn10Warning(
|
| 132 |
+
"Passing nodeid string to parsefactories is deprecated. "
|
| 133 |
+
"Use parsefactories(holder=obj, node=node) instead."
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def check_ispytest(ispytest: bool) -> None:
|
| 138 |
+
if not ispytest:
|
| 139 |
+
warn(PRIVATE, stacklevel=3)
|