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 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