From afb220525ba2e04cf9f1c63b67c7eb2783173c1e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 15:41:26 +0100 Subject: [PATCH 01/19] test on py3.14 --- .github/workflows/test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 99ca032db9d..e7539028bc4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -118,6 +118,10 @@ jobs: os: windows-latest tox_env: "py313" + - name: "windows-py314" + python: "3.14" + os: windows-latest + tox_env: "py314" - name: "ubuntu-py39-lsof-numpy-pexpect" python: "3.9" @@ -163,6 +167,12 @@ jobs: tox_env: "py313-pexpect" use_coverage: true + - name: "ubuntu-py314" + python: "3.14" + os: ubuntu-latest + tox_env: "py314" + use_coverage: true + - name: "ubuntu-pypy3-xdist" python: "pypy-3.9" os: ubuntu-latest @@ -190,6 +200,10 @@ jobs: os: macos-latest tox_env: "py313-xdist" + - name: "macos-py314" + python: "3.14" + os: macos-latest + tox_env: "py314-xdist" - name: "plugins" python: "3.12" @@ -240,6 +254,7 @@ jobs: with: python-version: ${{ matrix.python }} check-latest: ${{ endsWith(matrix.python, '-dev') }} + allow-prereleases: true - name: Install dependencies run: | From 765376f7fae6419ebc2dffcd000f9829b807e5cf Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 15:42:46 +0100 Subject: [PATCH 02/19] add news --- changelog/13308.improvement.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/13308.improvement.rst diff --git a/changelog/13308.improvement.rst b/changelog/13308.improvement.rst new file mode 100644 index 00000000000..906315977db --- /dev/null +++ b/changelog/13308.improvement.rst @@ -0,0 +1 @@ +support Python 3.14 From d04caf75116b8c79eb8c51a94e3e3fa104c9de33 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 15:43:44 +0100 Subject: [PATCH 03/19] add 314 to tox.ini --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 850def411cb..8f7d8495285 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ envlist = py311 py312 py313 + py314 pypy3 py39-{pexpect,xdist,unittestextras,numpy,pluggymain,pylib} doctesting From 938239a51be70e6e9dc3b1e5fd4af434f3356865 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 15:56:22 +0100 Subject: [PATCH 04/19] fix ResourceWarning on pastebin http failures --- src/_pytest/pastebin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index d5c4f29c4c3..aaf74cdae2a 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -78,6 +78,7 @@ def create_new_paste(contents: str | bytes) -> str: import re from urllib.parse import urlencode from urllib.request import urlopen + from urllib.error import HTTPError params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpa.st" @@ -85,8 +86,11 @@ def create_new_paste(contents: str | bytes) -> str: response: str = ( urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") ) - except OSError as exc_info: # urllib errors - return f"bad response: {exc_info}" + except HTTPError as e: # urllib.error errors + with e: + return f"bad response: {e}" + except OSError as e: # urllib errors + return f"bad response: {e}" m = re.search(r'href="/raw/(\w+)"', response) if m: return f"{url}/show/{m.group(1)}" From 8f93112f468698d2ba4ebdf99a7354cf2ea431f0 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 15:56:41 +0100 Subject: [PATCH 05/19] fix ResourceWarning on pytest.raises with urllib.error --- testing/python/raises.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index 3da260d1837..b02676f8d51 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -401,5 +401,7 @@ def test_issue_11872(self) -> None: """ from urllib.error import HTTPError - with pytest.raises(HTTPError, match="Not Found"): + with pytest.raises(HTTPError, match="Not Found") as exc_info: raise HTTPError(code=404, msg="Not Found", fp=None, hdrs=None, url="") # type: ignore [arg-type] + with exc_info.value: + pass From a4a63f553a46c164863c3906f1fef9a85f2f743f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 14:57:08 +0000 Subject: [PATCH 06/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/pastebin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index aaf74cdae2a..cad736454c7 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -76,9 +76,9 @@ def create_new_paste(contents: str | bytes) -> str: :returns: URL to the pasted contents, or an error message. """ import re + from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import urlopen - from urllib.error import HTTPError params = {"code": contents, "lexer": "text", "expiry": "1week"} url = "https://bpa.st" From 73ec95e4d61958d678b6badb336253f5cd469d11 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 16:14:41 +0100 Subject: [PATCH 07/19] pass real types for HTTPError so it works in cmgr --- testing/python/raises.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index b02676f8d51..ba45ae1d92e 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs from __future__ import annotations +import io import re import sys @@ -399,9 +400,12 @@ def test_issue_11872(self) -> None: https://github.com/python/cpython/issues/98778 """ + from email.message import Message from urllib.error import HTTPError with pytest.raises(HTTPError, match="Not Found") as exc_info: - raise HTTPError(code=404, msg="Not Found", fp=None, hdrs=None, url="") # type: ignore [arg-type] + raise HTTPError( + code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" + ) with exc_info.value: pass From bd315277250a99aa6aa45a5b7e471dfc884e9191 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 16:15:04 +0100 Subject: [PATCH 08/19] xfail test_raises_bdbquit_with_eoferror --- testing/test_debugging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 45883568b11..47870db81a9 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1373,6 +1373,7 @@ def do_quit(self, *args): result.stdout.fnmatch_lines(["*runcall_called*", "* 1 passed in *"]) +@pytest.mark.xfail(sys.version_info >= (3, 14), reason="I don't know why this fails") def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" p1 = pytester.makepyfile( From 98a538e7fd17352deac8b3ab4e6c9eff6f6e475f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 17:14:02 +0100 Subject: [PATCH 09/19] fix message for unraisable exceptions --- testing/test_unraisableexception.py | 33 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/testing/test_unraisableexception.py b/testing/test_unraisableexception.py index 328177a7ba3..6c0dc542e93 100644 --- a/testing/test_unraisableexception.py +++ b/testing/test_unraisableexception.py @@ -12,6 +12,24 @@ PYPY = hasattr(sys, "pypy_version_info") +UNRAISABLE_LINE = ( + ( + " * PytestUnraisableExceptionWarning: Exception ignored while calling " + "deallocator : None" + ) + if sys.version_info >= (3, 14) + else " * PytestUnraisableExceptionWarning: Exception ignored in: " +) + +TRACEMALLOC_LINES = ( + () + if sys.version_info >= (3, 14) + else ( + " Enable tracemalloc to get traceback where the object was allocated.", + " See https* for more info.", + ) +) + @pytest.mark.skipif(PYPY, reason="garbage-collection differences make this flaky") @pytest.mark.filterwarnings("default::pytest.PytestUnraisableExceptionWarning") @@ -36,13 +54,12 @@ def test_2(): pass [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", - " Enable tracemalloc to get traceback where the object was allocated.", - " See https* for more info.", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) @@ -75,13 +92,12 @@ def test_2(): pass [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", - " Enable tracemalloc to get traceback where the object was allocated.", - " See https* for more info.", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) @@ -115,13 +131,12 @@ def test_2(): pass [ "*= warnings summary =*", "test_it.py::test_it", - " * PytestUnraisableExceptionWarning: Exception ignored in: ", + UNRAISABLE_LINE, " ", " Traceback (most recent call last):", " ValueError: del is broken", " ", - " Enable tracemalloc to get traceback where the object was allocated.", - " See https* for more info.", + *TRACEMALLOC_LINES, " warnings.warn(pytest.PytestUnraisableExceptionWarning(msg))", ] ) From 024c2b87ff4340a18f4dcf110aa82181ff11f950 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 20:27:45 +0100 Subject: [PATCH 10/19] use improved prog default value for argparse.ArgumentParser --- testing/test_parseopt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index 14e2b5f69fb..36db7b13989 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -28,7 +28,7 @@ def test_no_help_by_default(self) -> None: def test_custom_prog(self, parser: parseopt.Parser) -> None: """Custom prog can be set for `argparse.ArgumentParser`.""" - assert parser._getparser().prog == os.path.basename(sys.argv[0]) + assert parser._getparser().prog == argparse.ArgumentParser().prog parser.prog = "custom-prog" assert parser._getparser().prog == "custom-prog" From 96700eb639ed1685b5280cedd569d569f48f1caa Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 26 May 2025 20:39:57 +0100 Subject: [PATCH 11/19] Update testing/python/raises.py --- testing/python/raises.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/python/raises.py b/testing/python/raises.py index ba45ae1d92e..40f9afea3ba 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -407,5 +407,4 @@ def test_issue_11872(self) -> None: raise HTTPError( code=404, msg="Not Found", fp=io.BytesIO(), hdrs=Message(), url="" ) - with exc_info.value: - pass + exc_info.value.close() # avoid a resource warning From 893d43bbe792be6f33f731e6dd051ef4748ae134 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 27 May 2025 09:49:10 +0100 Subject: [PATCH 12/19] Update testing/test_debugging.py --- testing/test_debugging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 47870db81a9..90b1b5404d2 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1373,7 +1373,7 @@ def do_quit(self, *args): result.stdout.fnmatch_lines(["*runcall_called*", "* 1 passed in *"]) -@pytest.mark.xfail(sys.version_info >= (3, 14), reason="I don't know why this fails") +@pytest.mark.xfail(sys.version_info >= (3, 14), reason="see https://github.com/python/cpython/issues/124703") def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" p1 = pytester.makepyfile( From 3396bc391e742f11fcd3d2c3a5f96725a8ee7e40 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 27 May 2025 08:49:33 +0000 Subject: [PATCH 13/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- testing/test_debugging.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 90b1b5404d2..89aa4727be5 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1373,7 +1373,10 @@ def do_quit(self, *args): result.stdout.fnmatch_lines(["*runcall_called*", "* 1 passed in *"]) -@pytest.mark.xfail(sys.version_info >= (3, 14), reason="see https://github.com/python/cpython/issues/124703") +@pytest.mark.xfail( + sys.version_info >= (3, 14), + reason="see https://github.com/python/cpython/issues/124703", +) def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" p1 = pytester.makepyfile( From 3998c06edc89b2e3daab6d6d3edfb75ab8c2703f Mon Sep 17 00:00:00 2001 From: jakkdl Date: Tue, 27 May 2025 11:46:00 +0200 Subject: [PATCH 14/19] be more explicit in debugging xfail --- testing/test_debugging.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/testing/test_debugging.py b/testing/test_debugging.py index 89aa4727be5..08ebf600253 100644 --- a/testing/test_debugging.py +++ b/testing/test_debugging.py @@ -1375,7 +1375,7 @@ def do_quit(self, *args): @pytest.mark.xfail( sys.version_info >= (3, 14), - reason="see https://github.com/python/cpython/issues/124703", + reason="C-D now quits the test session, rather than failing the test. See https://github.com/python/cpython/issues/124703", ) def test_raises_bdbquit_with_eoferror(pytester: Pytester) -> None: """It is not guaranteed that DontReadFromInput's read is called.""" @@ -1391,6 +1391,7 @@ def test(monkeypatch): """ ) result = pytester.runpytest(str(p1)) + result.assert_outcomes(failed=1) result.stdout.fnmatch_lines(["E *BdbQuit", "*= 1 failed in*"]) assert result.ret == 1 From 25682a07ac7f0875711c2528f9aaba4b8a169487 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 27 May 2025 12:34:25 +0100 Subject: [PATCH 15/19] Update changelog/13308.improvement.rst Co-authored-by: Bruno Oliveira --- changelog/13308.improvement.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/13308.improvement.rst b/changelog/13308.improvement.rst index 906315977db..70018c66d59 100644 --- a/changelog/13308.improvement.rst +++ b/changelog/13308.improvement.rst @@ -1 +1 @@ -support Python 3.14 +Added official support for Python 3.14. From d1a82c03d143f16dacf577b9fce92df5b41ad9bf Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 27 May 2025 12:35:01 +0100 Subject: [PATCH 16/19] Update src/_pytest/pastebin.py --- src/_pytest/pastebin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index cad736454c7..bb26e629b7b 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -87,7 +87,7 @@ def create_new_paste(contents: str | bytes) -> str: urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") ) except HTTPError as e: # urllib.error errors - with e: + with e: # HTTPErrors are also http responses that must be closed! return f"bad response: {e}" except OSError as e: # urllib errors return f"bad response: {e}" From 552df0f5d3a66ae0348b5e6fd9e7e5dd4f170bb1 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 28 May 2025 09:17:53 +0100 Subject: [PATCH 17/19] add 3.14 trove classifier --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c7db5947cf4..4d4055147d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Testing", "Topic :: Utilities", @@ -349,7 +350,7 @@ ignore = "W009" [tool.pyproject-fmt] indent = 4 -max_supported_python = "3.13" +max_supported_python = "3.14" [tool.pytest.ini_options] minversion = "2.0" From cf24e6b244dffb78a38f7913023c83b37b1114d3 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 29 May 2025 16:46:36 +0100 Subject: [PATCH 18/19] patch coverage --- src/_pytest/pastebin.py | 4 ++-- testing/test_pastebin.py | 47 +++++++++++++++++++++++----------------- 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/_pytest/pastebin.py b/src/_pytest/pastebin.py index bb26e629b7b..c7b39d96f02 100644 --- a/src/_pytest/pastebin.py +++ b/src/_pytest/pastebin.py @@ -86,10 +86,10 @@ def create_new_paste(contents: str | bytes) -> str: response: str = ( urlopen(url, data=urlencode(params).encode("ascii")).read().decode("utf-8") ) - except HTTPError as e: # urllib.error errors + except HTTPError as e: with e: # HTTPErrors are also http responses that must be closed! return f"bad response: {e}" - except OSError as e: # urllib errors + except OSError as e: # eg urllib.error.URLError return f"bad response: {e}" m = re.search(r'href="/raw/(\w+)"', response) if m: diff --git a/testing/test_pastebin.py b/testing/test_pastebin.py index 8fdd60bac75..9b928e00c06 100644 --- a/testing/test_pastebin.py +++ b/testing/test_pastebin.py @@ -3,6 +3,7 @@ import email.message import io +from unittest import mock from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester @@ -90,23 +91,6 @@ class TestPaste: def pastebin(self, request): return request.config.pluginmanager.getplugin("pastebin") - @pytest.fixture - def mocked_urlopen_fail(self, monkeypatch: MonkeyPatch): - """Monkeypatch the actual urlopen call to emulate a HTTP Error 400.""" - calls = [] - - import urllib.error - import urllib.request - - def mocked(url, data): - calls.append((url, data)) - raise urllib.error.HTTPError( - url, 400, "Bad request", email.message.Message(), io.BytesIO() - ) - - monkeypatch.setattr(urllib.request, "urlopen", mocked) - return calls - @pytest.fixture def mocked_urlopen_invalid(self, monkeypatch: MonkeyPatch): """Monkeypatch the actual urlopen calls done by the internal plugin @@ -158,10 +142,33 @@ def test_pastebin_invalid_url(self, pastebin, mocked_urlopen_invalid) -> None: ) assert len(mocked_urlopen_invalid) == 1 - def test_pastebin_http_error(self, pastebin, mocked_urlopen_fail) -> None: - result = pastebin.create_new_paste(b"full-paste-contents") + def test_pastebin_http_error(self, pastebin) -> None: + import urllib.error + + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.HTTPError( + url="https://bpa.st", + code=400, + msg="Bad request", + hdrs=email.message.Message(), + fp=io.BytesIO(), + ), + ) as mock_urlopen: + result = pastebin.create_new_paste(b"full-paste-contents") assert result == "bad response: HTTP Error 400: Bad request" - assert len(mocked_urlopen_fail) == 1 + assert len(mock_urlopen.mock_calls) == 1 + + def test_pastebin_url_error(self, pastebin) -> None: + import urllib.error + + with mock.patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("the url was bad"), + ) as mock_urlopen: + result = pastebin.create_new_paste(b"full-paste-contents") + assert result == "bad response: " + assert len(mock_urlopen.mock_calls) == 1 def test_create_new_paste(self, pastebin, mocked_urlopen) -> None: result = pastebin.create_new_paste(b"full-paste-contents") From 35e2715467deccd6aafd681cd7296966789644db Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 May 2025 22:11:01 +0100 Subject: [PATCH 19/19] Update .github/workflows/test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7539028bc4..73c2892575b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -253,7 +253,7 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - check-latest: ${{ endsWith(matrix.python, '-dev') }} + check-latest: true allow-prereleases: true - name: Install dependencies