Add CLI interface and pyproject.toml for PyPI packaging

This commit is contained in:
KakiFilem Team 2026-02-02 19:33:18 +08:00
parent 3bbab71301
commit 2f7d5a7949
5 changed files with 394 additions and 185 deletions

BIN
__init__.py Normal file

Binary file not shown.

166
cli.py Normal file
View File

@ -0,0 +1,166 @@
import argparse
import shutil
import os
import sys
import textwrap
import importlib.metadata
from main import run_backup
def get_version():
try:
return importlib.metadata.version("pg-r2-backup")
except importlib.metadata.PackageNotFoundError:
return "dev"
def mask(value, show=4):
if not value:
return ""
if len(value) <= show:
return "*" * len(value)
return value[:show] + "*" * (len(value) - show)
def doctor():
print("pg-r2-backup doctor\n")
if shutil.which("pg_dump") is None:
print("[FAIL] pg_dump not found in PATH")
else:
print("[OK] pg_dump found")
required_envs = [
"DATABASE_URL",
"R2_ACCESS_KEY",
"R2_SECRET_KEY",
"R2_BUCKET_NAME",
"R2_ENDPOINT",
]
missing = [e for e in required_envs if not os.environ.get(e)]
if missing:
print("\n[FAIL] Missing environment variables:")
for m in missing:
print(f" - {m}")
else:
print("\n[OK] Required environment variables set")
use_public = os.environ.get("USE_PUBLIC_URL", "false").lower() == "true"
print(f"\nDatabase URL mode : {'public' if use_public else 'private'}")
if os.environ.get("BACKUP_PASSWORD"):
print("Compression : 7z (encrypted)")
else:
print("Compression : gzip")
print("\nDoctor check complete.")
def config_show():
print("pg-r2-backup config\n")
config = {
"USE_PUBLIC_URL": os.environ.get("USE_PUBLIC_URL", "false"),
"DUMP_FORMAT": os.environ.get("DUMP_FORMAT", "dump"),
"FILENAME_PREFIX": os.environ.get("FILENAME_PREFIX", "backup"),
"MAX_BACKUPS": os.environ.get("MAX_BACKUPS", "7"),
"BACKUP_TIME": os.environ.get("BACKUP_TIME", "00:00"),
"R2_BUCKET_NAME": os.environ.get("R2_BUCKET_NAME", ""),
"R2_ENDPOINT": os.environ.get("R2_ENDPOINT", ""),
"R2_ACCESS_KEY": mask(os.environ.get("R2_ACCESS_KEY")),
"R2_SECRET_KEY": mask(os.environ.get("R2_SECRET_KEY")),
}
for k, v in config.items():
print(f"{k:<16} : {v}")
def init_env():
if os.path.exists(".env"):
print("[ERROR] .env already exists")
return
example = ".env.example"
if not os.path.exists(example):
print("[ERROR] .env.example not found")
return
shutil.copy(example, ".env")
print("[SUCCESS] .env created from .env.example")
print("Edit the file before running backups.")
def schedule_info():
print(textwrap.dedent("""
pg-r2-backup scheduling
Linux / macOS (cron):
0 0 * * * pg-r2-backup run
Windows (Task Scheduler):
Program : pg-r2-backup
Args : run
Start in: folder containing .env (working directory)
Railway / Docker:
Use the platform scheduler
""").strip())
def main():
parser = argparse.ArgumentParser(
prog="pg-r2-backup",
description="PostgreSQL backup tool for Cloudflare R2",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""
Examples:
pg-r2-backup doctor
pg-r2-backup run
pg-r2-backup config show
pg-r2-backup init
pg-r2-backup schedule
""")
)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {get_version()}"
)
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("run", help="Run backup immediately")
subparsers.add_parser("doctor", help="Check environment & dependencies")
subparsers.add_parser("schedule", help="Show scheduling examples")
config_parser = subparsers.add_parser("config", help="Show configuration")
config_sub = config_parser.add_subparsers(dest="subcommand")
config_sub.add_parser("show", help="Show current configuration")
subparsers.add_parser("init", help="Create .env from .env.example")
args = parser.parse_args()
if args.command == "run":
run_backup()
elif args.command == "doctor":
doctor()
elif args.command == "config" and args.subcommand == "show":
config_show()
elif args.command == "init":
init_env()
elif args.command == "schedule":
schedule_info()
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

368
main.py
View File

@ -1,180 +1,188 @@
import os import os
import subprocess import subprocess
import boto3 import boto3
from boto3.session import Config from boto3.session import Config
from datetime import datetime, timezone from datetime import datetime, timezone
from boto3.s3.transfer import TransferConfig from boto3.s3.transfer import TransferConfig
from dotenv import load_dotenv from dotenv import load_dotenv, find_dotenv
import time import time
import schedule import schedule
import py7zr import py7zr
import shutil import shutil
import gzip
load_dotenv()
load_dotenv(find_dotenv(usecwd=True), override=True)
## ENV
## ENV
DATABASE_URL = os.environ.get("DATABASE_URL")
DATABASE_PUBLIC_URL = os.environ.get("DATABASE_PUBLIC_URL") DATABASE_URL = os.environ.get("DATABASE_URL")
R2_ACCESS_KEY = os.environ.get("R2_ACCESS_KEY") DATABASE_PUBLIC_URL = os.environ.get("DATABASE_PUBLIC_URL")
R2_SECRET_KEY = os.environ.get("R2_SECRET_KEY") R2_ACCESS_KEY = os.environ.get("R2_ACCESS_KEY")
R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME") R2_SECRET_KEY = os.environ.get("R2_SECRET_KEY")
R2_ENDPOINT = os.environ.get("R2_ENDPOINT") R2_BUCKET_NAME = os.environ.get("R2_BUCKET_NAME")
MAX_BACKUPS = int(os.environ.get("MAX_BACKUPS", 7)) R2_ENDPOINT = os.environ.get("R2_ENDPOINT")
BACKUP_PREFIX = os.environ.get("BACKUP_PREFIX", "") MAX_BACKUPS = int(os.environ.get("MAX_BACKUPS", 7))
FILENAME_PREFIX = os.environ.get("FILENAME_PREFIX", "backup") BACKUP_PREFIX = os.environ.get("BACKUP_PREFIX", "")
DUMP_FORMAT = os.environ.get("DUMP_FORMAT", "dump") FILENAME_PREFIX = os.environ.get("FILENAME_PREFIX", "backup")
BACKUP_PASSWORD = os.environ.get("BACKUP_PASSWORD") DUMP_FORMAT = os.environ.get("DUMP_FORMAT", "dump")
USE_PUBLIC_URL = os.environ.get("USE_PUBLIC_URL", "false").lower() == "true" BACKUP_PASSWORD = os.environ.get("BACKUP_PASSWORD")
BACKUP_TIME = os.environ.get("BACKUP_TIME", "00:00") USE_PUBLIC_URL = os.environ.get("USE_PUBLIC_URL", "false").lower() == "true"
S3_REGION = os.environ.get("S3_REGION", "us-east-1") BACKUP_TIME = os.environ.get("BACKUP_TIME", "00:00")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
def log(msg):
print(msg, flush=True) def log(msg):
print(msg, flush=True)
## Validate BACKUP_TIME
try: ## Validate BACKUP_TIME
hour, minute = BACKUP_TIME.split(":") try:
if not (0 <= int(hour) <= 23 and 0 <= int(minute) <= 59): hour, minute = BACKUP_TIME.split(":")
raise ValueError if not (0 <= int(hour) <= 23 and 0 <= int(minute) <= 59):
except ValueError: raise ValueError
log("[WARNING] Invalid BACKUP_TIME format. Using default: 00:00") except ValueError:
BACKUP_TIME = "00:00" log("[WARNING] Invalid BACKUP_TIME format. Using default: 00:00")
BACKUP_TIME = "00:00"
def get_database_url():
if USE_PUBLIC_URL: def get_database_url():
if not DATABASE_PUBLIC_URL: if USE_PUBLIC_URL:
raise ValueError("[ERROR] DATABASE_PUBLIC_URL not set but USE_PUBLIC_URL=true!") if not DATABASE_PUBLIC_URL:
return DATABASE_PUBLIC_URL raise ValueError("[ERROR] DATABASE_PUBLIC_URL not set but USE_PUBLIC_URL=true!")
return DATABASE_PUBLIC_URL
if not DATABASE_URL:
raise ValueError("[ERROR] DATABASE_URL not set!") if not DATABASE_URL:
return DATABASE_URL raise ValueError("[ERROR] DATABASE_URL not set!")
return DATABASE_URL
def run_backup():
if shutil.which("pg_dump") is None: def gzip_compress(src):
log("[ERROR] pg_dump not found. Install postgresql-client.") dst = src + ".gz"
return with open(src, "rb") as f_in:
with gzip.open(dst, "wb") as f_out:
database_url = get_database_url() shutil.copyfileobj(f_in, f_out)
log(f"[INFO] Using {'public' if USE_PUBLIC_URL else 'private'} database URL") return dst
format_map = { def run_backup():
"sql": ("p", "sql"), if shutil.which("pg_dump") is None:
"plain": ("p", "sql"), log("[ERROR] pg_dump not found. Install postgresql-client.")
"dump": ("c", "dump"), return
"custom": ("c", "dump"),
"tar": ("t", "tar") database_url = get_database_url()
} log(f"[INFO] Using {'public' if USE_PUBLIC_URL else 'private'} database URL")
pg_format, ext = format_map.get(DUMP_FORMAT.lower(), ("c", "dump"))
format_map = {
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") "sql": ("p", "sql"),
backup_file = f"{FILENAME_PREFIX}_{timestamp}.{ext}" "plain": ("p", "sql"),
"dump": ("c", "dump"),
compressed_file = ( "custom": ("c", "dump"),
f"{backup_file}.7z" if BACKUP_PASSWORD else f"{backup_file}.gz" "tar": ("t", "tar")
) }
pg_format, ext = format_map.get(DUMP_FORMAT.lower(), ("c", "dump"))
compressed_file_r2 = f"{BACKUP_PREFIX}{compressed_file}"
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
## Create backup backup_file = f"{FILENAME_PREFIX}_{timestamp}.{ext}"
try:
log(f"[INFO] Creating backup {backup_file}") compressed_file = (
f"{backup_file}.7z" if BACKUP_PASSWORD else f"{backup_file}.gz"
dump_cmd = [ )
"pg_dump",
f"--dbname={database_url}", compressed_file_r2 = f"{BACKUP_PREFIX}{compressed_file}"
"-F", pg_format,
"--no-owner", ## Create backup
"--no-acl", try:
"-f", backup_file log(f"[INFO] Creating backup {backup_file}")
]
dump_cmd = [
subprocess.run(dump_cmd, check=True) "pg_dump",
f"--dbname={database_url}",
if BACKUP_PASSWORD: "-F", pg_format,
log("[INFO] Encrypting backup with 7z...") "--no-owner",
with py7zr.SevenZipFile(compressed_file, "w", password=BACKUP_PASSWORD) as archive: "--no-acl",
archive.write(backup_file) "-f", backup_file
log("[SUCCESS] Backup encrypted successfully") ]
else:
log("[INFO] Compressing backup with gzip...") subprocess.run(dump_cmd, check=True)
subprocess.run(["gzip", "-f", backup_file], check=True)
log("[SUCCESS] Backup compressed successfully") if BACKUP_PASSWORD:
log("[INFO] Encrypting backup with 7z...")
except subprocess.CalledProcessError as e: with py7zr.SevenZipFile(compressed_file, "w", password=BACKUP_PASSWORD) as archive:
log(f"[ERROR] Backup creation failed: {e}") archive.write(backup_file)
return log("[SUCCESS] Backup encrypted successfully")
finally: else:
if os.path.exists(backup_file): log("[INFO] Compressing backup with gzip...")
os.remove(backup_file) gzip_compress(backup_file)
log("[SUCCESS] Backup compressed successfully")
## Upload to R2
if os.path.exists(compressed_file): except subprocess.CalledProcessError as e:
size = os.path.getsize(compressed_file) log(f"[ERROR] Backup creation failed: {e}")
log(f"[INFO] Final backup size: {size / 1024 / 1024:.2f} MB") return
finally:
try: if os.path.exists(backup_file):
client = boto3.client( os.remove(backup_file)
"s3",
endpoint_url=R2_ENDPOINT, ## Upload to R2
aws_access_key_id=R2_ACCESS_KEY, if os.path.exists(compressed_file):
aws_secret_access_key=R2_SECRET_KEY, size = os.path.getsize(compressed_file)
region_name=S3_REGION, log(f"[INFO] Final backup size: {size / 1024 / 1024:.2f} MB")
config=Config(
s3={"addressing_style": "path"} try:
) client = boto3.client(
) "s3",
endpoint_url=R2_ENDPOINT,
config = TransferConfig( aws_access_key_id=R2_ACCESS_KEY,
multipart_threshold=8 * 1024 * 1024, aws_secret_access_key=R2_SECRET_KEY,
multipart_chunksize=8 * 1024 * 1024, region_name=S3_REGION,
max_concurrency=4, config=Config(
use_threads=True s3={"addressing_style": "path"}
) )
)
client.upload_file(
compressed_file, config = TransferConfig(
R2_BUCKET_NAME, multipart_threshold=8 * 1024 * 1024,
compressed_file_r2, multipart_chunksize=8 * 1024 * 1024,
Config=config max_concurrency=4,
) use_threads=True
)
log(f"[SUCCESS] Backup uploaded: {compressed_file_r2}")
client.upload_file(
objects = client.list_objects_v2( compressed_file,
Bucket=R2_BUCKET_NAME, R2_BUCKET_NAME,
Prefix=BACKUP_PREFIX compressed_file_r2,
) Config=config
)
if "Contents" in objects:
backups = sorted( log(f"[SUCCESS] Backup uploaded: {compressed_file_r2}")
objects["Contents"],
key=lambda x: x["LastModified"], objects = client.list_objects_v2(
reverse=True Bucket=R2_BUCKET_NAME,
) Prefix=BACKUP_PREFIX
)
for obj in backups[MAX_BACKUPS:]:
client.delete_object( if "Contents" in objects:
Bucket=R2_BUCKET_NAME, backups = sorted(
Key=obj["Key"] objects["Contents"],
) key=lambda x: x["LastModified"],
log(f"[INFO] Deleted old backup: {obj['Key']}") reverse=True
)
except Exception as e:
log(f"[ERROR] R2 operation failed: {e}") for obj in backups[MAX_BACKUPS:]:
finally: client.delete_object(
if os.path.exists(compressed_file): Bucket=R2_BUCKET_NAME,
os.remove(compressed_file) Key=obj["Key"]
)
if __name__ == "__main__": log(f"[INFO] Deleted old backup: {obj['Key']}")
log("[INFO] Starting backup scheduler...")
log(f"[INFO] Scheduled backup time: {BACKUP_TIME} UTC") except Exception as e:
log(f"[ERROR] R2 operation failed: {e}")
schedule.every().day.at(BACKUP_TIME).do(run_backup) finally:
if os.path.exists(compressed_file):
run_backup() os.remove(compressed_file)
while True: if __name__ == "__main__":
schedule.run_pending() log("[INFO] Starting backup scheduler...")
time.sleep(60) log(f"[INFO] Scheduled backup time: {BACKUP_TIME} UTC")
schedule.every().day.at(BACKUP_TIME).do(run_backup)
run_backup()
while True:
schedule.run_pending()
time.sleep(60)

35
pyproject.toml Normal file
View File

@ -0,0 +1,35 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pg-r2-backup"
version = "1.0.5"
description = "PostgreSQL backup tool for Cloudflare R2 (S3 Compatible)"
readme = "README.md"
requires-python = ">=3.9"
authors = [
{ name = "Aman" }
]
license = "MIT"
dependencies = [
"boto3",
"python-dotenv",
"schedule",
"py7zr"
]
[project.urls]
Homepage = "https://github.com/BigDaddyAman/pg-r2-backup"
Repository = "https://github.com/BigDaddyAman/pg-r2-backup"
Issues = "https://github.com/BigDaddyAman/pg-r2-backup/issues"
[tool.setuptools]
packages = ["cli"]
py-modules = ["main"]
[project.scripts]
pg-r2-backup = "cli.cli:main"

View File

@ -1,5 +1,5 @@
boto3==1.42.26 boto3==1.42.39
psycopg2-binary==2.9.10 psycopg2-binary==2.9.10
python-dotenv==1.2.1 python-dotenv==1.2.1
py7zr==1.1.0 py7zr==1.1.0
schedule==1.2.2 schedule==1.2.2