バックアップツール

スマホで撮った写真や動画をバックアップするときに使う自分用のツールです。

コピー元のディレクトリと、コピー先のディレクトリを指定すると、 コピー元のディレクトリ以下のツリーを、コピー先のディレクトリ以下にコピーするだけの 単純なツールです。

ただし、すでにコピー済みのファイルはスキップして、 新規追加されたファイルと内容が更新されたであろうファイルだけをコピーするように 心がけてます。

bk.py


使い方

$ chmod +x bk.py

$ ./bk.py
Usage ./bk.py srcdir dstdir [bk.yaml]
$ 

コピー元のディレクトリパス、コピー先のディレクトリのパスの順で指定します。

$ mkdir -p /Users/kondoh/foo/bar/hoge
$ mkdir -p /Users/kondoh/foo/bar/fuga
$ echo hello > /Users/kondoh/foo/bar/hoge/hello.txt
$ echo bye > /Users/kondoh/foo/bar/fuga/bye.txt

$ find /Users/kondoh/foo
/Users/kondoh/foo
/Users/kondoh/foo/bar
/Users/kondoh/foo/bar/fuga
/Users/kondoh/foo/bar/fuga/bye.txt
/Users/kondoh/foo/bar/hoge
/Users/kondoh/foo/bar/hoge/hello.txt
$

例えば、このようなツリーに2つファイルを配置したとして...

$ rm -rf /tmp/foo

$ ./bk.py /Users/kondoh/foo /tmp/foo
6 / 10 : bar/hoge/hello.txt / 6
10 / 10 : bar/fuga/bye.txt / 4
$ 

$ diff -ur /Users/kondoh/foo /tmp/foo
$ 

テストのためコピー先を削除しておいてから実行すると、 ファイルが2つコピーされます。

$ ./bk.py /Users/kondoh/foo /tmp/foo
already backuped
$ 

一度コピーした後同じように実行しても、 コピー済みなので何もしません。

$ echo kon > /Users/kondoh/foo/bar/kon.txt
$ echo byebye > /Users/kondoh/foo/bar/fuga/bye.txt 

ツリーにファイルを新規追加したり、ファイルのサイズや日付が変更されていると...

$ ./bk.py /Users/kondoh/foo /tmp/foo
7 / 11 : bar/fuga/bye.txt / 7
11 / 11 : bar/kon.txt / 4
$ 

そのファイルだけコピーされます。

touch /Users/kondoh/foo/bar/hoge/hello.txt 

日付だけ更新しても

$ ./bk.py /Users/kondoh/foo /tmp/foo
6 / 6 : bar/hoge/hello.txt / 6
$ 

ほれこの通り。

コピー時の表示は次の通り。

コピー済みサイズ / コピー予定全体サイズ : 実行中ファイルのパス / 実効中ファイルのサイズ

bk.yaml としてファイル名を指定すると、 コピー元、コピー先のツリーの状態をYAML形式で書き出します。

起動時に bk.yaml として指定したYAMLファイルが存在し、 コピー元、コピー先の情報が含まれていると、 ツリーの情報をYAMLファイルから取得します。


ソースの簡単な説明

 :
def exit(msg='', code=0):
	print msg
	sys.exit(code)

# 簡単なツールなのでエラー処理はサボります。
# メッセージを表示して終了するだけ。


def exec_cmd(cmd, out=True):
	if out:
		return subprocess.check_output(cmd, shell=True)
	subprocess.call(cmd, shell=True)

# コマンド実行用。
# 実行結果の文字列を返しますが、out=False 指定では返しません。
# (区別しなくても、返された文字列を使わなければいいだけかも...)


def mkdir_if_not(path):
	if not os.path.exists(path):
		cmd = "mkdir -p '{}'".format(path)
		exec_cmd(cmd, False)

# 指定の path が存在しなければ、指定の path のディレクトリを作成します。


def yaml_load(fname, def_val):
	v = def_val
	if os.path.exists(fname):
		with open(fname, 'r') as f:
			v = yaml.load(f)
	return v

# fnameのYAMLファイルをロードして返します。


def file_write(fname, s):
	if fname == '-':
		sys.stdout.write(s)
		return
	with open(fname, 'w') as f:
		f.write(s)

# fname のファイルに文字列 s を書き込みます。


yaml_save = lambda fname, v, default_flow_style=False: file_write( fname, yaml.dump(v, default_flow_style=default_flow_style) )

# fname のYAMLファイルをセーブします。


def get_info(path):
	if os.path.islink(path):
		return { 'realpath': os.path.realpath(path) }
	return { 'size': os.path.getsize(path), 'mtime': os.path.getmtime(path) }

# path のファイルについて、サイズと更新時刻の情報を辞書にして返します。
# path がシンボリックリンクの時は realpath の情報を辞書にして返します。


db_sz = lambda db, p: db.get(p, {}).get('size', 0)

# ツリーDB辞書にあるキーp(path)のファイルのサイズを返します。


