import ftplib import gzip import re from datetime import datetime from pathlib import Path from tempfile import NamedTemporaryFile from time import gmtime, strftime import click import decouple class CommonTools(object): TIMESTAMP = strftime("%Y%m%d%H%M%S", gmtime()) @staticmethod def get_timestamp(name): """ Gets the timestamp from a given file name (not a pathlib.Path) :param name: (string) Path of a file generated by AlchemyDumps :return: (string) The backup numeric id (in case of success) or False """ pattern = r"(.*)(-)(?P<timestamp>[\d]{14})(-)(.*)(.gz)" match = re.search(pattern, name) return match.group("timestamp") if match else False @staticmethod def parse_timestamp(timestamp): """Transforms a timestamp ID in a humanized date""" date_parsed = datetime.strptime(timestamp, "%Y%m%d%H%M%S") return date_parsed.strftime("%b %d, %Y at %H:%M:%S") class LocalTools(CommonTools): """Manage backup directory and files in local file system""" def __init__(self, backup_path): self.path = Path(backup_path).absolute() self.path.mkdir(exist_ok=True) def get_files(self): """List all files in the backup directory""" yield from ( path for path in self.path.glob("*") if path.is_file() and self.get_timestamp(path.name) ) def create_file(self, name, contents): """ Creates a gzip file :param name: (str) Name of the file to be created (without path) :param contents: (bytes) Contents to be written in the file :return: (pathlib.Path) Path of the created file """ path = self.path / name with gzip.open(path, "wb") as handler: handler.write(contents) return path def read_file(self, name): """ Reads the contents of a gzip file :param name: (str) Name of the file to be read (without path) :return: (bytes) Content of the file """ path = self.path / name with gzip.open(path, "rb") as handler: return handler.read() def delete_file(self, name): """ Delete a file :param name: (str) Name of the file to be deleted (without path) """ path = self.path / name path.unlink() class RemoteTools(CommonTools): """Manage backup files in a remote file system via FTP""" def __init__(self, ftp): """Receives a Python FTP class instance""" self.ftp = ftp self.path = self.normalize_path() def normalize_path(self): """Add missing slash to the end of the FTP url to be used in stdout""" url = f"ftp://{self.ftp.host}{self.ftp.pwd()}" return url if url.endswith("/") else url + "/" def get_files(self): """List all files in the backup directory""" yield from (name for name in self.ftp.nlst() if self.get_timestamp(name)) def create_file(self, name, contents): """ Creates a gzip file :param name: (str) Name of the file to be created (without path) :param contents: (bytes) Contents to be written in the file :return: (str) path of the created file """ with NamedTemporaryFile() as tmp: with gzip.open(tmp.name, "wb") as handler: handler.write(contents) with open(tmp.name, "rb") as handler: self.ftp.storbinary(f"STOR {name}", handler) return f"{self.path}{name}" def read_file(self, name): """ Reads the contents of a gzip file :param name: (str) Name of the file to be read (without path) :return: (bytes) Content of the file """ with NamedTemporaryFile() as tmp: with open(tmp.name, "wb") as handler: self.ftp.retrbinary(f"RETR {name}", handler.write) with gzip.open(tmp.name, "rb") as handler: return handler.read() def delete_file(self, name): """ Delete a file :param name: (str) Name of the file to be deleted (without path) """ self.ftp.delete(name) class Backup(object): DIR = "alchemydumps-backup" PRE = "db-bkp" def __init__(self): """ Bridge backups to local file system or to FTP server according to env vars set to allow FTP usage (see connect method). """ self.ftp = self.ftp_connect() self.dir = decouple.config("ALCHEMYDUMPS_DIR", default=self.DIR) self.prefix = decouple.config("ALCHEMYDUMPS_PREFIX", default=self.PRE) self.files = None self.target = self.get_target() def ftp_connect(self): """ Tries to connect to FTP server according to env vars: * `ALCHEMYDUMPS_FTP_SERVER` * `ALCHEMYDUMPS_FTP_USER` * `ALCHEMYDUMPS_FTP_PASSWORD` * `ALCHEMYDUMPS_FTP_PATH` :return: Python FTP class instance or False """ server = decouple.config("ALCHEMYDUMPS_FTP_SERVER", default=None) user = decouple.config("ALCHEMYDUMPS_FTP_USER", default=None) password = decouple.config("ALCHEMYDUMPS_FTP_PASSWORD", default=None) path = decouple.config("ALCHEMYDUMPS_FTP_PATH", default=None) if not server or not user: return False try: ftp = ftplib.FTP(server, user, password) except ftplib.error_perm: click.secho(f"==> Couldn't connect to {server}", fg="red") return False return self.ftp_change_path(ftp, path) @staticmethod def ftp_change_path(ftp, path): """ Changes path at FTP server :param ftp: Python FTP class instance :param path: (str) Path at the FTP server :return: Python FTP class instance or False """ change_path = ftp.cwd(path) if not change_path.startswith("250 "): click.secho(f"==> Path doesn't exist: {path}", fg="red") ftp.quit() return False return ftp def close_ftp(self): if self.ftp: self.ftp.quit() def get_target(self): """Returns the object to manage backup files (Local or Remote)""" return RemoteTools(self.ftp) if self.ftp else LocalTools(self.dir) def get_timestamps(self): """ Gets the different existing timestamp numeric IDs :return: (tuple) Existing timestamps in backup directory """ if not self.files: self.files = tuple(self.target.get_files()) timestamps = set(self.target.get_timestamp(path.name) for path in self.files) return tuple(timestamp for timestamp in timestamps if timestamp) def by_timestamp(self, timestamp): """ Gets the list of all backup files with a given timestamp :param timestamp: (str) Timestamp to be used as filter :param files: (list) List of backup file names :return: (generator) Backup file names matching the timestamp """ if not self.files: self.files = tuple(self.target.get_files()) for path in self.files: if timestamp == self.target.get_timestamp(path.name): yield path def valid(self, timestamp): """Check backup files for the given timestamp""" if timestamp and timestamp in self.get_timestamps(): return True click.secho( '==> Invalid id. Use "history" to list existing downloads', fg="red" ) return False def get_name(self, class_name, timestamp=None): """ Gets a backup file name given the timestamp and the name of the SQLAlchemy mapped class. """ timestamp = timestamp or self.target.TIMESTAMP return f"{self.prefix}-{timestamp}-{class_name}.gz"