~antmak/duplicity/0.7-par2-fix

« back to all changes in this revision

Viewing changes to duplicity/backends/gdocsbackend.py

  • Committer: Kenneth Loafman
  • Date: 2014-04-29 15:35:47 UTC
  • mfrom: (978.2.10 backend-unification)
  • Revision ID: kenneth@loafman.com-20140429153547-to2j1tyyl0ps1hi6
* Merged in lp:~mterry/duplicity/backend-unification
  - Reorganize and simplify backend code.  Specifically:
    - Formalize the expected API between backends and duplicity.  See the new
      file duplicity/backends/README for the instructions I've given authors.
    - Add some tests for our backend wrapper class as well as some tests for
      individual backends.  For several backends that have some commands do all
      the heavy lifting (hsi, tahoe, ftp), I've added fake little mock commands
      so that we can test them locally.  This doesn't truly test our integration
      with those commands, but at least lets us test the backend glue code.
    - Removed a lot of duplicate and unused code which backends were using (or
      not using).  This branch drops 700 lines of code (~20%)
      in duplicity/backends!
    - Simplified expectations of backends.  Our wrapper code now does all the
      retrying, and all the exception handling.  Backends can 'fire and forget'
      trusting our wrappers to give the user a reasonable error message.
      Obviously, backends can also add more details and make nicer error
      messages.  But they don't *have* to.
    - Separate out the backend classes from our wrapper class.  Now there is no
      possibility of namespace collision.  All our API methods use one
      underscore.  Anything else (zero or two underscores) are for the backend
      class's use.
    - Added the concept of a 'backend prefix' which is used by par2 and gio
      backends to provide generic support for "schema+" in urls -- like par2+
      or gio+.  I've since marked the '--gio' flag as deprecated, in favor of
      'gio+'.  Now you can even nest such backends like
      par2+gio+file://blah/blah.
    - The switch to control which cloudfiles backend had a typo.  I fixed this,
      but I'm not sure I should have?  If we haven't had complaints, maybe we
      can just drop the old backend.
    - I manually tested all the backends we have (except hsi and tahoe -- but
      those are simple wrappers around commands and I did test those via mocks
      per above).  I also added a bunch more manual backend tests to
      ./testing/manual/backendtest.py, which can now be run like the above to
      test all the files you have configured in config.py or you can pass it a
      URL which it will use for testing (useful for backend authors).

Show diffs side-by-side

added added

removed removed

Lines of Context:
23
23
import urllib
24
24
 
25
25
import duplicity.backend
26
 
from duplicity.backend import retry
27
 
from duplicity import log
28
 
from duplicity.errors import * #@UnusedWildImport
 
26
from duplicity.errors import BackendException
29
27
 
30
28
 
31
29
class GDocsBackend(duplicity.backend.Backend):
53
51
        self.client = gdata.docs.client.DocsClient(source='duplicity $version')
54
52
        self.client.ssl = True
55
53
        self.client.http_client.debug = False
56
 
        self.__authorize(parsed_url.username + '@' + parsed_url.hostname, self.get_password())
 
54
        self._authorize(parsed_url.username + '@' + parsed_url.hostname, self.get_password())
57
55
 
58
56
        # Fetch destination folder entry (and crete hierarchy if required).
59
57
        folder_names = string.split(parsed_url.path[1:], '/')
60
58
        parent_folder = None
61
59
        parent_folder_id = GDocsBackend.ROOT_FOLDER_ID
62
60
        for folder_name in folder_names:
63
 
            entries = self.__fetch_entries(parent_folder_id, 'folder', folder_name)
 
61
            entries = self._fetch_entries(parent_folder_id, 'folder', folder_name)
64
62
            if entries is not None:
65
63
                if len(entries) == 1:
66
64
                    parent_folder = entries[0]
77
75
                raise BackendException("Error while fetching destination folder '%s'." % folder_name)
78
76
        self.folder = parent_folder
79
77
 
80
 
    @retry
81
 
    def put(self, source_path, remote_filename=None, raise_errors=False):
82
 
        """Transfer source_path to remote_filename"""
83
 
        # Default remote file name.
84
 
        if not remote_filename:
