v0.2.0

Paths

Assertions on path shape, and assertions that touch the filesystem, kept apart by the type system. A PurePath subject genuinely has no exists() to call by mistake, so a test about path shape cannot accidentally hit the disk.

6 min readEdit this page

tip

Full signatures: PurePathExpect[T] and PathExpect.

Value Subject Touches the filesystem
PurePath, PurePosixPath, PureWindowsPath PurePathExpect[T] no — pure name algebra
Path, PosixPath, WindowsPath PathExpect yes — and inherits all of the above

note

pathlib is never imported by this library — the dispatch matches these types by name. See Performance.

Path shape (no I/O)

python
from pathlib import PurePosixPath

from lovely_assertions import expect, AssertionFailure

artefact = PurePosixPath("build/report.tar.gz")
expect(artefact).has_name("report.tar.gz").and_.has_suffix(".gz")

try:
    expect(artefact).has_suffix(".txt")
except AssertionFailure as failure:
    print(failure)

try:
    expect(artefact).has_parent(PurePosixPath("dist"))
except AssertionFailure as failure:
    print(failure)
Expected artefact to have the suffix '.txt', but 'build/report.tar.gz' has the suffix '.gz'.
Expected artefact to have the parent 'dist', but 'build/report.tar.gz' has the parent 'build'.

The family: has_name, has_stem, has_suffix, has_suffixes, has_no_suffix, has_parent, is_absolute, is_relative, is_relative_to, is_not_relative_to, matches_pattern.

has_suffixes takes the whole list, and a filename can have more than you expect:

python
from pathlib import PurePosixPath

from lovely_assertions import expect, AssertionFailure

artefact = PurePosixPath("build/report.tar.gz")
try:
    expect(artefact).has_suffixes([".tar", ".zip"])
except AssertionFailure as failure:
    print(failure)
Expected artefact to have the suffixes ['.tar', '.zip'], but 'build/report.tar.gz' has ['.tar', '.gz'].

Watch out for version numbers: app-1.2.3.whl has suffixes ['.2', '.3', '.whl'], because pathlib splits on every dot.

Filesystem assertions

python
from pathlib import Path

from lovely_assertions import expect, AssertionFailure

Path("notes.txt").write_text("hello world", encoding="utf-8")
notes = Path("notes.txt")

expect(notes).exists().and_.is_file()

try:
    expect(Path("missing.txt")).exists()
except AssertionFailure as failure:
    print(failure)

try:
    expect(notes).is_directory()
except AssertionFailure as failure:
    print(failure)
Expected Path("missing.txt") to exist, but nothing is there at 'missing.txt'.
Expected notes to be a directory, but 'notes.txt' is a regular file.

The family: exists, does_not_exist, is_file, is_not_file, is_directory, is_not_directory.

"is a regular file" rather than "is not a directory" — the message says what the thing is, which is usually the fact that resolves the confusion.

Contents

python
from pathlib import Path

from lovely_assertions import expect, AssertionFailure

Path("notes.txt").write_text("hello world", encoding="utf-8")
notes = Path("notes.txt")

expect(notes).has_text("hello world").and_.contains_text("world")

try:
    expect(notes).has_text("goodbye")
except AssertionFailure as failure:
    print(failure)

try:
    expect(notes).has_size(999)
except AssertionFailure as failure:
    print(failure)
Expected notes to have the text 'goodbye', but 'notes.txt' holds 'hello world'.
Expected notes to hold 999 bytes, but 'notes.txt' holds 11 bytes.

All three text assertions — has_text, contains_text and does_not_contain_text — take text, never bytes, and accept an encoding= that defaults to UTF-8. Content that will not decode fails with the codec's own reason and the byte that stopped it. An encoding name that does not exist raises LookupError, but only once the bytes are in hand: a missing file fails the assertion before the codec is ever looked up.

Also: has_size_greater_than, has_size_less_than, is_empty, is_not_empty.

Directories

python
from pathlib import Path

from lovely_assertions import expect, AssertionFailure

