Merge lp:~facundo/ubuntuone-client/delete-to-trash into lp:ubuntuone-client
- delete-to-trash
- Merge into trunk
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 | ||||
Related bugs: |
|
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.
- 936. By Facundo Batista
-
Better help message
Preview Diff
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: |
=== modified file 'data/syncdaemo n.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.