def add_db_if_not(db, path):
	if path in db:
		return
	cmd = 'find {} -type f -or -type l'.format(path)
	s = exec_cmd(cmd)
	if not s:
		return
	lst = s.strip().split('\n')
	pn = len(path)
	db[path] = dict( map( lambda p: ( p[pn+1:], get_info(p) ), lst ) )

# フルパスpath以下のツリーの情報が、
# 全体のDBに無ければ、ツリーDBを作成して登録します。


def is_same(a, b):
	k = 'realpath'
	if k in a != k in b:
		return False
	if k in a:
		return a.get(k) == b.get(k)

	k = 'size'
	if a.get(k) != b.get(k):
		return False
	k = 'mtime'
	return abs( a.get(k) - b.get(k) ) < 4.0 # FAT resolution is 2 sec !

# ファイル(あるいはシンボリック)の情報の辞書a, bを比較し、
# 一致するか判定して返します。
# FATの更新時刻の精度が2秒という事があったりするので、
# 更新時刻の差が4秒未満なら、一致しているものとしてます。


def cp_rm_lst(db, s, d):
	db_s = db.get(s, {})
	db_d = db.get(d, {})

	need_copy = lambda (k, v): k not in db_d or not is_same(db_d.get(k), v)
	items = filter( need_copy, db_s.items() )
	if not items:
		return ([], [])
	(lst, _) = zip(*items)
	lst = list(lst)
	rm_lst = filter( lambda p: p in db_d, lst )
	return (lst, rm_lst)

# 全体のDB、コピー元のパスs、コピー先のパスdを与えて、
# コピーが必要なファイルのリストと、
# そのリストの中でコピー先に古いファイルがあり、上書きされるリストを返します。
# リスト要素のパスは、ツリー内の相対パスで返されます。


def get_disk_free(path):
	cmd = "df -k '{}'".format(path)
	s = exec_cmd(cmd)
	lst = s.strip().split('\n')
	lst = map( lambda s: s.split(), lst )
	i = lst[0].index('Available') # !
	return int( lst[1][i] ) * 1024

# path の含まれるディスクの空き容量を返します。
# dfコマンドの表示形式に強く依存してますので、
# 環境によっては上手くいかないかも...
# 自分用のツールということなので「えいや」になってます。


def kmgt_str(v):
	ut = 'KMGT'
	i = 0
	while v >= 1024 * 10 and i < 4:
		v /= 1024
		i += 1
	return str(v) + ( ut[i-1] if i > 0 else '' )

# v の整数を適当にキロ、メガ、ギガ、テラの単位付きの文字列にして返します。


def rm(p, d, db_d, rm_lst): # ret removed size
	sz = db_sz(db_d, p)
	cmd = "rm -f '{}'".format( os.path.join(d, p) )
	exec_cmd(cmd, False)
	rm_lst.remove(p)
	return sz

# コピー先のファイルを削除するための関数。
# ツリー内の相対パス p
# コピー先の(ツリー先頭の)ディレクトリ d
# コピー先ツリーDB辞書 db_d
# 削除候補ファイルパスのリスト rm_lst を与えて、
# 指定のファイルを削除し、rm_lst から指定のパスを削除し、
# 削除したファイルのサイズを返します。


def cp(p, s, d, db_s): # ret cp size
	#cmd = "tar cf - -C {} '{}' | tar xf - -C {}".format(s, p, d)
	#exec_cmd(cmd, False)

	(dn, fn) = os.path.split(p)
	d_dn = os.path.join(d, dn)
	mkdir_if_not(d_dn)
	s_p = os.path.join(s, p)
	cmd = "cp -p '{}' '{}'".format(s_p, d_dn)
	exec_cmd(cmd, False)
	return db_sz(db_s, p)

# ファイルのコピーする関数。
# ツリー内の相対パス p
# コピー元の(ツリー先頭の)ディレクトリ s
# コピー先の(ツリー先頭の)ディレクトリ d
# コピー元ツリーDB辞書 db_s を与えて、
# 指定のファイルをコピーし、コピーしたファイルのサイズを返します。

# 最初はコメント箇所のtarでよかったのですが...
# 自分のMacの環境では、FATの場合に時刻がコピー先に復元できなかったので、
# cp -p を使うように変更しました。

# あぁ、cp にしたのでシンボリックリンクが実体になってしまう...
# でも、そもそもファイルシステムがFATとかだと、
# シンボリックリンクをサポートしてないか
#むむむ、シンボリックリンクの扱いはどうすべしか?


