Skip to content

fileutils.rotate_file(keep=1) deletes the file it just rotated; keep=N retains N-1 generations #479

Description

@ArtJack

rotate_file removes the oldest generation after the rename ladder has already moved every file up by one, so the generation it just wrote is the one it deletes. With keep=1 the file is destroyed and no backup exists; with any keep=N, N-1 generations survive.

Reproduction (fresh clone at 967864f, Python 3.13 and 3.14, everything in a tempdir):

import os, tempfile
from boltons.fileutils import rotate_file

with tempfile.TemporaryDirectory() as d:
    p = os.path.join(d, "f.txt")
    open(p, "w").write("CRITICAL DATA")
    rotate_file(p, keep=1)
    print(sorted(os.listdir(d)))      # []  — the file is gone, nothing was kept

with tempfile.TemporaryDirectory() as d:
    p = os.path.join(d, "f.txt")
    for i in range(1, 6):
        open(os.path.join(d, f"f.{i}.txt"), "w").write(f"gen {i}")
    open(p, "w").write("current")
    rotate_file(p, keep=5)
    print(sorted(os.listdir(d)))      # ['f.1.txt', 'f.2.txt', 'f.3.txt', 'f.4.txt'] — five requested, four kept

Mechanism — boltons/fileutils.py, rotate_file:

    fns = [filename] + kept_names
    for orig_name, kept_name in reversed(list(zip(fns, fns[1:]))):   # lines 720-723: f.4 → f.5, …, f → f.1
        if not os.path.exists(orig_name):
            continue
        os.rename(orig_name, kept_name)

    if os.path.exists(kept_names[-1]):                                 # lines 725-726: then f.5 — just written — is removed
        os.remove(kept_names[-1])

The docstring promises "dropping any files beyond keep"; the code drops the file at keep. The removal belongs before the ladder (delete the current .keep so the ladder has room), or the ladder should stop at keep-1.

Note that tests/test_fileutils.py::test_rotate_file_full_rotation (and its _no_ext twin) currently assert not (tmp_path / 'test_file.5.txt').exists() after keep=5, i.e. they pin the off-by-one, so a fix arrives with those two tests updated. The behaviour has been this way since the function was introduced in 3bfcfdd (2024-11-28); git describe at HEAD is 26.1.0-24-g…, so it is still fixable before 26.1.1.

Found by an automated QA pass over the repository (Verdict); the reproduction above was re-run by hand on a fresh clone before filing.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions