/
backuper.py
174 lines (136 loc) · 6.04 KB
/
backuper.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
import os
import sys
import stat
import json
import shutil
import logging
import argparse
import datetime
from re import search, compile
from dirsync import sync
from pathlib import Path
CONFIGURATION_FILE_PATH = r'conf.json'
DATETIME_FORMAT = r'%Y%m%dT%H%M%S'
ROOT_SUBFOLDER = 'backup_{}'.format(
datetime.datetime.now().replace(microsecond=0).strftime(DATETIME_FORMAT))
def handle_unhandled_exception(exc_type, exc_value, exc_traceback):
'''
Handler for unhandled exceptions that will write to the logs.
@StolenFrom: https://www.scrygroup.com/tutorial/2018-02-06/python-excepthook-logging/
'''
if issubclass(exc_type, KeyboardInterrupt):
# call the default excepthook saved at __excepthook__
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.critical("Unhandled exception: ", exc_info=(
exc_type, exc_value, exc_traceback))
sys.excepthook = handle_unhandled_exception
def retrieve_configuration_from_file(file_path: Path):
'''
Gets information from the given file, and return an array of strings
'''
try:
json_file = {}
with open(file_path) as raw_file:
json_file = json.load(raw_file)
# Verify that we have the mandatory field in the configuration file
for field in ['output_folder', 'folders_to_backup', 'history_level']:
if field not in json_file:
raise RuntimeError('{}'.format(field))
output_folder = Path(json_file['output_folder'])
history_level = json_file['history_level']
# Build the folders mapping information
base_target = output_folder / ROOT_SUBFOLDER
folders_to_backup = []
for element in json_file['folders_to_backup']:
target = base_target / \
element['subfolder'] if 'subfolder' in element else base_target
for path in element['folders']:
source = Path(path)
folders_to_backup.append(
{"source": source, "target": Path(target)})
except RuntimeError as e:
logging.critical(
'Missing field {} from the configuration file'.format(e))
raise e
except json.JSONDecodeError as e:
logging.critical(
'Error when reading the configuration file: {}'.format(e))
raise e
return output_folder, history_level, folders_to_backup
def remove_old_backup(output_folder: Path, history_level: int):
'''
Removes the oldest backups stored in the output folder if there are more that history_level
'''
# Error handler for files that are marked as read-only
def on_delete_error(func, path, exc_info):
os.chmod(path, stat.S_IWRITE)
os.unlink(path)
# Lists all backups and sort then by creation date
folder_in_output_folder = sorted(
Path(output_folder).iterdir(), key=os.path.getmtime, reverse=True)
folder_in_output_folder = [f for f in folder_in_output_folder if search(
r'backup_\d\d\d\d\d\d\d\dT\d\d\d\d\d\d', f.name) is not None]
while len(folder_in_output_folder) >= history_level:
# Removing the oldest folder and it's content. Let except so the error is catch by the launcher
folder_to_remove = folder_in_output_folder.pop()
logging.info('Removing folder {}'.format(folder_to_remove))
shutil.rmtree(folder_to_remove, onerror=on_delete_error)
def store_backup(folder_to_backup: list):
'''
Stores every folder into the ourput folder, concatenating current date time to keep history
'''
def get_duplicate_suffix(path: Path, name: str) -> str:
'''
Returns a suffix (N) where N is the number of duplicate, or empty if there's none
'''
suffix = ''
if path.exists():
regex = compile(name+r'[\(\d+\)]*')
folders_in_path = [item.stem for item in path.iterdir()]
duplicates = list(filter(regex.match, folders_in_path))
if duplicates:
suffix = '({})'.format(len(duplicates))
return suffix
logging.info('Beginning backup')
for folder in folder_to_backup:
# If there's a duplicate, add (N) to the target name
duplicate_sufix = get_duplicate_suffix(
folder['target'], folder['source'].stem)
sync(folder['source'], folder['target'] / (folder['source'].stem + duplicate_sufix), 'sync',
purge=True, create=True, verbose=True)
def process(file_path: Path):
'''
Retrives data from file, and syncs folders into a new folder with current datetime
'''
# Retrieving configuration from file
output_folder, history_level, folders_to_backup = retrieve_configuration_from_file(
file_path)
# Removing the oldest backup in case there are more than history_level
remove_old_backup(output_folder, history_level)
# Actually storing the backup
store_backup(folders_to_backup)
def main():
'''
Deals with input arguments and calles process
'''
formatter = logging.Formatter('%(message)s')
logging.getLogger('').setLevel(logging.DEBUG)
fh = logging.FileHandler('las_run.log')
fh.setLevel(logging.DEBUG)
fh.setFormatter(formatter)
logging.getLogger('').addHandler(fh)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
ch.setFormatter(formatter)
logging.getLogger('').addHandler(ch)
# logging.basicConfig(filename='las_run.log',
# filemode='w+', level=logging.DEBUG)
parser = argparse.ArgumentParser(
description='Synchronizes all folders and files given in a txt file, into the destiny folder')
parser.add_argument('--file_path', type=Path, default=Path(CONFIGURATION_FILE_PATH),
help='Path to the file that contains information. The first line of that file, is the destiny folder')
args = parser.parse_args()
process(args.file_path)
if __name__ == '__main__':
main()