85
 
            remote_filename = source_path.get_filename()
86
 
 
87
 
        # Upload!
88
 
        try:
89
 
            # If remote file already exists in destination folder, remove it.
90
 
            entries = self.__fetch_entries(self.folder.resource_id.text,
91
 
                                           GDocsBackend.BACKUP_DOCUMENT_TYPE,
92
 
                                           remote_filename)
93
 
            for entry in entries:
94
 
                self.client.delete(entry.get_edit_link().href + '?delete=true', force=True)
95
 
 
96
 
            # Set uploader instance. Note that resumable uploads are required in order to
97
 
            # enable uploads for all file types.
98
 
            # (see http://googleappsdeveloper.blogspot.com/2011/05/upload-all-file-types-to-any-google.html)
99
 
            file = source_path.open()
100
 
            uploader = gdata.client.ResumableUploader(
101
 
              self.client, file, GDocsBackend.BACKUP_DOCUMENT_TYPE, os.path.getsize(file.name),
102
 
              chunk_size=gdata.client.ResumableUploader.DEFAULT_CHUNK_SIZE,
103
 
              desired_class=gdata.docs.data.Resource)
104
 
            if uploader:
105
 
                # Chunked upload.
106
 
                entry = gdata.docs.data.Resource(title=atom.data.Title(text=remote_filename))
107
 
                uri = self.folder.get_resumable_create_media_link().href + '?convert=false'
108
 
                entry = uploader.UploadFile(uri, entry=entry)
109
 
                if not entry:
110
 
                    self.__handle_error("Failed to upload file '%s' to remote folder '%s'"
111
 
                                        % (source_path.get_filename(), self.folder.title.text), raise_errors)
112
 
            else:
113
 
                self.__handle_error("Failed to initialize upload of file '%s' to remote folder '%s'"
114
 
                         % (source_path.get_filename(), self.folder.title.text), raise_errors)
115
 
            assert not file.close()
116
 
        except Exception as e:
117
 
            self.__handle_error("Failed to upload file '%s' to remote folder '%s': %s"
118
 
                                % (source_path.get_filename(), self.folder.title.text, str(e)), raise_errors)
119
 
 
120
 
    @retry
121
 
    def get(self, remote_filename, local_path, raise_errors=False):
122
 
        """Get remote filename, saving it to local_path"""
123
 
        try:
124
 
            entries = self.__fetch_entries(self.folder.resource_id.text,
125
 
                                           GDocsBackend.BACKUP_DOCUMENT_TYPE,
126
 
                                           remote_filename)
127
 
            if len(entries) == 1:
128
 
                entry = entries[0]
129
 
                self.client.DownloadResource(entry, local_path.name)
130
 
                local_path.setdata()
131
 
                return
132
 
            else:
133
 
                self.__handle_error("Failed to find file '%s' in remote folder '%s'"
134
 
                                    % (remote_filename, self.folder.title.text), raise_errors)
135
 
        except Exception as e:
136
 
            self.__handle_error("Failed to download file '%s' in remote folder '%s': %s"
137
 
                                 % (remote_filename, self.folder.title.text, str(e)), raise_errors)
138
 
 
139
 
    @retry
140
 
    def _list(self, raise_errors=False):
141
 
        """List files in folder"""
142
 
        try:
143
 
            entries = self.__fetch_entries(self.folder.resource_id.text,
144
 
                                           GDocsBackend.BACKUP_DOCUMENT_TYPE)
145
 
            return [entry.title.text for entry in entries]
146
 
        except Exception as e:
147
 
            self.__handle_error("Failed to fetch list of files in remote folder '%s': %s"
148
 
                                % (self.folder.title.text, str(e)), raise_errors)
149
 
 
150
 
    @retry
151
 
    def delete(self, filename_list, raise_errors=False):
152
 
        """Delete files in filename_list"""
153
 
        for filename in filename_list:
154
 
            try:
155
 
                entries = self.__fetch_entries(self.folder.resource_id.text,
156
 
                                               GDocsBackend.BACKUP_DOCUMENT_TYPE,
157
 
                                               filename)
158
 
                if len(entries) > 0:
159
 
                    success = True
160
 
                    for entry in entries:
