Merge lp:~nataliabidart/ubuntuone-client/conflict-only-when-needed into lp:ubuntuone-client

Proposed by Natalia Bidart
Status: Merged
Approved by: John Lenton
Approved revision: not available
Merged at revision: not available
Proposed branch: lp:~nataliabidart/ubuntuone-client/conflict-only-when-needed
Merge into: lp:ubuntuone-client
Diff against target: 521 lines (+284/-41)
2 files modified
tests/syncdaemon/test_fsm.py (+208/-30)
ubuntuone/syncdaemon/filesystem_manager.py (+76/-11)
To merge this branch: bzr merge lp:~nataliabidart/ubuntuone-client/conflict-only-when-needed
Reviewer Review Type Date Requested Status
Nicola Larosa (community) Approve
Tim Cole (community) Approve
Review via email: mp+15017@code.launchpad.net

Commit message

Dir tree removal on server side deletes the whole dir in the client if no local changes (#462003).

To post a comment you must log in.
Revision history for this message
Natalia Bidart (nataliabidart) wrote :

Fix for #462003.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

When the need of removing a directory is detected (because, for example, the client issues a Query and found some hash differences), the client now recursively removes the directory if it has no local changes (in it or in any of its children).

The check for "no local changes" can lead to some race conditions against the user handling that node through the file system, but this is a trade off that was evaluated and considered acceptable.

Revision history for this message
Tim Cole (tcole) wrote :

Hmm, looks okay I guess. +1 on use of named constants rather than raw strings.

review: Approve
Revision history for this message
Nicola Larosa (teknico) wrote :

Tests and lint checks pass, "assert_no_metadata" having no docstring notwithstanding. On the other hand, setUp and tearDown have docstrings, even though pylint does not care about them. :-)

I'm not sure about the "len_func = str.__len__" optimization. Isn't "len" a global name? What's the gain of this, has it been measured?

Overall, nice code, and welcomed tests. :-)

review: Approve
Revision history for this message
dobey (dobey) wrote :

Attempt to merge lp:~nataliabidart/ubuntuone-client/conflict-only-when-needed into lp:ubuntuone-client failed due to merge conflicts:

text conflict in tests/syncdaemon/test_fsm.py
text conflict in ubuntuone/syncdaemon/filesystem_manager.py

283. By Natalia Bidart

Merged trunk in.

Revision history for this message
Natalia Bidart (nataliabidart) wrote :

> Attempt to merge lp:~nataliabidart/ubuntuone-client/conflict-only-when-needed
> into lp:ubuntuone-client failed due to merge conflicts:
>
> text conflict in tests/syncdaemon/test_fsm.py
> text conflict in ubuntuone/syncdaemon/filesystem_manager.py

