77import shutil
88import hashlib
99from pathlib import Path
10+ from dataclasses import dataclass
11+
1012import tkinter as tk
1113from tkinter import ttk
1214from tkfilebrowser import askopendirnames , askopenfilenames , askopenfilename , askopendirname
1315from PIL import Image , ImageTk
1416import yaml
15-
1617import tk_async_execute as tae
1718
1819DEFAULT_TITLE = 'Backup'
@@ -27,12 +28,100 @@ def load_image(filename):
2728 img2 .paste (img )
2829 return ImageTk .PhotoImage (img2 )
2930
31+
32+ @dataclass
33+ class BackupItem :
34+ """Backup Item."""
35+ name : str
36+ itemtype : str
37+ path : str
38+
39+ @dataclass
40+ class BackupFile :
41+ """Backup File."""
42+ source : str
43+ target : str
44+
45+ class BackupProject :
46+ """Backup Project."""
47+
48+ filename : str
49+ items : list [BackupItem ]
50+
51+ def __init__ (self , filename : str ):
52+ self .filename = filename
53+ self .items = []
54+ try :
55+ with open (self .filename , 'r' , encoding = 'utf-8' ) as f :
56+ doc = yaml .load (f , Loader = yaml .SafeLoader )
57+ if doc ['schema_version' ] != 1 :
58+ raise AssertionError ('unknown schema' )
59+ for item in doc ['items' ]:
60+ name = item ['name' ]
61+ itemtype = item ['type' ]
62+ path = item ['path' ]
63+ self .items .append (BackupItem (name , itemtype , path ))
64+ # pylint: disable-next=broad-exception-caught
65+ except Exception as ex :
66+ print (f'warn: failed to load project \' { self .filename } \' : { ex } ' )
67+ self .items = []
68+
69+ def save (self ):
70+ """Saves the backup project."""
71+ try :
72+ items = []
73+ for item in self .items :
74+ items .append ({'name' : item .name , 'type' : item .itemtype , 'path' : item .path })
75+ data = {'schema_version' : 1 , 'items' : items }
76+ with open (self .filename , 'w' , encoding = 'utf-8' ) as f :
77+ f .write (yaml .dump (data , sort_keys = False ))
78+ # pylint: disable-next=broad-exception-caught
79+ except Exception as ex :
80+ print (f'failed to save \' { self .filename } \' : { ex } ' )
81+
82+ def backup (self , folder ):
83+ """Performs a backup."""
84+ if not os .path .exists (folder ):
85+ os .makedirs (folder )
86+
87+ if os .listdir (folder ):
88+ print ('error: folder is not empty' )
89+ return
90+
91+ files = []
92+ for item in self .items :
93+ if item .itemtype == 'Folder' :
94+ for dirname , _ , fnames in os .walk (item .path ):
95+ targetdir = dirname [len (os .path .commonpath ([item .path , dirname ])):]
96+ if targetdir .startswith ('/' ):
97+ targetdir = targetdir [1 :]
98+ targetdir = os .path .join (item .name , targetdir )
99+ for fname in fnames :
100+ source = os .path .join (dirname , fname )
101+ target = os .path .join (targetdir , fname )
102+ files .append (BackupFile (source , target ))
103+ elif item .itemtype == 'File' :
104+ files .append (BackupFile (item .path , item .name ))
105+
106+ with open (os .path .join (folder , 'checksums.txt' ), 'w' ,
107+ newline = '\n ' , encoding = 'utf-8' ) as checksum_file :
108+ for file in files :
109+ target = os .path .join (folder , file .target )
110+ os .makedirs (os .path .dirname (target ), exist_ok = True )
111+ shutil .copy2 (file .source , target )
112+ with open (target , 'rb' ) as f :
113+ digest = hashlib .file_digest (f , "sha256" )
114+ checksum_file .write (f'SHA256 ({ file .target } ) = { digest .hexdigest ()} \n ' )
115+
30116# pylint: disable-next=too-many-instance-attributes
31117class MainWindow :
32118 """MainWindow class."""
33119
120+ root : tk .Tk
121+ project : BackupProject
122+
34123 # pylint: disable-next=too-many-locals,too-many-statements
35- def __init__ (self , project : str ):
124+ def __init__ (self , project : BackupProject ):
36125 root = tk .Tk ()
37126 self .root = root
38127 root .title (DEFAULT_TITLE )
@@ -136,15 +225,24 @@ def __init__(self, project: str):
136225 self .progressbar .pack (side = tk .TOP , fill = tk .X , padx = (5 ,5 ), pady = (5 ,5 ))
137226
138227 # load project
139- items = self .load_project ()
228+ items = self .project . items
140229 for item in items :
141- name = item ['name' ]
142- itemtype = item ['type' ]
143- path = item ['path' ]
144- image = self .get_image (itemtype )
145- self .listbox .insert ('' , tk .END , text = name , image = image , values = (itemtype , path ))
230+ image = self .get_image (item .itemtype )
231+ self .listbox .insert ('' , tk .END , text = item .name ,
232+ image = image , values = (item .itemtype , item .path ))
146233 self .on_selection_changed (None )
147234
235+ def get_items (self ) -> list [BackupItem ]:
236+ """Return the list of items"""
237+ items = []
238+ for itemid in self .listbox .get_children ():
239+ item = self .listbox .item (itemid )
240+ name = item ['text' ]
241+ itemtype = item ['values' ][0 ]
242+ path = item ['values' ][1 ]
243+ items .append (BackupItem (name , itemtype , path ))
244+ return items
245+
148246 def get_image (self , itemtype : str ):
149247 """Returns an image by the provided items type."""
150248 if itemtype == 'Folder' :
@@ -153,37 +251,10 @@ def get_image(self, itemtype: str):
153251 return self .file_image
154252 return self .file_image
155253
156- def load_project (self ):
157- """Loads the project file."""
158- items = []
159- try :
160- with open (self .project , 'r' , encoding = 'utf-8' ) as f :
161- doc = yaml .load (f , Loader = yaml .SafeLoader )
162- if doc ['schema_version' ] != 1 :
163- raise AssertionError ('unknown schema' )
164- for item in doc ['items' ]:
165- items .append (item )
166- # pylint: disable-next=broad-exception-caught
167- except Exception as ex :
168- print (f'warn: failed to load project \' { self .project } \' : { ex } ' )
169- return items
170-
171254 def save_project (self ):
172255 """Saves the proves file."""
173- try :
174- items = []
175- for itemid in self .listbox .get_children ():
176- item = self .listbox .item (itemid )
177- name = item ['text' ]
178- itemtype = item ['values' ][0 ]
179- path = item ['values' ][1 ]
180- items .append ({'name' : name , 'type' : itemtype , 'path' : path })
181- data = {'schema_version' : 1 , 'items' : items }
182- with open (self .project , 'w' , encoding = 'utf-8' ) as f :
183- f .write (yaml .dump (data , sort_keys = False ))
184- # pylint: disable-next=broad-exception-caught
185- except Exception as ex :
186- print (f'failed to save \' { self .project } \' : { ex } ' )
256+ self .project .items = self .get_items ()
257+ self .project .save ()
187258
188259 def add_folder (self ):
189260 """Adds a new folder."""
@@ -262,53 +333,15 @@ def start_backup(self):
262333 folder = askopendirname ()
263334 if folder :
264335 print ('backup started' )
265- items = []
266- for itemid in self .listbox .get_children ():
267- item = self .listbox .item (itemid )
268- name = item ['text' ]
269- itemtype = item ['values' ][0 ]
270- path = item ['values' ][1 ]
271- items .append ({'name' : name , 'type' : itemtype , 'path' : path })
336+ self .project .items = self .get_items ()
272337 self .progressbar .start ()
273- tae .async_execute (self .do_backup (items , folder ),
338+ tae .async_execute (self .do_backup (self . project , folder ),
274339 callback = self .finish_backup , wait = False , visible = False )
275340
276341 # pylint: disable-next=too-many-locals
277- async def do_backup (self , items , folder ):
342+ async def do_backup (self , project , folder ):
278343 """Performs the actual backup"""
279- # get file list
280- if os .listdir (folder ):
281- print ('error: folder is not empty' )
282- return
283-
284- files = []
285- for item in items :
286- itemtype = item ['type' ]
287- if itemtype == 'Folder' :
288- path = item ['path' ]
289- for dirname , _ , fnames in os .walk (item ['path' ]):
290- targetdir = dirname [len (os .path .commonpath ([path , dirname ])):]
291- if targetdir .startswith ('/' ):
292- targetdir = targetdir [1 :]
293- targetdir = os .path .join (item ['name' ], targetdir )
294- for fname in fnames :
295- source = os .path .join (dirname , fname )
296- target = os .path .join (targetdir , fname )
297- files .append ({'source' : source , 'target' : target })
298- elif itemtype == 'File' :
299- files .append ({'source' : item ['path' ], 'target' : item ['name' ]})
300-
301- with open (os .path .join (folder , 'checksums.txt' ), 'w' ,
302- newline = '\n ' , encoding = 'utf-8' ) as checksum_file :
303- for file in files :
304- source = file ['source' ]
305- rel_target = file ['target' ]
306- target = os .path .join (folder , rel_target )
307- os .makedirs (os .path .dirname (target ), exist_ok = True )
308- shutil .copy2 (source , target )
309- with open (target , 'rb' ) as f :
310- digest = hashlib .file_digest (f , "sha256" )
311- checksum_file .write (f'SHA256 ({ rel_target } ) = { digest .hexdigest ()} \n ' )
344+ project .backup (folder )
312345
313346 def on_closing (self ):
314347 """Saves the project when the application is closed."""
@@ -331,9 +364,16 @@ def main():
331364 default_project = os .path .join (Path .home (), 'backup-py.yaml' )
332365 parser = argparse .ArgumentParser ()
333366 parser .add_argument ('-p' , '--project' , type = str , help = 'project file' , default = default_project )
367+ parser .add_argument ('-t' , '--target' , type = str , help = 'target directory' , default = None )
334368 args = parser .parse_args ()
335- app = MainWindow (args .project )
336- app .run ()
369+
370+ project = BackupProject (args .project )
371+
372+ if args .target :
373+ project .backup (args .target )
374+ else :
375+ app = MainWindow (project )
376+ app .run ()
337377
338378
339379if __name__ == '__main__' :
0 commit comments