Option C Tools
Loading...
Searching...
No Matches
test_archive_rotation.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/tests_formatter/test_archive_rotation.py
4
5"""
6Purpose
7-------
8Validate the OI-424 formatter backup archive rotation in
9``oct.formatter.oct_format``.
10
11Responsibilities
12----------------
13- Confirm ``_archive_file`` trims old backups to the configured cap.
14- Confirm pruning is scoped to the source file (other files untouched).
15- Confirm ``max_backups == 0`` disables pruning.
16- Confirm a per-file unlink failure is swallowed.
17- Confirm ``FormatterContext.max_backups_per_file`` is threaded through
18 to the archiver in the write path.
19
20Diagnostics
21-----------
22Domain: TESTS.FORMATTER
23L2: test lifecycle
24L3: fixture creation, assertion details
25L4: deep tracing of archive operations
26
27Contracts
28---------
29Inputs: tmp_path pytest fixture
30Outputs: pass/fail assertions
31"""
32
33import os
34import time
35from pathlib import Path
36
37import pytest
38
39from oct.formatter.oct_format import (
40 _DEFAULT_MAX_ARCHIVE_BYTES,
41 _DEFAULT_MAX_BACKUPS_PER_FILE,
42 _archive_file,
43 _prune_archive,
44 _prune_archive_by_size,
45)
46
47
49 archive_dir: Path, source_name: str, count: int, start_age: float = 100.0,
50) -> list[Path]:
51 """Create ``count`` fake backups with staggered mtimes (oldest first).
52
53 Returns the list of created paths in the order they were created.
54 """
55 archive_dir.mkdir(parents=True, exist_ok=True)
56 created: list[Path] = []
57 now = time.time()
58 for i in range(count):
59 stamp = 20260101_000000 + i
60 path = archive_dir / f"{source_name}.{stamp}.bak"
61 path.write_text(f"backup {i}\n", encoding="utf-8")
62 # Oldest backup has the oldest mtime.
63 mtime = now - (start_age - i)
64 os.utime(path, (mtime, mtime))
65 created.append(path)
66 return created
67
68
69def test_archive_keeps_most_recent_n(tmp_path: Path) -> None:
70 """Archiving into a dir with 15 backups prunes to ``max_backups``."""
71 source = tmp_path / "foo.py"
72 source.write_text("print('x')\n", encoding="utf-8")
73 archive_dir = tmp_path / ".formatter_archive"
74 seeded = _seed_backups(archive_dir, "foo.py", 15)
75
76 _archive_file(source, max_backups=10)
77
78 remaining = sorted(archive_dir.glob("foo.py.*.bak"))
79 # 10 retained total: the newly-written one plus the 9 newest seeded.
80 assert len(remaining) == 10
81 # The oldest 6 seeded files must be gone.
82 for stale in seeded[:6]:
83 assert not stale.exists()
84 # The 9 most recent seeded files survive.
85 for fresh in seeded[6:]:
86 assert fresh.exists()
87
88
89def test_archive_prune_handles_empty_dir(tmp_path: Path) -> None:
90 """A fresh archive dir retains exactly one backup after archival."""
91 source = tmp_path / "bar.py"
92 source.write_text("print('y')\n", encoding="utf-8")
93
94 _archive_file(source, max_backups=10)
95
96 archive_dir = tmp_path / ".formatter_archive"
97 assert len(list(archive_dir.glob("bar.py.*.bak"))) == 1
98
99
101 """Pruning ``foo.py`` must not delete ``bar.py`` backups."""
102 archive_dir = tmp_path / ".formatter_archive"
103 _seed_backups(archive_dir, "foo.py", 5)
104 bar_seeds = _seed_backups(archive_dir, "bar.py", 5)
105
106 removed = _prune_archive(archive_dir, "foo.py", max_backups=2)
107
108 assert removed == 3
109 # All bar.py backups must survive.
110 for bar in bar_seeds:
111 assert bar.exists()
112 # foo.py should have exactly 2 left.
113 assert len(list(archive_dir.glob("foo.py.*.bak"))) == 2
114
115
117 """``max_backups=0`` preserves every existing backup."""
118 archive_dir = tmp_path / ".formatter_archive"
119 _seed_backups(archive_dir, "foo.py", 20)
120
121 removed = _prune_archive(archive_dir, "foo.py", max_backups=0)
122
123 assert removed == 0
124 assert len(list(archive_dir.glob("foo.py.*.bak"))) == 20
125
126
128 tmp_path: Path, monkeypatch,
129) -> None:
130 """A single ``OSError`` during unlink must not stop pruning the rest."""
131 archive_dir = tmp_path / ".formatter_archive"
132 _seed_backups(archive_dir, "foo.py", 5)
133
134 # Make one specific unlink raise; all others succeed.
135 real_unlink = Path.unlink
136 fail_name = sorted(archive_dir.glob("foo.py.*.bak"))[0].name
137
138 def flaky_unlink(self, *args, **kwargs):
139 if self.name == fail_name:
140 raise OSError("simulated")
141 return real_unlink(self, *args, **kwargs)
142
143 monkeypatch.setattr(Path, "unlink", flaky_unlink)
144
145 removed = _prune_archive(archive_dir, "foo.py", max_backups=2)
146
147 # 3 candidates beyond the cap; 1 fails to unlink; 2 are removed.
148 assert removed == 2
149
150
152 """Sanity check on the documented default."""
153 assert _DEFAULT_MAX_BACKUPS_PER_FILE == 10
154
155
156# ---------------------------------------------------------------------
157# OI-516 — per-directory aggregate size cap.
158# ---------------------------------------------------------------------
159
160
162 archive_dir: Path, name: str, size: int, mtime_offset: float,
163) -> Path:
164 """Create a ``.bak`` of given size with an offset mtime (seconds)."""
165 archive_dir.mkdir(parents=True, exist_ok=True)
166 path = archive_dir / name
167 path.write_bytes(b"x" * size)
168 now = time.time()
169 os.utime(path, (now + mtime_offset, now + mtime_offset))
170 return path
171
172
174 """OI-516: aggregate size cap evicts oldest-first."""
175 archive = tmp_path / ".formatter_archive"
176 _mk_sized_backup(archive, "a.py.001.bak", 200, -30)
177 _mk_sized_backup(archive, "a.py.002.bak", 200, -20)
178 _mk_sized_backup(archive, "a.py.003.bak", 200, -10)
179
180 removed = _prune_archive_by_size(archive, max_total_bytes=500)
181
182 assert removed == 1
183 remaining = sorted(p.name for p in archive.glob("*.bak"))
184 assert remaining == ["a.py.002.bak", "a.py.003.bak"]
185
186
187def test_size_prune_noop_when_under_cap(tmp_path: Path) -> None:
188 """OI-516: already under cap → nothing removed."""
189 archive = tmp_path / ".formatter_archive"
190 _mk_sized_backup(archive, "a.py.001.bak", 100, -10)
191 assert _prune_archive_by_size(archive, max_total_bytes=10_000) == 0
192 assert len(list(archive.glob("*.bak"))) == 1
193
194
196 """OI-516: ``max_total_bytes <= 0`` disables size pruning entirely."""
197 archive = tmp_path / ".formatter_archive"
198 _mk_sized_backup(archive, "a.py.001.bak", 500, -10)
199 assert _prune_archive_by_size(archive, 0) == 0
200 assert _prune_archive_by_size(archive, -1) == 0
201 assert len(list(archive.glob("*.bak"))) == 1
202
203
205 """OI-516: ``_archive_file`` triggers size pruning after writing the backup."""
206 source = tmp_path / "big.py"
207 source.write_bytes(b"y" * 400)
208
209 archive = tmp_path / ".formatter_archive"
210 _mk_sized_backup(archive, "big.py.001.bak", 400, -30)
211 _mk_sized_backup(archive, "big.py.002.bak", 400, -20)
212 _mk_sized_backup(archive, "big.py.003.bak", 400, -10)
213
214 # Cap well below the 4 × 400 = 1600-byte aggregate the write would
215 # create. Count cap stays at 10 so the size cap is the active pass.
216 _archive_file(source, max_backups=10, max_archive_bytes=800)
217
218 total = sum(p.stat().st_size for p in archive.glob("*.bak"))
219 assert total <= 800
220
221
223 """OI-516: default aggregate cap is 5 MiB."""
224 assert _DEFAULT_MAX_ARCHIVE_BYTES == 5 * 1024 * 1024
None test_size_prune_evicts_oldest_until_under_cap(Path tmp_path)
list[Path] _seed_backups(Path archive_dir, str source_name, int count, float start_age=100.0)
None test_archive_file_enforces_size_cap_end_to_end(Path tmp_path)
None test_archive_prune_max_backups_zero_disables(Path tmp_path)
Path _mk_sized_backup(Path archive_dir, str name, int size, float mtime_offset)
None test_size_prune_disabled_with_nonpositive_cap(Path tmp_path)
None test_archive_prune_respects_per_file_scoping(Path tmp_path)
None test_archive_prune_handles_unlink_failure(Path tmp_path, monkeypatch)