Skip to content

Commit ae46cfd

Browse files
authored
NamedTextIOWrapper: stop closing buffer (#3139)
2 parents b6afbf0 + 777a89e commit ae46cfd

5 files changed

Lines changed: 587 additions & 12 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,25 @@ jobs:
3232
with:
3333
python-version: ${{ matrix.python }}
3434
- run: uv run --locked tox run -e ${{ matrix.tox || format('py{0}', matrix.python) }}
35+
stress:
36+
name: stress (${{ matrix.name || matrix.python }})
37+
runs-on: ${{ matrix.os || 'ubuntu-latest' }}
38+
strategy:
39+
fail-fast: false
40+
matrix:
41+
include:
42+
- {python: '3.14'}
43+
- {name: free-threaded, python: '3.14t', tox: stress-py3.14t}
44+
steps:
45+
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
46+
- uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
47+
with:
48+
enable-cache: true
49+
prune-cache: false
50+
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
51+
with:
52+
python-version: ${{ matrix.python }}
53+
- run: uv run --locked tox run -e ${{ matrix.tox || format('stress-py{0}', matrix.python) }}
3554
typing:
3655
runs-on: ubuntu-latest
3756
steps:

CHANGES.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ Unreleased
66
used without an explicit value. :issue:`3084`
77
- Hide ``Sentinel.UNSET`` values as ``None`` when using ``lookup_default()``.
88
:issue:`3136` :pr:`3199` :pr:`3202` :pr:`3209` :pr:`3212` :pr:`3224`
9+
- Prevent ``_NamedTextIOWrapper`` from closing streams owned by ``StreamMixer``.
10+
:issue:`824` :issue:`2991` :issue:`2993` :issue:`3110` :pr:`3139` :pr:`3140`
11+
- Add comprehensive tests for ``CliRunner`` stream lifecycle, covering
12+
logging interaction, multi-threaded safety, and sequential invocation
13+
isolation. Add high-iteration stress tests behind a ``stress`` marker
14+
with a dedicated CI job. :pr:`3139`
915

1016
Version 8.3.1
1117
--------------

pyproject.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ testpaths = ["tests"]
8383
filterwarnings = [
8484
"error",
8585
]
86+
markers = [
87+
"stress: high-iteration stress tests for race conditions (deselect with '-m \"not stress\"')",
88+
]
89+
addopts = "-m 'not stress'"
8690

8791
[tool.coverage.run]
8892
branch = true
@@ -142,6 +146,7 @@ env_list = [
142146
"py3.14", "py3.13", "py3.12", "py3.11", "py3.10",
143147
"py3.14t",
144148
"pypy3.11",
149+
"stress-py3.14", "stress-py3.14t",
145150
"style",
146151
"typing",
147152
"docs",
@@ -160,6 +165,16 @@ commands = [[
160165
{replace = "posargs", default = [], extend = true},
161166
]]
162167

168+
[tool.tox.env.stress]
169+
description = "stress tests for stream lifecycle race conditions"
170+
commands = [[
171+
"pytest", "-v", "--tb=short", "-x", "-m", "stress",
172+
"--basetemp={env_tmp_dir}",
173+
"--override-ini=addopts=",
174+
"tests/test_stream_lifecycle.py",
175+
{replace = "posargs", default = [], extend = true},
176+
]]
177+
163178
[tool.tox.env.style]
164179
description = "run all pre-commit hooks on all files"
165180
dependency_groups = ["pre-commit"]

src/click/testing.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -98,18 +98,6 @@ def __init__(self) -> None:
9898
self.stdout: io.BytesIO = BytesIOCopy(copy_to=self.output)
9999
self.stderr: io.BytesIO = BytesIOCopy(copy_to=self.output)
100100

101-
def __del__(self) -> None:
102-
"""
103-
Guarantee that embedded file-like objects are closed in a
104-
predictable order, protecting against races between
105-
self.output being closed and other streams being flushed on close
106-
107-
.. versionadded:: 8.2.2
108-
"""
109-
self.stderr.close()
110-
self.stdout.close()
111-
self.output.close()
112-
113101

114102
class _NamedTextIOWrapper(io.TextIOWrapper):
115103
def __init__(
@@ -119,6 +107,15 @@ def __init__(
119107
self._name = name
120108
self._mode = mode
121109

110+
def close(self) -> None:
111+
"""
112+
The buffer this object contains belongs to some other object, so
113+
prevent the default __del__ implementation from closing that buffer.
114+
115+
.. versionadded:: 8.3.2
116+
"""
117+
...
118+
122119
@property
123120
def name(self) -> str:
124121
return self._name

0 commit comments

Comments
 (0)