Input & UX

Validating File and Directory Paths in CLIs

Validate paths in a Python CLI - built-in existence and permission checks, refusing paths that escape a root, and errors that print the resolved path.

Updated

Paths account for more command-line validation than everything else combined, and both frameworks have enough built in that you should rarely write the checks by hand. What is left over is the interesting part: paths that do not exist yet, paths that must stay inside a root, and errors that tell the user which path the tool actually looked at.

TL;DR

  • Use the built-in checks: exists, file_okay, dir_okay, readable, writable, resolve_path.
  • For a path you will create, check the parent, not the path.
  • resolve() then is_relative_to(root) is the whole containment check — string prefixes are not.
  • Always print the resolved absolute path in errors; it ends the "but it is right there" conversation.
  • Use a distinct exit code for a missing input file (66 is conventional).

Prerequisites

A Typer or Click application, and pathlib. Everything here works the same in argparse with a type= callable.

What the framework already checks

The path checks frameworks give you A table of built-in path validation options in Click and Typer, covering existence, file or directory, readability, writability and resolution. The path checks frameworks give you Option Checks Typical use exists=True the path is there now inputs file_okay / dir_okay which kind it may be a file, not a folder readable / writable permission to use it before doing work resolve_path=True expands to absolute values you store or log (none) accepts anything paths you will create Five keywords remove almost every hand-written path check from command bodies.
@app.command()
def convert(
    source: Annotated[Path, typer.Argument(
        exists=True, dir_okay=False, readable=True,
        help="File to convert.",
    )],
    out_dir: Annotated[Path, typer.Option(
        file_okay=False, writable=True, resolve_path=True,
        help="Directory to write results into.",
    )] = Path("."),
) -> None:
    ...

Five keywords, and the command body contains no path checks at all. A missing file, a directory where a file was expected, or a permission problem all produce a usage error naming the option and exit code 2 before your code runs.

resolve_path=True is the one people miss. It expands the value to an absolute path, which matters whenever you store it, log it, or compare it — a relative path recorded in a config file means something different the next time the tool runs from a different directory.

The Click spelling is the same set of arguments on click.Path:

@click.option("--out-dir", type=click.Path(file_okay=False, writable=True, path_type=Path))

Note path_type=Path: without it Click hands you a str, and you end up converting at every call site.

Paths that do not exist yet

The gap in the built-ins is an output path, where the file should not exist and the directory should. That needs a small validator:

def writable_target(path: Path) -> Path:
    resolved = path.expanduser().resolve()
    if resolved.exists():
        raise typer.BadParameter(f"{resolved} already exists (use --force to overwrite)")
    if not resolved.parent.is_dir():
        raise typer.BadParameter(f"{resolved.parent} does not exist")
    return resolved

@app.command()
def export(
    output: Annotated[Path, typer.Option(parser=writable_target, help="Where to write the export.")],
) -> None:
    ...

Two details make it useful. expanduser() handles ~/exports/data.json, which the shell expands only when unquoted — a quoted path arrives with the tilde intact and fails confusingly without it. And the message names --force, so the user learns the fix rather than the rule.

There is a theoretical race here — the file could appear between the check and the write — and for a CLI that is acceptable. What matters is catching the common mistake before twenty minutes of processing, not guaranteeing atomicity.

Refusing a path that escapes its root

Refusing a path that escapes its root A user-supplied path is resolved to an absolute path and compared against the allowed root before it is used. Refusing a path that escapes its root User input possibly ../../etc resolve() absolute, symlinks followed is_relative_to(root) inside, or refuse Use it known-safe path normalise check then Resolving before comparing is the whole check — string prefixes are trivially bypassed.

When a path comes from a config file, an archive entry, or a remote payload, it may be trying to leave the directory you intended:

def inside(root: Path, candidate: Path) -> Path:
    resolved = (root / candidate).resolve()
    if not resolved.is_relative_to(root.resolve()):
        raise InputDataError(f"{candidate} escapes the output directory")
    return resolved

resolve() before comparing is the entire check: it normalises .. segments and follows symlinks, which is what a string prefix test misses. Path.is_relative_to has been available since Python 3.9 and is clearer than comparing parts.

This matters most when extracting archives or writing files whose names came from data — the classic path traversal shape. If your tool never handles untrusted paths, you do not need it; if it does, this is the check.

Errors people can act on

