An unattended backup that says something when it fails.
Ride Me Cycles' Access database backup was a manual copy-paste at the end of the day, which meant it was the first job skipped on a busy one. A scheduled script does the same copy every night, and, critically, only stays quiet when it actually worked.
BlackTor Group LtdDartmoor, UK
A note on this page
A reference, not a client write-up.
Illustrative, per the note on the main Python page: not real client work. It sits alongside the Product Photos and Duplicate Customers pages as a deeper dive on one script that comes up often enough to be worth its own write-up.
The issue
A successful copy isn't proof of a usable backup.
A plain file copy looks like a complete solution and isn't. The database can be briefly mid-write when a scheduled copy runs, particularly one that starts the instant Access happens to release its file lock: the copy finishes, reports success, and is the right size, but isn't a byte-for-byte match of a working database.
A copy that fails outright is just as much of a problem if nobody hears about it until the backup is actually needed. And a backup folder with no cleanup grows forever, one dated file per night, until something else runs out of disk space first.
Verified backup
run_backup(): copy, then prove the copy is actually correct
"""nightly_backup.py
Scheduled (Windows Task Scheduler, nightly after close) backup of the
Access database behind the Ride Me Cycles billing system. Verifies the
copy by checksum rather than trusting a successful shutil.copy2 call
alone, emails on any failure, and prunes backups older than
RETENTION_DAYS so the backup folder doesn't grow forever.
"""
from __future__ import annotations
import hashlib
import logging
import shutil
import smtplib
from datetime import datetime, timedelta
from email.message import EmailMessage
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
SOURCE_DATABASE: Path = Path(r"\\FILESERVER\Billing\RideMeCycles.accdb")
BACKUP_FOLDER: Path = Path(r"\\FILESERVER\Backups\Billing")
RETENTION_DAYS: int = 30
ALERT_RECIPIENT: str = "enquiries@blacktorgroup.com"
def file_checksum(file_path: Path, chunk_size: int = 1_048_576) -> str:
"""Return the SHA-256 checksum of a file, read in fixed-size chunks
so a multi-hundred-megabyte database never has to be loaded into memory
whole.
"""
hasher = hashlib.sha256()
with file_path.open("rb") as file_handle:
for chunk in iter(lambda: file_handle.read(chunk_size), b""):
hasher.update(chunk)
return hasher.hexdigest()
def run_backup() -> Path:
"""Copy the source database to a dated backup file and confirm the
copy is byte-identical to the source before trusting it, raising if not.
"""
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
destination_path = BACKUP_FOLDER / f"RideMeCycles-{timestamp}.accdb"
shutil.copy2(SOURCE_DATABASE, destination_path)
# A copy that completed without raising isn't proof it's usable: the
# source can be mid-write when Access briefly releases its lock,
# giving a copy that's the right size but the wrong bytes. Comparing
# checksums catches that; comparing file sizes alone would not.
if file_checksum(SOURCE_DATABASE) != file_checksum(destination_path):
destination_path.unlink(missing_ok=True)
raise IOError(f"Checksum mismatch: {destination_path.name} is not a valid backup")
return destination_path
def prune_old_backups() -> int:
"""Delete backups older than RETENTION_DAYS and return how many were
removed.
"""
cutoff_time = datetime.now() - timedelta(days=RETENTION_DAYS)
removed_count = 0
for backup_path in BACKUP_FOLDER.glob("RideMeCycles-*.accdb"):
if datetime.fromtimestamp(backup_path.stat().st_mtime) < cutoff_time:
backup_path.unlink()
removed_count += 1
return removed_count
def send_failure_alert(error: Exception) -> None:
"""Email ALERT_RECIPIENT so a failed backup gets noticed the next
morning, rather than the next time someone actually needs the backup and
finds it isn't there.
"""
message = EmailMessage()
message["Subject"] = "Ride Me Cycles backup FAILED"
message["From"] = "backups@ridemecycles.internal"
message["To"] = ALERT_RECIPIENT
message.set_content(f"Nightly backup failed: {error}")
with smtplib.SMTP("localhost") as smtp_connection:
smtp_connection.send_message(message)
if __name__ == "__main__":
try:
backup_path = run_backup()
removed_count = prune_old_backups()
logger.info("Backup OK: %s (%d old backup(s) pruned)", backup_path.name, removed_count)
except Exception as error: # deliberately broad: any failure here must alert
logger.exception("Backup failed")
send_failure_alert(error)
file_checksum is the step that actually proves the backup is trustworthy: reading in fixed one-megabyte chunks means checksumming a large database costs a small, constant amount of memory rather than loading the whole file at once, and the comparison would catch even a single corrupted byte that a file-size check never would.
The broad except Exception at the bottom is deliberate, not carelessness: this script runs once, unattended, at night, and whatever kind of failure happens, a missing network drive, a locked file, a checksum mismatch, the one thing that must always happen is somebody finding out by morning.
prune_old_backups runs only after a successful, verified backup, so a failed night never deletes a good backup from thirty days ago on its way out.
Result
A backup that's either there, verified, or someone already knows it isn't.
The backup runs itself every night, and the only time anyone hears about it is exactly the time they need to: when it didn't work.