tmpkit 1.0.0__tar.gz → 1.1.0__tar.gz

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.
Files changed (48) hide show
  1. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/ISSUE_TEMPLATE/bug_report.yml +1 -1
  2. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/dependabot.yml +2 -2
  3. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/workflows/ci.yml +5 -5
  4. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/workflows/release.yml +21 -8
  5. {tmpkit-1.0.0 → tmpkit-1.1.0}/.pre-commit-config.yaml +2 -2
  6. {tmpkit-1.0.0 → tmpkit-1.1.0}/CHANGELOG.md +44 -11
  7. {tmpkit-1.0.0 → tmpkit-1.1.0}/CODE_OF_CONDUCT.md +1 -1
  8. {tmpkit-1.0.0 → tmpkit-1.1.0}/CONTRIBUTING.md +4 -3
  9. {tmpkit-1.0.0 → tmpkit-1.1.0}/LICENSE +1 -1
  10. {tmpkit-1.0.0 → tmpkit-1.1.0}/PKG-INFO +35 -21
  11. {tmpkit-1.0.0 → tmpkit-1.1.0}/README.md +30 -16
  12. {tmpkit-1.0.0 → tmpkit-1.1.0}/SECURITY.md +4 -4
  13. {tmpkit-1.0.0 → tmpkit-1.1.0}/pyproject.toml +24 -5
  14. tmpkit-1.1.0/tests/__init__.py +1 -0
  15. tmpkit-1.1.0/tests/e2e/__init__.py +1 -0
  16. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/e2e/test_real_cleanup.py +1 -1
  17. tmpkit-1.1.0/tests/integration/__init__.py +1 -0
  18. tmpkit-1.1.0/tests/unit/__init__.py +1 -0
  19. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_async_dir.py +1 -1
  20. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_atomic.py +134 -5
  21. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_decorators.py +47 -0
  22. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_error_paths.py +49 -6
  23. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_keep.py +9 -8
  24. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_registry.py +2 -3
  25. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_smoke.py +1 -1
  26. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_sync_dir.py +48 -4
  27. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_sync_file.py +34 -0
  28. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_windows.py +0 -29
  29. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/__init__.py +5 -2
  30. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_async.py +24 -10
  31. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_atomic.py +102 -34
  32. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_decorators.py +38 -6
  33. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_registry.py +4 -0
  34. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_sync.py +62 -34
  35. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_types.py +2 -0
  36. tmpkit-1.0.0/tests/conftest.py +0 -26
  37. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/FUNDING.yml +0 -0
  38. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  39. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  40. {tmpkit-1.0.0 → tmpkit-1.1.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  41. {tmpkit-1.0.0 → tmpkit-1.1.0}/.gitignore +0 -0
  42. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/integration/test_concurrent.py +0 -0
  43. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/integration/test_subprocess.py +0 -0
  44. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_async_file.py +0 -0
  45. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_config.py +0 -0
  46. {tmpkit-1.0.0 → tmpkit-1.1.0}/tests/unit/test_types.py +0 -0
  47. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/_config.py +0 -0
  48. {tmpkit-1.0.0 → tmpkit-1.1.0}/tmpkit/py.typed +0 -0
@@ -59,7 +59,7 @@ body:
59
59
  id: tmpkit-version
60
60
  attributes:
61
61
  label: tmpkit Version
62
- placeholder: "0.1.0"
62
+ placeholder: "1.1.0"
63
63
  validations:
64
64
  required: true
65
65
 
@@ -1,7 +1,7 @@
1
1
  version: 2
2
2
  updates:
3
3
  - package-ecosystem: pip
4
- directory: /
4
+ directory: "/"
5
5
  schedule:
6
6
  interval: weekly
7
7
  day: monday
@@ -14,7 +14,7 @@ updates:
14
14
  include: scope
15
15
 
16
16
  - package-ecosystem: github-actions
17
- directory: /
17
+ directory: "/"
18
18
  schedule:
19
19
  interval: weekly
20
20
  day: monday
@@ -13,8 +13,8 @@ jobs:
13
13
  lint:
14
14
  runs-on: ubuntu-latest
15
15
  steps:
16
- - uses: actions/checkout@v4
17
- - uses: actions/setup-python@v5
16
+ - uses: actions/checkout@v7
17
+ - uses: actions/setup-python@v7
18
18
  with:
19
19
  python-version: "3.13"
20
20
  - name: Install dev deps
@@ -36,8 +36,8 @@ jobs:
36
36
  python-version: ["3.11", "3.12", "3.13", "3.14"]
37
37
  runs-on: ${{ matrix.os }}
38
38
  steps:
39
- - uses: actions/checkout@v4
40
- - uses: actions/setup-python@v5
39
+ - uses: actions/checkout@v7
40
+ - uses: actions/setup-python@v7
41
41
  with:
42
42
  python-version: ${{ matrix.python-version }}
43
43
  - name: Install dev deps
@@ -46,7 +46,7 @@ jobs:
46
46
  run: pytest --cov=tmpkit --cov-report=xml --cov-branch
47
47
  - name: Upload coverage
48
48
  if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13'
49
- uses: codecov/codecov-action@v4
49
+ uses: codecov/codecov-action@v7
50
50
  with:
51
51
  files: ./coverage.xml
52
52
  token: ${{ secrets.CODECOV_TOKEN }}
@@ -14,8 +14,8 @@ jobs:
14
14
  permissions:
15
15
  contents: read
16
16
  steps:
17
- - uses: actions/checkout@v4
18
- - uses: actions/setup-python@v5
17
+ - uses: actions/checkout@v7
18
+ - uses: actions/setup-python@v7
19
19
  with:
20
20
  python-version: "3.13"
21
21
  - name: Install build
@@ -23,7 +23,7 @@ jobs:
23
23
  - name: Build dist
24
24
  run: python -m build
25
25
  - name: Upload artifacts
26
- uses: actions/upload-artifact@v4
26
+ uses: actions/upload-artifact@v7
27
27
  with:
28
28
  name: dist
29
29
  path: dist/
@@ -35,7 +35,7 @@ jobs:
35
35
  permissions:
36
36
  id-token: write
37
37
  steps:
38
- - uses: actions/download-artifact@v4
38
+ - uses: actions/download-artifact@v8
39
39
  with:
40
40
  name: dist
41
41
  path: dist/
@@ -48,8 +48,8 @@ jobs:
48
48
  permissions:
49
49
  contents: write
50
50
  steps:
51
- - uses: actions/checkout@v4
52
- - uses: actions/download-artifact@v4
51
+ - uses: actions/checkout@v7
52
+ - uses: actions/download-artifact@v8
53
53
  with:
54
54
  name: dist
55
55
  path: dist/
@@ -88,12 +88,25 @@ jobs:
88
88
  needs: publish-pypi
89
89
  runs-on: ubuntu-latest
90
90
  steps:
91
- - uses: actions/setup-python@v5
91
+ - uses: actions/setup-python@v7
92
92
  with:
93
93
  python-version: "3.13"
94
94
  - name: Install from PyPI
95
95
  run: |
96
96
  VERSION="${GITHUB_REF_NAME#v}"
97
- pip install "tmpkit==${VERSION}"
97
+ # PyPI indexing can lag behind publication; retry until available.
98
+ for i in $(seq 1 12); do
99
+ if pip install "tmpkit==${VERSION}" 2>/dev/null; then
100
+ echo "Installed tmpkit==${VERSION} after ${i} attempt(s)."
101
+ break
102
+ fi
103
+ echo "Attempt ${i}: tmpkit==${VERSION} not yet indexed on PyPI. Retrying in 15s..."
104
+ sleep 15
105
+ done
106
+ INSTALLED="$(python -c 'import importlib.metadata; print(importlib.metadata.version("tmpkit"))' 2>/dev/null)"
107
+ [ "$INSTALLED" = "$VERSION" ] || {
108
+ echo "ERROR: expected tmpkit==${VERSION}, got '${INSTALLED:-not installed}' after 3 minutes."
109
+ exit 1
110
+ }
98
111
  - name: Smoke test
99
112
  run: python -c "from tmpkit import temp_file, temp_dir; print('ok')"
@@ -1,12 +1,12 @@
1
1
  repos:
2
2
  - repo: https://github.com/astral-sh/ruff-pre-commit
3
- rev: v0.6.0
3
+ rev: v0.16.1
4
4
  hooks:
5
5
  - id: ruff
6
6
  args: [--fix]
7
7
  - id: ruff-format
8
8
  - repo: https://github.com/pre-commit/mirrors-mypy
9
- rev: v1.11.0
9
+ rev: v2.3.1
10
10
  hooks:
11
11
  - id: mypy
12
12
  additional_dependencies: []
@@ -5,31 +5,64 @@ All notable changes to tmpkit will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.1.0] - 2026-10-07
9
9
 
