フォルダ同期ツール「Harmonize.py」

フォルダ・ファイル構成を同じに保ちます。

tags:python, filesync, utility
created:2007-02-05T20:24:24

フォルダ同期ツール 2つのフォルダを指定することで同じ状態になるよう同期させます。

概要

2つのフォルダペアを指定すると、その2フォルダの中身が同じになるよう ファイルの転送などを行います。

コマンドラインユーティリティとして作ってありますので、 バッチファイルやシェルからの利用がしやすいでしょう。

良くある同期ツールと違って特徴的なのは「 削除 」機能を持っていることです。

2つのフォルダをレフト、ライトと呼びます。

流れとしては、

  1. 起動時にレフト、ライトそれぞれのフォルダ内容のリストを取得します。
  2. 最後のファイルリストにあってレフトにないものはライトから削除。
  3. 最後のファイルリストにあってライトにないものはレフトから削除。
  4. レフトにあってライトにないものはライトにコピー。
  5. ライトにあってレフトにないものはレフトにコピー。
  6. 共通してあるものは日付をみて古いほうを新しいほうで上書き。
  7. レフトとライトは同じになったので(無視するものを除き)そのリストを保存。

という手順をとります。

保存したリストがない場合、もしくは「-d」オプションが指定されていない場合は、 「2.」、「3.」の手順は行いません。

警告

削除機能はうまく働くうちは便利なのですが、あくまで慎重に使ってください。 そのうち、ごみ箱へ移動するように変更するつもりです。

ソースコード

若干だらだらと長くなってしまいましたが・・・。

単発ツールはファイル1つの方が使い勝手が良いので。