def backup(db, s, d, lst, rm_lst):

	# バックアップ用のコピーを実行します。

	# 全体のDBである db
	# コピー元の(ツリー先頭の)ディレクトリ s
	# コピー先の(ツリー先頭の)ディレクトリ d
	# コピーが必要なファイルのリスト lst
	# コピーで上書きされる削除候補ファイルのリスト rm_lst


	db_s = db.get(s, {})
	db_d = db.get(d, {})

	# コピー元DB辞書 db_s
	# コピー先DB辞書 db_d


	add_sz = sum( map( lambda p: db_sz(db_s, p), lst ) )

	# コピーするファイルのサイズの合計 add_sz


	rm_sz = sum( map( lambda p: db_sz(db_d, p), rm_lst ) )

	# 上書きされるファイルのサイズの合計 rm_sz


	dst_free = get_disk_free(d)

	# コピー先ディスクの空き容量 dst_free


	over = add_sz - rm_sz - dst_free
	if over > 0:
		exit( 'No disk space, over ' + kmgt_str(over), 2 )

	# バックアップのコピーを実行したとして、
	# 空き容量に収まらなければ、超過分のサイズを表示して終了。


	cp_sz = 0
	while lst:
		p = lst.pop(0)

		# コピー候補リストから1つ取り出して、


		if p in rm_lst:
			dst_free += rm(p, d, db_d, rm_lst)

			# それが上書きするファイルなら
			# コピー先のファイルを削除して、
			# 削除候補リストを更新しつつ
			# コピー先の空き容量も更新。


		sz = db_sz(db_s, p)

		# コピーするファイルのサイズ sz


		while sz > dst_free:
			rm_p = rm_lst.pop()
			dst_free += rm(rm_p, d, db_d, rm_lst)
		
		# コピーするファイルのサイズがコピー先の空き容量より大きいうちは、
		# 削除候補リストから順次削除を実行して、
		# コピー先の空き容量も更新。


		sz = cp(p, s, d, db_s)

		# コピーを実行して、サイズを sz に。
		# (ここは、sz 必要なかったか...)


		dst_free -= sz
		cp_sz += sz

		# コピー済みサイズとコピー先空き容量を更新。


		print kmgt_str(cp_sz) + ' / ' + kmgt_str(add_sz) + ' : ' + p + ' / ' + kmgt_str(sz)

		# コピー状況を表示。


	for k in lst:
		db_d[k] = db_s.get(k).copy()

	# コピー先DB辞書のコピーしたファイルの情報を追加、更新。


if __name__ == "__main__":
	if len(sys.argv) < 3:
		exit( 'Usage {} srcdir dstdir [bk.yaml]'.format( sys.argv[0] ) )

	# 引数が足りてないと、説明を表示して終了。


	cut_tail = lambda s: s[:-1] if s[-1] == '/' else s

	# ディレクトリ指定の文字列の末尾の '/' があれば削除する関数 cut_tail


	s = cut_tail( sys.argv[1] )
	if not os.path.exists(s):
		exit( 'Not found ' + s, 1 ) 

	# コピー元の(ツリー先頭の)ディレクトリ s
	# 存在しなければエラー表示出して終了。


	d = cut_tail( sys.argv[2] )
	mkdir_if_not(d)

	# コピー先の(ツリー先頭の)ディレクトリ d
	# 存在しなければ、ディレクトリを作成。


	i = 3
	fn = sys.argv[i] if i < len(sys.argv) else ''
	db = yaml_load(fn, {}) if fn else {}

	# YAMLファイル指定があれば fn に設定して、
	# ロードした結果を全体のDB db に


	add_db_if_not(db, s)
	add_db_if_not(db, d)

	# コピー元ツリー、コピー先ツリーの情報が、
	# 全体のDBに無ければ、ツリーDBを作成して登録します。


	(lst, rm_lst) = cp_rm_lst(db, s, d)

	# コピーが必要なファイルのリスト lst
	# そのリストの中でコピー先に古いファイルがあり、上書きされるリスト rm_lst


	if not lst:
		exit( 'already backuped' )

	# コピーが必要なリストが空なら、何もせず終了。


	backup(db, s, d, lst, rm_lst)

	# バックアップ用のコピーを実行します。


	if fn:
		yaml_save(fn, db)

	# YAMLファイルの指定があれば、全体のDBをファイルにセーブします。
# EOF


空のディレクトリはコピーされないけど、まぁ良しとします。

シンボリックリンクの扱いをどうすべしか?


バージョン2

シンボリックをサポートしていないファイルシステムの場合はさておいて、、、

サポートしている前提で、コピー箇所をtarを使うように修正しました。

他の箇所も少々整理。

v2.patch
$ cat v2.patch | patch -p1


バージョン3

レイトレーシング 2018春 で、ふいに python3 にバージョンを上げてしまってました。

こちらのツールを使おうとして、エラーでショック。

python3 対応しておきます。

v3.patch
$ cat v3.patch | patch -p1


バージョン4

表示の数値の桁数が変わってしまってました。 python3 で割り算'/'の仕様が変わってました。 元通りになるように修正します。

v4.patch
$ cat v4.patch | patch -p1


バージョン5

プログラムからコマンドを実行する際に、 subprocessモジュールでshellを開いて、実行してます。

パス文字列を指定するコマンドのときに、 パス中にスペースが入っている場合に備えて、 '...' とシングルクォートで囲うようにしてました。

音楽ファイルのバックアップをとってると、 ファイル名中にまさかのシングルクォート!

ムーンライダースの「Don't trust over 30」でした。

シングルクォートがあるときは "..." とダブルクォートで囲う対応にしてみました。

シングルクォートもダブククォートも含む凶悪なファイル名の場合は、 ダブルクォートはシングルクォート2つ矯正することにしました。

v5.patch
$ cat v5.patch | patch -p1


工事中