source: tailor/vcpx/svn.py @ 933

Revision 933, 23.4 KB checked in by lele@…, 8 years ago (diff)

Use an incremental parser for svn log
This transforms the changesets_from_svnlog() function into a generator
that yields changesets as they are parsed out from XML, that is read
in chunks.

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Subversion details
3# :Creato:   ven 18 giu 2004 15:00:52 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains supporting classes for Subversion.
10"""
11
12__docformat__ = 'reStructuredText'
13
14from shwrap import ExternalCommand, PIPE, STDOUT, ReopenableNamedTemporaryFile
15from source import UpdatableSourceWorkingDir, \
16     ChangesetApplicationFailure, GetUpstreamChangesetsFailure
17from target import SyncronizableTargetWorkingDir, TargetInitializationFailure
18from config import ConfigurationError
19
20def changesets_from_svnlog(log, repository, module, chunksize=2**15):
21    from xml.sax import make_parser
22    from xml.sax.handler import ContentHandler, ErrorHandler
23    from changes import ChangesetEntry, Changeset
24    from datetime import datetime
25
26    def get_entry_from_path(path, module=module):
27        # Given the repository url of this wc, say
28        #   "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
29        # extract the "entry" portion (a relative path) from what
30        # svn log --xml says, ie
31        #   "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
32        # that is to say "tests/PloneTestCase.py"
33
34        if path.startswith(module):
35            relative = path[len(module):]
36            if relative.startswith('/'):
37                return relative[1:]
38            else:
39                return relative
40
41        # The path is outside our tracked tree...
42        return None
43
44    class SvnXMLLogHandler(ContentHandler):
45        # Map between svn action and tailor's.
46        # NB: 'R', in svn parlance, means REPLACED, something other
47        # system may view as a simpler ADD, taking the following as
48        # the most common idiom::
49        #
50        #   # Rename the old file with a better name
51        #   $ svn mv somefile nicer-name-scheme.py
52        #
53        #   # Be nice with lazy users
54        #   $ echo "exec nicer-name-scheme.py" > somefile
55        #
56        #   # Add the wrapper with the old name
57        #   $ svn add somefile
58        #
59        #   $ svn commit -m "Longer name for somefile"
60
61        ACTIONSMAP = {'R': 'R', # will be ChangesetEntry.ADDED
62                      'M': ChangesetEntry.UPDATED,
63                      'A': ChangesetEntry.ADDED,
64                      'D': ChangesetEntry.DELETED}
65
66        def __init__(self):
67            self.changesets = []
68            self.current = None
69            self.current_field = []
70            self.renamed = {}
71            self.external_copies = []
72
73        def startElement(self, name, attributes):
74            if name == 'logentry':
75                self.current = {}
76                self.current['revision'] = attributes['revision']
77                self.current['entries'] = []
78            elif name in ['author', 'date', 'msg']:
79                self.current_field = []
80            elif name == 'path':
81                self.current_field = []
82                if attributes.has_key('copyfrom-path'):
83                    self.current_path_action = (
84                        attributes['action'],
85                        attributes['copyfrom-path'],
86                        attributes['copyfrom-rev'])
87                else:
88                    self.current_path_action = attributes['action']
89
90        def endElement(self, name):
91            if name == 'logentry':
92                # Sort the paths to make tests easier
93                self.current['entries'].sort(lambda a,b: cmp(a.name, b.name))
94
95                # Eliminate "useless" entries: SVN does not have atomic
96                # renames, but rather uses a ADD+RM duo.
97                #
98                # So cycle over all entries of this patch, discarding
99                # the deletion of files that were actually renamed, and
100                # at the same time change related entry from ADDED to
101                # RENAMED.
102
103                # When copying a directory from another location in the
104                # repository (outside the tracked tree), SVN will report files
105                # below this dir that are not being committed as being
106                # removed.
107
108                # We thus need to change the action_kind for all entries
109                # that are below a dir that was "copyfrom" from a path
110                # outside of this module:
111                #  D -> Remove entry completely (it's not going to be in here)
112                #  (M,A,R) -> A
113
114                mv_or_cp = {}
115                for e in self.current['entries']:
116                    if e.action_kind == e.ADDED and e.old_name is not None:
117                        mv_or_cp[e.old_name] = e
118
119                def parent_was_copied_externally(n):
120                    for p in self.external_copies:
121                        if n.startswith(p):
122                            return True
123                    return False
124
125                entries = []
126                for e in self.current['entries']:
127                    if e.action_kind==e.DELETED and mv_or_cp.has_key(e.name):
128                        mv_or_cp[e.name].action_kind = e.RENAMED
129                    elif e.action_kind=='R':
130                        # In svn parlance, 'R' means Replaced: a typical
131                        # scenario is
132                        #   $ svn mv a.txt b.txt
133                        #   $ touch a.txt
134                        #   $ svn add a.txt
135                        if mv_or_cp.has_key(e.name):
136                            mv_or_cp[e.name].action_kind = e.RENAMED
137                        e.action_kind = e.ADDED
138                        entries.append(e)
139                    elif parent_was_copied_externally(e.name):
140                        if e.action_kind != e.DELETED:
141                            e.action_kind = e.ADDED
142                            entries.append(e)
143                    else:
144                        entries.append(e)
145
146                svndate = self.current['date']
147                # 2004-04-16T17:12:48.000000Z
148                y,m,d = map(int, svndate[:10].split('-'))
149                hh,mm,ss = map(int, svndate[11:19].split(':'))
150                ms = int(svndate[20:-1])
151                timestamp = datetime(y, m, d, hh, mm, ss, ms)
152
153                changeset = Changeset(self.current['revision'],
154                                      timestamp,
155                                      self.current.get('author'),
156                                      self.current['msg'],
157                                      entries)
158                self.changesets.append(changeset)
159                self.current = None
160            elif name in ['author', 'date', 'msg']:
161                self.current[name] = ''.join(self.current_field)
162            elif name == 'path':
163                path = ''.join(self.current_field)
164                entrypath = get_entry_from_path(path)
165                if entrypath:
166                    entry = ChangesetEntry(entrypath)
167
168                    if type(self.current_path_action) == type( () ):
169                        old = get_entry_from_path(self.current_path_action[1])
170                        if old:
171                            entry.action_kind = self.ACTIONSMAP[self.current_path_action[0]]
172                            entry.old_name = old
173                            self.renamed[entry.old_name] = True
174                        else:
175                            self.external_copies.append(entry.name)
176                            entry.action_kind = entry.ADDED
177                    else:
178                        entry.action_kind = self.ACTIONSMAP[self.current_path_action]
179
180                    self.current['entries'].append(entry)
181
182        def characters(self, data):
183            self.current_field.append(data)
184
185    parser = make_parser()
186    handler = SvnXMLLogHandler()
187    parser.setContentHandler(handler)
188    parser.setErrorHandler(ErrorHandler())
189
190    chunk = log.read(chunksize)
191    while chunk:
192        parser.feed(chunk)
193        for cs in handler.changesets:
194            yield cs
195        handler.changesets = []
196        chunk = log.read(chunksize)
197    parser.close()
198    for cs in handler.changesets:
199        yield cs
200
201
202class SvnWorkingDir(UpdatableSourceWorkingDir, SyncronizableTargetWorkingDir):
203
204    ## UpdatableSourceWorkingDir
205
206    def _getUpstreamChangesets(self, sincerev=None):
207        if sincerev:
208            sincerev = int(sincerev)
209        else:
210            sincerev = 0
211
212        cmd = self.repository.command("log", "--verbose", "--xml",
213                                      "--revision", "%d:HEAD" % (sincerev+1))
214        svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
215        log = svnlog.execute('.', stdout=PIPE, TZ='UTC')[0]
216
217        if svnlog.exit_status:
218            return []
219
220        if self.repository.filter_badchars:
221            from string import maketrans
222            from cStringIO import StringIO
223
224            # Apparently some (SVN repo contains)/(SVN server dumps) some
225            # characters that are illegal in an XML stream. This was the case
226            # with Twisted Matrix master repository. To be safe, we replace
227            # all of them with a question mark.
228
229            if isinstance(self.repository.filter_badchars, string):
230                allbadchars = self.repository.filter_badchars
231            else:
232                allbadchars = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09" \
233                              "\x0B\x0C\x0E\x0F\x10\x11\x12\x13\x14\x15" \
234                              "\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x7f"
235
236            tt = maketrans(allbadchars, "?"*len(allbadchars))
237            log = StringIO(log.read().translate(tt))
238
239        return changesets_from_svnlog(log,
240                                      self.repository.repository,
241                                      self.repository.module)
242
243    def _applyChangeset(self, changeset):
244        from time import sleep
245
246        cmd = self.repository.command("update",
247                                      "--revision", changeset.revision, ".")
248        svnup = ExternalCommand(cwd=self.basedir, command=cmd)
249
250        retry = 0
251        while True:
252            out, err = svnup.execute(stdout=PIPE, stderr=PIPE)
253
254            if svnup.exit_status == 1:
255                retry += 1
256                if retry>3:
257                    break
258                delay = 2**retry
259                self.log_info("%s returned status %s saying %r, "
260                              "retrying in %d seconds..." %
261                              (str(svnup), svnup.exit_status, err.read(),
262                               delay))
263                sleep(delay)
264            else:
265                break
266
267        if svnup.exit_status:
268            raise ChangesetApplicationFailure(
269                "%s returned status %s saying %r" % (str(svnup),
270                                                     svnup.exit_status,
271                                                     err.read()))
272
273        self.log_info("%s updated to %s" % (
274            ','.join([e.name for e in changeset.entries]),
275            changeset.revision))
276
277        result = []
278        for line in out:
279            if len(line)>2 and line[0] == 'C' and line[1] == ' ':
280                self.log_info("Conflict after 'svn update': '%s'" % line)
281                result.append(line[2:-1])
282
283        return result
284
285    def _checkoutUpstreamRevision(self, revision):
286        """
287        Concretely do the checkout of the upstream revision.
288        """
289
290        from os.path import join, exists
291
292        # Verify that the we have the root of the repository: do that
293        # iterating an "svn ls" over the hierarchy until one fails
294
295        cmd = self.repository.command("ls")
296        svnls = ExternalCommand(command=cmd)
297        svnls.execute(self.repository.repository)
298
299        lastok = self.repository.repository
300        reporoot = lastok[:lastok.rfind('/')]
301        while '/' in reporoot:
302            svnls.execute(reporoot)
303            if svnls.exit_status:
304                break
305            lastok = reporoot
306            reporoot = reporoot[:reporoot.rfind('/')]
307
308        if lastok <> self.repository.repository:
309            module = self.repository.repository[len(lastok):]
310            module += self.repository.module
311            raise ConfigurationError("Non-root svn repository %r. "
312                                     "Please specify that as 'repository=%s' "
313                                     "and 'module=%s'." %
314                                     (self.repository.repository,
315                                      lastok, module.rstrip('/')))
316
317        if revision == 'INITIAL':
318            initial = True
319            cmd = self.repository.command("log", "--verbose", "--xml",
320                                          "--limit", "1", "--stop-on-copy",
321                                          "--revision", "1:HEAD")
322            svnlog = ExternalCommand(command=cmd)
323            out, err = svnlog.execute("%s%s" % (self.repository.repository,
324                                                self.repository.module),
325                                      stdout=PIPE, stderr=PIPE)
326
327            if svnlog.exit_status:
328                raise TargetInitializationFailure(
329                    "%s returned status %d saying %r" %
330                    (str(svnlog), svnlog.exit_status, err.read()))
331
332            csets = changesets_from_svnlog(out,
333                                           self.repository.repository,
334                                           self.repository.module)
335            revision = csets[0].revision
336        else:
337            initial = False
338
339        if not exists(join(self.basedir, '.svn')):
340            self.log_info("checking out a working copy")
341            cmd = self.repository.command("co", "--quiet",
342                                          "--revision", revision)
343            svnco = ExternalCommand(command=cmd)
344            out, err = svnco.execute("%s%s" % (self.repository.repository,
345                                               self.repository.module),
346                                     self.basedir, stdout=PIPE, stderr=PIPE)
347            if svnco.exit_status:
348                raise TargetInitializationFailure(
349                    "%s returned status %s saying %r" % (str(svnco),
350                                                         svnco.exit_status,
351                                                         err.read()))
352        else:
353            self.log_info("%s already exists, assuming it's a svn working dir" % self.basedir)
354
355        if not initial:
356            if revision=='HEAD':
357                revision = 'COMMITTED'
358            cmd = self.repository.command("log", "--verbose", "--xml",
359                                          "--revision", revision)
360            svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
361            out, err = svnlog.execute(stdout=PIPE, stderr=PIPE)
362
363            if svnlog.exit_status:
364                raise TargetInitializationFailure(
365                    "%s returned status %d saying %r" %
366                    (str(changes), changes.exit_status, err.read()))
367
368            csets = changesets_from_svnlog(out,
369                                           self.repository.repository,
370                                           self.repository.module)
371
372        last = csets[0]
373
374        self.log_info("working copy up to svn revision %s" % last.revision)
375
376        return last
377
378    ## SyncronizableTargetWorkingDir
379
380    def _addPathnames(self, names):
381        """
382        Add some new filesystem objects.
383        """
384
385        cmd = self.repository.command("add", "--quiet", "--no-auto-props",
386                                      "--non-recursive")
387        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
388
389    def _getCommitEntries(self, changeset):
390        """
391        Extract the names of the entries for the commit phase.  Since SVN
392        handles "rename" operations as "remove+add", both entries must be
393        committed.
394        """
395
396        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
397                                                                  changeset)
398        entries.extend([e.old_name for e in changeset.renamedEntries()])
399
400        return entries
401
402    def _commit(self, date, author, patchname, changelog=None, entries=None):
403        """
404        Commit the changeset.
405        """
406
407        from locale import getpreferredencoding
408
409        encoding = ExternalCommand.FORCE_ENCODING or getpreferredencoding()
410
411        logmessage = []
412        if patchname:
413            logmessage.append(patchname.encode(encoding))
414        if changelog:
415            logmessage.append(changelog.encode(encoding))
416
417        # If we cannot use propset, fall back to old behaviour of
418        # appending these info to the changelog
419
420        if not self.USE_PROPSET:
421            logmessage.append('')
422            logmessage.append('Original author: %s' % author.encode(encoding))
423            logmessage.append('Date: %s' % date)
424
425        rontf = ReopenableNamedTemporaryFile('svn', 'tailor')
426        log = open(rontf.name, "w")
427        log.write('\n'.join(logmessage))
428        log.close()
429
430        cmd = self.repository.command("commit", "--file", rontf.name)
431        commit = ExternalCommand(cwd=self.basedir, command=cmd)
432
433        if not entries:
434            entries = ['.']
435
436        out, err = commit.execute(entries, stdout=PIPE, stderr=PIPE, LANG='C')
437
438        if commit.exit_status:
439            raise ChangesetApplicationFailure("%s returned status %d saying %r"
440                                              % (str(commit),
441                                                 commit.exit_status,
442                                                 err.read()))
443        line = out.readline()
444        if not line:
445            # svn did not find anything to commit
446            return
447
448        while line and not line.startswith('Committed revision '):
449            if line <> '\n' and not line.startswith('Sending ') and \
450               not line.startswith('Transmitting file data ') and \
451               not line.startswith('Adding ') and \
452               not line.startswith('Deleting '):
453                break
454            line = out.readline()
455
456        if not line.startswith('Committed revision '):
457            out.seek(0)
458            raise ChangesetApplicationFailure("%s wrote unexpected line %r. "
459                                              "This the whole output:\n%s" %
460                                              (str(commit), line, out.read()))
461        revision = line[19:-2]
462
463        if self.USE_PROPSET:
464            cmd = self.repository.command("propset", "%(propname)s",
465                                          "--quiet", "--revprop",
466                                          "--revision", revision)
467            propset = ExternalCommand(cwd=self.basedir, command=cmd)
468
469            propset.execute(date.isoformat()+".000000Z", propname='svn:date')
470            propset.execute(author.encode(encoding), propname='svn:author')
471
472        cmd = self.repository.command("update", "--quiet",
473                                      "--revision", revision)
474        ExternalCommand(cwd=self.basedir, command=cmd).execute()
475
476    def _removePathnames(self, names):
477        """
478        Remove some filesystem objects.
479        """
480
481        cmd = self.repository.command("remove", "--quiet", "--force")
482        remove = ExternalCommand(cwd=self.basedir, command=cmd)
483        remove.execute(names)
484
485    def _renamePathname(self, oldname, newname):
486        """
487        Rename a filesystem object.
488        """
489
490        cmd = self.repository.command("mv", "--quiet")
491        move = ExternalCommand(cwd=self.basedir, command=cmd)
492        move.execute(oldname, newname)
493        if move.exit_status:
494            # Subversion does not seem to allow
495            #   $ mv a.txt b.txt
496            #   $ svn mv a.txt b.txt
497            # Here we are in this situation, since upstream VCS already
498            # moved the item. OTOH, svn really treats "mv" as "cp+rm",
499            # so we do the same here
500            self._removePathnames([oldname])
501            self._addPathnames([newname])
502
503    def __createRepository(self, target_repository, target_module):
504        """
505        Create a local repository.
506        """
507
508        from os.path import join
509        from sys import platform
510
511        assert target_repository.startswith('file:///')
512        repodir = target_repository[7:]
513        cmd = self.repository.command("create", "--fs-type", "fsfs",
514                                      svnadmin=True)
515        svnadmin = ExternalCommand(command=cmd)
516        svnadmin.execute(repodir)
517
518        if svnadmin.exit_status:
519            raise TargetInitializationFailure("Was not able to create a 'fsfs' "
520                                              "svn repository at %r" %
521                                              target_repository)
522        if self.USE_PROPSET:
523            hookname = join(repodir, 'hooks', 'pre-revprop-change')
524            if platform == 'win32':
525                hookname += '.bat'
526            prehook = open(hookname, 'wU')
527            if platform <> 'win32':
528                prehook.write('#!/bin/sh\n')
529            prehook.write('exit 0\n')
530            prehook.close()
531            if platform <> 'win32':
532                from os import chmod
533                chmod(hookname, 0755)
534
535        if target_module and target_module <> '/':
536            cmd = self.repository.command("mkdir", "-m",
537                                          "This directory will host the "
538                                          "upstream sources")
539            svnmkdir = ExternalCommand(command=cmd)
540            svnmkdir.execute(target_repository + target_module)
541            if svnmkdir.exit_status:
542                raise TargetInitializationFailure("Was not able to create the "
543                                                  "module %r, maybe more than "
544                                                  "one level directory?" %
545                                                  target_module)
546
547    def _prepareTargetRepository(self):
548        """
549        Check for target repository existence, eventually create it.
550        """
551
552        if not self.repository.repository:
553            return
554
555        # Verify the existence of repository by listing its root
556        cmd = self.repository.command("ls")
557        svnls = ExternalCommand(command=cmd)
558        svnls.execute(self.repository.repository)
559
560        if svnls.exit_status:
561            if self.repository.repository.startswith('file:///'):
562                self.__createRepository(self.repository.repository,
563                                        self.repository.module)
564            else:
565                raise TargetInitializationFailure("%r does not exist and "
566                                                  "cannot be created since "
567                                                  "it's not a local (file:///) "
568                                                  "repository" %
569                                                  self.repository.repository)
570
571    def _prepareWorkingDirectory(self, source_repo):
572        """
573        Checkout a working copy of the target SVN repository.
574        """
575
576        from os.path import join, exists
577
578        if not self.repository.repository or exists(join(self.basedir, '.svn')):
579            return
580
581        cmd = self.repository.command("co", "--quiet")
582        svnco = ExternalCommand(command=cmd)
583        svnco.execute("%s%s" % (self.repository.repository,
584                                self.repository.module), self.basedir)
585
586    def _initializeWorkingDir(self):
587        """
588        Add the given directory to an already existing svn working tree.
589        """
590
591        from os.path import exists, join
592
593        if not exists(join(self.basedir, '.svn')):
594            raise TargetInitializationFailure("'%s' needs to be an SVN working copy already under SVN" % self.basedir)
595
596        SyncronizableTargetWorkingDir._initializeWorkingDir(self)
Note: See TracBrowser for help on using the repository browser.