Created
April 25, 2011 08:49
-
-
Save ssokolow/940290 to your computer and use it in GitHub Desktop.
Python port of Tjb0607's Minecraft Backup Script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# -*- coding: utf-8 -*- | |
"""Tjb0607's Minecraft Backup Script | |
Ported to Python and enhanced by ssokolow | |
Version: 2.1_01+py02 | |
Changes: | |
- 2.1_01+py02: Add a simple corruption check using the level.dat gzip checksum. | |
- 2.1_01+py01: Initial Python port | |
""" | |
# Metadata used by some Python tools/sites | |
__appname__ = "Tjb0607's Minecraft Backup Script" | |
__author__ = "Tjb0607, deitarion/SSokolow" | |
__version__ = "2.1_01+py02" | |
#TODO: Set __license__ | |
# --== Tuning Parameters ==-- | |
CHUNK_SIZE = 65536 # Chunked file operations will read this many bytes at once | |
# --== Code ==-- | |
import gzip, logging, os, shutil, sys | |
# Set up default message format and verbosity | |
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') | |
# See http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html | |
XDG_DATA_DIR = os.environ.get('XDG_DATA_HOME', os.path.expanduser('~/.local/share')) | |
XDG_CONFIG_DIR = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config')) | |
# Note: In my `python -m timeit` tests, the difference between MD5 and SHA1 was | |
# negligible, so there is no meaningful reason not to take advantage of the | |
# reduced potential for hash collisions SHA-1's greater hash size offers. | |
try: | |
import hashlib | |
hasher = hashlib.sha1 | |
except ImportError: | |
# Backwards-compatibility for pre-2.5 Python. | |
import sha | |
hasher = sha.new | |
def appdata_path(name): | |
"""Retrieve the application data directory with the given name. | |
(Many people don't realize it's possible to move them) | |
Non-Linux portions adapted from | |
http://stackoverflow.com/questions/1084697/how-do-i-store-desktop-application-data-in-a-cross-platform-way-for-python/1088459#1088459 | |
""" | |
if sys.platform == 'darwin': | |
try: | |
from AppKit import NSSearchPathForDirectoriesInDomains as getdir | |
except ImportError: | |
#NOTE: PyObjC comes with OSX but only for the system Python. | |
logging.warn("PyObjC not found. Guessing location of user " | |
"application support directory") | |
return os.path.expanduser( | |
os.path.join('~', 'Library', 'Application Support', name)) | |
else: | |
return os.path.join(getdir(14, 1, True)[0], name) | |
elif os.name == 'nt': | |
return os.path.join(os.environ['AppData'], name) | |
else: | |
#NOTE: Config files go in XDG_CONFIG_DIR instead. | |
proper = os.path.join(XDG_DATA_DIR, name) | |
legacy = os.path.expanduser(os.path.join("~/." + name)) | |
if os.path.exists(proper): | |
return proper # If the proper one is in use, give it. | |
elif os.path.exists(legacy): | |
return legacy # Else, fall back to the legacy one. | |
else: | |
return proper # ...unless neither exists. | |
def hashFile(handle, want_hex=False): | |
"""Generate an SHA1 hash for a potentially long file. | |
Accepts paths and file-like objects. | |
If passed a file-like object, digesting will obey CHUNK_SIZE to conserve | |
memory. | |
If you pass in a file-like object, it is your responsibility to close it. | |
""" | |
if isinstance(handle, basestring): | |
handle = file(handle, 'rb') | |
fhash = hasher() | |
# Chunked digest generation (conserve memory) | |
for block in iter(lambda: handle.read(CHUNK_SIZE), ''): | |
fhash.update(block) | |
return want_hex and fhash.hexdigest() or fhash.digest() | |
def backup_level_file(src, dest, dest_old): | |
"""Backup routine for old MCLevel-format Minecraft levels.""" | |
lv_name = os.path.splitext(os.path.basename(src))[0] | |
if not (os.path.isfile(src) and | |
os.path.splitext(src)[1].lower() == '.mclevel'): | |
#TODO: Figure out how to recognize MCLevel files by their headers. | |
logging.warning("Not a Minecraft level. Skipping: %s", | |
os.path.basename(src)) | |
return True | |
logging.info("Detected /indev/ level. Backing up: %s", lv_name) | |
# Run a basic check of the GZip checksum | |
try: | |
fobj = gzip.GzipFile(src) | |
fobj.read() | |
fobj.close() | |
except IOError: | |
logging.error("%s: Error gunzipping level. Not overwriting backup " | |
"with corrupted version.", lv_name) | |
return False | |
try: | |
# Since it's all one file anyway and loading from disk is the slow | |
# part, hashing wouldn't save any time. | |
if os.path.exists(dest_old): | |
os.remove(dest_old) | |
if os.path.exists(dest): | |
shutil.move(dest, dest_old) | |
shutil.copy(src, dest) | |
except OSError: | |
logging.error("%s: backup attempt failed\n", lv_name) | |
return False | |
return True | |
def backup_level_dir(src, dest, dest_old): | |
"""Backup routine for one directory-based Minecraft level.""" | |
lv_name = os.path.basename(src) | |
new_dat = os.path.join(src, 'level.dat') | |
old_dat = os.path.join(dest, 'level.dat') | |
if not os.path.exists(new_dat): | |
logging.warning("%s: Not a recognized Minecraft level. Skipping.", src) | |
return True # We didn't recognize it, but there was no error. | |
# (eg. This happens when you've got cruft from when an | |
# older Minecraft deleted a level and didn't completely | |
# remove it) | |
logging.debug("%s: checking sha1sum...", lv_name) | |
if os.path.exists(old_dat) and hashFile(new_dat) == hashFile(old_dat): | |
logging.info("sha1 check: level.dat files are the same. " | |
"Not backing up: %s", lv_name) | |
else: | |
# Also run a basic check of the level.dat GZip checksum | |
try: | |
fobj = gzip.GzipFile(new_dat) | |
fobj.read() | |
fobj.close() | |
except IOError: | |
logging.error("%s: Error gunzipping level.dat. Not overwriting " | |
"backup with corrupted version.", lv_name) | |
return False | |
logging.info("sha1 check: level.dat files are not the same. " | |
"Backing up: %s", lv_name) | |
try: | |
# On UNIXy operating systems, you can overwrite with a move to | |
# guarantee that the operation can't fail half-way through. | |
# (As long as both are on the same drive, of course) | |
# On others, you have to delete the target first | |
if os.name != 'posix' and os.path.exists(dest_old): | |
shutil.rmtree(dest_old) | |
logging.debug("%s: deleted oldest backup", lv_name) | |
if os.path.exists(dest): | |
#TODO: Should probably use Python's zipfile module to compact | |
# the backups first like many Windows GUI backup tools do. | |
shutil.move(dest, dest_old) | |
logging.debug("%s: renamed old backup", lv_name) | |
shutil.copytree(src, dest) | |
except OSError: | |
logging.error("%s: backup attempt failed\n", lv_name) | |
return False # Inform the caller of a failed backup attempt | |
else: | |
logging.debug("%s: backed up\n", lv_name) | |
return True # If we reach here, no error occurred. | |
# Don't run the following if the file is imported as a library | |
if __name__ == '__main__': | |
# optparse gets us stuff like --help for free | |
from optparse import OptionParser | |
parser = OptionParser( | |
description=__doc__, | |
version="%%prog v%s" % __version__) | |
parser.add_option('-q', '--quiet', action="store_const", const=-1, | |
dest="verbose", default=0, help="Decrease verbosity") | |
parser.add_option('-v', '--verbose', action="store_const", const=1, | |
dest="verbose", default=0, help="Increase verbosity") | |
# Kill newline-stripping in word-wrap in the --help description output | |
import textwrap | |
OptionParser.format_description = lambda self, formatter: textwrap.fill( | |
self.description, replace_whitespace=False) | |
opts, args = parser.parse_args() | |
if opts.verbose < 0: | |
logging.getLogger().setLevel(logging.WARNING) | |
elif opts.verbose > 0: | |
logging.getLogger().setLevel(logging.DEBUG) | |
mcdir = appdata_path('minecraft') | |
mc_saves = os.path.join(mcdir, 'saves') | |
mc_backups = os.path.join(mcdir, 'saves.backup') | |
mc_backups_old = os.path.join(mcdir, 'saves.backup', 'old') | |
# This is the best place to start to failsafe any potential bugs | |
if not os.path.isdir(mc_saves): | |
logging.critical("Could not find Minecraft saves dir at %s", mc_saves) | |
sys.exit(1) | |
for path in (mc_backups, mc_backups_old): | |
# Do each one individually so it doesn't break if someone moves | |
# mc_backups_old outside of mc_backups | |
if not os.path.exists(path): | |
os.makedirs(path) | |
all_success = True | |
for entry in os.listdir(mc_saves): | |
# It's cleaner without using chdir in my opinion | |
path = os.path.join(mc_saves, entry) | |
backup = os.path.join(mc_backups, entry) | |
old = os.path.join(mc_backups_old, entry) | |
# Some people still have /indev/ saves (among other things) in mcdir | |
if os.path.isdir(path): | |
# A little redstone-esque trick to remember if anything failed | |
all_success = all_success and backup_level_dir(path, backup, old) | |
else: | |
all_success = all_success and backup_level_file(path, backup, old) | |
if all_success: | |
logging.info("All saves backed up") | |
else: | |
logging.info("Some saves could not be backed up") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment