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/_decorators.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Decorators: @temp_dir() and @temp_file() for functions and test classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, TypeVar
|
|
9
|
+
|
|
10
|
+
from tmpkit._async import temp_dir as async_temp_dir
|
|
11
|
+
from tmpkit._async import temp_file as async_temp_file
|
|
12
|
+
from tmpkit._sync import temp_dir as sync_temp_dir
|
|
13
|
+
from tmpkit._sync import temp_file as sync_temp_file
|
|
14
|
+
from tmpkit._types import StrPath
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def temp_dir(
|
|
20
|
+
*,
|
|
21
|
+
suffix: str | None = None,
|
|
22
|
+
prefix: str | None = None,
|
|
23
|
+
dir: StrPath | None = None,
|
|
24
|
+
cwd: bool = True,
|
|
25
|
+
keep: bool = False,
|
|
26
|
+
keep_on_error: bool = False,
|
|
27
|
+
ignore_cleanup_errors: bool = True,
|
|
28
|
+
) -> Callable[[F], F]:
|
|
29
|
+
"""Decorator that provides a fresh temp directory for each call.
|
|
30
|
+
|
|
31
|
+
By default ``cwd=True`` — the function runs inside the temp dir and
|
|
32
|
+
the original cwd is restored on exit.
|
|
33
|
+
|
|
34
|
+
Works on:
|
|
35
|
+
- Sync functions: wrapped normally.
|
|
36
|
+
- Async functions: detected via ``inspect.iscoroutinefunction``.
|
|
37
|
+
- Test classes: each method gets a fresh temp dir as ``self.tmpdir``.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
suffix: Directory name suffix.
|
|
41
|
+
prefix: Directory name prefix.
|
|
42
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
43
|
+
cwd: If ``True``, changes working directory to temp dir. Defaults to ``True``.
|
|
44
|
+
keep: If ``True``, directory is NOT removed.
|
|
45
|
+
keep_on_error: If ``True``, directory is kept only on exception.
|
|
46
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def decorator(func_or_cls: F) -> F:
|
|
50
|
+
if inspect.isclass(func_or_cls):
|
|
51
|
+
_decorate_class(
|
|
52
|
+
func_or_cls,
|
|
53
|
+
suffix=suffix,
|
|
54
|
+
prefix=prefix,
|
|
55
|
+
dir=dir,
|
|
56
|
+
cwd=cwd,
|
|
57
|
+
keep=keep,
|
|
58
|
+
keep_on_error=keep_on_error,
|
|
59
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
60
|
+
)
|
|
61
|
+
return func_or_cls
|
|
62
|
+
|
|
63
|
+
if inspect.iscoroutinefunction(func_or_cls):
|
|
64
|
+
|
|
65
|
+
@functools.wraps(func_or_cls)
|
|
66
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
67
|
+
async with async_temp_dir(
|
|
68
|
+
suffix=suffix,
|
|
69
|
+
prefix=prefix,
|
|
70
|
+
dir=dir,
|
|
71
|
+
cwd=cwd,
|
|
72
|
+
keep=keep,
|
|
73
|
+
keep_on_error=keep_on_error,
|
|
74
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
75
|
+
) as tmp:
|
|
76
|
+
return await func_or_cls(tmp, *args, **kwargs)
|
|
77
|
+
|
|
78
|
+
return async_wrapper # type: ignore[return-value]
|
|
79
|
+
|
|
80
|
+
@functools.wraps(func_or_cls)
|
|
81
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
82
|
+
with sync_temp_dir(
|
|
83
|
+
suffix=suffix,
|
|
84
|
+
prefix=prefix,
|
|
85
|
+
dir=dir,
|
|
86
|
+
cwd=cwd,
|
|
87
|
+
keep=keep,
|
|
88
|
+
keep_on_error=keep_on_error,
|
|
89
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
90
|
+
) as tmp:
|
|
91
|
+
return func_or_cls(tmp, *args, **kwargs)
|
|
92
|
+
|
|
93
|
+
return sync_wrapper # type: ignore[return-value]
|
|
94
|
+
|
|
95
|
+
return decorator
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def temp_file(
|
|
99
|
+
*,
|
|
100
|
+
suffix: str | None = None,
|
|
101
|
+
prefix: str | None = None,
|
|
102
|
+
dir: StrPath | None = None,
|
|
103
|
+
mode: str = "w+b",
|
|
104
|
+
content: str | bytes | None = None,
|
|
105
|
+
dest: StrPath | None = None,
|
|
106
|
+
keep: bool = False,
|
|
107
|
+
keep_on_error: bool = False,
|
|
108
|
+
ignore_cleanup_errors: bool = True,
|
|
109
|
+
) -> Callable[[F], F]:
|
|
110
|
+
"""Decorator that injects a temp file as the first positional argument.
|
|
111
|
+
|
|
112
|
+
Works on sync and async **plain functions**. The temp file object is
|
|
113
|
+
injected as the very first positional argument, before any user-supplied
|
|
114
|
+
arguments. This decorator does **not** support methods or classes — use
|
|
115
|
+
it on module-level functions whose signature begins with the temp file
|
|
116
|
+
parameter.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
suffix: File name suffix.
|
|
120
|
+
prefix: File name prefix.
|
|
121
|
+
dir: Parent directory. Defaults to system temp dir.
|
|
122
|
+
mode: Open mode. Defaults to ``"w+b"``.
|
|
123
|
+
content: Pre-populate file with this content.
|
|
124
|
+
dest: Destination path. On success, temp is moved here.
|
|
125
|
+
keep: If ``True``, file is NOT deleted.
|
|
126
|
+
keep_on_error: If ``True``, file is kept only on exception.
|
|
127
|
+
ignore_cleanup_errors: If ``True``, ``OSError`` during cleanup is silently ignored.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def decorator(func: F) -> F:
|
|
131
|
+
if inspect.iscoroutinefunction(func):
|
|
132
|
+
|
|
133
|
+
@functools.wraps(func)
|
|
134
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
135
|
+
async with async_temp_file(
|
|
136
|
+
suffix=suffix,
|
|
137
|
+
prefix=prefix,
|
|
138
|
+
dir=dir,
|
|
139
|
+
mode=mode,
|
|
140
|
+
content=content,
|
|
141
|
+
dest=dest,
|
|
142
|
+
keep=keep,
|
|
143
|
+
keep_on_error=keep_on_error,
|
|
144
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
145
|
+
) as f:
|
|
146
|
+
return await func(f, *args, **kwargs)
|
|
147
|
+
|
|
148
|
+
return async_wrapper # type: ignore[return-value]
|
|
149
|
+
|
|
150
|
+
@functools.wraps(func)
|
|
151
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
with sync_temp_file(
|
|
153
|
+
suffix=suffix,
|
|
154
|
+
prefix=prefix,
|
|
155
|
+
dir=dir,
|
|
156
|
+
mode=mode,
|
|
157
|
+
content=content,
|
|
158
|
+
dest=dest,
|
|
159
|
+
keep=keep,
|
|
160
|
+
keep_on_error=keep_on_error,
|
|
161
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
162
|
+
) as f:
|
|
163
|
+
return func(f, *args, **kwargs)
|
|
164
|
+
|
|
165
|
+
return sync_wrapper # type: ignore[return-value]
|
|
166
|
+
|
|
167
|
+
return decorator
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _decorate_class(
|
|
171
|
+
cls: type,
|
|
172
|
+
*,
|
|
173
|
+
suffix: str | None,
|
|
174
|
+
prefix: str | None,
|
|
175
|
+
dir: StrPath | None,
|
|
176
|
+
cwd: bool,
|
|
177
|
+
keep: bool,
|
|
178
|
+
keep_on_error: bool,
|
|
179
|
+
ignore_cleanup_errors: bool,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Decorate a test class so each test method gets a fresh temp dir.
|
|
182
|
+
|
|
183
|
+
The temp dir path is available as ``self.tmpdir`` during the test.
|
|
184
|
+
Each method starting with ``test_`` is wrapped to enter/exit a temp dir
|
|
185
|
+
context around the original call.
|
|
186
|
+
"""
|
|
187
|
+
for name, method in list(vars(cls).items()):
|
|
188
|
+
if not name.startswith("test_"):
|
|
189
|
+
continue
|
|
190
|
+
if not callable(method):
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if inspect.iscoroutinefunction(method):
|
|
194
|
+
wrapped = _wrap_async_test_method(
|
|
195
|
+
method,
|
|
196
|
+
suffix=suffix,
|
|
197
|
+
prefix=prefix,
|
|
198
|
+
dir=dir,
|
|
199
|
+
cwd=cwd,
|
|
200
|
+
keep=keep,
|
|
201
|
+
keep_on_error=keep_on_error,
|
|
202
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
203
|
+
)
|
|
204
|
+
else:
|
|
205
|
+
wrapped = _wrap_sync_test_method(
|
|
206
|
+
method,
|
|
207
|
+
suffix=suffix,
|
|
208
|
+
prefix=prefix,
|
|
209
|
+
dir=dir,
|
|
210
|
+
cwd=cwd,
|
|
211
|
+
keep=keep,
|
|
212
|
+
keep_on_error=keep_on_error,
|
|
213
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
214
|
+
)
|
|
215
|
+
setattr(cls, name, wrapped)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _wrap_sync_test_method(
|
|
219
|
+
method: Callable[..., Any],
|
|
220
|
+
*,
|
|
221
|
+
suffix: str | None,
|
|
222
|
+
prefix: str | None,
|
|
223
|
+
dir: StrPath | None,
|
|
224
|
+
cwd: bool,
|
|
225
|
+
keep: bool,
|
|
226
|
+
keep_on_error: bool,
|
|
227
|
+
ignore_cleanup_errors: bool,
|
|
228
|
+
) -> Callable[..., Any]:
|
|
229
|
+
@functools.wraps(method)
|
|
230
|
+
def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
231
|
+
with sync_temp_dir(
|
|
232
|
+
suffix=suffix,
|
|
233
|
+
prefix=prefix,
|
|
234
|
+
dir=dir,
|
|
235
|
+
cwd=cwd,
|
|
236
|
+
keep=keep,
|
|
237
|
+
keep_on_error=keep_on_error,
|
|
238
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
239
|
+
) as tmp:
|
|
240
|
+
self.tmpdir = tmp
|
|
241
|
+
return method(self, *args, **kwargs)
|
|
242
|
+
|
|
243
|
+
return wrapper
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _wrap_async_test_method(
|
|
247
|
+
method: Callable[..., Any],
|
|
248
|
+
*,
|
|
249
|
+
suffix: str | None,
|
|
250
|
+
prefix: str | None,
|
|
251
|
+
dir: StrPath | None,
|
|
252
|
+
cwd: bool,
|
|
253
|
+
keep: bool,
|
|
254
|
+
keep_on_error: bool,
|
|
255
|
+
ignore_cleanup_errors: bool,
|
|
256
|
+
) -> Callable[..., Any]:
|
|
257
|
+
@functools.wraps(method)
|
|
258
|
+
async def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
259
|
+
async with async_temp_dir(
|
|
260
|
+
suffix=suffix,
|
|
261
|
+
prefix=prefix,
|
|
262
|
+
dir=dir,
|
|
263
|
+
cwd=cwd,
|
|
264
|
+
keep=keep,
|
|
265
|
+
keep_on_error=keep_on_error,
|
|
266
|
+
ignore_cleanup_errors=ignore_cleanup_errors,
|
|
267
|
+
) as tmp:
|
|
268
|
+
self.tmpdir = tmp
|
|
269
|
+
return await method(self, *args, **kwargs)
|
|
270
|
+
|
|
271
|
+
return wrapper
|
tmpkit/_registry.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Temp registry: track all temp files/dirs for cleanup and inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class TempRecord:
|
|
16
|
+
"""Record of a single temp file or directory.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
path: Filesystem path of the temp.
|
|
20
|
+
kind: ``"file"`` or ``"dir"``.
|
|
21
|
+
created_at: Creation timestamp (epoch seconds from ``time.time``).
|
|
22
|
+
cleaned: ``True`` once the temp has been deleted/moved.
|
|
23
|
+
kept: ``True`` if the temp was kept (not cleaned up).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
path: Path
|
|
27
|
+
kind: Literal["file", "dir"]
|
|
28
|
+
created_at: float
|
|
29
|
+
cleaned: bool = False
|
|
30
|
+
kept: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TempRegistry:
|
|
34
|
+
"""Thread-safe registry of temporary files and directories.
|
|
35
|
+
|
|
36
|
+
Disabled by default for zero overhead. Enable via ``enable()`` or
|
|
37
|
+
setting ``TMPKIT_REGISTRY=1`` environment variable at import time.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("_enabled", "_lock", "_records")
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._lock = threading.Lock()
|
|
44
|
+
self._records: list[TempRecord] = []
|
|
45
|
+
self._enabled = os.environ.get("TMPKIT_REGISTRY") == "1"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def enabled(self) -> bool:
|
|
49
|
+
"""Whether the registry is currently active."""
|
|
50
|
+
with self._lock:
|
|
51
|
+
return self._enabled
|
|
52
|
+
|
|
53
|
+
def enable(self) -> None:
|
|
54
|
+
"""Enable the registry."""
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._enabled = True
|
|
57
|
+
|
|
58
|
+
def disable(self) -> None:
|
|
59
|
+
"""Disable the registry. Existing records are preserved."""
|
|
60
|
+
with self._lock:
|
|
61
|
+
self._enabled = False
|
|
62
|
+
|
|
63
|
+
def register(self, path: Path, kind: Literal["file", "dir"]) -> TempRecord | None:
|
|
64
|
+
"""Register a new temp. Returns the record, or ``None`` if disabled."""
|
|
65
|
+
with self._lock:
|
|
66
|
+
if not self._enabled:
|
|
67
|
+
return None
|
|
68
|
+
record = TempRecord(
|
|
69
|
+
path=path,
|
|
70
|
+
kind=kind,
|
|
71
|
+
created_at=_now(),
|
|
72
|
+
)
|
|
73
|
+
self._records.append(record)
|
|
74
|
+
return record
|
|
75
|
+
|
|
76
|
+
def mark_cleaned(self, record: TempRecord) -> None:
|
|
77
|
+
"""Mark a record as cleaned up."""
|
|
78
|
+
with self._lock:
|
|
79
|
+
record.cleaned = True
|
|
80
|
+
|
|
81
|
+
def mark_kept(self, record: TempRecord) -> None:
|
|
82
|
+
"""Mark a record as kept (not cleaned up)."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
record.kept = True
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def all(self) -> list[TempRecord]:
|
|
88
|
+
"""All records (active + cleaned)."""
|
|
89
|
+
with self._lock:
|
|
90
|
+
return list(self._records)
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def active(self) -> list[TempRecord]:
|
|
94
|
+
"""Records for temps that have not been cleaned or kept."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
return [r for r in self._records if not r.cleaned and not r.kept]
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def cleaned(self) -> list[TempRecord]:
|
|
100
|
+
"""Records for temps that have been cleaned up."""
|
|
101
|
+
with self._lock:
|
|
102
|
+
return [r for r in self._records if r.cleaned]
|
|
103
|
+
|
|
104
|
+
def cleanup_all(self) -> int:
|
|
105
|
+
"""Delete all active temps. Returns the number of temps deleted."""
|
|
106
|
+
count = 0
|
|
107
|
+
with self._lock:
|
|
108
|
+
active = [r for r in self._records if not r.cleaned and not r.kept]
|
|
109
|
+
|
|
110
|
+
for record in active:
|
|
111
|
+
# Re-check under the lock that the record is still active;
|
|
112
|
+
# another thread may have marked it kept/cleaned since the snapshot.
|
|
113
|
+
with self._lock:
|
|
114
|
+
if record.cleaned or record.kept:
|
|
115
|
+
continue
|
|
116
|
+
try:
|
|
117
|
+
if not record.path.exists():
|
|
118
|
+
# Already gone on disk — mark cleaned without counting.
|
|
119
|
+
with self._lock:
|
|
120
|
+
record.cleaned = True
|
|
121
|
+
continue
|
|
122
|
+
if record.kind == "dir":
|
|
123
|
+
shutil.rmtree(record.path)
|
|
124
|
+
else:
|
|
125
|
+
record.path.unlink()
|
|
126
|
+
with self._lock:
|
|
127
|
+
record.cleaned = True
|
|
128
|
+
count += 1
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
return count
|
|
132
|
+
|
|
133
|
+
def keep_all(self) -> int:
|
|
134
|
+
"""Mark all active temps as kept. Returns the number marked."""
|
|
135
|
+
with self._lock:
|
|
136
|
+
count = 0
|
|
137
|
+
for record in self._records:
|
|
138
|
+
if not record.cleaned and not record.kept:
|
|
139
|
+
record.kept = True
|
|
140
|
+
count += 1
|
|
141
|
+
return count
|
|
142
|
+
|
|
143
|
+
def clear_history(self) -> None:
|
|
144
|
+
"""Remove all cleaned records from history."""
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._records = [r for r in self._records if not r.cleaned]
|
|
147
|
+
|
|
148
|
+
def reset(self) -> None:
|
|
149
|
+
"""Clear all records and disable. Useful for testing."""
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._records.clear()
|
|
152
|
+
self._enabled = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _now() -> float:
|
|
156
|
+
return time.time()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
temp_registry = TempRegistry()
|