161
 
                        if not self.client.delete(entry.get_edit_link().href + '?delete=true', force=True):
162
 
                            success = False
163
 
                    if not success:
164
 
                        self.__handle_error("Failed to remove file '%s' in remote folder '%s'"
165
 
                                            % (filename, self.folder.title.text), raise_errors)
166
 
                else:
167
 
                    log.Warn("Failed to fetch file '%s' in remote folder '%s'"
168
 
                             % (filename, self.folder.title.text))
169
 
            except Exception as e:
170
 
                self.__handle_error("Failed to remove file '%s' in remote folder '%s': %s"
171
 
                                    % (filename, self.folder.title.text, str(e)), raise_errors)
172
 
 
173
 
    def __handle_error(self, message, raise_errors=True):
174
 
        if raise_errors:
175
 
            raise BackendException(message)
176
 
        else:
177
 
            log.FatalError(message, log.ErrorCode.backend_error)
178
 
 
179
 
    def __authorize(self, email, password, captcha_token=None, captcha_response=None):
 
78
    def _put(self, source_path, remote_filename):
 
79
        self._delete(remote_filename)
 
80
 
 
81
        # Set uploader instance. Note that resumable uploads are required in order to
 
82
        # enable uploads for all file types.
 
83
        # (see http://googleappsdeveloper.blogspot.com/2011/05/upload-all-file-types-to-any-google.html)
 
84
        file = source_path.open()
 
85
        uploader = gdata.client.ResumableUploader(
 
86
          self.client, file, GDocsBackend.BACKUP_DOCUMENT_TYPE, os.path.getsize(file.name),
 
87
          chunk_size=gdata.client.ResumableUploader.DEFAULT_CHUNK_SIZE,
 
88
          desired_class=gdata.docs.data.Resource)
 
89
        if uploader:
 
90
            # Chunked upload.
 
91
            entry = gdata.docs.data.Resource(title=atom.data.Title(text=remote_filename))
 
92
            uri = self.folder.get_resumable_create_media_link().href + '?convert=false'
 
93
            entry = uploader.UploadFile(uri, entry=entry)
 
94
            if not entry:
 
95
                raise BackendException("Failed to upload file '%s' to remote folder '%s'"
 
96
                                       % (source_path.get_filename(), self.folder.title.text))
 
97
        else:
 
98
            raise BackendException("Failed to initialize upload of file '%s' to remote folder '%s'"
 
99
                                   % (source_path.get_filename(), self.folder.title.text))
 
100
        assert not file.close()
 
101
 
 
102
    def _get(self, remote_filename, local_path):
 
103
        entries = self._fetch_entries(self.folder.resource_id.text,
 
104
                                      GDocsBackend.BACKUP_DOCUMENT_TYPE,
 
105
                                      remote_filename)
 
106
        if len(entries) == 1:
 
107
            entry = entries[0]
 
108
            self.client.DownloadResource(entry, local_path.name)
 
109
        else:
 
110
            raise BackendException("Failed to find file '%s' in remote folder '%s'"
 
111
                                   % (remote_filename, self.folder.title.text))
 
112
 
 
113
    def _list(self):
 
114
        entries = self._fetch_entries(self.folder.resource_id.text,
 
115
                                      GDocsBackend.BACKUP_DOCUMENT_TYPE)
 
116
        return [entry.title.text for entry in entries]
 
117
 
 
118
    def _delete(self, filename):
 
119
        entries = self._fetch_entries(self.folder.resource_id.text,
 
120
                                      GDocsBackend.BACKUP_DOCUMENT_TYPE,
 
121
                                      filename)
 
122
        for entry in entries:
 
123
            self.client.delete(entry.get_edit_link().href + '?delete=true', force=True)
 
124
 
 
125
    def _authorize(self, email, password, captcha_token=None, captcha_response=None):
180
126
        try:
