Merge lp:~facundo/ubuntuone-client/delete-to-trash into lp:ubuntuone-client

Proposed by Facundo Batista
Status: Merged
Approved by: Facundo Batista
Approved revision: 936
Merged at revision: 934
Proposed branch: lp:~facundo/ubuntuone-client/delete-to-trash
Merge into: lp:ubuntuone-client
Diff against target: 893 lines (+397/-120)
9 files modified
data/syncdaemon.conf (+6/-0)
tests/platform/linux/test_os_helper.py (+105/-0)
tests/syncdaemon/test_config.py (+5/-0)
tests/syncdaemon/test_fsm.py (+107/-30)
ubuntuone/platform/linux/__init__.py (+2/-1)
ubuntuone/platform/linux/os_helper.py (+26/-0)
ubuntuone/syncdaemon/config.py (+17/-3)
ubuntuone/syncdaemon/filesystem_manager.py (+120/-80)
ubuntuone/syncdaemon/sync.py (+9/-6)
To merge this branch: bzr merge lp:~facundo/ubuntuone-client/delete-to-trash
Reviewer Review Type Date Requested Status
Guillermo Gonzalez Approve
Lucio Torre (community) Approve
Review via email: mp+56000@code.launchpad.net

Commit message

Move to trash instead of plain deletion (LP: #690673)

Description of the change

Move to trash instead of plain deletion

For this, os_helper grown a new "move_to_trash" operation (only the linux part, for the windows version see #747741).

FSM calls this new method instead of just removing the file. The only issue was that for tree deletion the code relied on shutil.rmtree's on_error calls to make the directories RW, and we couldn't do that (because if we need to remove a whole tree, that whole tree must go to trash, not files and directories individually).

So, I had to refactor how EnableShareWrite works, to support a "recursive" option.

Finally, note that this new behaviour is configurable. However, to make it work, I had to fix the path in config.py, to take also the configuration from the branch (this was broken somewhen, you only notice that when a new parameter is added).

Tests included for everything.

To post a comment you must log in.
Revision history for this message
Lucio Torre (lucio.torre) wrote :

=== modified file 'data/syncdaemon.conf'
+use_trash.help = Send the files and folders to XDG Trash folder instead of
+ removing them permanently.

syncdaemon.conf is not platform dependent and i am not sure the trashbin in windows or other OSes is done/managed or anything by xdg.

we should get guillos coment on this one as well.

review: Approve
936. By Facundo Batista

Better help message

Revision history for this message
Guillermo Gonzalez (verterok) wrote :

+1

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'data/syncdaemon.conf'
2--- data/syncdaemon.conf 2011-02-09 08:23:04 +0000
3+++ data/syncdaemon.conf 2011-04-04 18:56:34 +0000
4@@ -83,6 +83,12 @@
5 \A.*\.sw[nopx]\Z
6 \A.*\.swpx\Z
7 \A\..*\.tmp\Z
8+
9+use_trash.default = True
10+use_trash.parser = bool
11+use_trash.action = store_true
12+use_trash.help = Send the files and folders to Trash folder instead of
13+ removing them permanently.
14
15
16 [notifications]
17
18=== modified file 'tests/platform/linux/test_os_helper.py'
19--- tests/platform/linux/test_os_helper.py 2011-01-18 15:24:38 +0000
20+++ tests/platform/linux/test_os_helper.py 2011-04-04 18:56:34 +0000
21@@ -19,14 +19,20 @@
22 """Linux specific tests for the platform module."""
23
24 import errno
25+import logging
26 import os
27
28+import gio
29+
30+from ubuntuone.devtools.handlers import MementoHandler
31+
32 from contrib.testing.testcase import BaseTwistedTestCase
33 from ubuntuone.platform import (
34 access,
35 allow_writes,
36 listdir,
37 make_dir,
38+ move_to_trash,
39 open_file,
40 path_exists,
41 remove_dir,
42@@ -39,17 +45,37 @@
43 stat_path,
44 )
45
46+class FakeGIOFile(object):
47+ """Fake File for gio."""
48+
49+ _bad_trash_call = None
50+
51+ def __init__(self, path):
52+ pass
53+
54+ def trash(self):
55+ """Fake trash call."""
56+ return self._bad_trash_call
57+
58
59 class OSWrapperTests(BaseTwistedTestCase):
60 """Tests for os wrapper functions."""
61
62 def setUp(self):
63+ """Set up."""
64 BaseTwistedTestCase.setUp(self)
65 self.basedir = self.mktemp('test_root')
66 self.testfile = os.path.join(self.basedir, "test_file")
67 open(self.testfile, 'w').close()
68
69+ self.handler = MementoHandler()
70+ self.handler.setLevel(logging.DEBUG)
71+ self._logger = logging.getLogger('ubuntuone.SyncDaemon')
72+ self._logger.addHandler(self.handler)
73+
74 def tearDown(self):
75+ """Tear down."""
76+ self._logger.removeHandler(self.handler)
77 self.rmtree(self.basedir)
78 BaseTwistedTestCase.tearDown(self)
79
80@@ -252,3 +278,82 @@
81 """The dir is not there."""
82 self.rmtree(self.basedir)
83 self.assertFalse(path_exists(self.basedir))
84+
85+ def test_movetotrash_file_ok(self):
86+ """Move a file to trash ok.
87+
88+ Just check it was removed because can't monkeypatch gio.File.trash
89+ to see that that was actually called. Note that the gio
90+ infrastructure is used is obvious after the other trash tests here.
91+ """
92+ path = os.path.join(self.basedir, 'foo')
93+ open(path, 'w').close()
94+ move_to_trash(path)
95+ self.assertFalse(os.path.exists(path))
96+
97+ def test_movetotrash_dir_ok(self):
98+ """Move a dir to trash ok.
99+
100+ Just check it was removed because can't monkeypatch gio.File.trash
101+ to see that that was actually called. Note that the gio
102+ infrastructure is used is obvious after the other trash tests here.
103+ """
104+ path = os.path.join(self.basedir, 'foo')
105+ os.mkdir(path)
106+ move_to_trash(path)
107+ self.assertFalse(os.path.exists(path))
108+
109+ def test_movetotrash_file_bad(self):
110+ """Something bad happen when moving to trash, removed anyway."""
111+ FakeGIOFile._bad_trash_call = False # error
112+ self.patch(gio, "File", FakeGIOFile)
113+ path = os.path.join(self.basedir, 'foo')
114+ open(path, 'w').close()
115+ move_to_trash(path)
116+ self.assertFalse(os.path.exists(path))
117+ self.assertTrue(self.handler.check_warning("Problems moving to trash!",
118+ "Removing anyway", "foo"))
119+
120+ def test_movetotrash_dir_bad(self):
121+ """Something bad happen when moving to trash, removed anyway."""
122+ FakeGIOFile._bad_trash_call = False # error
123+ self.patch(gio, "File", FakeGIOFile)
124+ path = os.path.join(self.basedir, 'foo')
125+ os.mkdir(path)
126+ open(os.path.join(path, 'file inside directory'), 'w').close()
127+ move_to_trash(path)
128+ self.assertFalse(os.path.exists(path))
129+ self.assertTrue(self.handler.check_warning("Problems moving to trash!",
130+ "Removing anyway", "foo"))
131+
132+
133+ def test_movetotrash_file_systemnotcapable(self):
134+ """The system is not capable of moving into trash."""
135+ FakeGIOFile._bad_trash_call = gio.ERROR_NOT_SUPPORTED
136+ self.patch(gio, "File", FakeGIOFile)
137+ path = os.path.join(self.basedir, 'foo')
138+ open(path, 'w').close()
139+ move_to_trash(path)
140+ self.assertFalse(os.path.exists(path))
141+ self.assertTrue(self.handler.check_warning("Problems moving to trash!",
142+ "Removing anyway", "foo",
143+ "ERROR_NOT_SUPPORTED"))
144+
145+ def test_movetotrash_dir_systemnotcapable(self):
146+ """The system is not capable of moving into trash."""
147+ FakeGIOFile._bad_trash_call = gio.ERROR_NOT_SUPPORTED
148+ self.patch(gio, "File", FakeGIOFile)
149+ path = os.path.join(self.basedir, 'foo')
150+ os.mkdir(path)
151+ open(os.path.join(path, 'file inside directory'), 'w').close()
152+ move_to_trash(path)
153+ self.assertFalse(os.path.exists(path))
154+ self.assertTrue(self.handler.check_warning("Problems moving to trash!",
155+ "Removing anyway", "foo",
156+ "ERROR_NOT_SUPPORTED"))
157+
158+ def test_movetotrash_notthere(self):
159+ """Try to move to trash something that is not there."""
160+ path = os.path.join(self.basedir, 'notthere')
161+ e = self.assertRaises(OSError, move_to_trash, path)
162+ self.assertEqual(e.errno, errno.ENOENT)
163
164=== modified file 'tests/syncdaemon/test_config.py'
165--- tests/syncdaemon/test_config.py 2011-02-09 08:23:04 +0000
166+++ tests/syncdaemon/test_config.py 2011-04-04 18:56:34 +0000
167@@ -585,3 +585,8 @@
168 self.cp.parse_all()
169 self.assertEquals(self.cp.get('__main__', 'ignore').value,
170 ['.*\\.pyc', '.*\\.sw[opnx]'])
171+
172+ def test_use_trash_default(self):
173+ """Test default configuration for use_trash."""
174+ self.cp.parse_all()
175+ self.assertEqual(self.cp.get('__main__', 'use_trash').value, True)
176
177=== modified file 'tests/syncdaemon/test_fsm.py'
178--- tests/syncdaemon/test_fsm.py 2011-03-12 00:11:44 +0000
179+++ tests/syncdaemon/test_fsm.py 2011-04-04 18:56:34 +0000
180@@ -27,6 +27,7 @@
181 import unittest
182
183 from twisted.internet import defer
184+from twisted.trial.unittest import TestCase as TwistedTestCase
185
186 from contrib.testing.testcase import (
187 BaseTwistedTestCase,
188@@ -37,6 +38,7 @@
189
190 from ubuntuone.devtools.handlers import MementoHandler
191 from ubuntuone.syncdaemon.filesystem_manager import (
192+ DirectoryNotRemovable,
193 EnableShareWrite,
194 FileSystemManager,
195 InconsistencyError,
196@@ -45,6 +47,7 @@
197 TrashTritcaskShelf,
198 TRASH_ROW_TYPE,
199 )
200+from ubuntuone.syncdaemon import filesystem_manager, config
201 from ubuntuone.syncdaemon.file_shelf import FileShelf
202 from ubuntuone.syncdaemon.tritcask import Tritcask
203 from ubuntuone.syncdaemon.event_queue import EventQueue
204@@ -69,13 +72,12 @@
205 defer.returnValue(share)
206
207
208-class FSMTestCase(unittest.TestCase):
209+class FSMTestCase(TwistedTestCase):
210 """ Base test case for FSM """
211
212 @defer.inlineCallbacks
213 def setUp(self):
214 """ Setup the test """
215- unittest.TestCase.setUp(self)
216 try:
217 os.mkdir(TESTS_DIR)
218 except OSError:
219@@ -96,6 +98,7 @@
220 self.fsm.register_eq(self.eq)
221 self.share = yield self.create_share('share', 'share_name')
222 self.share_path = self.share.path
223+ config.get_user_config().set_use_trash(True)
224
225 # add a in-memory logger handler
226 self.handler = MementoHandler()
227@@ -1164,7 +1167,7 @@
228 self.assertEqual(mdobj.baz, "baz1")
229
230 # test using uuid
231- self.assertRaises(ValueError, self.fsm.set_by_node_id, None, "sh", f=3)
232+ self.assertRaises(ValueError, self.fsm.set_by_node_id, None, "sh", j=3)
233 self.fsm.set_by_node_id("uuid", "share", foo="foo2")
234 self.fsm.set_by_node_id("uuid", "share", bar="bar2", baz="baz2")
235 mdobj = self.fsm.get_by_node_id("share", "uuid")
236@@ -2175,8 +2178,8 @@
237 mdobj = self.fsm.get_by_path(otherfile)
238 self.assertEqual(mdobj.path, "pa")
239
240- def test_delete_file(self):
241- """Test that a file is deleted."""
242+ def _delete_file(self):
243+ """Helper to test that a file is deleted."""
244 testfile = os.path.join(self.share_path, "path")
245 open(testfile, "w").close()
246 mdid = self.fsm.create(testfile, "share")
247@@ -2187,8 +2190,34 @@
248 self.assertFalse(os.path.exists(testfile))
249 self.assert_no_metadata(mdid, testfile, "share", "uuid")
250
251- def test_delete_dir(self):
252- """Test that an empty dir is deleted."""
253+ def test_delete_file_directly(self):
254+ """Really delete the file."""
255+ config.get_user_config().set_use_trash(False)
256+
257+ # check it was sent to trash, not just deleted
258+ called = []
259+ orig_call = filesystem_manager.remove_file
260+ self.patch(filesystem_manager, 'remove_file',
261+ lambda path: called.append(True) or orig_call(path))
262+
263+ self._delete_file()
264+ self.assertTrue(called)
265+
266+ def test_delete_file_trash(self):
267+ """Move the file to trash."""
268+ config.get_user_config().set_use_trash(True)
269+
270+ # check it was sent to trash, not just deleted
271+ called = []
272+ orig_call = filesystem_manager.move_to_trash
273+ self.patch(filesystem_manager, 'move_to_trash',
274+ lambda path: called.append(True) or orig_call(path))
275+
276+ self._delete_file()
277+ self.assertTrue(called)
278+
279+ def _delete_dir(self):
280+ """Helper to test that an empty dir is deleted."""
281 testdir = os.path.join(self.share.path, "path")
282 os.mkdir(testdir)
283 mdid = self.fsm.create(testdir, "share", is_dir=True)
284@@ -2207,8 +2236,34 @@
285 self.assertFalse(os.path.exists(testdir))
286 self.assert_no_metadata(mdid, testdir, "share", "uuid")
287
288- def test_delete_dir_when_non_empty_and_no_modifications(self):
289- """Test that a dir is deleted, when is not empty and unmodified."""
290+ def test_delete_dir_directly(self):
291+ """Really delete the dir."""
292+ config.get_user_config().set_use_trash(False)
293+
294+ # check it was sent to trash, not just deleted
295+ called = []
296+ orig_call = filesystem_manager.remove_dir
297+ self.patch(filesystem_manager, 'remove_dir',
298+ lambda path: called.append(True) or orig_call(path))
299+
300+ self._delete_dir()
301+ self.assertTrue(called)
302+
303+ def test_delete_dir_trash(self):
304+ """Move the dir to trash."""
305+ config.get_user_config().set_use_trash(True)
306+
307+ # check it was sent to trash, not just deleted
308+ called = []
309+ orig_call = filesystem_manager.move_to_trash
310+ self.patch(filesystem_manager, 'move_to_trash',
311+ lambda path: called.append(True) or orig_call(path))
312+
313+ self._delete_dir()
314+ self.assertTrue(called)
315+
316+ def _delete_dir_when_non_empty_and_no_modifications(self):
317+ """Helper to test that a dir is deleted, non empty but ok to clean."""
318 local_dir = os.path.join(self.root_dir, "foo")
319 os.mkdir(local_dir)
320 mdid = self.fsm.create(local_dir, "", is_dir=True)
321@@ -2230,6 +2285,32 @@
322 self.assertFalse(os.path.exists(local_dir))
323 self.assert_no_metadata(mdid, local_dir, "", "uuid")
324
325+ def test_delete_nonempty_cleanable_dir_directly(self):
326+ """Really delete the non empty but cleanable dir."""
327+ config.get_user_config().set_use_trash(False)
328+
329+ # check it was sent to trash, not just deleted
330+ called = []
331+ orig_call = shutil.rmtree
332+ self.patch(shutil, 'rmtree',
333+ lambda path: called.append(True) or orig_call(path))
334+
335+ self._delete_dir_when_non_empty_and_no_modifications()
336+ self.assertTrue(called)
337+
338+ def test_delete_nonempty_cleanable_dir_trash(self):
339+ """Move the non empty but cleanable dir to trash."""
340+ config.get_user_config().set_use_trash(True)
341+
342+ # check it was sent to trash, not just deleted
343+ called = []
344+ orig_call = filesystem_manager.move_to_trash
345+ self.patch(filesystem_manager, 'move_to_trash',
346+ lambda path: called.append(True) or orig_call(path))
347+
348+ self._delete_dir_when_non_empty_and_no_modifications()
349+ self.assertTrue(called)
350+
351 def test_delete_dir_when_non_empty_and_modifications_prior_delete(self):
352 """Test that a dir is deleted, when is not empty and modified."""
353 local_dir = os.path.join(self.root_dir, "foo")
354@@ -2245,7 +2326,8 @@
355
356 assert len(os.listdir(local_dir)) > 0 # local_dir is not empty
357 assert self.fsm.changed(path=local_file) == self.fsm.CHANGED_LOCAL
358- self.assertRaises(OSError, self.fsm.delete_file, local_dir)
359+ self.assertRaises(DirectoryNotRemovable,
360+ self.fsm.delete_file, local_dir)
361
362 def test_delete_dir_when_non_empty_and_prior_conflict_on_file(self):
363 """Test that a dir is not deleted, when there is a conflicted file."""
364@@ -2260,7 +2342,8 @@
365 open(local_file, 'w').close() # touch bar.txt.u1conflict
366
367 assert not local_file in self.fsm._idx_path
368- self.assertRaises(OSError, self.fsm.delete_file, local_dir)
369+ self.assertRaises(DirectoryNotRemovable,
370+ self.fsm.delete_file, local_dir)
371
372 infos = [record.message for record in self.handler.records
373 if record.levelname == 'INFO']
374@@ -2280,7 +2363,8 @@
375 os.mkdir(local_subdir)
376
377 assert not local_subdir in self.fsm._idx_path
378- self.assertRaises(OSError, self.fsm.delete_file, local_dir)
379+ self.assertRaises(DirectoryNotRemovable,
380+ self.fsm.delete_file, local_dir)
381
382 infos = [record.message for record in self.handler.records
383 if record.levelname == 'INFO']
384@@ -3054,9 +3138,12 @@
385 self.fsm.upload_finished(mdid, self.fsm.get_by_mdid(mdid).local_hash)
386 self.assertTrue(os.path.exists(testdir))
387 self.assertTrue(os.path.exists(testfile))
388- # make the dir read-only
389+
390+ # make the dir read-only, the error should be logged
391 os.chmod(testdir, 0555)
392- self.assertRaises(OSError, self.fsm.delete_file, testdir)
393+ self.fsm.delete_file(testdir)
394+ self.assertTrue(self.handler.check_warning("OSError", testdir,
395+ "when trying to remove"))
396 self.assertTrue(os.path.exists(testdir))
397 self.assertTrue(os.path.exists(testfile))
398
399@@ -3118,7 +3205,7 @@
400
401 @defer.inlineCallbacks
402 def test_file_rw_share(self):
403- """ Test that the creation of a file using fsm, works on a rw-share."""
404+ """Test that the creation of a file using fsm, works on a rw-share."""
405 self.share = yield self.create_share('ro_share', 'ro_share_name')
406 testfile = os.path.join(self.share.path, "a_file")
407 self.fsm.create(testfile, self.share.volume_id, is_dir=False)
408@@ -3165,7 +3252,7 @@
409 self.share_ro_path = self.share_ro.path
410
411 def test_write_in_ro_share(self):
412- """test the EnableShareWrite context manager in a ro share"""
413+ """Test the EnableShareWrite context manager in a ro share."""
414 path = os.path.join(self.share_ro_path, 'foo', 'a_file_in_a_ro_share')
415 data = 'yes I can write!'
416 can_write_parent = os.access(os.path.dirname(self.share_ro_path),
417@@ -3174,16 +3261,15 @@
418 self.assertTrue(enabled.ro)
419 with open(path, 'w') as f:
420 f.write(data)
421- self.assertEquals(data, open(path, 'r').read())
422+ self.assertEqual(data, open(path, 'r').read())
423 self.assertFalse(os.access(self.share_ro_path, os.W_OK))
424 # check that the parent permissions are ok
425- self.assertEquals(can_write_parent,
426- os.access(os.path.dirname(self.share_ro_path),
427- os.W_OK))
428+ self.assertEqual(can_write_parent,
429+ os.access(os.path.dirname(self.share_ro_path),
430+ os.W_OK))
431 # fail to write directly in the share
432 self.assertRaises(IOError, open, path, 'w')
433
434-
435 def test_write_in_rw_share(self):
436 """test the EnableShareWrite context manager in a rw share"""
437 path = os.path.join(self.share_path, 'a_file_in_a_rw_share')
438@@ -3204,7 +3290,6 @@
439 @defer.inlineCallbacks
440 def setUp(self):
441 """ Setup the test """
442- unittest.TestCase.setUp(self)
443 try:
444 os.mkdir(TESTS_DIR)
445 except OSError:
446@@ -3885,11 +3970,3 @@
447 """Tear down."""
448 self.db.shutdown()
449 self.rmtree(self.trash_dir)
450-
451-
452-def test_suite():
453- # pylint: disable-msg=C0111
454- return unittest.TestLoader().loadTestsFromName(__name__)
455-
456-if __name__ == "__main__":
457- unittest.main()
458
459=== modified file 'ubuntuone/platform/linux/__init__.py'
460--- ubuntuone/platform/linux/__init__.py 2011-02-09 16:24:05 +0000
461+++ ubuntuone/platform/linux/__init__.py 2011-04-04 18:56:34 +0000
462@@ -36,6 +36,7 @@
463 remove_file,
464 rename,
465 make_link,
466+ move_to_trash,
467 set_dir_readonly,
468 set_dir_readwrite,
469 set_no_rights,
470@@ -46,7 +47,7 @@
471 from ubuntuone.platform.linux.vm_helper import (
472 create_shares_link,
473 get_share_path,
474- get_udf_path_name,
475+ get_udf_path_name,
476 get_udf_path,
477 VMMetadataUpgraderMixIn
478 )
479
480=== modified file 'ubuntuone/platform/linux/os_helper.py'
481--- ubuntuone/platform/linux/os_helper.py 2011-01-14 02:50:32 +0000
482+++ ubuntuone/platform/linux/os_helper.py 2011-04-04 18:56:34 +0000
483@@ -22,13 +22,20 @@
484 to support the linux platform.
485 """
486
487+import errno
488+import logging
489 import os
490+import shutil
491 import stat
492
493+import gio
494+
495 from contextlib import contextmanager
496
497 platform = "linux2"
498
499+logger = logging.getLogger('ubuntuone.SyncDaemon')
500+
501
502 def set_no_rights(path):
503 """Remove all rights from the file."""
504@@ -101,3 +108,22 @@
505 """Return stat info about a path."""
506 return os.lstat(path)
507
508+def move_to_trash(path):
509+ """Move the file or dir to trash.
510+
511+ If had any error, or the system can't do it, just remove it.
512+ """
513+ try:
514+ return_code = gio.File(path).trash()
515+ except gio.Error:
516+ exc = OSError()
517+ exc.errno = errno.ENOENT
518+ raise exc
519+
520+ if not return_code or return_code == gio.ERROR_NOT_SUPPORTED:
521+ logger.warning("Problems moving to trash! (%s) Removing anyway: %r",
522+ return_code, path)
523+ if os.path.isdir(path):
524+ shutil.rmtree(path)
525+ else:
526+ os.remove(path)
527
528=== modified file 'ubuntuone/syncdaemon/config.py'
529--- ubuntuone/syncdaemon/config.py 2011-02-09 08:23:04 +0000
530+++ ubuntuone/syncdaemon/config.py 2011-04-04 18:56:34 +0000
531@@ -147,9 +147,15 @@
532 # reverse the list as load_config_paths returns the user dir first
533 config_files.reverse()
534 # if we are running from a branch, get the config files from it too
535- config_file = os.path.join('data', CONFIG_FILE)
536- if os.path.exists(config_file):
537- config_files.append(config_file)
538+ try:
539+ from bzrlib.workingtree import WorkingTree
540+ except ImportError:
541+ pass # surely not inside a branch
542+ else:
543+ rootdir = WorkingTree.open_containing(__file__)[0].basedir
544+ config_file = os.path.join(rootdir, 'data', CONFIG_FILE)
545+ if os.path.exists(config_file):
546+ config_files.append(config_file)
547
548 config_logs = os.path.join('data', CONFIG_LOGS)
549 if os.path.exists(config_logs):
550@@ -365,6 +371,14 @@
551 def get_autoconnect(self):
552 return self.get_parsed(MAIN, 'autoconnect')
553
554+ @requires_section(MAIN)
555+ def get_use_trash(self):
556+ return self.get_parsed(MAIN, 'use_trash')
557+
558+ @requires_section(MAIN)
559+ def set_use_trash(self, enabled):
560+ self.set(MAIN, 'use_trash', str(enabled))
561+
562 @requires_section(NOTIFICATIONS)
563 def set_show_all_notifications(self, enabled):
564 self.set(NOTIFICATIONS, 'show_all_notifications', str(enabled))
565
566=== modified file 'ubuntuone/syncdaemon/filesystem_manager.py'
567--- ubuntuone/syncdaemon/filesystem_manager.py 2011-03-12 00:11:44 +0000
568+++ ubuntuone/syncdaemon/filesystem_manager.py 2011-04-04 18:56:34 +0000
569@@ -29,13 +29,14 @@
570 import stat
571 import uuid
572
573-from ubuntuone.syncdaemon import file_shelf
574+from ubuntuone.syncdaemon import file_shelf, config
575 from ubuntuone.syncdaemon.volume_manager import VolumeDoesNotExist
576 from ubuntuone.syncdaemon.interfaces import IMarker
577 from ubuntuone.syncdaemon.marker import MDMarker
578 from ubuntuone.syncdaemon.tritcask import TritcaskShelf
579 from ubuntuone.platform import (
580 make_dir,
581+ move_to_trash,
582 open_file,
583 path_exists,
584 remove_dir,
585@@ -151,13 +152,19 @@
586 is_forbidden = set("info path node_id share_id is_dir".split()
587 ).intersection
588
589+
590 class InconsistencyError(Exception):
591 """Inconsistency between internal records and filesystem itself."""
592
593+
594 class Despair(Exception):
595 """This should never happen, we're in an impossible condition!"""
596
597
598+class DirectoryNotRemovable(Exception):
599+ """The directory can not be emptied to delete."""
600+
601+
602 class _MDObject(object):
603 """Wrapper around MD dict."""
604 def __init__(self, **mdobj):
605@@ -339,6 +346,9 @@
606 load_method = getattr(self, "_load_metadata_%s" % md_version)
607 load_method(md_version)
608
609+ # load some config
610+ self.user_config = config.get_user_config()
611+
612 logger("initialized: idx_path: %s, idx_node_id: %s, shares: %s",
613 len(self._idx_path), len(self._idx_node_id), len(self.shares))
614
615@@ -874,21 +884,17 @@
616 del self._idx_node_id[(mdobj["share_id"], mdobj["node_id"])]
617 del self.fs[mdid]
618
619- def _delete_dir_tree(self, path, share_id):
620- """Helper function to recursively remove a non-empty directory.
621-
622- Remove the dir if there aren't local changes, and return True.
623- If there are, return False.
624-
625- Notifications are disabled for every sub-path within path.
626-
627+ def _delete_dir_tree(self, path):
628+ """Tell if it's ok to delete a dir tree.
629+
630+ Raise an exception if the directory can not be removed.
631 """
632 # check metadata to see if any node in LOCAL
633 subtree = self.get_paths_starting_with(path, include_base=False)
634 for p, is_dir in subtree:
635 if self.changed(path=p) == self.CHANGED_LOCAL:
636 logger("Conflicting dir on remove because %r is local", p)
637- return False
638+ raise DirectoryNotRemovable()
639
640 # check disk searching for previous conflicts
641 for (dirpath, dirnames, filenames) in os.walk(path):
642@@ -896,29 +902,9 @@
643 if fname.endswith(self.CONFLICT_SUFFIX):
644 logger("Conflicting dir on remove because of previous "
645 "conflict on: %r", os.path.join(dirpath, fname))
646- return False
647-
648- # all green
649- for p, is_dir in subtree:
650- filter_name = is_dir and "FS_DIR_DELETE" or "FS_FILE_DELETE"
651- self.eq.add_to_mute_filter(filter_name, path=p)
652- self.delete_metadata(p)
653-
654- # get the share and if it's read-only
655- share = self._get_share(share_id)
656- is_ro_share = not share.can_write()
657- def handle_ro_share(func, subpath, exc_info):
658- """Handle rmtree EACCES error for ro-shares."""
659- if exc_info[0] == OSError and exc_info[1].errno == errno.EACCES \
660- and is_ro_share:
661- # use EnableShareWrite directly to avoid a lot of share
662- # lookups.
663- with EnableShareWrite(share, subpath):
664- func(subpath)
665- else:
666- raise
667- shutil.rmtree(path, onerror=handle_ro_share)
668- return True
669+ raise DirectoryNotRemovable()
670+
671+ return subtree
672
673 def delete_file(self, path):
674 """Delete a file/dir and the metadata."""
675@@ -928,26 +914,48 @@
676 mdobj = self.fs[mdid]
677 log_debug("delete: path=%r mdid=%r", path, mdid)
678
679- # delete the file
680- if self.is_dir(path=path):
681- expected_event = "FS_DIR_DELETE"
682- remove_action = remove_dir
683+ is_dir = self.is_dir(path=path)
684+ if is_dir:
685+ filter_event = "FS_DIR_DELETE"
686 else:
687- expected_event = "FS_FILE_DELETE"
688- remove_action = remove_file
689+ filter_event = "FS_FILE_DELETE"
690+ self.eq.add_to_mute_filter(filter_event, path=path)
691+
692 try:
693- with self._enable_share_write(mdobj['share_id'], path):
694- self.eq.add_to_mute_filter(expected_event, path=path)
695- remove_action(path)
696+ if is_dir:
697+ if os.listdir(path):
698+ # not empty, need to check if we can delete it
699+ subtree = self._delete_dir_tree(path=path)
700+ for p, is_dir in subtree:
701+ filter_name = "FS_DIR_DELETE" if is_dir else "FS_FILE_DELETE"
702+ self.eq.add_to_mute_filter(filter_name, path=p)
703+ self.delete_metadata(p)
704+
705+ with self._enable_share_write(mdobj['share_id'], path, recursive=True):
706+ if self.user_config.get_use_trash():
707+ move_to_trash(path)
708+ else:
709+ shutil.rmtree(path)
710+ else:
711+ # empty, just delete it
712+ with self._enable_share_write(mdobj['share_id'], path):
713+ if self.user_config.get_use_trash():
714+ move_to_trash(path)
715+ else:
716+ remove_dir(path)
717+ else:
718+ # it's a file, just delete it
719+ with self._enable_share_write(mdobj['share_id'], path):
720+ if self.user_config.get_use_trash():
721+ move_to_trash(path)
722+ else:
723+ remove_file(path)
724+
725 except OSError, e:
726- self.eq.rm_from_mute_filter(expected_event, path=path)
727- if e.errno == errno.ENOTEMPTY:
728- if not self._delete_dir_tree(path=path,
729- share_id=mdobj['share_id']):
730- raise
731- else:
732- m = "OSError %s when trying to remove file/dir %r"
733- log_warning(m, e, path)
734+ self.eq.rm_from_mute_filter(filter_event, path=path)
735+ log_warning("OSError %s when trying to remove file/dir %r",
736+ e, path)
737+
738 self.delete_metadata(path)
739
740 def move_to_conflict(self, mdid):
741@@ -1277,10 +1285,10 @@
742 else:
743 return os.path.join(share_path, path)
744
745- def _enable_share_write(self, share_id, path):
746+ def _enable_share_write(self, share_id, path, recursive=False):
747 """Helper to create a EnableShareWrite context manager."""
748 share = self._get_share(share_id)
749- return EnableShareWrite(share, path)
750+ return EnableShareWrite(share, path, recursive)
751
752 def get_paths_starting_with(self, base_path, include_base=True):
753 """Return a list of paths that are starts with base_path.
754@@ -1464,45 +1472,77 @@
755 class EnableShareWrite(object):
756 """Context manager to allow write in ro-shares."""
757
758- def __init__(self, share, path):
759+ def __init__(self, share, path, recursive=False):
760 self.share = share
761 self.path = path
762 self.ro = not self.share.can_write()
763+ self.recursive = recursive
764+
765+ # list of (path, isdir) to restore permissions
766+ self._changed_nodes = []
767
768 def __enter__(self):
769- """ContextManager API."""
770+ """Change the nodes to be writable."""
771+ if not self.ro:
772+ return self
773+
774+ # the parent should be writable for us to change path
775+ parent = os.path.dirname(self.path)
776+ parent_stat = get_stat(parent)
777+ if parent_stat is None:
778+ # if we don't have the parent yet, create it
779+ with EnableShareWrite(self.share, parent):
780+ make_dir(parent)
781+ set_dir_readwrite(parent)
782+ self._changed_nodes.append((parent, True))
783+
784+ # so, change path if exists
785 path_stat = get_stat(self.path)
786- if path_stat is not None and stat.S_ISDIR(path_stat.st_mode) and \
787- os.path.normpath(self.path) == os.path.normpath(self.share.path):
788- self.parent = self.path
789- parent_stat = path_stat
790- else:
791- self.parent = os.path.dirname(self.path)
792- parent_stat = get_stat(self.parent)
793-
794- if self.ro:
795- if parent_stat is None:
796- # if we don't have the parent yet, create it
797- with EnableShareWrite(self.share, self.parent):
798- make_dir(self.parent)
799- set_dir_readwrite(self.parent)
800- # refresh the stat
801- path_stat = get_stat(self.path)
802- if path_stat is not None and not stat.S_ISDIR(path_stat.st_mode):
803+ if path_stat is not None:
804+ if stat.S_ISDIR(path_stat.st_mode):
805+ set_dir_readwrite(self.path)
806+ self._changed_nodes.append((self.path, True))
807+ else:
808 set_file_readwrite(self.path)
809+ self._changed_nodes.append((self.path, False))
810+
811+ # if needed, change the whole subtree
812+ if self.recursive:
813+ for dirpath, dirnames, filenames in os.walk(self.path, topdown=False):
814+ for dname in dirnames:
815+ path = os.path.join(dirpath, dname)
816+ set_dir_readwrite(path)
817+ self._changed_nodes.append((path, True))
818+ for fname in filenames:
819+ path = os.path.join(dirpath, fname)
820+ set_file_readwrite(path)
821+ self._changed_nodes.append((path, False))
822 return self
823
824 def __exit__(self, *exc_info):
825- """ContextManager API."""
826- if self.ro:
827- path_stat = get_stat(self.path)
828- if path_stat is not None:
829- if stat.S_ISDIR(path_stat.st_mode):
830- set_dir_readonly(self.path)
831+ """Restore node permissions.
832+
833+ Note that this is done backwards, from the leaf to the root.
834+ """
835+ if not self.ro:
836+ return
837+
838+ # restore self.path, that may not have existed at __enter__ time
839+ path_stat = get_stat(self.path)
840+ if path_stat is not None:
841+ if stat.S_ISDIR(path_stat.st_mode):
842+ set_dir_readonly(self.path)
843+ else:
844+ set_file_readonly(self.path)
845+
846+ # restore all saved ones
847+ exists = os.path.exists
848+ for path, isdir in self._changed_nodes[::-1]:
849+ if exists(path):
850+ if isdir:
851+ set_dir_readonly(path)
852 else:
853- set_file_readonly(self.path)
854- if path_exists(self.parent):
855- set_dir_readonly(self.parent)
856+ set_file_readonly(path)
857
858
859 def get_stat(path):
860
861=== modified file 'ubuntuone/syncdaemon/sync.py'
862--- ubuntuone/syncdaemon/sync.py 2011-03-31 00:36:23 +0000
863+++ ubuntuone/syncdaemon/sync.py 2011-04-04 18:56:34 +0000
864@@ -30,7 +30,10 @@
865 StateMachineRunner, StateMachine
866 from ubuntuone.syncdaemon import u1fsfsm
867 from ubuntuone.syncdaemon.logger import DebugCapture
868-from ubuntuone.syncdaemon.filesystem_manager import InconsistencyError
869+from ubuntuone.syncdaemon.filesystem_manager import (
870+ DirectoryNotRemovable,
871+ InconsistencyError,
872+)
873 from ubuntuone.syncdaemon.volume_manager import VolumeDoesNotExist
874 from ubuntuone.platform import (
875 stat_path,
876@@ -658,12 +661,12 @@
877 """server file was deleted."""
878 try:
879 self.key.delete_file()
880+ except DirectoryNotRemovable:
881+ # directory not empty and with stuff that should not be deleted
882+ self.key.move_to_conflict()
883+ self.key.delete_metadata()
884 except OSError, e:
885- if e.errno == 39:
886- # if directory not empty
887- self.key.move_to_conflict()
888- self.key.delete_metadata()
889- elif e.errno == 2:
890+ if e.errno == 2:
891 # file gone
892 pass
893 else:

Subscribers

People subscribed via source and target branches