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