181
127
            self.client.client_login(email,
182
128
                                     password,
189
135
            answer = None
190
136
            while not answer:
191
137
                answer = raw_input('Answer to the challenge? ')
192
 
            self.__authorize(email, password, challenge.captcha_token, answer)
 
138
            self._authorize(email, password, challenge.captcha_token, answer)
193
139
        except gdata.client.BadAuthentication:
194
 
            self.__handle_error('Invalid user credentials given. Be aware that accounts '
195
 
                                'that use 2-step verification require creating an application specific '
196
 
                                'access code for using this Duplicity backend. Follow the instrucction in '
197
 
                                'http://www.google.com/support/accounts/bin/static.py?page=guide.cs&guide=1056283&topic=1056286 '
198
 
                                'and create your application-specific password to run duplicity backups.')
199
 
        except Exception as e:
200
 
            self.__handle_error('Error while authenticating client: %s.' % str(e))
 
140
            raise BackendException('Invalid user credentials given. Be aware that accounts '
 
141
                                   'that use 2-step verification require creating an application specific '
 
142
                                   'access code for using this Duplicity backend. Follow the instruction in '
 
143
                                   'http://www.google.com/support/accounts/bin/static.py?page=guide.cs&guide=1056283&topic=1056286 '
 
144
                                   'and create your application-specific password to run duplicity backups.')
201
145
 
202
 
    def __fetch_entries(self, folder_id, type, title=None):
 
146
    def _fetch_entries(self, folder_id, type, title=None):
203
147
        # Build URI.
204
148
        uri = '/feeds/default/private/full/%s/contents' % folder_id
205
149
        if type == 'folder':
211
155
        if title:
212
156
            uri += '&title=' + urllib.quote(title) + '&title-exact=true'
213
157
 
214
 
        try:
215
 
            # Fetch entries.
216
 
            entries = self.client.get_all_resources(uri=uri)
217
 
 
218
 
            # When filtering by entry title, API is returning (don't know why) documents in other
219
 
            # folders (apart from folder_id) matching the title, so some extra filtering is required.
220
 
            if title:
221
 
                result = []
222
 
                for entry in entries:
223
 
                    resource_type = entry.get_resource_type()
224
 
                    if (not type) \
225
 
                       or (type == 'folder' and resource_type == 'folder') \
226
 
                       or (type == GDocsBackend.BACKUP_DOCUMENT_TYPE and resource_type != 'folder'):
227
 
 
228
 
                        if folder_id != GDocsBackend.ROOT_FOLDER_ID:
229
 
                            for link in entry.in_collections():
230
 
                                folder_entry = self.client.get_entry(link.href, None, None,
231
 
                                                                     desired_class=gdata.docs.data.Resource)
232
 
                                if folder_entry and (folder_entry.resource_id.text == folder_id):
233
 
                                    result.append(entry)
234
 
                        elif len(entry.in_collections()) == 0:
235
 
                            result.append(entry)
236
 
            else:
237
 
                result = entries
238
 
 
239
 
            # Done!
240
 
            return result
241
 
        except Exception as e:
242
 
            self.__handle_error('Error while fetching remote entries: %s.' % str(e))
 
158
        # Fetch entries.
 
159
        entries = self.client.get_all_resources(uri=uri)
 
160
 
 
161
        # When filtering by entry title, API is returning (don't know why) documents in other
 
162
        # folders (apart from folder_id) matching the title, so some extra filtering is required.
 
163
        if title:
 
164
            result = []
 
165
            for entry in entries:
 
166
                resource_type = entry.get_resource_type()
 
167
                if (not type) \
 
168
                   or (type == 'folder' and resource_type == 'folder') \
 
169
                   or (type == GDocsBackend.BACKUP_DOCUMENT_TYPE and resource_type != 'folder'):
 
170
 
 
171
                    if folder_id != GDocsBackend.ROOT_FOLDER_ID:
 
172
                        for link in entry.in_collections():
 
173
                            folder_entry = self.client.get_entry(link.href, None, None,
 
174
                                                                 desired_class=gdata.docs.data.Resource)
 
175
                            if folder_entry and (folder_entry.resource_id.text == folder_id):
 
176
                                result.append(entry)
 
177
                    elif len(entry.in_collections()) == 0:
 
178
                        result.append(entry)
 
179
        else:
 
180
            result = entries
 
181
 
 
182
        # Done!
 
183
        return result
243
184
 
244
185
duplicity.backend.register_backend('gdocs', GDocsBackend)