4
4
import sys
5
5
import tempfile
6
6
from pathlib import Path
7
+ from shutil import rmtree
8
+ from typing import Generator
7
9
from typing import Optional
10
+ from typing import TYPE_CHECKING
11
+ from typing import Union
12
+
13
+ if TYPE_CHECKING :
14
+ from typing_extensions import Literal
15
+
16
+ RetentionType = Literal ["all" , "failed" , "none" ]
17
+
8
18
9
19
import attr
20
+ from _pytest .config .argparsing import Parser
10
21
11
22
from .pathlib import LOCK_TIMEOUT
12
23
from .pathlib import make_numbered_dir
13
24
from .pathlib import make_numbered_dir_with_cleanup
14
25
from .pathlib import rm_rf
26
+ from .pathlib import cleanup_dead_symlink
15
27
from _pytest .compat import final
16
28
from _pytest .config import Config
29
+ from _pytest .config import ExitCode
30
+ from _pytest .config import hookimpl
17
31
from _pytest .deprecated import check_ispytest
18
32
from _pytest .fixtures import fixture
19
33
from _pytest .fixtures import FixtureRequest
@@ -31,10 +45,14 @@ class TempPathFactory:
31
45
_given_basetemp = attr .ib (type = Optional [Path ])
32
46
_trace = attr .ib ()
33
47
_basetemp = attr .ib (type = Optional [Path ])
48
+ _retention_count = attr .ib (type = int )
49
+ _retention_policy = attr .ib (type = "RetentionType" )
34
50
35
51
def __init__ (
36
52
self ,
37
53
given_basetemp : Optional [Path ],
54
+ retention_count : int ,
55
+ retention_policy : "RetentionType" ,
38
56
trace ,
39
57
basetemp : Optional [Path ] = None ,
40
58
* ,
@@ -49,6 +67,8 @@ def __init__(
49
67
# Path.absolute() exists, but it is not public (see https://bugs.python.org/issue25012).
50
68
self ._given_basetemp = Path (os .path .abspath (str (given_basetemp )))
51
69
self ._trace = trace
70
+ self ._retention_count = retention_count
71
+ self ._retention_policy = retention_policy
52
72
self ._basetemp = basetemp
53
73
54
74
@classmethod
@@ -63,9 +83,23 @@ def from_config(
63
83
:meta private:
64
84
"""
65
85
check_ispytest (_ispytest )
86
+ count = int (config .getini ("tmp_path_retention_count" ))
87
+ if count < 0 :
88
+ raise ValueError (
89
+ f"tmp_path_retention_count must be >= 0. Current input: { count } ."
90
+ )
91
+
92
+ policy = config .getini ("tmp_path_retention_policy" )
93
+ if policy not in ("all" , "failed" , "none" ):
94
+ raise ValueError (
95
+ f"tmp_path_retention_policy must be either all, failed, none. Current intput: { policy } ."
96
+ )
97
+
66
98
return cls (
67
99
given_basetemp = config .option .basetemp ,
68
100
trace = config .trace .get ("tmpdir" ),
101
+ retention_count = count ,
102
+ retention_policy = policy ,
69
103
_ispytest = True ,
70
104
)
71
105
@@ -146,10 +180,13 @@ def getbasetemp(self) -> Path:
146
180
)
147
181
if (rootdir_stat .st_mode & 0o077 ) != 0 :
148
182
os .chmod (rootdir , rootdir_stat .st_mode & ~ 0o077 )
183
+ keep = self ._retention_count
184
+ if self ._retention_policy == "none" :
185
+ keep = 0
149
186
basetemp = make_numbered_dir_with_cleanup (
150
187
prefix = "pytest-" ,
151
188
root = rootdir ,
152
- keep = 3 ,
189
+ keep = keep ,
153
190
lock_timeout = LOCK_TIMEOUT ,
154
191
mode = 0o700 ,
155
192
)
@@ -184,6 +221,21 @@ def pytest_configure(config: Config) -> None:
184
221
mp .setattr (config , "_tmp_path_factory" , _tmp_path_factory , raising = False )
185
222
186
223
224
+ def pytest_addoption (parser : Parser ) -> None :
225
+ parser .addini (
226
+ "tmp_path_retention_count" ,
227
+ help = "How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`." ,
228
+ default = 3 ,
229
+ )
230
+
231
+ parser .addini (
232
+ "tmp_path_retention_policy" ,
233
+ help = "Controls which directories created by the `tmp_path` fixture are kept around, based on test outcome. "
234
+ "(all/failed/none)" ,
235
+ default = "failed" ,
236
+ )
237
+
238
+
187
239
@fixture (scope = "session" )
188
240
def tmp_path_factory (request : FixtureRequest ) -> TempPathFactory :
189
241
"""Return a :class:`pytest.TempPathFactory` instance for the test session."""
@@ -200,7 +252,9 @@ def _mk_tmp(request: FixtureRequest, factory: TempPathFactory) -> Path:
200
252
201
253
202
254
@fixture
203
- def tmp_path (request : FixtureRequest , tmp_path_factory : TempPathFactory ) -> Path :
255
+ def tmp_path (
256
+ request : FixtureRequest , tmp_path_factory : TempPathFactory
257
+ ) -> Generator [Path , None , None ]:
204
258
"""Return a temporary directory path object which is unique to each test
205
259
function invocation, created as a sub directory of the base temporary
206
260
directory.
@@ -213,4 +267,46 @@ def tmp_path(request: FixtureRequest, tmp_path_factory: TempPathFactory) -> Path
213
267
The returned object is a :class:`pathlib.Path` object.
214
268
"""
215
269
216
- return _mk_tmp (request , tmp_path_factory )
270
+ path = _mk_tmp (request , tmp_path_factory )
271
+ yield path
272
+
273
+ # Remove the tmpdir if the policy is "failed" and the test passed.
274
+ tmp_path_factory : TempPathFactory = request .session .config ._tmp_path_factory # type: ignore
275
+ policy = tmp_path_factory ._retention_policy
276
+ if policy == "failed" and request .node ._tmp_path_result_call .passed :
277
+ # We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
278
+ # permissions, etc, in which case we ignore it.
279
+ rmtree (path , ignore_errors = True )
280
+
281
+ # remove dead symlink
282
+ basetemp = tmp_path_factory ._basetemp
283
+ if basetemp is None :
284
+ return
285
+ cleanup_dead_symlink (basetemp )
286
+
287
+
288
+ def pytest_sessionfinish (session , exitstatus : Union [int , ExitCode ]):
289
+ """After each session, remove base directory if all the tests passed,
290
+ the policy is "failed", and the basetemp is not specified by a user.
291
+ """
292
+ tmp_path_factory : TempPathFactory = session .config ._tmp_path_factory
293
+ if tmp_path_factory ._basetemp is None :
294
+ return
295
+ policy = tmp_path_factory ._retention_policy
296
+ if (
297
+ exitstatus == 0
298
+ and policy == "failed"
299
+ and tmp_path_factory ._given_basetemp is None
300
+ ):
301
+ passed_dir = tmp_path_factory ._basetemp
302
+ if passed_dir .exists ():
303
+ # We do a "best effort" to remove files, but it might not be possible due to some leaked resource,
304
+ # permissions, etc, in which case we ignore it.
305
+ rmtree (passed_dir , ignore_errors = True )
306
+
307
+
308
+ @hookimpl (tryfirst = True , hookwrapper = True )
309
+ def pytest_runtest_makereport (item , call ):
310
+ outcome = yield
311
+ result = outcome .get_result ()
312
+ setattr (item , "_tmp_path_result_" + result .when , result )
0 commit comments