Every CLI that survives a few releases accumulates files of its own: a config file the user edits, a cache of downloaded metadata, a record of the last run, a log for debugging. Where those files go is one of the first things users notice. Drop a .mytool directory into the home directory root and Linux users sigh; write a cache into the working directory and it ends up committed to someone's repository; hard-code ~/.config and Windows users get a folder their system does not recognise. This guide uses the platformdirs package to put each kind of file in the directory each operating system designates for it, adds environment and flag overrides, and gives users a command to find and clean it all. It belongs to the filesystem topic.
Prerequisites
- Python 3.10+ and
platformdirs4.x (uv add platformdirs). - A Typer or Click CLI.
- A clear idea of which files your tool owns. If you are unsure, the decision section below will help.
Four kinds of file, four directories
Operating systems distinguish between files by what losing them would cost. The XDG base-directory specification on Linux, Apple's guidelines on macOS and Microsoft's known-folders on Windows all draw roughly the same lines, and platformdirs maps a single API onto each:
- Config — settings the user chooses and may edit by hand. Users back this up and sync it between machines.
- Cache — anything your tool can rebuild: downloaded indexes, compiled templates, API responses. Deleting it must be harmless, and system cleanup tools may do exactly that.
- State — data the tool itself maintains that is not worth backing up but is annoying to lose: command history, "last checked for updates" timestamps, a record of which migrations ran. (On macOS and Windows this lands next to the data directory; on Linux it is the separate
~/.local/state.) - Logs — diagnostic output kept between runs.
There is also a data directory, for files the user would lose work without but does not edit directly — a local database or downloaded plugins. Many CLIs never need it.
The recipe
Put the directory logic in one module. Everything else in the CLI imports paths from it and never computes a location itself.
# src/mytool/paths.py
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from platformdirs import PlatformDirs
APP = "mytool"
@dataclass(frozen=True)
class AppPaths:
config_dir: Path
cache_dir: Path
state_dir: Path
log_dir: Path
@property
def config_file(self) -> Path:
return self.config_dir / "config.toml"
def ensure(self) -> None:
for d in (self.config_dir, self.cache_dir, self.state_dir, self.log_dir):
d.mkdir(parents=True, exist_ok=True)
def _override(var: str) -> Path | None:
value = os.environ.get(var)
return Path(value).expanduser() if value else None
def resolve_paths(config_dir: Path | None = None) -> AppPaths:
"""Directories for this run: flag > MYTOOL_* env var > platform default."""
dirs = PlatformDirs(APP, appauthor=False)
return AppPaths(
config_dir=config_dir or _override("MYTOOL_CONFIG_DIR") or dirs.user_config_path,
cache_dir=_override("MYTOOL_CACHE_DIR") or dirs.user_cache_path,
state_dir=_override("MYTOOL_STATE_DIR") or dirs.user_state_path,
log_dir=_override("MYTOOL_LOG_DIR") or dirs.user_log_path,
)
A few deliberate choices:
appauthor=False. On Windows,platformdirsotherwise nests directories under an author name (%LOCALAPPDATA%\Acme\mytool). For a developer tool with no company brand, a flat directory is what users expect.- Nothing is created at import time.
ensure()runs only when a command is about to write. A--helpor a read-only command should never create empty directories. (PlatformDirs(..., ensure_exists=True)exists, but it creates directories whenever a path property is accessed, which is too eager for a CLI.) - Overrides are layered. A
--config-dirflag beats aMYTOOL_CONFIG_DIRenvironment variable, which beats the platform default — the same precedence the site recommends for every setting in config precedence: flags, env, files and defaults. On Linux,platformdirsalready honoursXDG_CONFIG_HOMEand friends, so your variables are an extra, tool-specific layer on top.
Now wire it into the CLI. The callback resolves paths once and stores them on the context so every command sees the same values:
# src/mytool/cli.py
import shutil
from pathlib import Path
import typer
from mytool.paths import AppPaths, resolve_paths
app = typer.Typer()
cache_app = typer.Typer(help="Manage the local cache.")
app.add_typer(cache_app, name="cache")
def _size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) if path.exists() else 0
@app.callback()
def main(
ctx: typer.Context,
config_dir: Path = typer.Option(None, "--config-dir", envvar="MYTOOL_CONFIG_DIR",
help="Read and write configuration here."),
) -> None:
"""mytool — an example of well-placed files."""
ctx.obj = resolve_paths(config_dir)
@app.command()
def paths(ctx: typer.Context) -> None:
"""Show where mytool keeps its files."""
p: AppPaths = ctx.obj
rows = [("config", p.config_dir), ("cache", p.cache_dir),
("state", p.state_dir), ("logs", p.log_dir)]
for label, d in rows:
extra = f" ({_size(d) / 1e6:.1f} MB)" if label == "cache" and d.exists() else ""
typer.echo(f"{label:<7} {d}{extra}")
@cache_app.command("clear")
def cache_clear(ctx: typer.Context) -> None:
"""Delete everything in the cache directory."""
p: AppPaths = ctx.obj
if not p.cache_dir.exists():
typer.echo("cache is already empty", err=True)
return
count = sum(1 for f in p.cache_dir.rglob("*") if f.is_file())
shutil.rmtree(p.cache_dir)
typer.echo(f"removed {count} files", err=True)
if __name__ == "__main__":
app()
Sharing resolved paths through ctx.obj is the standard pattern from sharing state with Click context objects; it keeps commands free of path logic and makes them trivial to test.
UX considerations
- Ship a
pathscommand. "Where does it keep its config?" is the most common support question for any tool with a config file. One command answers it on every platform, and it doubles as a debugging aid when overrides are in play. - Make the cache disposable, and say so. A
cache clearcommand, plus a sentence in the help text that the cache can be deleted at any time, gives users confidence to reclaim space. - Never write to the working directory implicitly. Output the user asked for (
-o report.csv) goes where they said. Your tool's own files never go in the project they happen to be standing in. - Honour a project-level config too, if it helps. Tools like linters benefit from a config file in the repository as well as a user one. That is a separate discovery mechanism — see discovering project config files by walking up directories.
- Migrate old locations gently. If earlier versions used
~/.mytool, read from it when the new location is empty, print a one-line notice, and move the files once. Silently ignoring the old directory looks like data loss.
Testing the behaviour
Tests must never touch the real home directory. Point every override at tmp_path in a fixture and use the CLI as normal:
# tests/test_paths.py
import pytest
from typer.testing import CliRunner
from mytool.cli import app
from mytool.paths import resolve_paths
runner = CliRunner()
@pytest.fixture
def isolated(tmp_path, monkeypatch):
for kind in ("CONFIG", "CACHE", "STATE", "LOG"):
monkeypatch.setenv(f"MYTOOL_{kind}_DIR", str(tmp_path / kind.lower()))
return tmp_path
def test_env_overrides(isolated):
p = resolve_paths()
assert p.config_file == isolated / "config" / "config.toml"
assert p.cache_dir == isolated / "cache"
def test_flag_beats_env(isolated, tmp_path):
p = resolve_paths(config_dir=tmp_path / "flag")
assert p.config_dir == tmp_path / "flag"
def test_paths_command_creates_nothing(isolated):
result = runner.invoke(app, ["paths"])
assert result.exit_code == 0
assert "config" in result.output
assert not (isolated / "cache").exists()
def test_cache_clear(isolated):
cache = isolated / "cache"
(cache / "sub").mkdir(parents=True)
(cache / "sub" / "a.json").write_text("{}")
result = runner.invoke(app, ["cache", "clear"])
assert result.exit_code == 0
assert not cache.exists()
The "creates nothing" test is worth keeping: it catches the regression where someone adds mkdir to an import-time path computation and every --help starts littering the home directory.
Conclusion
Put each file where the operating system expects that kind of file — config, cache, state, logs — using platformdirs so one line of code is right on Linux, macOS and Windows. Resolve directories in one module, layer flag and environment overrides on top, create directories only when writing, and give users a paths command and a way to clear the cache. Combined with atomic writes for the files that matter, your tool's footprint on a user's machine becomes predictable and easy to manage.
Frequently asked questions
Isn't ~/.mytoolrc simpler?
It is simpler for you and worse for users. A home directory full of dot-files is hard to manage, and nothing distinguishes config worth backing up from cache worth deleting. If you already ship a dot-file, keep reading it as a legacy location while writing to the platform directory.
Should macOS use ~/.config instead of Application Support?
Many developer tools do, because their users expect Linux-style paths and share dotfiles across systems. platformdirs follows Apple's convention. If your audience strongly prefers ~/.config, honour XDG_CONFIG_HOME explicitly on macOS as an opt-in, and document it in your paths output.
Where should credentials go?
Not in any of these directories as plain text if you can avoid it. Use the system keychain via keyring, and keep only non-secret metadata (which account is active) in state. See storing tokens with keyring.
How should the tool behave in CI or a read-only container?
Assume the home directory may be missing, read-only or thrown away after every job. Treat a failure to create the cache directory as a warning, not an error — run without a cache and say so once on stderr. Let operators redirect every directory with the MYTOOL_*_DIR variables so a CI job can point the cache at a directory its runner persists between builds, which is often the single biggest speed-up available for tools that download metadata. And never require a config file to exist: defaults plus flags and environment variables should be enough to run anywhere.
What about per-project caches, like .pytest_cache?
A cache tied to one project and invalidated with it can reasonably live in that project, as pytest and mypy do — but create it only when the user runs your tool there, add a .gitignore inside it containing *, and document it.