We have a large set of projects at work. It's very important we keep track of those and every so often we need to run a full back-up that's unrelated to the 'normal' back-ups IT is running (snapshots every day, week, month, etc.). Recently we had a big migration on all of the older projects. To make sure nothing is irreparably damaged during the migration (basically a version upgrade), everything is backed-up twice before the upgrade.
There are network drives and slow computers involved (so everything is IO-bound), which makes CPU usage largely irrelevant.
Current speed:
- under a minute/GB (local-to-local)
- around 3 minutes/GB (local-to-network)
- around 4 minutes/GB (network -> local)
- under 2 minutes/GB (network -> network)
We're talking 60+ GB for the source directory, spread over 7k folders and 180k files. If we could somehow read/write differently to make it easier on the disks and the network, that would be great.
I'm working on an application that eventually does 3 things:
- backup
- set a secondary program loose on the original data for the migration
- clean-up
This question is purely concerned about the first part of that process, the rest of the story is purely to explain the context and motivation. It can be used stand-alone just fine, so it's ready for review.
This program will perform some basic sanity checks on the directories involved, verify the required space is available and perform a backup to as many locations as you like. At the end it prints how many bytes were written, for a quick visual check whether anything went wrong. It's important that meta-data like the dates of creation and last modification are kept intact.
I've split the code into somewhat sensible functions. Some are doing the heavy lifting, others are just helpers to avoid repeating myself. I'm not happy about how I've implemented the directory validation, the reporting and exception handling is shoddy at best and I'm sure other functions can be improved in terms of re-usability.
I've tried doing some fancy things on the directory checking so I could recursively determine the validity without any care whether I passed a string, a WindowsPath or a dictionary. Which failed horribly. I feel like I'm not optimally using pathlib
, but considering I'm legacy tools (I was running into fairly odd PermissionError
trouble with shutil
while xcopy
"just works") I'm not entirely sure that can be helped. I think all the readouts and at least part of the validation could be done with decorators, like here but that's still fairly magical to me.
I'm fairly sure it shouldn't all be in one file either, but considering I'd just take everything except main
and put it in a utils.py
, I hadn't gone that route yet. I'm open to ideas. Honestly I'm surprised my code still works, it doesn't look like it should. I've attempted (not shown) to prettify it with wrappers and decorators, but it seems like it would require a rewrite before that's going to work. As long as the data gets copied in a cleanly matter, the rest of the code can still be changed any which way. The spec isn't set in stone.
# -*- coding: utf-8 -*-
"""
Created on Wed May 5 13:10:37 2021
@author: Mast
Notes:
# xcopy & psutil don't seem to handle pathlib.WindowsPath too well,
# converting to string conveniently turns / into \\
# shutil kept running into PermissionError where xcopy has no trouble
"""
import os
import sys
import subprocess
import psutil
import time
from datetime import datetime
from pathlib import Path
PROJECTS_SOURCE = Path('Z:/EPLAN_DATA/Gegevens/Projecten/')
BACKUP_DIRECTORIES = {
'local': Path('C:/backups/eplanprojects'),
'network': Path('N:/BackupsEplan')
}
XCOPY_ARGS = "/e /h /i /q /s /z"
MAX_BACKUPS = 3
SLEEP_TIME = 60
FORCE = True
def to_megabytes(size):
"""
Parameters
----------
size : int
Returns
-------
str
Turn Bytes into rounded MegaBytes.
"""
return "{0} MB".format(round(size / 1000000.0, 2))
def get_directory_size(directory):
"""
Parameters
----------
directory : pathlib.WindowsPath
Returns
-------
int
Size of the contents of the directory in Bytes.
"""
print("Calculating size of {0}".format(directory))
size = 0
for path, _, files in os.walk(directory):
for file in files:
file_path = os.path.join(path, file)
size += os.path.getsize(file_path)
print(to_megabytes(size))
return size
def free_space(directory):
"""
Parameters
----------
directory : pathlib.WindowsPath
Returns
-------
int
Free space available on partition the directory belongs to.
"""
return psutil.disk_usage(str(directory)).free
def terminate_program(additional_info=""):
"""
Parameters
----------
additional_info : str (OPTIONAL)
Print additional_info, wait & exit.
"""
print("Program terminated before completion.")
if additional_info:
print(additional_info)
time.sleep(SLEEP_TIME)
sys.exit()
def validate_directory(directory):
"""
Parameters
----------
directory : pathlib.WindowsPath
Raise FileNotFoundError if directory does not exist.
"""
if not directory.exists():
print("Invalid directory: {0}".format(directory))
raise FileNotFoundError
def verify_available_backup_space(source_size, backup_directories):
"""
Parameters
----------
source_size : int
backup_directories : dict of pathlib.WindowsPath
Validate backup directories exist and are big enough to hold source.
Terminate on failure.
"""
for backup_directory in backup_directories.values():
validate_directory(backup_directory)
backup_space_available = free_space(backup_directory)
if source_size > backup_space_available:
# WARNING: If multiple back-up locations are on the SAME partition,
# this check is insufficient
print("That's not going to fit.\nTarget: {0} available.\nSource: {1}".format(
backup_space_available, source_size))
terminate_program()
def backup_projects(source, backup_directories):
"""
Parameters
----------
source : pathlib.WindowsPath
backup_directories : dict of pathlib.WindowsPath
Returns
-------
list of int : bytes_written
Perform backup of source directory to (multiple) backup directories.
"""
bytes_written = []
for backup_directory in backup_directories:
if len([f.path for f in os.scandir(backup_directories.get(backup_directory)) if f.is_dir()]) >= MAX_BACKUPS:
print("Amount of immediate subdirectories in ({0}) is higher or equal to maximum amount of backups ({1}) configured.".format(
backup_directory, MAX_BACKUPS))
if not FORCE:
terminate_program()
else:
print("Backup forced. Continuing.")
print(backup_directories[backup_directory])
start_time = datetime.now()
print("Start copy {0}".format(start_time))
try:
subfolder = "_{}".format(start_time).replace(
':', '-').replace(' ', '_').split('.')[0]
print(subfolder)
syscall = "xcopy {source} {destination}\\{subfolder} {args}".format(
source=str(source),
destination=str(backup_directories[backup_directory]),
subfolder=subfolder,
args=XCOPY_ARGS)
print(syscall)
subprocess.run(syscall, check=True)
except PermissionError:
print("Permission denied: {0}".format(syscall))
terminate_program()
end_time = datetime.now()
print("Started: {0}\nFinished: {1}\nExecution time {2}".format(
start_time,
end_time,
end_time - start_time)
)
bytes_written.append(get_directory_size(
str(backup_directories[backup_directory]) + '\\' + subfolder))
for value in bytes_written:
print(to_megabytes(value))
def main():
validate_directory(PROJECTS_SOURCE)
projects_source_size = get_directory_size(PROJECTS_SOURCE)
verify_available_backup_space(projects_source_size, BACKUP_DIRECTORIES)
backup_projects(PROJECTS_SOURCE, BACKUP_DIRECTORIES)
if __name__ == "__main__":
main()
print("Press any key...")
input()
Real output:
Calculating size of Z:\EPLAN_DATA\Gegevens\Projecten
60585.67 MB
C:\backups\eplanprojects
Start copy 2021-05-07 17:21:57.007150
_2021-05-07_17-21-57
xcopy Z:\EPLAN_DATA\Gegevens\Projecten C:\backups\eplanprojects\_2021-05-07_17-21-57 /e /h /i /q /s /z
178642 File(s) copied
Started: 2021-05-07 17:21:57.007150
Finished: 2021-05-07 21:41:52.366467
Execution time 4:19:55.359317
Calculating size of C:\backups\eplanprojects\_2021-05-07_17-21-57
60585.67 MB
N:\BackupsEplan
Start copy 2021-05-07 21:43:31.600948
_2021-05-07_21-43-31
xcopy Z:\EPLAN_DATA\Gegevens\Projecten N:\BackupsEplan\_2021-05-07_21-43-31 /e /h /i /q /s /z
178642 File(s) copied
Started: 2021-05-07 21:43:31.600948
Finished: 2021-05-07 23:31:07.970629
Execution time 1:47:36.369681
Calculating size of N:\BackupsEplan\_2021-05-07_21-43-31
60585.67 MB
60585.67 MB
60585.67 MB
Press any key...
Anything and everything is up for review. Nitpick away.
Python 3.8.5 on Windows 10 x64, no limitations on libraries this time and no need to be cross-platform.