harmonize.py( ダウンロード

# -*- encoding: utf-8 -*-

import sys
import os
import pickle
import shutil
import imp

if hasattr(sys,"setdefaultencoding"):
    sys.setdefaultencoding("utf-8")

def is_frozen():
    return (hasattr(sys, "frozen") or # new py2exe
            hasattr(sys, "importers") # old py2exe
            or imp.is_frozen("__main__")) # tools/freeze

def executable():
    if is_frozen():
        return os.path.abspath(sys.executable)
    return os.path.abspath(sys.argv[0])

class Harmonize:

  def __init__(self, conf_file=''):
    self.conf_file = conf_file
    self.conf = {
      'ignore_dirs': ['RCS', 'CVS', 'tags', '.svn'],
      'ignore_file_ext': ['.pyc', '.pyo', '.obj', '.db', '.pdb'],
      'folders': {}
    }
    if os.path.exists(conf_file):
      self.load()

  def load(self):
    self.conf = pickle.load(file(self.conf_file))

  def save(self):
    pickle.dump(self.conf, file(self.conf_file, 'w'))

  def listdir(self, path):
    ans = {}
    for item in os.listdir(path):
      fullpath = os.path.join(path, item)
      if os.path.isdir(fullpath):
        if item in self.conf['ignore_dirs']:
          continue
        ans[item+os.sep] = self.listdir(fullpath)
      else:
        fname, ext = os.path.splitext(item)
        if ext.lower() in self.conf['ignore_file_ext']:
          continue
        ans[item] = ()#os.path.getmtime(fullpath), os.path.getsize(fullpath))
    return ans

  def subtract(self, include, exclude):
    ans = {}
    for k,v in include.iteritems():
      if isinstance(v,dict):
        if v!=exclude.get(k,None):
          ans[k] = self.subtract(v, exclude.get(k,{}))
      else:
        if not exclude.has_key(k):
          ans[k] = tuple(v)
    return ans

  def merge(self, include, extend):
    ans = self.subtract(include, {})
    for k,v in extend.iteritems():
      if isinstance(v,dict):
        ans[k] = self.merge(v, include.get(k, {}))
      else:
        ans[k] = tuple(v)
    return ans

  def append(self, src, dst):
    if self.conf['folders'].has_key((src,dst)):
      return
    if self.conf['folders'].has_key((dst,src)):
      return
    self.conf['folders'][(src,dst)] = {}
    self.save()

  def list(self):
    return self.conf['folders'].keys()

  def remove(self, src, dst):
    del self.conf['folders'][(src, dst)]
    self.save()

  def index_list(self, path, index):
    for key, value in index.iteritems():
      child_path = os.path.join(path,key)
      yield child_path
      if isinstance(value, dict):
        for item in self.index_list(child_path, value):
          yield item

  def copy(self, src_path, dst_path, index):
    if not os.path.exists(dst_path):
      print 'make folder:', dst_path
      os.makedirs(dst_path)
    for key, value in index.iteritems():
      src_path_ = os.path.join(src_path, key)
      dst_path_ = os.path.join(dst_path, key)
      if isinstance(value, dict):
        self.copy(src_path_, dst_path_, value)
      else:
        print 'copy file:', src_path_
        shutil.copy2(src_path_, dst_path_)

  def move(self, src_path, dst_path, index):
    if not os.path.exists(dst_path):
      print 'make folder:', dst_path
      os.makedirs(dst_path)
    for key, value in index.iteritems():
      src_path_ = os.path.join(src_path, key)
      dst_path_ = os.path.join(dst_path, key)
      if isinstance(value, dict):
        self.move(src_path_, dst_path_, value)
      else:
        print 'move file:', src_path_
        shutil.move(src_path_, dst_path_)

  def delete(self, path, original, reduced):
    for key, value in original.iteritems():
      path_ = os.path.join(path, key)
      if isinstance(value, dict):
        if reduced.has_key(key):
          self.delete(path_, value, reduced[key])
        else:
          shutil.rmtree(path_, True)
          print 'delete folder:', path_
      else:
        if not reduced.has_key(key):
          try:
            os.remove(path_)
            print 'delete file:', path_
          except OSError:
            pass

  def update(self, src_path, dst_path, index):
    for key, value in index.iteritems():
      src_path_ = os.path.join(src_path, key)
      dst_path_ = os.path.join(dst_path, key)
      if isinstance(value, dict):
        self.update(src_path_, dst_path_, value)
      else:
        d = os.path.getmtime(src_path_) - os.path.getmtime(dst_path_)
        if d>0:
          print 'update -->:', src_path_
          shutil.copy2(src_path_, dst_path_)
        elif d<0:
          print 'update <--:', src_path_
          shutil.copy2(dst_path_, src_path_)

  def synchronize(self, src_path, dst_path, delete=False):
    print 'Synchronize :',src_path, dst_path
    index={}
    loaded = True
    try:
      index = self.conf['folders'][(src_path, dst_path)]
    except KeyError:
      try:
        index = self.conf['folders'].get((dst_path, src_path), {})
      except KeyError:
        loaded = False
    sub = self.subtract
    src = self.listdir(src_path)
    dst = self.listdir(dst_path)
    src_remove = sub(index, dst)
    dst_remove = sub(index, src)
    common = self.merge(src, dst)
    common = sub(common, sub(src, dst))
    common = sub(common, sub(dst, src))
    common = sub(common, src_remove)
    common = sub(common, dst_remove)
    to_dst = sub(src, dst)
    to_src = sub(dst, src)
    if delete:
      self.delete(src_path, index, dst)
      self.delete(dst_path, index, src)
      to_dst = sub(to_dst, src_remove)
      to_src = sub(to_src, dst_remove)
    self.copy(src_path, dst_path, to_dst)
    self.copy(dst_path, src_path, to_src)
    self.update(src_path, dst_path, common)
    if loaded:
      self.conf['folders'][(src_path, dst_path)] = self.listdir(src_path)
      self.save()

  def all_synchronize(self, delete=False):
    for s,d in self.conf['folders'].iterkeys():
      self.synchronize(s, d, delete)

def main():
  default_conf = os.path.splitext(executable())[0] + '.conf'
  print 'config file:', default_conf
  from optparse import OptionParser
  parser = OptionParser(usage="%prog [-l] [-a] [-m] [-r n] [-c CONFIG] folder_1 folder_2", version="%prog 1.0")
  parser.add_option("-m", "--manage", action="store_true", default=False,
    help="append harmonize pair")
  parser.add_option("-r", "--remove",
    help="remove harmonize pair.", metavar="INDEX")
  parser.add_option("-c", "--config", default=default_conf,
    help="arrow config file.", metavar="CONFIG_FILENAME")
  parser.add_option("-a", "--all",
    action="store_true", default=False, help="all synchronize.")
  parser.add_option("-d", "--delete",
    action="store_true", default=False, help="enable deletion.")
  parser.add_option("-l", "--list",
    action="store_true", default=False, help="managed listing.")
  (options, args) = parser.parse_args()
  harmonize = Harmonize(options.config)
  if options.all:
    harmonize.all_synchronize(options.delete)
  elif options.list:
    print 'listing harmonize pair(s):'
    for num, (s,d) in enumerate(harmonize.list()):
      print '  %2d:' % num,s,d
  elif options.remove:
    s,d = harmonize.list()[int(options.remove)]
    print 'remove harmonize pair:', s,d
    harmonize.remove(s,d)
  else:
    if len(args)!=2:
      parser.error("need for harmonize pair path or use -a option")
    src_path = unicode(args[0], sys.getfilesystemencoding())
    if not src_path.endswith(os.sep):
      src_path += os.sep
    dst_path = unicode(args[1], sys.getfilesystemencoding())
    if not dst_path.endswith(os.sep):
      dst_path += os.sep
    if options.manage:
      harmonize.append(src_path, dst_path)
    harmonize.synchronize(src_path, dst_path, options.delete)

if __name__ == '__main__':
  main()

使い方

同期フォルダペアの登録

初めて同期をするフォルダのとき、

harmonize.py -m hoge moge

としてください。

削除の発生しないモードよる同期の後、 harmonize.confというファイルにファイルリストが保存されます。

同期させたいフォルダがいくつもあるなら上記のコマンドをそれぞれ繰り返してください。

登録リストの参照

harmonize.py -l

これで、0から始まる番号の登録されたフォルダペアのリストが表示されます。

登録フォルダペアの削除

harmonize.py -r 数字

数字には先ほどのリストの各行先頭にある番号を入力してください。 これで、リストの中から該当する行の登録を削除できます。

単独同期

harmonize.py -d hoge moge

これで、ひとつのフォルダペアの同期を行います。 「-d」をつけると以前の保存データがあれば削除つきで同期を行います。

一括同期

harmonize.py -d -a

「-d」は削除つきの同期を行います。 「-a」は登録リストすべての同期を行います。

ヘルプメッセージ

harmonize.py -h

以下のような説明が表示されます。

usage: harmonize.py [-l] [-a] [-m] [-r n] [-c CONFIG] folder_1 folder_2

options:
  --version             show program's version number and exit
  -h, --help            show this help message and exit
  -m, --manage          append harmonize pair
  -r INDEX, --remove=INDEX
                        remove harmonize pair.
  -c CONFIG_FILENAME, --config=CONFIG_FILENAME
                        arrow config file.
  -a, --all             all synchronize.
  -d, --delete          enable deletion.
  -l, --list            managed listing.

結論

このソースにあるミソは、

  • ネストされたdictオブジェクトの差分やマージ
  • OptionParserの例

というところでしょうか。

ファイル群のリストアップはレフトとライトそれぞれ一回だけに行うようになっているので、 ネットワークフォルダ相手でも意外に高速に同期できます。

filecmp.dircmpを使おうと最初検討していたんですが、 削除リストの取得ができなさそうだったので自作となりました。

途中でのエラーリカバリなどをあまり留意して作っていないので利用するときは注意してください。