Path("notes.txt").write_text("hello world", encoding="utf-8")

expect(Path(".")).is_directory().and_.has_child("notes.txt")

try:
    expect(Path(".")).has_child("missing.txt")
except AssertionFailure as failure:
    print(failure)
Expected Path(".") to have a child named 'missing.txt', but '.' holds ['notes.txt'].

It lists what is actually there, which is the answer to "why isn't my file found" most of the time. The listing is clipped for a large directory.

does_not_have_child asserts the negative about a directory that exists, so a subject that is not a directory fails it outright rather than passing vacuously. It is one of the negations that are not complements.

has_child takes one entry name, never a route: "logs/app.log", ".." and an absolute path each raise ValueError at the call rather than failing. Assert on the child path itself for anything deeper.

is_symlink, is_not_symlink and is_same_file_as round out the set. is_same_file_as asks the filesystem rather than the strings, so a hard link, a symbolic link and ./x against x are all the same file. Two files that both exist and differ get a flat mismatch; when one of the two cannot be read, the message names which one.

Gotchas

A suffix carries its leading dot

python
from pathlib import PurePosixPath

from lovely_assertions import expect

artefact = PurePosixPath("build/report.tar.gz")
try:
    expect(artefact).has_suffix("gz")
except ValueError as error:
    print(error)
a suffix carries its leading dot, the way PurePath.suffix reports it: got 'gz', did you mean '.gz'?

Raised at the call rather than failing, because "gz" could never be any path's suffix — it is a bug in the test, and the message says what you meant.

matches_pattern is anchored at the right

A relative pattern is matched against the tail of the path, so "*.txt" asks about the last component only and says nothing about the rest:

python
from pathlib import PurePosixPath

from lovely_assertions import expect, AssertionFailure

log = PurePosixPath("/var/log/app.txt")
expect(log).matches_pattern("*.txt")

try:
    expect(log).matches_pattern("/*.txt")
except AssertionFailure as failure:
    print(failure)
Expected log to match the pattern '/*.txt', but '/var/log/app.txt' does not.

Starting the pattern with a separator anchors it at the left, as above; reach for PurePath.full_match when you want the whole path in one comparison. Case sensitivity follows the path's flavour — a PureWindowsPath matches case-insensitively — so pass case_sensitive= when the answer has to be the same on every machine. An empty pattern raises ValueError.

is_relative_to is string algebra, not containment

It compares path components, and does not resolve symlinks, .., or the actual filesystem. Never use it as a path-traversal guard. For that you want Path.resolve() first, and then a check you have thought about properly.

The negated disk assertions are not complements

is_not_file, is_not_directory, is_not_symlink, is_not_empty and does_not_have_child each assert the negative about a path that exists, so every one of them fails when nothing is there — and so does does_not_contain_text, which cannot read a file that is not there. A path that does not exist fails is_file() and is_not_file() — the second asserts "this is on disk and is not a regular file", which a missing path is not. does_not_exist() is the assertion that means "nothing is there".

A dangling symlink is the sharp version: it fails exists() and does_not_exist(), while is_symlink() passes.

The size family raises for a bad question and fails for a bad answer

A negative size raises ValueError on all three — no file has one, so it is a bug in the test rather than a finding about the value. has_size_less_than(0) raises for the same reason one step along: nothing is smaller than nothing, and is_empty is the zero-byte claim.

python
from pathlib import Path

from lovely_assertions import expect

try:
    expect(Path("notes.txt")).has_size(-1)
except ValueError as error:
    print(error)

try:
    expect(Path("notes.txt")).has_size_less_than(0)
except ValueError as error:
    print(error)
a size in bytes is never negative, got -1
no file holds fewer than zero bytes; is_empty is the zero-byte claim

Asking the size of a directory is a different thing: that is a fact about the value, so it fails, and the message says what the path actually is.

esc

Nothing matches that. The reference is generated, so try an assertion name.

No results. The reference is generated, so try an assertion name.

↑↓ navigate↵ open30 pages, every example executed in CI