Converted Script: Nightly Uploads Backup with Rotation (Bash → Python 3)
1. Summary
| Item | Detail |
|---|---|
| Source language | Bash (given) |
| Target language and version | Python 3.10+ |
| Target environment | Ubuntu 22.04 server, run by cron at 2:00 AM |
| Lines in / lines out | ~23 / ~150 (the extra lines come from the flags, logging, docstrings and comments) |
| External dependencies | The /var/www/app/uploads folder (must be readable), the /backups folder (must be writable), and the cron entry. The script no longer calls any outside programs (tar, ls, xargs, rm are all replaced). |
| Third-party packages added | None (standard library only: argparse, logging, tarfile, pathlib, datetime) |
Assumptions
- The new flags are
--src-dir,--dest-dirand--keep. Their defaults are/var/www/app/uploads,/backupsand7, the same as the original. --keepmust be at least 1. The original'sKEEPwas hard-coded, so this is a new safety check: withKEEP=0the original would delete the backup it had just made.- Logging: normal (INFO) messages go to stdout, as you asked. Errors and warnings still go to stderr, like the original's
>&2messages. Every line looks like2025-01-31 02:00:01 INFO Backup complete: .... - Messages keep their original wording ("Source dir missing: ...", "Backup failed", "Backup complete: ...") but now start with a timestamp and a level.
- The backup file name keeps the same local-time stamp format:
uploads_YYYY-MM-DD_HHMM.tar.gz. - The script runs as the same user as before, which may be root, with the same permissions.
What it does: It checks that the uploads folder exists and then packs its whole contents into a gzip-compressed tar file named uploads_<date>_<time>.tar.gz in the backup folder. Then it deletes all but the 7 newest uploads_*.tar.gz files, judged by modification time. Exit codes: 0 = success, 1 = source folder missing, 2 = backup failed.
2. Converted Code
#!/usr/bin/env python3
"""Nightly backup: zip the uploads folder, keep last 7 backups.
Python 3 port of the original Bash script. Creates a gzip-compressed tar
archive of SRC_DIR in DEST_DIR, then deletes all but the newest KEEP
uploads_*.tar.gz files.
Exit codes (same as the original):
0 backup completed
1 source directory missing
2 backup failed (could not create DEST_DIR or write the archive)
"""
import argparse
import logging
import sys
import tarfile
from datetime import datetime
from pathlib import Path
DEFAULT_SRC_DIR = "/var/www/app/uploads"
DEFAULT_DEST_DIR = "/backups"
DEFAULT_KEEP = 7
BACKUP_PREFIX = "uploads_"
BACKUP_SUFFIX = ".tar.gz"
EXIT_OK = 0
EXIT_SRC_MISSING = 1
EXIT_BACKUP_FAILED = 2
log = logging.getLogger("backup_uploads")
def setup_logging() -> None:
"""Timestamped logging: INFO to stdout, WARNING/ERROR to stderr.
The original wrote its error messages to stderr (>&2) and its success
message to stdout; this split keeps that behavior.
"""
formatter = logging.Formatter(
"%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.DEBUG)
stdout_handler.addFilter(lambda record: record.levelno < logging.WARNING)
stdout_handler.setFormatter(formatter)
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.WARNING)
stderr_handler.setFormatter(formatter)
log.setLevel(logging.INFO)
log.addHandler(stdout_handler)
log.addHandler(stderr_handler)
def positive_int(value: str) -> int:
"""argparse type: an integer >= 1 (KEEP=0 would delete the new backup)."""
try:
number = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"not an integer: {value!r}")
if number < 1:
raise argparse.ArgumentTypeError("must be 1 or greater")
return number
def parse_args(argv=None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Archive the uploads folder and keep only the newest N backups."
)
parser.add_argument(
"--src-dir",
default=DEFAULT_SRC_DIR,
help="directory to back up (default: %(default)s)",
)
parser.add_argument(
"--dest-dir",
default=DEFAULT_DEST_DIR,
help="directory to write backups to (default: %(default)s)",
)
parser.add_argument(
"--keep",
type=positive_int,
default=DEFAULT_KEEP,
help="number of newest backups to keep (default: %(default)s)",
)
return parser.parse_args(argv)
def create_archive(src_dir: Path, archive_path: Path) -> None:
"""Equivalent of: tar -czf ARCHIVE -C SRC_DIR .
- arcname="." stores entries as ./file, ./subdir/file (like `-C SRC .`).
- compresslevel=6 matches gzip's default used by `tar -z`
(tarfile would otherwise use 9).
- GNU_FORMAT matches GNU tar's default archive format on Ubuntu.
- Symlinks are stored as links, not followed (same as tar's default).
- Like `tar -czf`, an existing file with the same name is overwritten.
"""
with tarfile.open(
archive_path, "w:gz", compresslevel=6, format=tarfile.GNU_FORMAT
) as tar:
tar.add(str(src_dir), arcname=".")
def prune_old_backups(dest_dir: Path, keep: int) -> None:
"""Delete old backups beyond KEEP.
Equivalent of:
ls -1t "$DEST_DIR"/uploads_*.tar.gz | tail -n +$((KEEP+1)) | xargs -r rm --
"""
backups = []
for path in dest_dir.glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}"):
try:
if path.is_file():
backups.append((path, path.stat().st_mtime_ns))
except OSError:
# File vanished or is unreadable between glob and stat; skip it.
continue
# `ls -t` sorts newest first and breaks ties by name. Sort by name first,
# then by mtime (Python's sort is stable, also wit