3 # Copyright 2011-2012 GRNET S.A. All rights reserved.
5 # Redistribution and use in source and binary forms, with or
6 # without modification, are permitted provided that the following
9 # 1. Redistributions of source code must retain the above
10 # copyright notice, this list of conditions and the following
13 # 2. Redistributions in binary form must reproduce the above
14 # copyright notice, this list of conditions and the following
15 # disclaimer in the documentation and/or other materials
16 # provided with the distribution.
18 # THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19 # OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21 # PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22 # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25 # USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26 # AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27 # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28 # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29 # POSSIBILITY OF SUCH DAMAGE.
31 # The views and conclusions contained in the software and
32 # documentation are those of the authors and should not be
33 # interpreted as representing official policies, either expressed
34 # or implied, of GRNET S.A.
40 from os.path import exists, expanduser, isdir, isfile, join, split
41 from shutil import copyfile
44 from pithos.lib.transfer import download, upload
45 from pithos.lib.client import Pithos_Client, Fault
46 from pithos.lib.hashmap import merkle
47 from pithos.lib.util import get_user, get_auth, get_url
50 DEFAULT_CONTAINER = 'pithos'
51 SETTINGS_DIR = expanduser('~/.pithos')
52 TRASH_DIR = '.pithos_trash'
54 SQL_CREATE_FILES_TABLE = '''CREATE TABLE IF NOT EXISTS files (
55 path TEXT PRIMARY KEY,
60 client = Pithos_Client(get_url(), get_auth(), get_user())
71 def __init__(self, syncdir, container):
72 self.syncdir = syncdir
73 self.container = container
74 self.trashdir = join(syncdir, TRASH_DIR)
75 self.deleted_dirs = set()
77 _makedirs(self.trashdir)
79 dbpath = join(SETTINGS_DIR, 'sync.db')
80 self.conn = sqlite3.connect(dbpath)
81 self.conn.execute(SQL_CREATE_FILES_TABLE)
84 def current_hash(self, path):
85 """Return the hash of the file as it exists now in the filesystem"""
87 fullpath = join(self.syncdir, path)
88 if fullpath in self.deleted_dirs:
90 if not exists(fullpath):
94 return merkle(fullpath)
96 def delete_inactive(self, timestamp):
97 sql = 'DELETE FROM files WHERE timestamp != ?'
98 self.conn.execute(sql, (timestamp,))
101 def download(self, path, hash):
102 fullpath = join(self.syncdir, path)
108 self.trash(path) # Trash any old version
109 localpath = self.find_hash(hash)
111 copyfile(localpath, fullpath)
113 print 'Downloading %s...' % path
114 download(client, self.container, path, fullpath)
116 current = self.current_hash(path)
117 assert current == hash, "Downloaded file does not match hash"
118 self.save(path, hash)
120 def empty_trash(self):
121 for filename in os.listdir(self.trashdir):
122 path = join(self.trashdir, filename)
125 def find_hash(self, hash):
126 sql = 'SELECT path FROM files WHERE hash = ?'
127 ret = self.conn.execute(sql, (hash,)).fetchone()
129 return join(self.syncdir, ret[0])
131 if hash in os.listdir(self.trashdir):
132 return join(self.trashdir, hash)
136 def previous_hash(self, path):
137 """Return the hash of the file according to the previous sync with
138 the server. Return DEL if not such entry exists."""
140 sql = 'SELECT hash FROM files WHERE path = ?'
141 ret = self.conn.execute(sql, (path,)).fetchone()
142 return ret[0] if ret else 'DEL'
144 def remote_hash(self, path):
145 """Return the hash of the file according to the server"""
148 meta = client.retrieve_object_metadata(self.container, path)
151 if meta.get('content-type', None) == 'application/directory':
154 return meta['x-object-hash']
156 def remove_deleted_dirs(self):
157 for path in sorted(self.deleted_dirs, key=len, reverse=True):
159 self.deleted_dirs.remove(path)
161 def resolve_conflict(self, path, hash):
162 """Resolve a sync conflict by renaming the local file and downloading
165 fullpath = join(self.syncdir, path)
166 resolved = fullpath + '.local'
168 while exists(resolved):
170 resolved = fullpath + '.local%d' % i
172 os.rename(fullpath, resolved)
173 self.download(path, hash)
175 def rmdir(self, path):
176 """Remove a dir or mark for deletion if non-empty
178 If a dir is empty delete it and check if any of its parents should be
179 deleted too. Else mark it for later deletion.
182 fullpath = join(self.syncdir, path)
183 if not exists(fullpath):
186 if os.listdir(fullpath):
187 # Directory not empty
188 self.deleted_dirs.add(fullpath)
192 self.deleted_dirs.discard(fullpath)
194 parent = dirname(fullpath)
195 while parent in self.deleted_dirs:
197 self.deleted_dirs.remove(parent)
198 parent = dirname(parent)
200 def save(self, path, hash):
201 """Save the hash value of a file. This value will be later returned
202 by `previous_hash`."""
204 sql = 'INSERT OR REPLACE INTO files (path, hash) VALUES (?, ?)'
205 self.conn.execute(sql, (path, hash))
208 def touch(self, path, now):
209 sql = 'UPDATE files SET timestamp = ? WHERE path = ?'
210 self.conn.execute(sql, (now, path))
213 def trash(self, path):
214 """Move a file to trash or delete it if it's a directory"""
216 fullpath = join(self.syncdir, path)
217 if not exists(fullpath):
221 hash = merkle(fullpath)
222 trashpath = join(self.trashdir, hash)
223 os.rename(fullpath, trashpath)
227 def upload(self, path, hash):
228 fullpath = join(self.syncdir, path)
230 client.delete_object(self.container, path)
232 client.create_directory_marker(self.container, path)
234 prefix, name = split(path)
237 print 'Uploading %s...' % path
238 upload(client, fullpath, self.container, prefix, name)
240 remote = self.remote_hash(path)
241 assert remote == hash, "Uploaded file does not match hash"
242 self.save(path, hash)
245 def sync(path, state):
246 previous = state.previous_hash(path)
247 current = state.current_hash(path)
248 remote = state.remote_hash(path)
250 if current == previous:
251 # No local changes, download any remote changes
252 if remote != previous:
253 state.download(path, remote)
254 elif remote == previous:
255 # No remote changes, upload any local changes
256 if current != previous:
257 state.upload(path, current)
259 # Both local and remote file have changes since last sync
260 if current == remote:
261 state.save(path, remote) # Local and remote changes match
263 state.resolve_conflict(path, remote)
266 def walk(dir, container):
267 """Iterates on the files of the hierarchy created by merging the files
268 in `dir` and the objects in `container`."""
275 root = pending.pop(0) # Depth First Traversal
276 if root == TRASH_DIR:
281 dirpath = join(dir, root)
283 for filename in os.listdir(dirpath):
284 path = join(root, filename)
285 if isdir(join(dir, path)):
290 for object in client.list_objects(container, format='json',
291 prefix=root, delimiter='/'):
292 if 'subdir' in object:
294 name = object['name']
295 if object['content_type'] == 'application/directory':
300 pending += sorted(dirs)
306 if len(sys.argv) != 2:
307 print 'syntax: %s <dir>' % sys.argv[0]
310 syncdir = sys.argv[1]
312 _makedirs(SETTINGS_DIR)
313 container = os.environ.get('PITHOS_SYNC_CONTAINER', DEFAULT_CONTAINER)
314 client.create_container(container)
316 state = State(syncdir, container)
319 for path in walk(syncdir, container):
320 print 'Syncing', path
322 state.touch(path, now)
324 state.delete_inactive(now)
326 state.remove_deleted_dirs()
329 if __name__ == '__main__':