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