Path errors users can act on Guidance for reporting path problems in a command line tool, and messages that leave the user guessing. Path errors users can act on Actionable Print the resolved absolute path, not what was typed Say which check failed: missing, not a file, not writable Name the flag or argument it came from Use a distinct exit code for missing input Leaves them guessing A FileNotFoundError traceback Printing a relative path from a different directory "invalid path" with no reason Failing after twenty minutes of work Printing the resolved path is what ends the "but the file is right there" conversation.

The single most useful habit is printing the resolved path rather than what the user typed:

raise InputDataError(f"cannot read {path.resolve()}: no such file")

A user who typed ./data/jobs.json from the wrong directory sees an absolute path that is obviously not where they thought, and the conversation ends there. Printing back what they typed tells them nothing they did not already believe.

Two more:

Say which check failed. "not a file" and "not readable" and "does not exist" are three different fixes. A generic "invalid path" makes the user try all three.

Use a distinct exit code. 66 (EX_NOINPUT) for a missing input file lets a wrapper script tell "the file is not there yet" from "the tool failed", which is a genuinely useful distinction in a pipeline.

UX considerations

Fail before the work, not after. Validating an output directory at the top of a command that runs for twenty minutes is the difference between an immediate error and a wasted afternoon. This is the argument for writable=True on the option rather than a check at write time.

Accept - where it makes sense. A path argument that could be stdin or stdout should honour the dash convention, as described in reading piped input.

Do not resolve paths you show back in progress output. An absolute path in every progress line is noise; show the relative form while working and the resolved form in errors.

Be careful with ~ in configuration. A path read from a file is not shell-expanded, so expanduser() is your job. It is a one-line fix and a recurring bug report without it.

Testing the behaviour

Path validation is cheap to test with tmp_path, and the cases worth covering are the failures:

def test_missing_input_exits_66(cli, tmp_path):
    result = cli.invoke(app, ["convert", str(tmp_path / "nope.txt")])

    assert result.exit_code in (2, 66)
    assert str(tmp_path) in result.stderr        # the resolved path is shown

def test_directory_where_a_file_is_expected(cli, tmp_path):
    assert cli.invoke(app, ["convert", str(tmp_path)]).exit_code == 2

def test_existing_output_needs_force(cli, tmp_path):
    target = tmp_path / "out.json"
    target.write_text("{}")

    result = cli.invoke(app, ["export", "--output", str(target)])

    assert result.exit_code == 2
    assert "--force" in result.stderr            # the message names the fix

@pytest.mark.parametrize("candidate", ["../escape.txt", "sub/../../escape.txt"])
def test_paths_escaping_the_root_are_refused(tmp_path, candidate):
    with pytest.raises(InputDataError):
        inside(tmp_path, Path(candidate))

The parametrised containment test is worth keeping even if you think the input is trusted — trust changes, and this is one of the few validation rules with a security consequence.

Conclusion

Let the framework check existence, kind and permissions; write a small validator for paths you will create; resolve before comparing when containment matters; and print the resolved path in every error. Between them these remove nearly all hand-written path handling from command bodies, and the handful of lines that remain are the ones worth reading.

Frequently asked questions

Should I use Path or str for path parameters?

Path, always — both frameworks will produce one for you, and it removes an entire category of string-manipulation bugs around separators and joining. In Click, remember path_type=Path; Typer infers it from the annotation.

What about paths with spaces or unusual characters?

They arrive correctly: the shell handles quoting, and Python gives you the decoded string. Problems here are nearly always your own code building a command line for a subprocess — pass a list of arguments rather than a formatted string and the issue disappears.

How do I validate that a directory is writable without creating a file?

os.access(path, os.W_OK) is what the frameworks use, and it is advisory — the definitive test is attempting the write. For a long-running command, an early advisory check plus a clear error at write time is the right combination.

Should the tool create a missing output directory?

Only if the user asked, with a flag or a documented default. Silently creating directories surprises people, especially when a typo produces a new directory tree instead of an error. --create-dirs is the common convention.

Does resolve() fail on a path that does not exist?

Not since Python 3.6 — it resolves as far as it can with strict=False, which is the default. Pass strict=True when you specifically want it to raise for a missing path, though the framework's exists=True is usually the better place for that check.

How do I handle a path argument that may be a glob?

Let the shell expand it and accept a list. mytool convert *.csv arrives as many arguments on Unix, so a list[Path] parameter is all you need. Expanding globs yourself is only necessary on Windows, where the shell does not — and if you do, do it explicitly with Path.glob rather than shelling out, and document that the pattern is interpreted by the tool.