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/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""tmpkit — Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes, async support, and zero dependencies."""
|
|
2
|
+
|
|
3
|
+
from tmpkit._async import atomic_write as async_atomic_write
|
|
4
|
+
from tmpkit._async import temp_dir as async_temp_dir
|
|
5
|
+
from tmpkit._async import temp_file as async_temp_file
|
|
6
|
+
from tmpkit._atomic import atomic_write
|
|
7
|
+
from tmpkit._decorators import temp_dir as temp_dir_decorator
|
|
8
|
+
from tmpkit._decorators import temp_file as temp_file_decorator
|
|
9
|
+
from tmpkit._registry import temp_registry
|
|
10
|
+
from tmpkit._sync import temp_dir, temp_file
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"async_atomic_write",
|
|
16
|
+
"async_temp_dir",
|
|
17
|
+
"async_temp_file",
|
|
18
|
+
"atomic_write",
|
|
19
|
+
"temp_dir",
|
|
20
|
+
"temp_dir_decorator",
|
|
21
|
+
"temp_file",
|
|
22
|
+
"temp_file_decorator",
|
|
23
|
+
"temp_registry",
|
|
24
|
+
]
|
tmpkit/_async.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Async context managers for temporary files and directories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tmpkit._atomic import _AtomicWriter
|
|
9
|
+
from tmpkit._sync import CleanupHook, _TempDir, _TempFile
|
|
10
|
+
from tmpkit._types import StrPath
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _AsyncTempFile:
|
|
14
|
+
"""Async temp file context manager. Returned by temp_file()."""
|
|
15
|
+
|
|
16
|
+
__slots__ = ("_sync",)
|
|
17
|
+
|
|
18
|
+
def __init__(self, sync: _TempFile) -> None:
|
|
19
|
+
self._sync = sync
|
|
20
|
+
|
|
21
|
+
async def __aenter__(self) -> _AsyncTempFile:
|
|
22
|
+
await asyncio.to_thread(self._sync.__enter__)
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
async def __aexit__(
|
|
26
|
+
self,
|
|
27
|
+
exc_type: type[BaseException] | None,
|
|
28
|
+
exc_val: BaseException | None,
|
|
29
|
+
exc_tb: object | None,
|
|
30
|
+
) -> None:
|
|
31
|
+
await asyncio.to_thread(self._sync.__exit__, exc_type, exc_val, exc_tb)
|
|
32
|
+
|
|
33
|
+
async def read(self, size: int = -1) -> bytes | str:
|
|
34
|
+
return await asyncio.to_thread(self._sync.read, size)
|
|
35
|
+
|
|
36
|
+
async def write(self, data: bytes | str) -> int:
|
|
37
|
+
return await asyncio.to_thread(self._sync.write, data)
|
|
38
|
+
|
|
39
|
+
async def seek(self, offset: int, whence: int = 0) -> int:
|
|
40
|
+
return await asyncio.to_thread(self._sync.seek, offset, whence)
|
|
41
|
+
|
|
42
|
+
async def tell(self) -> int:
|
|
43
|
+
return await asyncio.to_thread(self._sync.tell)
|
|
44
|
+
|
|
45
|
+
async def flush(self) -> None:
|
|
46
|
+
await asyncio.to_thread(self._sync.flush)
|
|
47
|
+
|
|
48
|
+
async def close(self) -> None:
|
|
49
|
+
await asyncio.to_thread(self._sync.close)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def path(self) -> Path:
|
|
53
|
+
return self._sync.path
|
|
54
|
+
|
|
55
|
+
def keep(self) -> None:
|
|
56
|
+
self._sync.keep()
|
|
57
|
+
|
|
58
|
+
def __fspath__(self) -> str:
|
|
59
|
+
return self._sync.__fspath__()
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"Async{self._sync!r}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def temp_file(
|
|
66
|
+
*,
|
|
67
|
+
suffix: str | None = None,
|
|
68
|
+
prefix: str | None = None,
|
|
69
|
+
dir: StrPath | None = None,
|
|
70
|
+
mode: str = "w+b",
|
|
71
|
+
content: str | bytes | None = None,
|
|
72
|
+
dest: StrPath | None = None,
|
|
73
|
+
keep: bool = False,
|
|
74
|
+
keep_on_error: bool = False,
|
|
75
|
+
ignore_cleanup_errors: bool = True,
|
|
76
|
+
cleanup_hook: CleanupHook | None = None,
|
|
77
|
+
) -> _AsyncTempFile:
|
|
78
|
+
"""Create an async temporary file context manager.
|
|
79
|
+
|
|
80
|
+
The file is created on ``__aenter__`` and deleted on ``__aexit__``
|
|
81
|
+
(unless keep conditions are met).
|
|
82
|
+
|
|
83
|
+
If ``dest`` is set and the context exits without error, the temp file
|
|
84
|
+
is moved to ``dest`` (via ``os.replace`` or ``shutil.move`` for cross-FS).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
suffix: File name suffix (e.g. ``".csv"``).
|
|
88
|
+
prefix: File name prefix (e.g. ``"myapp_"``).
|
|
89
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
90
|
+
mode: Open mode passed to ``os.fdopen``. Defaults to ``"w+b"``.
|
|
91
|
+
content: Pre-populate file with this content. ``str`` for text modes, ``bytes`` for binary.
|
|
92
|
+
dest: Destination path. On success, temp is moved here.
|
|
93
|
+
keep: If ``True``, file is NOT deleted on context exit.
|
|
94
|
+
keep_on_error: If ``True``, file is kept only when an exception propagates.
|
|
95
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
96
|
+
cleanup_hook: Optional callable invoked with the temp path before standard cleanup.
|
|
97
|
+
Called only when the temp is being deleted (not when kept). Hook errors are
|
|
98
|
+
ignored if ``ignore_cleanup_errors=True``; otherwise they propagate after
|
|
99
|
+
cleanup, with body exceptions taking precedence.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
An ``_AsyncTempFile`` context manager.
|
|
103
|
+
"""
|
|
104
|
+
return _AsyncTempFile(
|
|
105
|
+
_TempFile(
|
|
106
|
+
suffix=suffix,
|
|
107
|
+
prefix=prefix,
|
|
108
|
+
dir=dir,
|
|
109
|
+
mode=mode,
|
|
110
|
+
content=content,
|
|
111
|
+
dest=dest,
|
|
112
|
+
keep=keep,
|
|
113
|
+
keep_on_error=keep_on_error,
|
|
114
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
115
|
+
cleanup_hook=cleanup_hook,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class _AsyncTempDir:
|
|
121
|
+
"""Async temp dir context manager. Returned by temp_dir()."""
|
|
122
|
+
|
|
123
|
+
__slots__ = ("_sync",)
|
|
124
|
+
|
|
125
|
+
def __init__(self, sync: _TempDir) -> None:
|
|
126
|
+
self._sync = sync
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self) -> Path:
|
|
129
|
+
return await asyncio.to_thread(self._sync.__enter__)
|
|
130
|
+
|
|
131
|
+
async def __aexit__(
|
|
132
|
+
self,
|
|
133
|
+
exc_type: type[BaseException] | None,
|
|
134
|
+
exc_val: BaseException | None,
|
|
135
|
+
exc_tb: object | None,
|
|
136
|
+
) -> None:
|
|
137
|
+
await asyncio.to_thread(self._sync.__exit__, exc_type, exc_val, exc_tb)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def path(self) -> Path:
|
|
141
|
+
return self._sync.path
|
|
142
|
+
|
|
143
|
+
def keep(self) -> None:
|
|
144
|
+
self._sync.keep()
|
|
145
|
+
|
|
146
|
+
def __fspath__(self) -> str:
|
|
147
|
+
return self._sync.__fspath__()
|
|
148
|
+
|
|
149
|
+
def __truediv__(self, other: str | Path) -> Path:
|
|
150
|
+
return self._sync.__truediv__(other)
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return f"Async{self._sync!r}"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def temp_dir(
|
|
157
|
+
*,
|
|
158
|
+
suffix: str | None = None,
|
|
159
|
+
prefix: str | None = None,
|
|
160
|
+
dir: StrPath | None = None,
|
|
161
|
+
cwd: bool = False,
|
|
162
|
+
keep: bool = False,
|
|
163
|
+
keep_on_error: bool = False,
|
|
164
|
+
ignore_cleanup_errors: bool = True,
|
|
165
|
+
cleanup_hook: CleanupHook | None = None,
|
|
166
|
+
) -> _AsyncTempDir:
|
|
167
|
+
"""Create an async temporary directory context manager.
|
|
168
|
+
|
|
169
|
+
The directory is created on ``__aenter__`` and removed on ``__aexit__``
|
|
170
|
+
(unless keep conditions are met).
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
suffix: Directory name suffix.
|
|
174
|
+
prefix: Directory name prefix.
|
|
175
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
176
|
+
cwd: If ``True``, changes working directory to temp dir on ``__aenter__``, restores on ``__aexit__``.
|
|
177
|
+
keep: If ``True``, directory is NOT removed on context exit.
|
|
178
|
+
keep_on_error: If ``True``, directory is kept only when an exception propagates.
|
|
179
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
180
|
+
cleanup_hook: Optional callable invoked with the temp path before standard cleanup.
|
|
181
|
+
Called only when the temp is being deleted (not when kept). Hook errors are
|
|
182
|
+
ignored if ``ignore_cleanup_errors=True``; otherwise they propagate after
|
|
183
|
+
cleanup, with body exceptions taking precedence.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
An ``_AsyncTempDir`` context manager.
|
|
187
|
+
"""
|
|
188
|
+
return _AsyncTempDir(
|
|
189
|
+
_TempDir(
|
|
190
|
+
suffix=suffix,
|
|
191
|
+
prefix=prefix,
|
|
192
|
+
dir=dir,
|
|
193
|
+
cwd=cwd,
|
|
194
|
+
keep=keep,
|
|
195
|
+
keep_on_error=keep_on_error,
|
|
196
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
197
|
+
cleanup_hook=cleanup_hook,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class _AsyncAtomicWriter:
|
|
203
|
+
"""Async atomic file writer. Wraps ``_AtomicWriter`` with async I/O."""
|
|
204
|
+
|
|
205
|
+
__slots__ = ("_sync",)
|
|
206
|
+
|
|
207
|
+
def __init__(self, sync: _AtomicWriter) -> None:
|
|
208
|
+
self._sync = sync
|
|
209
|
+
|
|
210
|
+
async def __aenter__(self) -> _AsyncAtomicWriter:
|
|
211
|
+
await asyncio.to_thread(self._sync.__enter__)
|
|
212
|
+
return self
|
|
213
|
+
|
|
214
|
+
async def __aexit__(
|
|
215
|
+
self,
|
|
216
|
+
exc_type: type[BaseException] | None,
|
|
217
|
+
exc_val: BaseException | None,
|
|
218
|
+
exc_tb: object | None,
|
|
219
|
+
) -> None:
|
|
220
|
+
await asyncio.to_thread(self._sync.__exit__, exc_type, exc_val, exc_tb)
|
|
221
|
+
|
|
222
|
+
async def write(self, data: bytes | str) -> int:
|
|
223
|
+
return await asyncio.to_thread(self._sync.write, data)
|
|
224
|
+
|
|
225
|
+
async def flush(self) -> None:
|
|
226
|
+
await asyncio.to_thread(self._sync.flush)
|
|
227
|
+
|
|
228
|
+
async def close(self) -> None:
|
|
229
|
+
await asyncio.to_thread(self._sync.close)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def path(self) -> Path:
|
|
233
|
+
return self._sync.path
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def dest(self) -> Path:
|
|
237
|
+
return self._sync.dest
|
|
238
|
+
|
|
239
|
+
def keep(self) -> None:
|
|
240
|
+
self._sync.keep()
|
|
241
|
+
|
|
242
|
+
def __fspath__(self) -> str:
|
|
243
|
+
return self._sync.__fspath__()
|
|
244
|
+
|
|
245
|
+
def __repr__(self) -> str:
|
|
246
|
+
return f"Async{self._sync!r}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def atomic_write(
|
|
250
|
+
dest: StrPath,
|
|
251
|
+
*,
|
|
252
|
+
mode: str = "w",
|
|
253
|
+
encoding: str | None = None,
|
|
254
|
+
newline: str | None = None,
|
|
255
|
+
prefix: str | None = None,
|
|
256
|
+
suffix: str = ".tmp",
|
|
257
|
+
fsync: bool = True,
|
|
258
|
+
keep_on_error: bool = False,
|
|
259
|
+
ignore_cleanup_errors: bool = True,
|
|
260
|
+
) -> _AsyncAtomicWriter:
|
|
261
|
+
"""Create an async atomic file writer context manager.
|
|
262
|
+
|
|
263
|
+
Writes to a temp file in ``dest.parent``, then atomically replaces
|
|
264
|
+
``dest`` via ``os.replace()`` on success. On error, the temp file is
|
|
265
|
+
deleted and ``dest`` is left untouched.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
dest: Final destination path.
|
|
269
|
+
mode: Open mode (e.g. ``"w"`` for text, ``"wb"`` for binary).
|
|
270
|
+
encoding: Text encoding (text modes only).
|
|
271
|
+
newline: Newline parameter (text modes only).
|
|
272
|
+
prefix: Temp file name prefix.
|
|
273
|
+
suffix: Temp file name suffix. Defaults to ``".tmp"``.
|
|
274
|
+
fsync: If ``True``, call ``os.fsync()`` before closing.
|
|
275
|
+
keep_on_error: If ``True``, keep temp file on exception (don't delete).
|
|
276
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
An ``_AsyncAtomicWriter`` context manager.
|
|
280
|
+
"""
|
|
281
|
+
return _AsyncAtomicWriter(
|
|
282
|
+
_AtomicWriter(
|
|
283
|
+
dest,
|
|
284
|
+
mode=mode,
|
|
285
|
+
encoding=encoding,
|
|
286
|
+
newline=newline,
|
|
287
|
+
prefix=prefix,
|
|
288
|
+
suffix=suffix,
|
|
289
|
+
fsync=fsync,
|
|
290
|
+
keep_on_error=keep_on_error,
|
|
291
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
292
|
+
)
|
|
293
|
+
)
|
tmpkit/_atomic.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Atomic file writing via temp file + os.replace()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import errno
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import stat
|
|
10
|
+
import tempfile
|
|
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
|
+
|
|
19
|
+
class _AtomicWriter:
|
|
20
|
+
"""Atomic file writer. Writes to a temp file, then atomically replaces dest on success."""
|
|
21
|
+
|
|
22
|
+
__slots__ = (
|
|
23
|
+
"_closed",
|
|
24
|
+
"_dest",
|
|
25
|
+
"_encoding",
|
|
26
|
+
"_exited",
|
|
27
|
+
"_file",
|
|
28
|
+
"_fsync",
|
|
29
|
+
"_ignore_cleanup_errors",
|
|
30
|
+
"_keep_on_error",
|
|
31
|
+
"_mode",
|
|
32
|
+
"_newlines",
|
|
33
|
+
"_path",
|
|
34
|
+
"_prefix",
|
|
35
|
+
"_record",
|
|
36
|
+
"_suffix",
|
|
37
|
+
"_user_keep",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
dest: StrPath,
|
|
43
|
+
*,
|
|
44
|
+
mode: str = "w",
|
|
45
|
+
encoding: str | None = None,
|
|
46
|
+
newline: str | None = None,
|
|
47
|
+
prefix: str | None = None,
|
|
48
|
+
suffix: str = ".tmp",
|
|
49
|
+
fsync: bool = True,
|
|
50
|
+
keep_on_error: bool = False,
|
|
51
|
+
ignore_cleanup_errors: bool = True,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._dest = Path(dest)
|
|
54
|
+
self._mode = mode
|
|
55
|
+
self._encoding = encoding
|
|
56
|
+
self._newlines = newline
|
|
57
|
+
self._prefix = _validate_prefix_suffix(prefix, "prefix")
|
|
58
|
+
validated_suffix = _validate_prefix_suffix(suffix, "suffix")
|
|
59
|
+
self._suffix = validated_suffix if validated_suffix is not None else ".tmp"
|
|
60
|
+
self._fsync = fsync
|
|
61
|
+
self._keep_on_error = keep_on_error
|
|
62
|
+
self._ignore_cleanup_errors = ignore_cleanup_errors
|
|
63
|
+
self._path: Path | None = None
|
|
64
|
+
self._file: Any = None
|
|
65
|
+
self._closed = False
|
|
66
|
+
self._exited = False
|
|
67
|
+
self._user_keep = False
|
|
68
|
+
self._record: TempRecord | None = None
|
|
69
|
+
|
|
70
|
+
def __enter__(self) -> _AtomicWriter:
|
|
71
|
+
# Reset state for re-entry (context managers can be reused).
|
|
72
|
+
self._closed = False
|
|
73
|
+
self._exited = False
|
|
74
|
+
self._user_keep = False
|
|
75
|
+
self._record = None
|
|
76
|
+
dest_parent = self._dest.parent
|
|
77
|
+
try:
|
|
78
|
+
stat_result = os.stat(dest_parent)
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
raise FileNotFoundError(
|
|
81
|
+
f"Destination parent does not exist: {dest_parent}"
|
|
82
|
+
) from None
|
|
83
|
+
if not stat.S_ISDIR(stat_result.st_mode):
|
|
84
|
+
raise NotADirectoryError(
|
|
85
|
+
f"Destination parent is not a directory: {dest_parent}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
fd, path_str = tempfile.mkstemp(
|
|
89
|
+
suffix=self._suffix,
|
|
90
|
+
prefix=self._prefix,
|
|
91
|
+
dir=str(dest_parent),
|
|
92
|
+
)
|
|
93
|
+
self._path = Path(path_str)
|
|
94
|
+
|
|
95
|
+
open_kwargs: dict[str, Any] = {}
|
|
96
|
+
if "b" not in self._mode:
|
|
97
|
+
if self._encoding is not None:
|
|
98
|
+
open_kwargs["encoding"] = self._encoding
|
|
99
|
+
if self._newlines is not None:
|
|
100
|
+
open_kwargs["newline"] = self._newlines
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
self._file = os.fdopen(fd, mode=self._mode, **open_kwargs)
|
|
104
|
+
except Exception:
|
|
105
|
+
with contextlib.suppress(OSError):
|
|
106
|
+
os.close(fd)
|
|
107
|
+
with contextlib.suppress(OSError):
|
|
108
|
+
os.unlink(path_str)
|
|
109
|
+
raise
|
|
110
|
+
self._record = temp_registry.register(self._path, "file")
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(
|
|
114
|
+
self,
|
|
115
|
+
exc_type: type[BaseException] | None,
|
|
116
|
+
exc_val: BaseException | None,
|
|
117
|
+
exc_tb: object | None,
|
|
118
|
+
) -> None:
|
|
119
|
+
self._exited = True
|
|
120
|
+
had_error = exc_type is not None
|
|
121
|
+
should_keep = (
|
|
122
|
+
_should_keep(
|
|
123
|
+
keep=False,
|
|
124
|
+
keep_on_error=self._keep_on_error,
|
|
125
|
+
had_error=had_error,
|
|
126
|
+
)
|
|
127
|
+
or self._user_keep
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Close file: flush + fsync + close
|
|
131
|
+
if not self._closed and self._file is not None:
|
|
132
|
+
try:
|
|
133
|
+
self._file.flush()
|
|
134
|
+
if self._fsync:
|
|
135
|
+
assert self._path is not None
|
|
136
|
+
os.fsync(self._file.fileno())
|
|
137
|
+
except OSError:
|
|
138
|
+
with contextlib.suppress(OSError):
|
|
139
|
+
self._file.close()
|
|
140
|
+
self._closed = True
|
|
141
|
+
effective_keep = should_keep or self._keep_on_error
|
|
142
|
+
self._finalize_keep_or_clean(effective_keep)
|
|
143
|
+
raise
|
|
144
|
+
with contextlib.suppress(OSError):
|
|
145
|
+
self._file.close()
|
|
146
|
+
self._closed = True
|
|
147
|
+
|
|
148
|
+
if should_keep:
|
|
149
|
+
if self._record is not None:
|
|
150
|
+
temp_registry.mark_kept(self._record)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if had_error:
|
|
154
|
+
self._finalize_clean()
|
|
155
|
+
return
|
|
156
|
+
|
|
157
|
+
# Success: atomic replace
|
|
158
|
+
assert self._path is not None
|
|
159
|
+
try:
|
|
160
|
+
os.replace(self._path, self._dest)
|
|
161
|
+
except OSError as exc:
|
|
162
|
+
if exc.errno == errno.EXDEV:
|
|
163
|
+
try:
|
|
164
|
+
shutil.move(self._path, self._dest)
|
|
165
|
+
except OSError:
|
|
166
|
+
self._finalize_clean()
|
|
167
|
+
raise
|
|
168
|
+
else:
|
|
169
|
+
self._finalize_clean()
|
|
170
|
+
raise
|
|
171
|
+
if self._record is not None:
|
|
172
|
+
temp_registry.mark_cleaned(self._record)
|
|
173
|
+
|
|
174
|
+
def _finalize_clean(self) -> None:
|
|
175
|
+
"""Unlink the temp file and mark the registry record as cleaned.
|
|
176
|
+
|
|
177
|
+
If the unlink fails, the record is left active so ``cleanup_all()``
|
|
178
|
+
can retry later — the registry must reflect on-disk reality.
|
|
179
|
+
"""
|
|
180
|
+
assert self._path is not None
|
|
181
|
+
try:
|
|
182
|
+
os.unlink(self._path)
|
|
183
|
+
except OSError:
|
|
184
|
+
if not self._ignore_cleanup_errors:
|
|
185
|
+
raise
|
|
186
|
+
return
|
|
187
|
+
if self._record is not None:
|
|
188
|
+
temp_registry.mark_cleaned(self._record)
|
|
189
|
+
|
|
190
|
+
def _finalize_keep_or_clean(self, effective_keep: bool) -> None:
|
|
191
|
+
"""Mark kept or attempt cleanup depending on ``effective_keep``."""
|
|
192
|
+
if effective_keep:
|
|
193
|
+
if self._record is not None:
|
|
194
|
+
temp_registry.mark_kept(self._record)
|
|
195
|
+
else:
|
|
196
|
+
self._finalize_clean()
|
|
197
|
+
|
|
198
|
+
def write(self, data: bytes | str) -> int:
|
|
199
|
+
if self._closed:
|
|
200
|
+
raise ValueError("I/O operation on closed file.")
|
|
201
|
+
assert self._file is not None
|
|
202
|
+
return self._file.write(data) # type: ignore[no-any-return]
|
|
203
|
+
|
|
204
|
+
def flush(self) -> None:
|
|
205
|
+
if self._closed or self._file is None:
|
|
206
|
+
return
|
|
207
|
+
self._file.flush()
|
|
208
|
+
|
|
209
|
+
def close(self) -> None:
|
|
210
|
+
if self._closed:
|
|
211
|
+
return
|
|
212
|
+
if self._file is not None:
|
|
213
|
+
self._file.close()
|
|
214
|
+
self._closed = True
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def path(self) -> Path:
|
|
218
|
+
assert self._path is not None
|
|
219
|
+
return self._path
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def dest(self) -> Path:
|
|
223
|
+
return self._dest
|
|
224
|
+
|
|
225
|
+
def keep(self) -> None:
|
|
226
|
+
if self._file is None:
|
|
227
|
+
raise RuntimeError("Cannot call .keep() before __enter__.")
|
|
228
|
+
if self._exited:
|
|
229
|
+
raise RuntimeError("Cannot call .keep() after __exit__.")
|
|
230
|
+
self._user_keep = True
|
|
231
|
+
|
|
232
|
+
def __fspath__(self) -> str:
|
|
233
|
+
assert self._path is not None
|
|
234
|
+
return str(self._path)
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
path_str = repr(self._path) if self._path else "None"
|
|
238
|
+
if self._closed:
|
|
239
|
+
state = "closed"
|
|
240
|
+
elif self._file is not None:
|
|
241
|
+
state = "open"
|
|
242
|
+
else:
|
|
243
|
+
state = "pending"
|
|
244
|
+
return f"AtomicWriter(path={path_str}, dest={self._dest!r}, {state=})"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def atomic_write(
|
|
248
|
+
dest: StrPath,
|
|
249
|
+
*,
|
|
250
|
+
mode: str = "w",
|
|
251
|
+
encoding: str | None = None,
|
|
252
|
+
newline: str | None = None,
|
|
253
|
+
prefix: str | None = None,
|
|
254
|
+
suffix: str = ".tmp",
|
|
255
|
+
fsync: bool = True,
|
|
256
|
+
keep_on_error: bool = False,
|
|
257
|
+
ignore_cleanup_errors: bool = True,
|
|
258
|
+
) -> _AtomicWriter:
|
|
259
|
+
"""Create an atomic file writer context manager.
|
|
260
|
+
|
|
261
|
+
Writes to a temp file in ``dest.parent``, then atomically replaces
|
|
262
|
+
``dest`` via ``os.replace()`` on success. On error, the temp file is
|
|
263
|
+
deleted and ``dest`` is left untouched.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
dest: Final destination path.
|
|
267
|
+
mode: Open mode (e.g. ``"w"`` for text, ``"wb"`` for binary).
|
|
268
|
+
encoding: Text encoding (text modes only).
|
|
269
|
+
newline: Newline parameter (text modes only).
|
|
270
|
+
prefix: Temp file name prefix.
|
|
271
|
+
suffix: Temp file name suffix. Defaults to ``".tmp"``.
|
|
272
|
+
fsync: If ``True``, call ``os.fsync()`` before closing.
|
|
273
|
+
keep_on_error: If ``True``, keep temp file on exception (don't delete).
|
|
274
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
An ``_AtomicWriter`` context manager.
|
|
278
|
+
"""
|
|
279
|
+
return _AtomicWriter(
|
|
280
|
+
dest,
|
|
281
|
+
mode=mode,
|
|
282
|
+
encoding=encoding,
|
|
283
|
+
newline=newline,
|
|
284
|
+
prefix=prefix,
|
|
285
|
+
suffix=suffix,
|
|
286
|
+
fsync=fsync,
|
|
287
|
+
keep_on_error=keep_on_error,
|
|
288
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
289
|
+
)
|
tmpkit/_config.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Configuration: DEBUG detection and keep/delete decision logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _debug_enabled() -> bool:
|
|
9
|
+
"""Check if DEBUG mode is enabled via environment variable.
|
|
10
|
+
|
|
11
|
+
TMPKIT_DEBUG takes precedence over DEBUG.
|
|
12
|
+
Returns True only if the resolved value is exactly "1".
|
|
13
|
+
"""
|
|
14
|
+
value = os.environ.get("TMPKIT_DEBUG")
|
|
15
|
+
if value is None:
|
|
16
|
+
value = os.environ.get("DEBUG")
|
|
17
|
+
return value == "1"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _should_keep(*, keep: bool, keep_on_error: bool, had_error: bool) -> bool:
|
|
21
|
+
"""Decide whether to keep a temp based on flags and error state.
|
|
22
|
+
|
|
23
|
+
Precedence:
|
|
24
|
+
1. ``keep=True`` — always keep.
|
|
25
|
+
2. ``_debug_enabled()`` — DEBUG env var forces keep.
|
|
26
|
+
3. ``keep_on_error`` and an error occurred — keep.
|
|
27
|
+
"""
|
|
28
|
+
if keep:
|
|
29
|
+
return True
|
|
30
|
+
if _debug_enabled():
|
|
31
|
+
return True
|
|
32
|
+
return keep_on_error and had_error
|