10
10
  ### Added
11
11
 
12
- - Nothing yet.
12
+ - `keep=True` and `cleanup_hook=` parameters on `atomic_write()` (sync and async).
13
+ - `cleanup_hook=` parameter on `@temp_dir()` and `@temp_file()` decorators.
14
+ - `TempRecord` and `TempFileLike` are now exported from `tmpkit`.
15
+ - `atomic_write()` fsyncs the destination directory after `os.replace()`
16
+ for rename durability (best-effort, no-op on Windows).
13
17
 
14
18
  ### Changed
15
19
 
16
- - Nothing yet.
20
+ - `read`/`write`/`seek`/`tell`/`.path`/`__fspath__` before `__enter__` now
21
+ raise `RuntimeError` instead of `AssertionError`.
22
+ - `@temp_file()` on a class now raises `TypeError` instead of silently
23
+ producing a broken function.
24
+ - Class decoration with `@temp_dir()` now skips `staticmethod` and
25
+ `classmethod` members named `test_*` (they have no `self`).
26
+ - Tests are now type-checked under `mypy --strict` with relaxed per-module
27
+ rules (`[[tool.mypy.overrides]]`); `tests/` is now a package.
28
+ - Dev dependency floors bumped (`ruff>=0.16.0`, `mypy>=2.3.0`) and
29
+ pre-commit hook revisions aligned (`ruff v0.16.1`, `mypy v2.3.1`).
17
30
 
18
- ### Deprecated
31
+ ### Fixed
19
32
 
20
- - Nothing yet.
33
+ - Release workflow: the PyPI verify job used `pip show tmpkit==X.Y.Z`,
34
+ which always failed (`pip show` doesn't accept version specifiers).
35
+ - Body exceptions are no longer masked by cleanup failures: a failing
36
+ `close()`/`flush()`/`fsync()`/`rmtree` during exception propagation
37
+ no longer replaces the original error.
38
+ - `temp_dir(cwd=True)`: a non-`OSError` failure while restoring the
39
+ working directory no longer leaks the temp directory.
40
+ - `atomic_write()` raises `IsADirectoryError` early when `dest` is an
41
+ existing directory.
42
+ - `__repr__` now reports `kept` for temps kept via the close/flush
43
+ failure path.
44
+ - `__enter__` re-entry fully resets internal state (`_path`, `_file`).
45
+ - README: fixed the `temp_dir` `.keep()` example (it called `.keep()`
46
+ after the `with` block, which raises `RuntimeError`), corrected
47
+ `async_temp_file()` naming, and documented the process-global nature
48
+ of `cwd=True`.
21
49
 
22
50
  ### Removed
23
51
 
24
- - Nothing yet.
25
-
26
- ### Fixed
52
+ - `tests/conftest.py`: dropped the unused `assert_no_temps_left` fixture.
53
+ - `TestWindowsNameMock` tests, which mocked `os.name` to `"nt"` — a no-op
54
+ on Windows (the only platform they ran on).
27
55
 
28
- - Nothing yet.
56
+ ## [1.0.1] - 2025-01-24
29
57
 
30
- ### Security
58
+ ### Fixed
31
59
 
32
- - Nothing yet.
60
+ - Bump `codecov/codecov-action` from v4 to v7 to resolve Node.js 20
61
+ deprecation warnings in CI.
62
+ - Fix `test_windows.py` failures on Linux/macOS — `TestWindowsNameMock`
63
+ tests now skip on non-Windows platforms.
64
+ - Fix macOS CI failures from `/var` symlink resolution — cwd tests now
65
+ compare resolved paths.
33
66
 
34
67
  ## [1.0.0] - 2025-01-24
35
68
 
@@ -60,7 +60,7 @@ representative at an online or offline event.
60
60
 
61
61
  Instances of abusive, harassing, or otherwise unacceptable behavior may be
62
62
  reported to the community leaders responsible for enforcement at
63
- **conduct@mathiaspaulenko.com**.
63
+ **mathias.paulenko@outlook.com**.
64
64
  All complaints will be reviewed and investigated promptly and fairly.
65
65
 
66
66
  All community leaders are obligated to respect the privacy and security of the
@@ -38,9 +38,11 @@ Thank you for your interest in contributing to tmpkit! This document describes t
38
38
  - **Type checker**: [mypy](https://mypy-lang.org/) with `--strict`.
39
39
 
40
40
  ```bash
41
- mypy --strict tmpkit/
41
+ mypy --strict tmpkit/ tests/
42
42
  ```
43
43
 
44
+ Tests are checked with relaxed rules (see `[[tool.mypy.overrides]]` in `pyproject.toml`) since they exercise decorator-injected arguments and private internals.
45
+
44
46
  - **Python**: Target 3.11+ syntax. Use `from __future__ import annotations` in all modules.
45
47
  - **Imports**: Sorted by ruff (isort-compatible). Stdlib first, then third-party, then local.
46
48
 
@@ -108,8 +110,7 @@ tmpkit/
108
110
  │ ├── unit/ # Unit tests
109
111
  │ ├── integration/ # Integration tests
110
112
  │ └── e2e/ # End-to-end tests
111
- ├── pyproject.toml # Build config, tool config
112
- └── ref/ # Internal design docs (not shipped)
113
+ └── pyproject.toml # Build config, tool config
113
114
  ```
114
115
 
115
116
  ## Reporting Issues
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Mathias Paulenko
3
+ Copyright (c) 2025-2026 Mathias Paulenko
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,16 +1,16 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tmpkit
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes, async support, and zero dependencies.
5
5
  Project-URL: Homepage, https://github.com/MathiasPaulenko/tmpkit
6
6
  Project-URL: Repository, https://github.com/MathiasPaulenko/tmpkit
7
7
  Project-URL: Issues, https://github.com/MathiasPaulenko/tmpkit/issues
8
8
  Project-URL: Changelog, https://github.com/MathiasPaulenko/tmpkit/blob/main/CHANGELOG.md
9
- Author: Mathias Paulenko
9
+ Author-email: Mathias Paulenko <mathias.paulenko@outlook.com>
10
10
  License-Expression: MIT
11
11
  License-File: LICENSE
12
12
  Keywords: async,atomic,cleanup,context manager,filesystem,temp,tempfile,temporary,tmp
13
- Classifier: Development Status :: 4 - Beta
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Operating System :: OS Independent
@@ -24,11 +24,11 @@ Classifier: Typing :: Typed
24
24
  Requires-Python: >=3.11
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: build>=1.0; extra == 'dev'
27
- Requires-Dist: mypy>=1.11.0; extra == 'dev'
27
+ Requires-Dist: mypy>=2.3.0; extra == 'dev'
28
28
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
29
  Requires-Dist: pytest-cov>=5.0; extra == 'dev'
30
30
  Requires-Dist: pytest>=8.0; extra == 'dev'
31
- Requires-Dist: ruff>=0.6.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.16.0; extra == 'dev'
32
32
  Description-Content-Type: text/markdown
33
33
 
34
34
  # tmpkit
@@ -39,7 +39,8 @@ Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes,
39
39
  [![PyPI](https://img.shields.io/pypi/v/tmpkit)](https://pypi.org/project/tmpkit/)
40
40
  [![Python](https://img.shields.io/pypi/pyversions/tmpkit)](https://pypi.org/project/tmpkit/)
41
41
  [![License](https://img.shields.io/pypi/l/tmpkit)](https://github.com/MathiasPaulenko/tmpkit/blob/main/LICENSE)
42
- [![Coverage](https://img.shields.io/codecov/c/github/MathiasPaulenko/tmpkit)](https://codecov.io/gh/MathiasPaulenko/tmpkit)
42
+ [![Coverage](https://codecov.io/gh/MathiasPaulenko/tmpkit/graph/badge.svg)](https://codecov.io/gh/MathiasPaulenko/tmpkit)
43
+ [![Downloads](https://img.shields.io/pypi/dm/tmpkit)](https://pypi.org/project/tmpkit/)
43
44
 
44
45
  ---
45
46
 
@@ -70,7 +71,7 @@ Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes,
70
71
 
71
72
  ## Why tmpkit?
72
73
 
73
- Python's `tempfile` gives you the pieces but forces you to write cleanup boilerplate every time. tmpkit wraps it in ergonomic context managers that **guarantee cleanup** — with features nobody else offers.
74
+ Python's `tempfile` gives you the pieces but forces you to write cleanup boilerplate every time. tmpkit wraps it in ergonomic context managers that **always attempt cleanup** on exit — with features nobody else offers.
74
75
 
75
76
  ```python
76
77
  # stdlib — verbose, easy to forget cleanup
@@ -82,7 +83,7 @@ try:
82
83
  finally:
83
84
  shutil.rmtree(tmpdir, ignore_errors=True)
84
85
 
85
- # tmpkit — one line, always cleans up
86
+ # tmpkit — one line, cleans up on exit
86
87
  from tmpkit import temp_file
87
88
  with temp_file(suffix=".csv") as f:
88
89
  f.write(data)
@@ -113,7 +114,7 @@ This installs `pytest`, `pytest-asyncio`, `pytest-cov`, `ruff`, `mypy`, and `bui
113
114
  ```python
114
115
  from tmpkit import temp_file, temp_dir, atomic_write, async_temp_file
115
116
 
116
- # Temp file
117
+ # Temp file (default mode is "w+b" — binary; use mode="w+" for str)
117
118
  with temp_file(suffix=".csv", prefix="myapp_") as f:
118
119
  f.write(data)
119
120
  # deleted on exit
@@ -143,7 +144,7 @@ with temp_file(dest="output.csv") as f:
143
144
  # Async
144
145
  async def main() -> None:
145
146
  async with async_temp_file(suffix=".json") as f:
146
- await f.write(data)
147
+ await f.write(data) # bytes by default; mode="w+" for str
147
148
  ```
148
149
 
149
150
  ---
@@ -163,7 +164,7 @@ async def main() -> None:
163
164
  - **`@temp_dir()` / `@temp_file()` decorators** — inject temps into functions and test classes.
164
165
  - **Close without delete** — file survives `close()`, deleted on context exit (Windows subprocess friendly).
165
166
  - **`.path` attribute** — `Path` object, no more `Path(f.name)` boilerplate.
166
- - **Async support** — `async with temp_file() as f:` with async I/O methods.
167
+ - **Async support** — `async with async_temp_file() as f:` with async I/O methods.
167
168
  - **Windows-safe by default** — no `O_TEMPORARY` lock, `ignore_cleanup_errors=True`.
168
169
  - **Zero dependencies** — stdlib only.
169
170
 
@@ -182,7 +183,7 @@ with temp_file(
182
183
  dir: str | Path | None = None, # parent directory
183
184
  mode: str = "w+b", # open mode
184
185
  content: str | bytes | None = None, # pre-populate
185
- dest: str | Path | None = None, # move here on success
186
+ dest: str | Path | None = None, # move here on success (parent must exist at exit)
186
187
  keep: bool = False, # always keep
187
188
  keep_on_error: bool = False, # keep only on exception
188
189
  ignore_cleanup_errors: bool = True,
@@ -225,12 +226,12 @@ with temp_dir(
225
226
  td = temp_dir()
226
227
  with td as d:
227
228
  (d / "file.txt").write_text("hello")
228
- td.keep() # runtime decision to keep
229
+ td.keep() # runtime decision to keep (must be inside the block)
229
230
  ```
230
231
 
231
- **Returns:** A `Path` object (the temp directory path) with `/` operator support. To call `.keep()`, use the context manager object directly (see example above).
232
+ **Returns:** A `Path` object (the temp directory path) with `/` operator support. To call `.keep()`, use the context manager object directly (see example above) — it must be called **inside** the `with` block; calling it after exit raises `RuntimeError`.
232
233
 
233
- **`cwd=True`:** Changes the working directory to the temp dir on `__enter__`, restores the original on `__exit__`.
234
+ **`cwd=True`:** Changes the working directory to the temp dir on `__enter__`, restores the original on `__exit__`. Warning: `os.chdir` is process-global — do not use `cwd=True` from multiple threads or concurrent async tasks.
234
235
 
235
236
  ### `atomic_write()`
236
237
 
@@ -245,8 +246,10 @@ with atomic_write(
245
246
  prefix: str | None = None,
246
247
  suffix: str = ".tmp",
247
248
  fsync: bool = True, # fsync before rename
249
+ keep: bool = False, # keep temp, dest untouched
248
250
  keep_on_error: bool = False,
249
251
  ignore_cleanup_errors: bool = True,
252
+ cleanup_hook: Callable[[Path], None] | None = None,
250
253
  ) as f:
251
254
  f.write(data)
252
255
  # on success: atomically renamed to dest
@@ -255,6 +258,10 @@ with atomic_write(
255
258
 
256
259
  Writes to a temp file in `dest`'s parent directory, then atomically replaces `dest` via `os.replace()` on success. On error, the temp is cleaned up and `dest` is left untouched.
257
260
 
261
+ **Note:** with `keep=True`, `.keep()`, `keep_on_error=True` + exception, or `DEBUG=1`, the temp file is kept and **the replace is skipped** — `dest` is left untouched even on success.
262
+
263
+ **Validation on enter:** raises `IsADirectoryError` if `dest` is an existing directory, `FileNotFoundError` if `dest`'s parent doesn't exist, and `NotADirectoryError` if the parent exists but isn't a directory — before writing anything.
264
+
258
265
  ### `@temp_dir()` Decorator
259
266
 
260
267
  ```python
@@ -281,9 +288,9 @@ class TestMyApp:
281
288
  assert self.tmpdir.exists()
282
289
  ```
283
290
 
284
- **Decorator defaults:** `cwd=True` (unlike the context manager where `cwd=False` by default).
291
+ **Decorator defaults:** `cwd=True` (unlike the context manager where `cwd=False` by default). Accepts the same parameters as `temp_dir()`, including `cleanup_hook`. Only plain functions and classes — decorating a single method injects the temp path in place of `self`.
285
292
 
286
- **Class decoration:** Each method starting with `test_` is wrapped. The temp dir is available as `self.tmpdir`. Works with both sync and async test methods.
293
+ **Class decoration:** Each method starting with `test_` is wrapped (`staticmethod`/`classmethod` members are skipped). The temp dir is available as `self.tmpdir`. Works with both sync and async test methods.
287
294
 
288
295
  ### `@temp_file()` Decorator
289
296
 
@@ -304,7 +311,7 @@ async def process_async(f, data: str) -> None:
304
311
  await f.write(data)
305
312
  ```
306
313
 
307
- The temp file object is injected as the **first positional argument**.
314
+ The temp file object is injected as the **first positional argument**. Accepts the same parameters as `temp_file()`, including `cleanup_hook`. Plain functions only — decorating a class raises `TypeError`.
308
315
 
309
316
  ### `temp_registry`
310
317
 
@@ -340,7 +347,7 @@ temp_registry.clear_history()
340
347
  temp_registry.disable()
341
348
  ```
342
349
 
343
- **`TempRecord` fields:**
350
+ **`TempRecord`** is importable for type annotations: `from tmpkit import TempRecord`. Fields:
344
351
 
345
352
  | Field | Type | Description |
346
353
  | --- | --- | --- |
@@ -352,6 +359,8 @@ temp_registry.disable()
352
359
 
353
360
  **Thread-safe:** All operations are protected by `threading.Lock`.
354
361
 
362
+ **Note:** records accumulate for the lifetime of the process. In long-running applications, call `temp_registry.clear_history()` periodically (or `reset()`) to bound memory usage.
363
+
355
364
  ### Async API
356
365
 
357
366
  All sync APIs have async counterparts with identical parameters:
@@ -384,7 +393,7 @@ Async file objects support `await f.read()`, `await f.write()`, `await f.seek()`
384
393
  | `DEBUG` | `1` | Same as `TMPKIT_DEBUG=1` (fallback) |
385
394
  | `TMPKIT_REGISTRY` | `1` | Enable `temp_registry` at import time |
386
395
 
387
- `TMPKIT_DEBUG` takes precedence over `DEBUG`.
396
+ `TMPKIT_DEBUG` takes precedence over `DEBUG`. Since `DEBUG` is a common variable name in other tools, you can set `TMPKIT_DEBUG=0` to explicitly disable keep-all even when `DEBUG=1` is present.
388
397
 
389
398
  ---
390
399
 
@@ -431,6 +440,11 @@ with temp_file(cleanup_hook=my_hook) as f:
431
440
  f.write(data)
432
441
  # hook is called, then standard cleanup runs
433
442
 
443
+ # Hook also runs when the temp is moved via dest=
444
+ with temp_file(dest="output.csv", cleanup_hook=my_hook) as f:
445
+ f.write(data)
446
+ # hook is called, then temp is moved to output.csv
447
+
434
448
  # Hook is called even on exceptions
435
449
  with temp_file(cleanup_hook=my_hook) as f:
436
450
  raise RuntimeError("oops")
@@ -503,4 +517,4 @@ See [CHANGELOG.md](CHANGELOG.md) for a full list of changes.
503
517
 
504
518
  ## License
505
519
 
506
- [MIT](LICENSE) — Copyright (c) 2025 Mathias Paulenko
520
+ [MIT](LICENSE) — Copyright (c) 2025-2026 Mathias Paulenko
@@ -6,7 +6,8 @@ Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes,
6
6
  [![PyPI](https://img.shields.io/pypi/v/tmpkit)](https://pypi.org/project/tmpkit/)
7
7
  [![Python](https://img.shields.io/pypi/pyversions/tmpkit)](https://pypi.org/project/tmpkit/)
8
8
  [![License](https://img.shields.io/pypi/l/tmpkit)](https://github.com/MathiasPaulenko/tmpkit/blob/main/LICENSE)
9
- [![Coverage](https://img.shields.io/codecov/c/github/MathiasPaulenko/tmpkit)](https://codecov.io/gh/MathiasPaulenko/tmpkit)
9
+ [![Coverage](https://codecov.io/gh/MathiasPaulenko/tmpkit/graph/badge.svg)](https://codecov.io/gh/MathiasPaulenko/tmpkit)
10
+ [![Downloads](https://img.shields.io/pypi/dm/tmpkit)](https://pypi.org/project/tmpkit/)
10
11
 
11
12
  ---
12
13
 
@@ -37,7 +38,7 @@ Ergonomic tempfile & tempdir context managers with auto-cleanup, atomic writes,
37
38
 
38
39
  ## Why tmpkit?
39
40
 
40
- Python's `tempfile` gives you the pieces but forces you to write cleanup boilerplate every time. tmpkit wraps it in ergonomic context managers that **guarantee cleanup** — with features nobody else offers.
41
+ Python's `tempfile` gives you the pieces but forces you to write cleanup boilerplate every time. tmpkit wraps it in ergonomic context managers that **always attempt cleanup** on exit — with features nobody else offers.
41
42
 
42
43
  ```python
43
44
  # stdlib — verbose, easy to forget cleanup
@@ -49,7 +50,7 @@ try:
49
50
  finally:
50
51
  shutil.rmtree(tmpdir, ignore_errors=True)
51
52
 
52
- # tmpkit — one line, always cleans up
53
+ # tmpkit — one line, cleans up on exit
53
54
  from tmpkit import temp_file
54
55
  with temp_file(suffix=".csv") as f:
55
56
  f.write(data)
@@ -80,7 +81,7 @@ This installs `pytest`, `pytest-asyncio`, `pytest-cov`, `ruff`, `mypy`, and `bui
80
81
  ```python
81
82
  from tmpkit import temp_file, temp_dir, atomic_write, async_temp_file
82
83
 
83
- # Temp file
84
+ # Temp file (default mode is "w+b" — binary; use mode="w+" for str)
84
85
  with temp_file(suffix=".csv", prefix="myapp_") as f:
85
86
  f.write(data)
86
87
  # deleted on exit
@@ -110,7 +111,7 @@ with temp_file(dest="output.csv") as f:
110
111
  # Async
111
112
  async def main() -> None:
112
113
  async with async_temp_file(suffix=".json") as f:
113
- await f.write(data)
114
+ await f.write(data) # bytes by default; mode="w+" for str
114
115
  ```
115
116
 
116
117
  ---
@@ -130,7 +131,7 @@ async def main() -> None:
130
131
  - **`@temp_dir()` / `@temp_file()` decorators** — inject temps into functions and test classes.
131
132
  - **Close without delete** — file survives `close()`, deleted on context exit (Windows subprocess friendly).
132
133
  - **`.path` attribute** — `Path` object, no more `Path(f.name)` boilerplate.
133
- - **Async support** — `async with temp_file() as f:` with async I/O methods.
134
+ - **Async support** — `async with async_temp_file() as f:` with async I/O methods.
134
135
  - **Windows-safe by default** — no `O_TEMPORARY` lock, `ignore_cleanup_errors=True`.
135
136
  - **Zero dependencies** — stdlib only.
136
137
 
@@ -149,7 +150,7 @@ with temp_file(
149
150
  dir: str | Path | None = None, # parent directory
150
151
  mode: str = "w+b", # open mode
151
152
  content: str | bytes | None = None, # pre-populate
152
- dest: str | Path | None = None, # move here on success
153
+ dest: str | Path | None = None, # move here on success (parent must exist at exit)
153
154
  keep: bool = False, # always keep
154
155
  keep_on_error: bool = False, # keep only on exception
155
156
  ignore_cleanup_errors: bool = True,
@@ -192,12 +193,12 @@ with temp_dir(
192
193
  td = temp_dir()
193
194
  with td as d:
194
195
  (d / "file.txt").write_text("hello")
195
- td.keep() # runtime decision to keep
196
+ td.keep() # runtime decision to keep (must be inside the block)
196
197
  ```
197
198
 
198
- **Returns:** A `Path` object (the temp directory path) with `/` operator support. To call `.keep()`, use the context manager object directly (see example above).
199
+ **Returns:** A `Path` object (the temp directory path) with `/` operator support. To call `.keep()`, use the context manager object directly (see example above) — it must be called **inside** the `with` block; calling it after exit raises `RuntimeError`.
199
200
 
200
- **`cwd=True`:** Changes the working directory to the temp dir on `__enter__`, restores the original on `__exit__`.
201
+ **`cwd=True`:** Changes the working directory to the temp dir on `__enter__`, restores the original on `__exit__`. Warning: `os.chdir` is process-global — do not use `cwd=True` from multiple threads or concurrent async tasks.
201
202
 
202
203
  ### `atomic_write()`
203
204
 
@@ -212,8 +213,10 @@ with atomic_write(
212
213
  prefix: str | None = None,
213
214
  suffix: str = ".tmp",
214
215
  fsync: bool = True, # fsync before rename
216
+ keep: bool = False, # keep temp, dest untouched
215
217
  keep_on_error: bool = False,
216
218
  ignore_cleanup_errors: bool = True,
219
+ cleanup_hook: Callable[[Path], None] | None = None,
217
220
  ) as f:
218
221
  f.write(data)
219
222
  # on success: atomically renamed to dest
@@ -222,6 +225,10 @@ with atomic_write(
222
225
 
223
226
  Writes to a temp file in `dest`'s parent directory, then atomically replaces `dest` via `os.replace()` on success. On error, the temp is cleaned up and `dest` is left untouched.
224
227
 
228
+ **Note:** with `keep=True`, `.keep()`, `keep_on_error=True` + exception, or `DEBUG=1`, the temp file is kept and **the replace is skipped** — `dest` is left untouched even on success.
229
+
230
+ **Validation on enter:** raises `IsADirectoryError` if `dest` is an existing directory, `FileNotFoundError` if `dest`'s parent doesn't exist, and `NotADirectoryError` if the parent exists but isn't a directory — before writing anything.
231
+
225
232
  ### `@temp_dir()` Decorator
226
233
 
227
234
  ```python
@@ -248,9 +255,9 @@ class TestMyApp:
248
255
  assert self.tmpdir.exists()
249
256
  ```
250
257
 
251
- **Decorator defaults:** `cwd=True` (unlike the context manager where `cwd=False` by default).
258
+ **Decorator defaults:** `cwd=True` (unlike the context manager where `cwd=False` by default). Accepts the same parameters as `temp_dir()`, including `cleanup_hook`. Only plain functions and classes — decorating a single method injects the temp path in place of `self`.
252
259
 
253
- **Class decoration:** Each method starting with `test_` is wrapped. The temp dir is available as `self.tmpdir`. Works with both sync and async test methods.
260
+ **Class decoration:** Each method starting with `test_` is wrapped (`staticmethod`/`classmethod` members are skipped). The temp dir is available as `self.tmpdir`. Works with both sync and async test methods.
254
261
 
255
262
  ### `@temp_file()` Decorator
256
263
 
@@ -271,7 +278,7 @@ async def process_async(f, data: str) -> None:
271
278
  await f.write(data)
272
279
  ```
273
280
 
274
- The temp file object is injected as the **first positional argument**.
281
+ The temp file object is injected as the **first positional argument**. Accepts the same parameters as `temp_file()`, including `cleanup_hook`. Plain functions only — decorating a class raises `TypeError`.
275
282
 
276
283
  ### `temp_registry`
277
284
 
@@ -307,7 +314,7 @@ temp_registry.clear_history()
307
314
  temp_registry.disable()
308
315
  ```
309
316
 
310
- **`TempRecord` fields:**
317
+ **`TempRecord`** is importable for type annotations: `from tmpkit import TempRecord`. Fields:
311
318
 
312
319
  | Field | Type | Description |
313
320
  | --- | --- | --- |
@@ -319,6 +326,8 @@ temp_registry.disable()
319
326
 
320
327
  **Thread-safe:** All operations are protected by `threading.Lock`.
321
328
 
329
+ **Note:** records accumulate for the lifetime of the process. In long-running applications, call `temp_registry.clear_history()` periodically (or `reset()`) to bound memory usage.
330
+
322
331
  ### Async API
323
332
 
324
333
  All sync APIs have async counterparts with identical parameters:
@@ -351,7 +360,7 @@ Async file objects support `await f.read()`, `await f.write()`, `await f.seek()`
351
360
  | `DEBUG` | `1` | Same as `TMPKIT_DEBUG=1` (fallback) |
352
361
  | `TMPKIT_REGISTRY` | `1` | Enable `temp_registry` at import time |
353
362
 
354
- `TMPKIT_DEBUG` takes precedence over `DEBUG`.
363
+ `TMPKIT_DEBUG` takes precedence over `DEBUG`. Since `DEBUG` is a common variable name in other tools, you can set `TMPKIT_DEBUG=0` to explicitly disable keep-all even when `DEBUG=1` is present.
355
364
 
356
365
  ---
357
366
 
@@ -398,6 +407,11 @@ with temp_file(cleanup_hook=my_hook) as f:
398
407
  f.write(data)
399
408
  # hook is called, then standard cleanup runs
400
409
 
410
+ # Hook also runs when the temp is moved via dest=
411
+ with temp_file(dest="output.csv", cleanup_hook=my_hook) as f:
412
+ f.write(data)
413
+ # hook is called, then temp is moved to output.csv
414
+
401
415
  # Hook is called even on exceptions
402
416
  with temp_file(cleanup_hook=my_hook) as f:
403
417
  raise RuntimeError("oops")
@@ -470,4 +484,4 @@ See [CHANGELOG.md](CHANGELOG.md) for a full list of changes.
470
484
 
471
485
  ## License
472
486
 
473
- [MIT](LICENSE) — Copyright (c) 2025 Mathias Paulenko
487
+ [MIT](LICENSE) — Copyright (c) 2025-2026 Mathias Paulenko
@@ -4,15 +4,15 @@
4
4
 
5
5
  | Version | Supported |
6
6
  |---------|--------------------|
7
- | 0.1.x | :white_check_mark: |
8
- | < 0.1 | :x: |
7
+ | 1.0.x | :white_check_mark: |
8
+ | < 1.0 | :x: |
9
9
 
10
10
  ## Reporting a Vulnerability
11
11
 
12
12
  If you discover a security vulnerability in tmpkit, please report it responsibly.
13
13
 
14
14
  1. **Do NOT open a public GitHub issue.**
15
- 2. Email **security@mathiaspaulenko.com** with a description of the vulnerability and, if possible, a proof of concept.
15
+ 2. Email **mathias.paulenko@outlook.com** with a description of the vulnerability and, if possible, a proof of concept.
16
16
  3. You will receive an acknowledgment within **48 hours**.
17
17
  4. We will investigate and, if confirmed, release a fix as soon as possible depending on severity.
18
18
 
@@ -27,5 +27,5 @@ If you discover a security vulnerability in tmpkit, please report it responsibly
27
27
  tmpkit is a zero-dependency library that wraps Python's `tempfile` module. The main security considerations are:
28
28
 
29
29
  - **Temp file permissions**: Uses `tempfile.mkstemp()` which creates files with restrictive permissions (0600 on Unix).
30
- - **Symlink attacks**: Uses `shutil.rmtree()` for directory cleanup. On Python 3.11+, this is protected against symlink attacks by default.
30
+ - **Symlink attacks**: Uses `shutil.rmtree()` for directory cleanup, which includes partial protections against symlink-based races on Python 3.11+. Avoid pointing `dir` at world-writable locations you don't control.
31
31
  - **Path traversal**: Temp paths are generated by the stdlib and are not user-controlled beyond `suffix`, `prefix`, and `dir` parameters. If you pass a user-controlled `dir`, validate it.