ephemdir (ephemeral directory) creates temporary directories that clean
themselves up — automatically removed once their lifetime expires or after the
machine restarts. Names are playful two-word slugs with a random token, like
brave-otter-a81f42c9d047315b, instead of dull ones like tmp_data — and you
can address them by any unique prefix (brave or brave-otter).
Supports Python 3.10+ on Linux and macOS with safe fd-relative cleanup.
Windows is intentionally unsupported until Python or a dedicated backend can
provide handle-bound recursive deletion; tempdir() fails before creating
anything on unsupported platforms. The package has no third-party runtime
dependencies on Python 3.11+; Python 3.10 uses tomli only for TOML
configuration parsing.
v0.5.0 is a POSIX-only hardening release. It adds stricter restart/recovery tracking: missing paths are reported and kept tracked until ephemdir verifies deletion or you explicitly forget them.
Important
Windows is not supported in ephemdir 0.5.0. The package may install on
Windows, but tempdir() and ephemdir new fail before creating anything
because Python does not expose the safe handle-bound recursive deletion
primitives ephemdir requires. Supported platforms are Linux and macOS.
brew install vindfjur/tap/ephemdirOr tap once and install by name:
brew tap vindfjur/tap
brew install ephemdirFor command-line use, pipx keeps ephemdir isolated from system packages:
pipx install ephemdirOr install from PyPI with pip:
pip install ephemdirAdd ephemdir to your project dependencies, or install it into the current environment:
python -m pip install ephemdirfrom ephemdir import tempdir
# Due after the next system restart (the default).
work = tempdir()
print(work) # -> /current/dir/brave-otter-a81f42c9d047315b
(work.path / "data.txt").write_text("hello")
# Also expires after a given lifetime.
cache = tempdir(lifetime="2h") # "30m", "1h30m", 3600, timedelta(hours=2) ...
# Survive restarts and rely only on the lifetime.
keep = tempdir(lifetime="7d", remove_on_restart=False)
# Do not delete while a process still has files open inside (Linux/macOS).
busy = tempdir(lifetime="1h", keep_while_in_use=True)
# Use as a context manager: removed automatically when the block exits.
with tempdir() as scratch:
(scratch / "tmp.bin").write_bytes(b"...")| Parameter | Default | Description |
|---|---|---|
lifetime |
None |
Time to live: seconds, timedelta, or "2h"/"1h30m". None = due after restart. |
remove_on_restart |
True |
Remove the directory after the machine reboots. |
keep_while_in_use |
False |
Defer deletion while files are still open inside (Linux/macOS). |
cleanup |
"auto" |
Use "next-sweep" to keep until an explicit full sweep. |
max_size |
None |
Optional byte or human string size limit such as "2GiB". |
name_style |
"secure" |
secure keeps a random suffix; clean requires a private parent. |
parent |
current dir | Where to create the directory. |
prefix |
"" |
Prefix prepended to the generated name. |
words |
2 |
Number of words in the generated name. |
Any option left unset falls back to the user config file, then to the built-in default shown above.
d = tempdir()
d.path # pathlib.Path to the directory
d.created_at # creation timestamp
d.expires_at # expiry timestamp or None
d.remove() # delete now and stop tracking
d.keep() # stop tracking but keep on disk (becomes permanent)
d.extend("2h") # fresh lifetime counted from now (None = no time limit)You can also manage any tracked directory later — by full path, exact name or a unique prefix of the name:
from ephemdir import keep, extend, remove, resolve, prune
keep("brave-otter") # liked it? make it permanent
extend("brave-otter", "2h") # give it two more hours from now
remove("brave-otter") # delete it right away
resolve("bra") # -> Path to the matching tracked directory
prune() # explicitly forget missing tracked entriesephemdir records each directory in a small JSON registry in your per-user data directory. Cleanup happens in two ways:
- Lazily — every call to
tempdir()first sweeps anything already due. - On demand — run
ephemdir sweepfrom the command line.
“Until restart” means a directory becomes due after the machine reboots; it is removed by the next sweep that can verify it is still the directory ephemdir created. Install the scheduled service if you want that sweep to happen automatically after login/reboot rather than only when you run ephemdir.
Use ephemdir sweep --dry-run to preview due directories without destructive
actions, and ephemdir explain <name> to see the reasons and blockers for one
tracked directory.
ephemdir never auto-deletes a directory it cannot prove it created:
- Each directory contains a hidden
.ephemdirmarker file with a random id that is also stored in the registry (plus the inode on Unix). Nothing is auto-deleted unless the marker matches — if you delete a directory manually and something else later appears at the same path, ephemdir leaves it alone and keeps the entry blocked (shown as⚠️ replacedinephemdir list). If the path is missing, ephemdir reports 👻missingand keeps tracking it until verified deletion succeeds or you explicitly runephemdir prune,ephemdir keep <name>orephemdir recover <name> --forgetfor recovery entries. Directories registered by ephemdir ≤ 0.3 have no marker, so they are never auto-removed either: they show as ⚪legacyuntil youephemdir rmorephemdir keepthem. - Deletion is journaled and claimed under locks: the registry records the
intent durably before the directory is renamed away and commits afterwards,
every step re-verifies the entry and the marker, and each directory has its
own OS-level deletion lock. A concurrent
keeporextendalways wins against a running sweep (keep()inside awith tempdir()block is honoured), two overlapping sweeps never delete the same tree twice, and a crash at any point is reconciled by the next sweep's recovery pass under the same deletion lock. A half-deleted tree stays at its private staging path and is retried; it is never renamed over a new object at the original path. Ambiguous cases are parked asrecoveryand can be handled withephemdir recover. Nothing mid-deletion becomes untracked. - On POSIX, recursive staging deletion is bound to the verified directory
object: ephemdir opens the staging tree with
O_DIRECTORY | O_NOFOLLOW, checksfstat()against the claimed inode/marker and removes contents viadir_fdoperations. If the staging pathname is replaced after verification, the replacement is left alone and the entry moves torecovery. - Linux cleanup refuses to cross mount boundaries by comparing mount ids from
opened directory fds. It uses
statx(STATX_MNT_ID)when available and falls back to/proc/self/fdinfoor/proc/self/mountinfoon older kernels. Mount capability is checked before claim, then rechecked before recursion. - Directory creation and claim/delete require a trusted parent chain: existing
components are opened without following symlinks and must be owned by you or
root. The final parent may be owner-controlled, or a root-owned sticky shared
directory like
/tmp; foreign-owned sticky parents and group/world-writable parents without sticky bit are refused before cleanup side effects or rename. - Platforms without safe fd-bound recursive deletion do not fall back to
pathname-based
rmtree.tempdir()refuses to create new managed directories, and cleanup capability is rechecked before claim so existing entries remain untouched. - A deletion guard refuses to touch the filesystem root, your home
directory or ephemdir's own data — even with
sweep --forceand even if the registry file was corrupted or edited by hand. A malformed registry entry invalidates the whole registry for that command; transactions copy the inspected bytes toregistry.json.corrupt-*, leave the active registry path in place as a blocking object and abort instead of saving a filtered or empty state. - Directories are created owner-only (
0700), liketempfile.mkdtemp. - Optional
lsofand macOSsysctlprobes ignore inheritedPATH, resolve helpers only from trusted system directories and run with a minimal environment. Ifkeep_while_in_useis enabled andlsofcannot run, the sweep keeps the directory and warns instead of deleting. - Generated names end with a 64-bit random token, so another local user
cannot pre-create the whole name space in a shared sticky directory like
/tmpand block creation. - The scheduled service runs
python -I -m ephemdirfrom/with a scrubbed environment, andinstall-serviceverifies both that isolated mode resolvesephemdirto the installed package and that every directory on the way to the systemd/launchd unit files is owned and writable only by you (service files are then writtendir_fd-relative to the verified directory). The installed job also pins the effective absoluteEPHEMDIR_DATA_DIRandEPHEMDIR_CONFIG_DIR, so future scheduled sweeps use the same registry/config target as the shell that installed the service. - The config file is honoured only when it is yours: a
config.tomlowned by another user or writable by group/others is ignored with a warning.
When an interrupted deletion is genuinely ambiguous, inspect it with
ephemdir list --json. After resolving the filesystem situation, retry it with
ephemdir recover <name>. ephemdir recover <name> --forget removes only the
registry entry and never deletes either the original or staging path.
To clean up reliably over time (and right after a reboot), install a scheduled sweep service for your platform — one command, no manual unit files:
ephemdir install-service --interval 600 # launchd / systemd
ephemdir uninstall-serviceinstall-service validates the persistent runtime before writing anything:
every component of the interpreter and package paths must be owned by you
(or root) and must not be writable by other users. This deliberately rejects
any group/world-writable component — including sticky directories like
/tmp — so a virtualenv created under /tmp cannot host the scheduled
service even though it is fine for one-off interactive use. Install the venv
under your home directory instead. It also checks the interpreter-startup
hooks Python runs before ephemdir is imported (.pth files in site-packages,
sitecustomize, pyvenv.cfg, and the tomli package on Python 3.10).
On macOS, install-service may reject a Homebrew or otherwise shared Python
runtime if any interpreter/package component is group/world-writable. That is
expected: launchd will run the interpreter later, so ephemdir refuses a runtime
another local user could modify after installation. For a dedicated service
runtime, use a private uv-managed virtual environment under your home directory:
uv python install 3.12
uv venv ~/.venvs/ephemdir-safe --python 3.12
uv pip install --python ~/.venvs/ephemdir-safe/bin/python ephemdir
~/.venvs/ephemdir-safe/bin/python -I -m ephemdir install-serviceTrust boundary for
install-service. The scheduled job runs your Python interpreter unattended and later, as your user. ephemdir verifies its own package, the interpreter, and the startup hooks above, but it cannot exhaustively vet every module a.pthfile or a runtime dependency might import — that is the same surface as any Python command you run. Only install the service from a Python environment whosesite-packagesare not writable by other local users (a normal per-userpip/venvinstall satisfies this). On a single-user machine there is nothing extra to do; on a shared multi-user host, ensure the environment is owned by you and not group/world-writable before scheduling sweeps.
Prefer to wire it up yourself? The equivalents are:
- Linux (cron):
*/10 * * * * ephemdir sweep - macOS (launchd): a
LaunchAgentrunningephemdir sweep; template inpackaging/.
You can also keep a foreground watcher running:
ephemdir watch --interval 600Set per-user defaults in a config.toml file in your config directory
(~/.config/ephemdir/ on Linux or ~/Library/Application Support/ephemdir/
on macOS). Every key is optional:
lifetime = "6h"
remove_on_restart = true
keep_while_in_use = true
prefix = "scratch-"
words = 2These apply to any option you do not pass explicitly to tempdir() or the
ephemdir new command. Reading the file uses the standard-library tomllib
(Python 3.11+) or tomli on older versions.
The environment variables EPHEMDIR_DATA_DIR (registry location) and
EPHEMDIR_CONFIG_DIR (config location) override the platform defaults —
handy for sandboxes, tests and dotfile-managed setups.
ephemdir new # create a directory, print its path
ephemdir new --lifetime 2h # with a lifetime
ephemdir new --keep-on-restart # do not remove on restart
ephemdir list # tracked directories, status and time left
ephemdir path brave-otter # print a tracked directory's path
ephemdir keep brave-otter # liked it? stop tracking, keep forever
ephemdir extend brave-otter 2h # fresh lifetime from now (--forever: no limit)
ephemdir rm brave-otter # remove a tracked directory now
ephemdir sweep # remove everything due now
ephemdir sweep --force # remove every tracked directory
ephemdir prune # explicitly forget missing tracked entries
ephemdir recover brave-otter # retry an interrupted deletion
ephemdir recover brave-otter --forget # forget it without deleting files
ephemdir watch # sweep periodically in the foreground
ephemdir install-service # schedule sweeps via the OS scheduler
ephemdir uninstall-service # remove the scheduled serviceEvery command that takes a directory accepts a full path, the exact name or a
unique prefix (bra or brave-otter for brave-otter-a81f42c9d047315b).
Add -v for more output or -q to
stay quiet.
ephemdir list shows each directory's status at a glance:
🟢 spotted-armadillo 1h 59m left ~/work/spotted-armadillo
🟡 vermilion-mackerel 4m 59s left ~/work/vermilion-mackerel
🔄 caped-dodo until restart ~/work/caped-dodo
🔴 furious-caiman expired 5m ago ~/work/furious-caiman
👻 lucky-yak missing; still tracked ~/work/lucky-yak
🟢 counting down · 🟡 less than 15 minutes left · 🔴 due on the next
sweep · 🔄 until restart · 📌 no auto-cleanup · 👻 missing from disk but
still tracked until explicit prune/keep · ⚠️ replaced by another directory
(never touched, still tracked) · ⚪ legacy entry from ephemdir ≤ 0.3 (resolve
with rm/keep) · 🚧 interrupted deletion requiring recover · ❓
temporarily inaccessible but still tracked. Terminals without emoji support
fall back to ASCII tags; --plain forces them, --json prints
machine-readable output for scripting.
A subprocess cannot change your shell's working directory, so ephemdir ships
shell functions instead (the same trick zoxide and nvm use). Add one line
to your shell rc file:
# ~/.bashrc or ~/.zshrc
eval "$(ephemdir shell-init)"
# ~/.config/fish/config.fish
ephemdir shell-init fish | source
Then:
enew -l 2h # create a directory and cd into it
ecd brave-otter # cd into a tracked directory by name or prefix
ecd # cd into the most recently created oneThe new and path commands print the path to stdout and all diagnostics
to stderr, so they also compose cleanly by hand: cd "$(ephemdir new)".
pip install -e ".[dev]"
pytestpyproject.toml only declares the minimum safe build backend
(setuptools>=78.1.1, which includes the fix for CVE-2025-47273), so a plain
isolated build resolves whatever backend version is current. To build exactly
what release CI builds, use the hash-pinned toolchain:
python -m pip install --require-hashes -r requirements-build.txt
python -m build --no-isolationRelease artifacts are produced this way in .github/workflows/publish.yml;
the workflow records SHA-256 hashes of dist/ right after the build and
verifies them again after the install smoke test.
MIT © vindfjur