tmpkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tmpkit/__init__.py +24 -0
- tmpkit/_async.py +293 -0
- tmpkit/_atomic.py +289 -0
- tmpkit/_config.py +32 -0
- tmpkit/_decorators.py +271 -0
- tmpkit/_registry.py +159 -0
- tmpkit/_sync.py +565 -0
- tmpkit/_types.py +42 -0
- tmpkit/py.typed +0 -0
- tmpkit-1.0.0.dist-info/METADATA +506 -0
- tmpkit-1.0.0.dist-info/RECORD +13 -0
- tmpkit-1.0.0.dist-info/WHEEL +4 -0
- tmpkit-1.0.0.dist-info/licenses/LICENSE +21 -0
tmpkit/_sync.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
"""Sync context managers for temporary files and directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from tmpkit._config import _should_keep
|
|
15
|
+
from tmpkit._registry import TempRecord, temp_registry
|
|
16
|
+
from tmpkit._types import StrPath, _validate_prefix_suffix
|
|
17
|
+
|
|
18
|
+
CleanupHook = Callable[[Path], None]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _TempFile:
|
|
22
|
+
"""Sync temp file context manager. Returned by temp_file()."""
|
|
23
|
+
|
|
24
|
+
__slots__ = (
|
|
25
|
+
"_cleanup_hook",
|
|
26
|
+
"_closed",
|
|
27
|
+
"_content",
|
|
28
|
+
"_deleted",
|
|
29
|
+
"_dest",
|
|
30
|
+
"_dir",
|
|
31
|
+
"_exited",
|
|
32
|
+
"_file",
|
|
33
|
+
"_ignore_cleanup_errors",
|
|
34
|
+
"_keep",
|
|
35
|
+
"_keep_on_error",
|
|
36
|
+
"_kept",
|
|
37
|
+
"_mode",
|
|
38
|
+
"_moved",
|
|
39
|
+
"_path",
|
|
40
|
+
"_prefix",
|
|
41
|
+
"_record",
|
|
42
|
+
"_suffix",
|
|
43
|
+
"_user_keep",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
suffix: str | None = None,
|
|
50
|
+
prefix: str | None = None,
|
|
51
|
+
dir: StrPath | None = None,
|
|
52
|
+
mode: str = "w+b",
|
|
53
|
+
content: str | bytes | None = None,
|
|
54
|
+
dest: StrPath | None = None,
|
|
55
|
+
keep: bool = False,
|
|
56
|
+
keep_on_error: bool = False,
|
|
57
|
+
ignore_cleanup_errors: bool = True,
|
|
58
|
+
cleanup_hook: CleanupHook | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._suffix = _validate_prefix_suffix(suffix, "suffix")
|
|
61
|
+
self._prefix = _validate_prefix_suffix(prefix, "prefix")
|
|
62
|
+
self._dir = str(dir) if dir is not None else None
|
|
63
|
+
self._mode = mode
|
|
64
|
+
self._content = content
|
|
65
|
+
self._dest = Path(dest) if dest is not None else None
|
|
66
|
+
self._keep = keep
|
|
67
|
+
self._keep_on_error = keep_on_error
|
|
68
|
+
self._ignore_cleanup_errors = ignore_cleanup_errors
|
|
69
|
+
self._cleanup_hook = cleanup_hook
|
|
70
|
+
self._path: Path | None = None
|
|
71
|
+
self._file: Any = None
|
|
72
|
+
self._closed = False
|
|
73
|
+
self._exited = False
|
|
74
|
+
self._user_keep = False
|
|
75
|
+
self._deleted = False
|
|
76
|
+
self._kept = False
|
|
77
|
+
self._moved = False
|
|
78
|
+
self._record: TempRecord | None = None
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> _TempFile:
|
|
81
|
+
# Reset state for re-entry (context managers can be reused).
|
|
82
|
+
self._closed = False
|
|
83
|
+
self._exited = False
|
|
84
|
+
self._deleted = False
|
|
85
|
+
self._kept = False
|
|
86
|
+
self._moved = False
|
|
87
|
+
self._user_keep = False
|
|
88
|
+
self._record = None
|
|
89
|
+
fd, path_str = tempfile.mkstemp(
|
|
90
|
+
suffix=self._suffix,
|
|
91
|
+
prefix=self._prefix,
|
|
92
|
+
dir=self._dir,
|
|
93
|
+
)
|
|
94
|
+
self._path = Path(path_str)
|
|
95
|
+
try:
|
|
96
|
+
self._file = os.fdopen(fd, mode=self._mode)
|
|
97
|
+
except Exception:
|
|
98
|
+
with contextlib.suppress(OSError):
|
|
99
|
+
os.close(fd)
|
|
100
|
+
with contextlib.suppress(OSError):
|
|
101
|
+
os.unlink(path_str)
|
|
102
|
+
raise
|
|
103
|
+
try:
|
|
104
|
+
if self._content is not None:
|
|
105
|
+
self._validate_content()
|
|
106
|
+
self._file.write(self._content)
|
|
107
|
+
self._file.seek(0)
|
|
108
|
+
except Exception:
|
|
109
|
+
with contextlib.suppress(OSError):
|
|
110
|
+
self._file.close()
|
|
111
|
+
self._closed = True
|
|
112
|
+
with contextlib.suppress(OSError):
|
|
113
|
+
os.unlink(path_str)
|
|
114
|
+
raise
|
|
115
|
+
self._record = temp_registry.register(self._path, "file")
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(
|
|
119
|
+
self,
|
|
120
|
+
exc_type: type[BaseException] | None,
|
|
121
|
+
exc_val: BaseException | None,
|
|
122
|
+
exc_tb: object | None,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._exited = True
|
|
125
|
+
had_error = exc_type is not None
|
|
126
|
+
should_keep = (
|
|
127
|
+
_should_keep(
|
|
128
|
+
keep=self._keep,
|
|
129
|
+
keep_on_error=self._keep_on_error,
|
|
130
|
+
had_error=had_error,
|
|
131
|
+
)
|
|
132
|
+
or self._user_keep
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not self._closed and self._file is not None:
|
|
136
|
+
try:
|
|
137
|
+
self._file.close()
|
|
138
|
+
except OSError:
|
|
139
|
+
self._closed = True
|
|
140
|
+
effective_keep = should_keep or self._keep_on_error
|
|
141
|
+
self._finalize_keep_or_clean(effective_keep)
|
|
142
|
+
raise
|
|
143
|
+
self._closed = True
|
|
144
|
+
|
|
145
|
+
# Call cleanup hook before standard cleanup (even on exception).
|
|
146
|
+
# If the hook fails, capture the error and proceed with cleanup
|
|
147
|
+
# so the temp file never leaks. Re-raise after cleanup if needed.
|
|
148
|
+
hook_error: BaseException | None = None
|
|
149
|
+
if self._cleanup_hook is not None and not should_keep:
|
|
150
|
+
assert self._path is not None
|
|
151
|
+
try:
|
|
152
|
+
self._cleanup_hook(self._path)
|
|
153
|
+
except Exception as e:
|
|
154
|
+
if not self._ignore_cleanup_errors:
|
|
155
|
+
hook_error = e
|
|
156
|
+
|
|
157
|
+
if should_keep:
|
|
158
|
+
self._kept = True
|
|
159
|
+
if self._record is not None:
|
|
160
|
+
temp_registry.mark_kept(self._record)
|
|
161
|
+
elif had_error:
|
|
162
|
+
self._finalize_clean()
|
|
163
|
+
elif self._dest is not None:
|
|
164
|
+
assert self._path is not None
|
|
165
|
+
if self._path == self._dest:
|
|
166
|
+
pass # no-op: dest same as temp
|
|
167
|
+
else:
|
|
168
|
+
try:
|
|
169
|
+
os.replace(self._path, self._dest)
|
|
170
|
+
except OSError as exc:
|
|
171
|
+
if exc.errno == errno.EXDEV:
|
|
172
|
+
try:
|
|
173
|
+
shutil.move(self._path, self._dest)
|
|
174
|
+
except OSError:
|
|
175
|
+
self._finalize_clean()
|
|
176
|
+
raise
|
|
177
|
+
else:
|
|
178
|
+
self._finalize_clean()
|
|
179
|
+
raise
|
|
180
|
+
self._moved = True
|
|
181
|
+
if self._record is not None:
|
|
182
|
+
temp_registry.mark_cleaned(self._record)
|
|
183
|
+
else:
|
|
184
|
+
self._finalize_clean()
|
|
185
|
+
|
|
186
|
+
if hook_error is not None and exc_type is None:
|
|
187
|
+
raise hook_error
|
|
188
|
+
|
|
189
|
+
def _finalize_clean(self) -> None:
|
|
190
|
+
"""Unlink the temp file and mark the registry record as cleaned.
|
|
191
|
+
|
|
192
|
+
If the unlink fails, the record is left active so ``cleanup_all()``
|
|
193
|
+
can retry later — the registry must reflect on-disk reality.
|
|
194
|
+
"""
|
|
195
|
+
assert self._path is not None
|
|
196
|
+
try:
|
|
197
|
+
os.unlink(self._path)
|
|
198
|
+
except OSError:
|
|
199
|
+
if not self._ignore_cleanup_errors:
|
|
200
|
+
raise
|
|
201
|
+
return
|
|
202
|
+
self._deleted = True
|
|
203
|
+
if self._record is not None:
|
|
204
|
+
temp_registry.mark_cleaned(self._record)
|
|
205
|
+
|
|
206
|
+
def _finalize_keep_or_clean(self, effective_keep: bool) -> None:
|
|
207
|
+
"""Mark kept or attempt cleanup depending on ``effective_keep``."""
|
|
208
|
+
if effective_keep:
|
|
209
|
+
if self._record is not None:
|
|
210
|
+
temp_registry.mark_kept(self._record)
|
|
211
|
+
else:
|
|
212
|
+
self._finalize_clean()
|
|
213
|
+
|
|
214
|
+
def read(self, size: int = -1) -> bytes | str:
|
|
215
|
+
if self._closed:
|
|
216
|
+
raise ValueError("I/O operation on closed file.")
|
|
217
|
+
assert self._file is not None
|
|
218
|
+
return self._file.read(size) # type: ignore[no-any-return]
|
|
219
|
+
|
|
220
|
+
def write(self, data: bytes | str) -> int:
|
|
221
|
+
if self._closed:
|
|
222
|
+
raise ValueError("I/O operation on closed file.")
|
|
223
|
+
assert self._file is not None
|
|
224
|
+
return self._file.write(data) # type: ignore[no-any-return]
|
|
225
|
+
|
|
226
|
+
def seek(self, offset: int, whence: int = 0) -> int:
|
|
227
|
+
if self._closed:
|
|
228
|
+
raise ValueError("I/O operation on closed file.")
|
|
229
|
+
assert self._file is not None
|
|
230
|
+
return self._file.seek(offset, whence) # type: ignore[no-any-return]
|
|
231
|
+
|
|
232
|
+
def tell(self) -> int:
|
|
233
|
+
if self._closed:
|
|
234
|
+
raise ValueError("I/O operation on closed file.")
|
|
235
|
+
assert self._file is not None
|
|
236
|
+
return self._file.tell() # type: ignore[no-any-return]
|
|
237
|
+
|
|
238
|
+
def flush(self) -> None:
|
|
239
|
+
if self._closed or self._file is None:
|
|
240
|
+
return
|
|
241
|
+
self._file.flush()
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
if self._closed:
|
|
245
|
+
return
|
|
246
|
+
if self._file is not None:
|
|
247
|
+
self._file.close()
|
|
248
|
+
self._closed = True
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def path(self) -> Path:
|
|
252
|
+
assert self._path is not None
|
|
253
|
+
return self._path
|
|
254
|
+
|
|
255
|
+
def _validate_content(self) -> None:
|
|
256
|
+
"""Validate content type matches mode."""
|
|
257
|
+
assert self._content is not None
|
|
258
|
+
is_text_mode = "b" not in self._mode
|
|
259
|
+
if is_text_mode and isinstance(self._content, bytes):
|
|
260
|
+
raise TypeError("content is bytes but mode is text (no 'b' in mode).")
|
|
261
|
+
if not is_text_mode and isinstance(self._content, str):
|
|
262
|
+
raise TypeError("content is str but mode is binary (contains 'b').")
|
|
263
|
+
|
|
264
|
+
def keep(self) -> None:
|
|
265
|
+
if self._file is None:
|
|
266
|
+
raise RuntimeError("Cannot call .keep() before __enter__.")
|
|
267
|
+
if self._exited:
|
|
268
|
+
raise RuntimeError("Cannot call .keep() after __exit__.")
|
|
269
|
+
self._user_keep = True
|
|
270
|
+
|
|
271
|
+
def __fspath__(self) -> str:
|
|
272
|
+
assert self._path is not None
|
|
273
|
+
return str(self._path)
|
|
274
|
+
|
|
275
|
+
def __repr__(self) -> str:
|
|
276
|
+
if self._path is None:
|
|
277
|
+
return "TempFile(path=None, state=pending)"
|
|
278
|
+
if self._moved:
|
|
279
|
+
state = "moved"
|
|
280
|
+
elif self._deleted:
|
|
281
|
+
state = "deleted"
|
|
282
|
+
elif self._kept:
|
|
283
|
+
state = "kept"
|
|
284
|
+
elif self._closed:
|
|
285
|
+
state = "closed"
|
|
286
|
+
else:
|
|
287
|
+
state = "open"
|
|
288
|
+
return f"TempFile(path={self._path!r}, {state=})"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def temp_file(
|
|
292
|
+
*,
|
|
293
|
+
suffix: str | None = None,
|
|
294
|
+
prefix: str | None = None,
|
|
295
|
+
dir: StrPath | None = None,
|
|
296
|
+
mode: str = "w+b",
|
|
297
|
+
content: str | bytes | None = None,
|
|
298
|
+
dest: StrPath | None = None,
|
|
299
|
+
keep: bool = False,
|
|
300
|
+
keep_on_error: bool = False,
|
|
301
|
+
ignore_cleanup_errors: bool = True,
|
|
302
|
+
cleanup_hook: CleanupHook | None = None,
|
|
303
|
+
) -> _TempFile:
|
|
304
|
+
"""Create a temporary file context manager.
|
|
305
|
+
|
|
306
|
+
The file is created on ``__enter__`` and deleted on ``__exit__``
|
|
307
|
+
(unless keep conditions are met).
|
|
308
|
+
|
|
309
|
+
If ``dest`` is set and the context exits without error, the temp file
|
|
310
|
+
is atomically moved to ``dest`` via ``os.replace`` (or ``shutil.move``
|
|
311
|
+
for cross-filesystem moves). On error, the temp is deleted and ``dest``
|
|
312
|
+
is left untouched.
|
|
313
|
+
|
|
314
|
+
Precedence: ``.keep()`` > ``keep=True`` > ``DEBUG=1`` > ``dest=`` move.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
suffix: File name suffix (e.g. ``".csv"``).
|
|
318
|
+
prefix: File name prefix (e.g. ``"myapp_"``).
|
|
319
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
320
|
+
mode: Open mode passed to ``os.fdopen``. Defaults to ``"w+b"``.
|
|
321
|
+
content: Pre-populate file with this content. ``str`` for text modes, ``bytes`` for binary.
|
|
322
|
+
dest: Destination path. On success, temp is moved here.
|
|
323
|
+
keep: If ``True``, file is NOT deleted on context exit.
|
|
324
|
+
keep_on_error: If ``True``, file is kept only when an exception propagates.
|
|
325
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
326
|
+
cleanup_hook: Optional callable invoked with the temp path before standard cleanup.
|
|
327
|
+
Called only when the temp is being deleted (not when kept). Hook errors are
|
|
328
|
+
ignored if ``ignore_cleanup_errors=True``; otherwise they propagate after
|
|
329
|
+
cleanup, with body exceptions taking precedence.
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
A ``_TempFile`` context manager.
|
|
333
|
+
"""
|
|
334
|
+
return _TempFile(
|
|
335
|
+
suffix=suffix,
|
|
336
|
+
prefix=prefix,
|
|
337
|
+
dir=dir,
|
|
338
|
+
mode=mode,
|
|
339
|
+
content=content,
|
|
340
|
+
dest=dest,
|
|
341
|
+
keep=keep,
|
|
342
|
+
keep_on_error=keep_on_error,
|
|
343
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
344
|
+
cleanup_hook=cleanup_hook,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class _TempDir:
|
|
349
|
+
"""Sync temp dir context manager. Returned by temp_dir()."""
|
|
350
|
+
|
|
351
|
+
__slots__ = (
|
|
352
|
+
"_cleanup_hook",
|
|
353
|
+
"_cwd",
|
|
354
|
+
"_cwd_ctx",
|
|
355
|
+
"_deleted",
|
|
356
|
+
"_dir",
|
|
357
|
+
"_exited",
|
|
358
|
+
"_ignore_cleanup_errors",
|
|
359
|
+
"_keep",
|
|
360
|
+
"_keep_on_error",
|
|
361
|
+
"_kept",
|
|
362
|
+
"_path",
|
|
363
|
+
"_prefix",
|
|
364
|
+
"_record",
|
|
365
|
+
"_suffix",
|
|
366
|
+
"_user_keep",
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def __init__(
|
|
370
|
+
self,
|
|
371
|
+
*,
|
|
372
|
+
suffix: str | None = None,
|
|
373
|
+
prefix: str | None = None,
|
|
374
|
+
dir: StrPath | None = None,
|
|
375
|
+
cwd: bool = False,
|
|
376
|
+
keep: bool = False,
|
|
377
|
+
keep_on_error: bool = False,
|
|
378
|
+
ignore_cleanup_errors: bool = True,
|
|
379
|
+
cleanup_hook: CleanupHook | None = None,
|
|
380
|
+
) -> None:
|
|
381
|
+
self._suffix = _validate_prefix_suffix(suffix, "suffix")
|
|
382
|
+
self._prefix = _validate_prefix_suffix(prefix, "prefix")
|
|
383
|
+
self._dir = str(dir) if dir is not None else None
|
|
384
|
+
self._cwd = cwd
|
|
385
|
+
self._cwd_ctx: Any = None
|
|
386
|
+
self._keep = keep
|
|
387
|
+
self._keep_on_error = keep_on_error
|
|
388
|
+
self._ignore_cleanup_errors = ignore_cleanup_errors
|
|
389
|
+
self._cleanup_hook = cleanup_hook
|
|
390
|
+
self._path: Path | None = None
|
|
391
|
+
self._user_keep = False
|
|
392
|
+
self._exited = False
|
|
393
|
+
self._deleted = False
|
|
394
|
+
self._kept = False
|
|
395
|
+
self._record: TempRecord | None = None
|
|
396
|
+
|
|
397
|
+
def __enter__(self) -> Path:
|
|
398
|
+
# Reset state for re-entry (context managers can be reused).
|
|
399
|
+
self._exited = False
|
|
400
|
+
self._deleted = False
|
|
401
|
+
self._kept = False
|
|
402
|
+
self._user_keep = False
|
|
403
|
+
self._record = None
|
|
404
|
+
self._cwd_ctx = None
|
|
405
|
+
path_str = tempfile.mkdtemp(
|
|
406
|
+
suffix=self._suffix,
|
|
407
|
+
prefix=self._prefix,
|
|
408
|
+
dir=self._dir,
|
|
409
|
+
)
|
|
410
|
+
self._path = Path(path_str)
|
|
411
|
+
if self._cwd:
|
|
412
|
+
try:
|
|
413
|
+
self._cwd_ctx = contextlib.chdir(self._path)
|
|
414
|
+
self._cwd_ctx.__enter__()
|
|
415
|
+
except Exception:
|
|
416
|
+
with contextlib.suppress(OSError):
|
|
417
|
+
shutil.rmtree(self._path)
|
|
418
|
+
self._path = None
|
|
419
|
+
raise
|
|
420
|
+
self._record = temp_registry.register(self._path, "dir")
|
|
421
|
+
return self._path
|
|
422
|
+
|
|
423
|
+
def __exit__(
|
|
424
|
+
self,
|
|
425
|
+
exc_type: type[BaseException] | None,
|
|
426
|
+
exc_val: BaseException | None,
|
|
427
|
+
exc_tb: object | None,
|
|
428
|
+
) -> None:
|
|
429
|
+
self._exited = True
|
|
430
|
+
cwd_error: OSError | None = None
|
|
431
|
+
if self._cwd_ctx is not None:
|
|
432
|
+
try:
|
|
433
|
+
self._cwd_ctx.__exit__(exc_type, exc_val, exc_tb)
|
|
434
|
+
except OSError as e:
|
|
435
|
+
cwd_error = e
|
|
436
|
+
# Best-effort: chdir to system temp so rmtree can succeed
|
|
437
|
+
# (can't delete cwd on Windows).
|
|
438
|
+
with contextlib.suppress(OSError):
|
|
439
|
+
os.chdir(tempfile.gettempdir())
|
|
440
|
+
|
|
441
|
+
had_error = exc_type is not None
|
|
442
|
+
should_keep = (
|
|
443
|
+
_should_keep(
|
|
444
|
+
keep=self._keep,
|
|
445
|
+
keep_on_error=self._keep_on_error,
|
|
446
|
+
had_error=had_error,
|
|
447
|
+
)
|
|
448
|
+
or self._user_keep
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Call cleanup hook before standard cleanup (even on exception).
|
|
452
|
+
# If the hook fails, capture the error and proceed with cleanup
|
|
453
|
+
# so the temp dir never leaks. Re-raise after cleanup if needed.
|
|
454
|
+
hook_error: BaseException | None = None
|
|
455
|
+
if self._cleanup_hook is not None and not should_keep:
|
|
456
|
+
assert self._path is not None
|
|
457
|
+
try:
|
|
458
|
+
self._cleanup_hook(self._path)
|
|
459
|
+
except Exception as e:
|
|
460
|
+
if not self._ignore_cleanup_errors:
|
|
461
|
+
hook_error = e
|
|
462
|
+
|
|
463
|
+
if should_keep:
|
|
464
|
+
self._kept = True
|
|
465
|
+
if self._record is not None:
|
|
466
|
+
temp_registry.mark_kept(self._record)
|
|
467
|
+
else:
|
|
468
|
+
self._finalize_clean()
|
|
469
|
+
|
|
470
|
+
if cwd_error is not None and exc_type is None:
|
|
471
|
+
raise cwd_error
|
|
472
|
+
if hook_error is not None and exc_type is None:
|
|
473
|
+
raise hook_error
|
|
474
|
+
|
|
475
|
+
def _finalize_clean(self) -> None:
|
|
476
|
+
"""Remove the temp dir and mark the registry record as cleaned.
|
|
477
|
+
|
|
478
|
+
If rmtree fails, the record is left active so ``cleanup_all()``
|
|
479
|
+
can retry later — the registry must reflect on-disk reality.
|
|
480
|
+
"""
|
|
481
|
+
assert self._path is not None
|
|
482
|
+
try:
|
|
483
|
+
shutil.rmtree(self._path)
|
|
484
|
+
except OSError:
|
|
485
|
+
if not self._ignore_cleanup_errors:
|
|
486
|
+
raise
|
|
487
|
+
return
|
|
488
|
+
self._deleted = True
|
|
489
|
+
if self._record is not None:
|
|
490
|
+
temp_registry.mark_cleaned(self._record)
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def path(self) -> Path:
|
|
494
|
+
assert self._path is not None
|
|
495
|
+
return self._path
|
|
496
|
+
|
|
497
|
+
def keep(self) -> None:
|
|
498
|
+
if self._path is None:
|
|
499
|
+
raise RuntimeError("Cannot call .keep() before __enter__.")
|
|
500
|
+
if self._exited:
|
|
501
|
+
raise RuntimeError("Cannot call .keep() after __exit__.")
|
|
502
|
+
self._user_keep = True
|
|
503
|
+
|
|
504
|
+
def __fspath__(self) -> str:
|
|
505
|
+
assert self._path is not None
|
|
506
|
+
return str(self._path)
|
|
507
|
+
|
|
508
|
+
def __truediv__(self, other: str | Path) -> Path:
|
|
509
|
+
assert self._path is not None
|
|
510
|
+
return self._path / other
|
|
511
|
+
|
|
512
|
+
def __repr__(self) -> str:
|
|
513
|
+
if self._path is None:
|
|
514
|
+
return "TempDir(path=None, state=pending)"
|
|
515
|
+
if self._deleted:
|
|
516
|
+
state = "deleted"
|
|
517
|
+
elif self._kept:
|
|
518
|
+
state = "kept"
|
|
519
|
+
else:
|
|
520
|
+
state = "active"
|
|
521
|
+
return f"TempDir(path={self._path!r}, {state=})"
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def temp_dir(
|
|
525
|
+
*,
|
|
526
|
+
suffix: str | None = None,
|
|
527
|
+
prefix: str | None = None,
|
|
528
|
+
dir: StrPath | None = None,
|
|
529
|
+
cwd: bool = False,
|
|
530
|
+
keep: bool = False,
|
|
531
|
+
keep_on_error: bool = False,
|
|
532
|
+
ignore_cleanup_errors: bool = True,
|
|
533
|
+
cleanup_hook: CleanupHook | None = None,
|
|
534
|
+
) -> _TempDir:
|
|
535
|
+
"""Create a temporary directory context manager.
|
|
536
|
+
|
|
537
|
+
The directory is created on ``__enter__`` and removed on ``__exit__``
|
|
538
|
+
(unless keep conditions are met).
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
suffix: Directory name suffix.
|
|
542
|
+
prefix: Directory name prefix.
|
|
543
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
544
|
+
cwd: If ``True``, changes working directory to temp dir on ``__enter__``, restores on ``__exit__``.
|
|
545
|
+
keep: If ``True``, directory is NOT removed on context exit.
|
|
546
|
+
keep_on_error: If ``True``, directory is kept only when an exception propagates.
|
|
547
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
548
|
+
cleanup_hook: Optional callable invoked with the temp path before standard cleanup.
|
|
549
|
+
Called only when the temp is being deleted (not when kept). Hook errors are
|
|
550
|
+
ignored if ``ignore_cleanup_errors=True``; otherwise they propagate after
|
|
551
|
+
cleanup, with body exceptions taking precedence.
|
|
552
|
+
|
|
553
|
+
Returns:
|
|
554
|
+
A ``_TempDir`` context manager.
|
|
555
|
+
"""
|
|
556
|
+
return _TempDir(
|
|
557
|
+
suffix=suffix,
|
|
558
|
+
prefix=prefix,
|
|
559
|
+
dir=dir,
|
|
560
|
+
cwd=cwd,
|
|
561
|
+
keep=keep,
|
|
562
|
+
keep_on_error=keep_on_error,
|
|
563
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
564
|
+
cleanup_hook=cleanup_hook,
|
|
565
|
+
)
|
tmpkit/_types.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Type system: shared type aliases, protocols, and input validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol, TypeAlias, runtime_checkable
|
|
8
|
+
|
|
9
|
+
StrPath: TypeAlias = str | Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _validate_prefix_suffix(value: str | None, name: str) -> str | None:
|
|
13
|
+
"""Validate that prefix/suffix doesn't contain path separators.
|
|
14
|
+
|
|
15
|
+
Python's ``tempfile.mkstemp``/``mkdtemp`` concatenate prefix and suffix
|
|
16
|
+
into the filename within ``dir``. If they contain path separators or are
|
|
17
|
+
absolute paths, the resulting temp file can escape the intended directory.
|
|
18
|
+
"""
|
|
19
|
+
if value is None:
|
|
20
|
+
return None
|
|
21
|
+
if not value:
|
|
22
|
+
return value
|
|
23
|
+
if os.path.isabs(value) or os.sep in value or "/" in value or "\\" in value:
|
|
24
|
+
raise ValueError(
|
|
25
|
+
f"{name} must not contain path separators or be an absolute path: {value!r}"
|
|
26
|
+
)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class TempFileLike(Protocol):
|
|
32
|
+
"""Protocol for temp file objects returned by temp_file()."""
|
|
33
|
+
|
|
34
|
+
path: Path
|
|
35
|
+
|
|
36
|
+
def read(self, size: int = -1, /) -> bytes | str: ...
|
|
37
|
+
def write(self, data: bytes | str, /) -> int: ...
|
|
38
|
+
def close(self) -> None: ...
|
|
39
|
+
def seek(self, offset: int, whence: int = 0) -> int: ...
|
|
40
|
+
def tell(self) -> int: ...
|
|
41
|
+
def flush(self) -> None: ...
|
|
42
|
+
def keep(self) -> None: ...
|
tmpkit/py.typed
ADDED
|
File without changes
|