Skip to content

Instantly share code, notes, and snippets.

@ssokolow
Created April 25, 2011 08:49
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ssokolow/940290 to your computer and use it in GitHub Desktop.
Save ssokolow/940290 to your computer and use it in GitHub Desktop.
Python port of Tjb0607's Minecraft Backup Script
#!/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