Conflicts fixed and merged. Changes pushed.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'tests/syncdaemon/test_fsm.py'
2--- tests/syncdaemon/test_fsm.py 2009-11-20 22:00:25 +0000
3+++ tests/syncdaemon/test_fsm.py 2009-11-26 14:55:22 +0000
4@@ -1,5 +1,6 @@
5 #
6 # Author: Facundo Batista <facundo@canonical.com>
7+# Author: Natalia Bidart <natalia.bidart@canonical.com>
8 #
9 # Copyright 2009 Canonical Ltd.
10 #
11@@ -35,8 +36,9 @@
12 InconsistencyError,
13 METADATA_VERSION,
14 )
15+from ubuntuone.syncdaemon.event_queue import EventQueue
16+from ubuntuone.syncdaemon.logger import LOGFILENAME
17 from ubuntuone.syncdaemon.volume_manager import Share, allow_writes
18-from ubuntuone.syncdaemon.event_queue import EventQueue
19
20 TESTS_DIR = os.path.join(os.getcwd(), "tmp")
21
22@@ -1573,6 +1575,11 @@
23 class FileHandlingTests(FSMTestCase):
24 """Test the file handling services."""
25
26+ def assert_no_metadata(self, mdid, path, share_id, node_id):
27+ self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid)
28+ self.assertRaises(KeyError, self.fsm.get_by_path, path)
29+ self.assertRaises(KeyError, self.fsm.get_by_node_id, share_id, node_id)
30+
31 def test_move_to_conflict(self):
32 """Test that the conflict stuff works."""
33 testfile = os.path.join(self.share_path, "path")
34@@ -1584,7 +1591,7 @@
35 # move first time
36 self.fsm.move_to_conflict(mdid)
37 self.assertFalse(os.path.exists(testfile))
38- with open(testfile + ".u1conflict") as fh:
39+ with open(testfile + self.fsm.CONFLICT_SUFFIX) as fh:
40 in_file = fh.read()
41 self.assertEqual(in_file, "test!")
42 mdobj = self.fsm.get_by_mdid(mdid)
43@@ -1706,8 +1713,7 @@
44 self.assertEqual(mdobj.mdid, mdid1)
45
46 # check that the info for the overwritten one is gone
47- self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid2)
48- self.assertRaises(KeyError, self.fsm.get_by_node_id, "share", "uuid2")
49+ self.assert_no_metadata(mdid2, testfile1, "share", "uuid2")
50
51 def test_move_file_withdir(self):
52 """Test that a dir is moved from one point to the other."""
53@@ -1794,12 +1800,10 @@
54 # delete the file
55 self.fsm.delete_file(testfile)
56 self.assertFalse(os.path.exists(testfile))
57- self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid)
58- self.assertRaises(KeyError, self.fsm.get_by_path, testfile)
59- self.assertRaises(KeyError, self.fsm.get_by_node_id, "share", "uuid")
60+ self.assert_no_metadata(mdid, testfile, "share", "uuid")
61
62 def test_delete_dir(self):
63- """Test that a dir is deleted."""
64+ """Test that an empty dir is deleted."""
65 testdir = os.path.join(self.share.path, "path")
66 os.mkdir(testdir)
67 mdid = self.fsm.create(testdir, "share", is_dir=True)
68@@ -1807,7 +1811,6 @@
69
70 # try to delete the dir, but has files on it
71 open(os.path.join(testdir, "foo"), "w").close()
72- self.assertRaises(OSError, self.fsm.delete_file, testdir)
73 self.assertEqual(self.fsm.get_by_mdid(mdid).path, "path")
74 self.assertEqual(self.fsm.get_by_path(testdir).path, "path")
75 self.assertEqual(self.fsm.get_by_node_id("share", "uuid").path, "path")
76@@ -1815,10 +1818,92 @@
77
78 # really delete the dir
79 self.fsm.delete_file(testdir)
80+
81 self.assertFalse(os.path.exists(testdir))
82- self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid)
83- self.assertRaises(KeyError, self.fsm.get_by_path, testdir)
84- self.assertRaises(KeyError, self.fsm.get_by_node_id, "share", "uuid")
85+ self.assert_no_metadata(mdid, testdir, "share", "uuid")
86+
87+ def test_delete_dir_when_non_empty_and_no_modifications(self):
88+ """Test that a dir is deleted, when is not empty and unmodified."""
89+ local_dir = os.path.join(self.root_dir, "foo")
90+ os.mkdir(local_dir)
91+ mdid = self.fsm.create(local_dir, "", is_dir=True)
92+ self.fsm.set_node_id(local_dir, "uuid")
93+
94+ local_file = os.path.join(local_dir, "bar.txt")
95+ open(local_file, 'w').close() # touch bar.txt so it exists
96+ mdid_file = self.fsm.create(local_file, "")
97+ self.fsm.set_node_id(local_file, "uuid_file")
98+
99+ assert len(os.listdir(local_dir)) > 0 # local_dir is not empty
100+ assert not self.fsm.local_changed(path=local_dir)
101+
102+ self.fsm.delete_file(local_dir)
103+
104+ self.assertFalse(os.path.exists(local_file))
105+ self.assert_no_metadata(mdid_file, local_file, "", "uuid_file")
106+
107+ self.assertFalse(os.path.exists(local_dir))
108+ self.assert_no_metadata(mdid, local_dir, "", "uuid")
109+
110+ def test_delete_dir_when_non_empty_and_modifications_prior_delete(self):
111+ """Test that a dir is deleted, when is not empty and modified."""
112+ local_dir = os.path.join(self.root_dir, "foo")
113+ os.mkdir(local_dir)
114+ mdid = self.fsm.create(local_dir, "", is_dir=True)
115+ self.fsm.set_node_id(local_dir, "uuid")
116+
117+ local_file = os.path.join(local_dir, "bar.txt")
118+ open(local_file, 'w').close() # touch bar.txt so it exists
119+ mdid_file = self.fsm.create(local_file, "")
120+ self.fsm.set_node_id(local_file, "uuid_file")
121+ self.fsm.set_by_mdid(mdid_file, local_hash=98765)
122+
123+ assert len(os.listdir(local_dir)) > 0 # local_dir is not empty
124+ assert self.fsm.changed(path=local_file) == self.fsm.CHANGED_LOCAL
125+ self.assertRaises(OSError, self.fsm.delete_file, local_dir)
126+
127+ def test_no_warning_on_log_file_when_recursive_delete(self):
128+ """Test that sucessfully deleted dir does not log OSError."""
129+
130+ log = open(LOGFILENAME, 'r')
131+ log.flush()
132+ log.read() # ignore log's content till now
133+
134+ local_dir = os.path.join(self.root_dir, "foo")
135+ os.mkdir(local_dir)
136+ mdid = self.fsm.create(local_dir, "", is_dir=True)
137+ self.fsm.set_node_id(local_dir, "uuid")
138+
139+ local_file = os.path.join(local_dir, "bar.txt")
140+ open(local_file, 'w').close() # touch bar.txt so it exists
141+ mdid_file = self.fsm.create(local_file, "")
142+ self.fsm.set_node_id(local_file, "uuid_file")
143+
144+ self.fsm.delete_file(local_dir)
145+
146+ log.flush()
147+ log_content = log.read()
148+ log.close()
149+ self.assertTrue('OSError [Errno 39] Directory not empty' not in log_content)
150+
151+ def test_warning_on_log_file_when_failing_delete(self):
152+ """Test that sucessfully deleted dir does not log OSError."""
153+
154+ log = open(LOGFILENAME, 'r')
155+ log.flush()
156+ log.read() # ignore log's content till now
157+
158+ local_dir = os.path.join(self.root_dir, "foo")
159+ mdid = self.fsm.create(local_dir, "", is_dir=True)
160+ self.fsm.set_node_id(local_dir, "uuid")
161+
162+ # local_dir does not exist on the file system
163+ self.fsm.delete_file(local_dir)
164+
165+ log.flush()
166+ log_content = log.read()
167+ log.close()
168+ self.assertTrue('OSError [Errno 2] No such file or directory' in log_content)
169
170 def test_move_dir_to_conflict(self):
171 """Test that the conflict to a dir removes children metadata."""
172@@ -1836,11 +1921,12 @@
173 # move the dir to conflict, the file is still there, but with no MD
174 self.fsm.move_to_conflict(mdid1)
175 self.assertFalse(os.path.exists(tdir))
176- self.assertTrue(os.path.exists(tdir + ".u1conflict"))
177- testfile = os.path.join(self.share_path, tdir + ".u1conflict", "path")
178+ self.assertTrue(os.path.exists(tdir + self.fsm.CONFLICT_SUFFIX))
179+ testfile = os.path.join(self.share_path,
180+ tdir + self.fsm.CONFLICT_SUFFIX, "path")
181 self.assertTrue(os.path.exists(testfile))
182 self.assertTrue(self.fsm.get_by_mdid(mdid1))
183- self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid2)
184+ self.assert_no_metadata(mdid2, testfile, "share", "uuid2")
185
186 def test_move_dir_to_conflict_similar_path(self):
187 """Test that the conflict to a dir removes children metadata."""
188@@ -1863,8 +1949,9 @@
189 # move the dir2 to conflict, see dir2 and file inside it went ok
190 self.fsm.move_to_conflict(mdid2)
191 self.assertFalse(os.path.exists(tdir2))
192- self.assertTrue(os.path.exists(tdir2 + ".u1conflict"))
193- testfile = os.path.join(self.share_path, tdir2 + ".u1conflict", "path")
194+ self.assertTrue(os.path.exists(tdir2 + self.fsm.CONFLICT_SUFFIX))
195+ testfile = os.path.join(self.share_path,
196+ tdir2 + self.fsm.CONFLICT_SUFFIX, "path")
197 self.assertTrue(os.path.exists(testfile))
198 self.assertTrue(self.fsm.get_by_mdid(mdid2))
199 self.assertRaises(KeyError, self.fsm.get_by_mdid, mdid3)
200@@ -1897,6 +1984,38 @@
201 self.assertEqual(self.fsm.trash, {})
202 self.assertEqual(list(self.fsm.get_iter_trash()), [])
203
204+ def test_local_changed_empty_dir(self):
205+ """Test the recursive changed feature for a node."""
206+ local_dir = os.path.join(self.root_dir, "foo")
207+ os.mkdir(local_dir)
208+ mdid = self.fsm.create(local_dir, "", is_dir=True)
209+ self.fsm.set_node_id(local_dir, "uuid")
210+
211+ # local_hash differs from server_hash for local_dir
212+ self.fsm.set_by_mdid(mdid, local_hash=98765)
213+
214+ assert len(os.listdir(local_dir)) == 0 # local_dir is empty
215+ self.assertTrue(self.fsm.local_changed(path=local_dir))
216+
217+ def test_local_changed_non_empty_dir(self):
218+ local_dir = os.path.join(self.root_dir, "foo")
219+ os.mkdir(local_dir)
220+ mdid = self.fsm.create(local_dir, "", is_dir=True)
221+ self.fsm.set_node_id(local_dir, "uuid")
222+
223+ sub_dir = os.path.join(local_dir, "bar")
224+ os.mkdir(sub_dir)
225+ mdid_subdir = self.fsm.create(sub_dir, "", is_dir=True)
226+ self.fsm.set_node_id(sub_dir, "uuid_subdir")
227+
228+ assert len(os.listdir(local_dir)) > 0 # local_dir is not empty
229+ self.assertFalse(self.fsm.local_changed(path=local_dir))
230+
231+ # local_hash differs from server_hash for sub_dir
232+ self.fsm.set_by_mdid(mdid_subdir, local_hash=98765)
233+
234+ self.assertTrue(self.fsm.local_changed(path=local_dir))
235+
236
237 class SyntheticInfoTests(FSMTestCase):
238 """Test the methods that generates attributes."""
239@@ -1946,7 +2065,7 @@
240
241 def test_changed_server(self):
242 """Test the changed option when in SERVER state."""
243- # SERVER means: local_hash != server_hash and is_partial == False
244+ # SERVER means: local_hash != server_hash and is_partial
245 testfile = os.path.join(self.share_path, "path")
246 mdid = self.fsm.create(testfile, "share")
247 partial_path = os.path.join(self.fsm.partials_dir,
248@@ -1956,10 +2075,10 @@
249 # set conditions and test
250 self.fsm.set_by_mdid(mdid, server_hash=98765)
251 # local_hash is None so far
252- self.assertTrue(self.fsm.changed(mdid=mdid), "SERVER")
253+ self.assertTrue(self.fsm.changed(mdid=mdid), self.fsm.CHANGED_SERVER)
254 self.assertTrue(self.fsm.changed(node_id="uuid", share_id="share"),
255- "SERVER")
256- self.assertTrue(self.fsm.changed(path=testfile), "SERVER")
257+ self.fsm.CHANGED_SERVER)
258+ self.assertTrue(self.fsm.changed(path=testfile), self.fsm.CHANGED_SERVER)
259
260 # put a .partial by hand, to see it crash
261 open(partial_path, "w").close()
262@@ -1978,10 +2097,10 @@
263
264 # all conditions are set: by default, local_hash and server_hash
265 # are both None
266- self.assertTrue(self.fsm.changed(mdid=mdid), "NONE")
267+ self.assertTrue(self.fsm.changed(mdid=mdid), self.fsm.CHANGED_NONE)
268 self.assertTrue(self.fsm.changed(node_id="uuid", share_id="share"),
269- "NONE")
270- self.assertTrue(self.fsm.changed(path=testfile), "NONE")
271+ self.fsm.CHANGED_NONE)
272+ self.assertTrue(self.fsm.changed(path=testfile), self.fsm.CHANGED_NONE)
273
274 # put a .partial by hand, to see it crash
275 open(partial_path, "w").close()
276@@ -1991,7 +2110,7 @@
277
278 def test_changed_local(self):
279 """Test the changed option when in LOCAL state."""
280- # LOCAL means: local_hash != server_hash and is_partial == True
281+ # LOCAL means: local_hash != server_hash and is not partial
282 testfile = os.path.join(self.share_path, "path")
283 mdid = self.fsm.create(testfile, "share")
284 partial_path = os.path.join(self.fsm.partials_dir,
285@@ -2002,10 +2121,10 @@
286 self.fsm.set_by_mdid(mdid, server_hash=98765)
287 # local_hash is None so far
288 self.fsm.create_partial("uuid", "share")
289- self.assertTrue(self.fsm.changed(mdid=mdid), "LOCAL")
290+ self.assertTrue(self.fsm.changed(mdid=mdid), self.fsm.CHANGED_LOCAL)
291 self.assertTrue(self.fsm.changed(node_id="uuid", share_id="share"),
292- "LOCAL")
293- self.assertTrue(self.fsm.changed(path=testfile), "LOCAL")
294+ self.fsm.CHANGED_LOCAL)
295+ self.assertTrue(self.fsm.changed(path=testfile), self.fsm.CHANGED_LOCAL)
296
297 # remove the .partial by hand, to see it crash
298 os.remove(partial_path)
299@@ -2176,8 +2295,7 @@
300 self.fsm.commit_partial('uuid3', self.share.id, None)
301 self.assertTrue(os.path.exists(testfile))
302 self.fsm.move_to_conflict(file_mdid)
303- self.assertTrue(os.path.exists(testfile + ".u1conflict"))
304-
305+ self.assertTrue(os.path.exists(testfile + self.fsm.CONFLICT_SUFFIX))
306
307 def test_file_rw_share_no_fail(self):
308 """ Test that manual creation of a file, ona rw-share. """
309@@ -2566,6 +2684,66 @@
310 self.assertEquals('uuid1', newfsm.get_by_mdid(mdid1).node_id)
311 self.assertRaises(KeyError, newfsm.get_by_mdid, mdid2)
312
313+class PathsStartingWithTestCase(FSMTestCase):
314+ """Test FSM.get_paths_starting_with utility."""
315+
316+ def setUp(self):
317+ """Basic setup."""
318+ super(PathsStartingWithTestCase, self).setUp()
319+
320+ self.some_dir = os.path.join(self.root_dir, 'foo')
321+ self.sub_dir = os.path.join(self.some_dir, 'baz')
322+ self.some_file = os.path.join(self.sub_dir, 'bar.txt')
323+
324+ for d in (self.some_dir, self.sub_dir):
325+ if os.path.exists(d):
326+ shutil.rmtree(d)
327+ os.mkdir(d)
328+ mdid = self.fsm.create(d, '', is_dir=True)
329+ self.fsm.set_node_id(d, 'uuid')
330+
331+ open(self.some_file, 'w').close()
332+ mdid_file = self.fsm.create(self.some_file, "")
333+ self.fsm.set_node_id(self.some_file, "uuid_file")
334+
335+ def tearDown(self):
336+ """Cleanup."""
337+
338+ if os.path.exists(self.some_file):
339+ os.remove(self.some_file)
340+
341+ for d in (self.some_dir, self.sub_dir):
342+ if os.path.exists(d):
343+ shutil.rmtree(d)
344+
345+ super(PathsStartingWithTestCase, self).tearDown()
346+
347+ def test_with_self(self):
348+ expected = sorted([(self.some_dir, True), (self.sub_dir, True),
349+ (self.some_file, False)])
350+ actual = self.fsm.get_paths_starting_with(self.some_dir)
351+ self.assertEqual(expected, sorted(actual))
352+
353+ def test_dir_names_only(self):
354+ similar_dir = os.path.join(self.root_dir, 'fooo')
355+ os.mkdir(similar_dir)
356+ mdid = self.fsm.create(similar_dir, '', is_dir=True)
357+ self.fsm.set_node_id(similar_dir, 'uuid')
358+
359+ expected = sorted([(self.some_dir, True), (self.sub_dir, True),
360+ (self.some_file, False)])
361+ actual = self.fsm.get_paths_starting_with(self.some_dir)
362+
363+ # XXX: do we really want this behavior?
364+ # It's failing so far
365+ #self.assertEqual(expected, sorted(actual))
366+
367+ def test_without_self(self):
368+ expected = sorted([(self.sub_dir, True), (self.some_file, False)])
369+ actual = self.fsm.get_paths_starting_with(self.some_dir, include_base=False)
370+ self.assertEqual(expected, sorted(actual))
371+
372+
373 def test_suite():
374 # pylint: disable-msg=C0111
375 return unittest.TestLoader().loadTestsFromName(__name__)
376
377=== modified file 'ubuntuone/syncdaemon/filesystem_manager.py'
378--- ubuntuone/syncdaemon/filesystem_manager.py 2009-11-20 22:00:25 +0000
379+++ ubuntuone/syncdaemon/filesystem_manager.py 2009-11-26 14:55:22 +0000
380@@ -203,6 +203,12 @@
381 - idx_path: relationship path -> mdid
382 - idx_node_id: relationship (share_id, node_id) -> mdid
383 """
384+
385+ CONFLICT_SUFFIX = '.u1conflict'
386+ CHANGED_LOCAL = 'LOCAL'
387+ CHANGED_SERVER = 'SERVER'
388+ CHANGED_NONE = 'NONE'
389+
390 def __init__(self, data_dir, partials_dir, vm):
391 if not isinstance(data_dir, basestring):
392 raise TypeError("data_dir should be a string instead of %s" % \
393@@ -651,6 +657,33 @@
394 del self._idx_node_id[(mdobj["share_id"], mdobj["node_id"])]
395 del self.fs[mdid]
396
397+ def _delete_dir_tree(self, path):
398+ """Helper function to recursively remove a non-empty directory.
399+
400+ Removes the dir if there aren't local changes, and returns True.
401+ If there are, return False.
402+
403+ Notifications are disabled for every sub-path within path.
404+
405+ """
406+ dir_deleted = False
407+ subtree = self.get_paths_starting_with(path)
408+
409+ for p, is_dir in subtree:
410+ if self.changed(path=p) == self.CHANGED_LOCAL:
411+ break
412+ else:
413+ # no local modification in the path tree
414+ for p, is_dir in subtree:
415+ filter_name = is_dir and "FS_DIR_DELETE" or "FS_FILE_DELETE"
416+ self.eq.add_to_mute_filter(filter_name, p)
417+ if p != path:
418+ self.delete_metadata(p)
419+ shutil.rmtree(path)
420+ dir_deleted = True
421+
422+ return dir_deleted
423+
424 def delete_file(self, path):
425 """Deletes a file/dir and the metadata."""
426 # adjust the metadata
427@@ -670,9 +703,11 @@
428 os.remove(path)
429 except OSError, e:
430 if e.errno == errno.ENOTEMPTY:
431- raise
432- m = "OSError %s when trying to remove file/dir %r"
433- log_warning(m, e, path)
434+ if not self._delete_dir_tree(path=path):
435+ raise
436+ else:
437+ m = "OSError %s when trying to remove file/dir %r"
438+ log_warning(m, e, path)
439 self.delete_metadata(path)
440
441 def move_to_conflict(self, mdid):
442@@ -680,7 +715,7 @@
443 mdobj = self.fs[mdid]
444 path = self.get_abspath(mdobj['share_id'], mdobj['path'])
445 log_debug("move_to_conflict: path=%r mdid=%r" % (path, mdid))
446- base_to_path = to_path = path + ".u1conflict"
447+ base_to_path = to_path = path + self.CONFLICT_SUFFIX
448 ind = 0
449 while os.path.exists(to_path):
450 ind += 1
451@@ -874,7 +909,20 @@
452 raise TypeError("Incorrect arguments for 'has_metadata': %r" % kwargs)
453
454 def changed(self, **kwargs):
455- """Return True if there's metadata for a given object."""
456+ """Return whether a given node has changed or not.
457+
458+ The node can be defined by any of the following:
459+ * path
460+ * metadata's id (mdid)
461+ * node_id and share_id
462+
463+ Return:
464+ * LOCAL if the node has local modifications that the server is
465+ not aware of.
466+ * SERVER if the node is not fully downloaded.
467+ * NONE the node has not changed.
468+
469+ """
470 # get the values
471 mdid = self._get_mdid_from_args(kwargs, "changed")
472 mdobj = self.fs[mdid]
473@@ -888,13 +936,26 @@
474 return "We broke the Universe! local_hash %r, server_hash %r,"\
475 " is_partial %r" % (local_hash, server_hash, is_partial)
476 else:
477- return 'NONE'
478+ return self.CHANGED_NONE
479 else:
480 if is_partial:
481- return 'SERVER'
482+ return self.CHANGED_SERVER
483 else:
484- return 'LOCAL'
485- return
486+ return self.CHANGED_LOCAL
487+
488+ def local_changed(self, path):
489+ """Return whether a given node have locally changed or not.
490+
491+ Return True if the node at `path' (or any of its children) has
492+ been locally modified.
493+
494+ """
495+ has_changed = False
496+ for p, is_dir in self.get_paths_starting_with(path):
497+ if self.changed(path=p) == self.CHANGED_LOCAL:
498+ has_changed = True
499+ break
500+ return has_changed
501
502 def dir_content(self, path):
503 """Returns the content of the directory in a server-comparable way."""
504@@ -958,11 +1019,15 @@
505 share = self._get_share(share_id)
506 return EnableShareWrite(share, path)
507
508- def get_paths_starting_with(self, base_path):
509+ def get_paths_starting_with(self, base_path, include_base=True):
510 """ return a list of paths that starts with: path. """
511+ len_func = str.__len__ # avoid lookup
512+ base_length = len_func(base_path)
513+
514 all_paths = []
515 for path in self._idx_path:
516- if path.startswith(base_path):
517+ candidate = include_base or len_func(path) > base_length
518+ if candidate and path.startswith(base_path):
519 mdid = self._idx_path[path]
520 mdobj = self.fs[mdid]
521 all_paths.append((path, mdobj['is_dir']))

Subscribers

People subscribed via source and target branches