source: tailor/vcpx/cvsps.py @ 467

Revision 467, 18.0 KB checked in by lele@…, 8 years ago (diff)

Renamed method, since it's one of those that subclasses should reimplement

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- CVS details
3# :Creato:   mer 16 giu 2004 00:46:12 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains supporting classes for CVS. To get a
10cross-repository revision number ala Subversion, the implementation
11uses `cvsps` to fetch the changes from the upstream repository.
12"""
13
14__docformat__ = 'reStructuredText'
15
16from shwrap import ExternalCommand, PIPE, STDOUT
17from source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
18     InvocationError
19from target import SyncronizableTargetWorkingDir, TargetInitializationFailure
20
21CVS_CMD = 'cvs'
22CVSPS_CMD = 'cvsps'   
23
24def changesets_from_cvsps(log, sincerev=None):
25    """
26    Parse CVSps log.
27    """
28
29    from changes import Changeset, ChangesetEntry
30    from datetime import datetime
31    from cvs import compare_cvs_revs
32   
33    # cvsps output sample:
34    ## ---------------------
35    ## PatchSet 1500
36    ## Date: 2004/05/09 17:54:22
37    ## Author: grubert
38    ## Branch: HEAD
39    ## Tag: (none)
40    ## Log:
41    ## Tell the reason for using mbox (not wrapping long lines).
42    ##
43    ## Members:
44    ##         docutils/writers/latex2e.py:1.78->1.79
45
46    l = None
47    while 1:
48        l = log.readline()
49        if l <> '---------------------\n':
50            break
51
52        l = log.readline()
53        assert l.startswith('PatchSet '), "Parse error: %s"%l
54
55        pset = {}
56        pset['revision'] = l[9:-1].strip()
57        l = log.readline()
58        while not l.startswith('Log:'):
59            field,value = l.split(':',1)
60            pset[field.lower()] = value.strip()
61            l = log.readline()
62
63        msg = []
64        l = log.readline()
65        msg.append(l)
66        l = log.readline()
67        while l <> 'Members: \n':
68            msg.append(l)
69            l = log.readline()
70
71        pset['log'] = ''.join(msg)
72
73        assert l.startswith('Members:'), "Parse error: %s" % l
74
75        pset['entries'] = entries = []
76        l = log.readline()
77        seen = {}
78        while l.startswith('\t'):
79            if not sincerev or (sincerev<int(pset['revision'])):
80                file,revs = l[1:-1].split(':')
81                fromrev,torev = revs.strip().split('->')
82
83                # Due to the fuzzy mechanism, cvsps may group
84                # together two commits on a single entry, thus
85                # giving something like:
86                #
87                #   Normalizer.py:1.12->1.13
88                #   Registry.py:1.22->1.23
89                #   Registry.py:1.21->1.22
90                #   Stopwords.py:1.9->1.10
91                #
92                # Collapse those into a single one.
93
94                e = seen.get(file)
95                if not e:
96                    e = ChangesetEntry(file)
97                    e.old_revision = fromrev
98                    e.new_revision = torev
99                    seen[file] = e
100                    entries.append(e)
101                else:
102                    if compare_cvs_revs(e.old_revision, fromrev)>0:
103                        e.old_revision = fromrev
104
105                    if compare_cvs_revs(e.new_revision, torev)<0:
106                        e.new_revision = torev
107
108                if fromrev=='INITIAL':
109                    e.action_kind = e.ADDED
110                elif "(DEAD)" in torev:
111                    e.action_kind = e.DELETED
112                    e.new_revision = torev[:torev.index('(DEAD)')]
113                else:
114                    e.action_kind = e.UPDATED
115
116            l = log.readline()
117
118        if not sincerev or (sincerev<int(pset['revision'])):
119            cvsdate = pset['date']
120            y,m,d = map(int, cvsdate[:10].split('/'))
121            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
122            timestamp = datetime(y, m, d, hh, mm, ss)
123            pset['date'] = timestamp
124
125            yield Changeset(**pset)
126
127
128class CvspsWorkingDir(UpdatableSourceWorkingDir,
129                      SyncronizableTargetWorkingDir):
130
131    """
132    An instance of this class represent a read/write CVS working directory,
133    so that it can be used both as source of patches, or as a target
134    repository.
135
136    It uses `cvsps` to do the actual fetch of the changesets metadata
137    from the server, so that we can reasonably group together related
138    changes that would otherwise be sparsed, as CVS is file-centric.
139    """
140
141    ## UpdatableSourceWorkingDir
142   
143    def _getUpstreamChangesets(self, root, repository, module, sincerev=None,
144                              branch=None):
145        from os.path import join, exists
146
147        if branch is None:
148            branch="HEAD"
149            fname = join(root, 'CVS', 'Tag')
150            if exists(fname):
151                tag = open(fname).read()
152                if tag.startswith('T'):
153                    branch=tag[1:-1]
154
155        if sincerev:
156            sincerev = int(sincerev)
157           
158        changesets = []
159        cmd = [CVSPS_CMD, "--cvs-direct", "-u", "-b", branch,
160               "--root", repository]
161        cvsps = ExternalCommand(command=cmd)
162        log = cvsps.execute(module, stdout=PIPE, TZ='UTC')
163       
164        for cs in changesets_from_cvsps(log, sincerev):
165            changesets.append(cs)
166
167        return changesets
168
169    def __maybeDeleteDirectory(self, root, entrydir, changeset):
170        from os.path import join, exists
171        from os import listdir
172       
173        if not entrydir:
174            return
175
176        absentrydir = join(root, entrydir)
177        if not exists(absentrydir) or listdir(absentrydir) == ['CVS']:
178            deldir = changeset.addEntry(entrydir, None)
179            deldir.action_kind = deldir.DELETED
180       
181    def _applyChangeset(self, root, changeset, logger=None):
182        from os.path import join, exists, dirname, split
183        from os import makedirs, listdir
184        from shutil import rmtree
185        from cvs import CvsEntries
186        from time import sleep
187       
188        entries = CvsEntries(root)
189
190        for e in changeset.entries:
191            if e.action_kind == e.UPDATED:
192                info = entries.getFileInfo(e.name)
193                if not info:
194                    if logger: logger.info("promoting '%s' to ADDED at "
195                                           "revision %s", e.name,
196                                           e.new_revision)
197                    e.action_kind = e.ADDED
198                    self.__createParentCVSDirectories(changeset, root, e.name)
199                elif info.cvs_version == e.new_revision:
200                    if logger: logger.debug("skipping '%s' since it's already "
201                                            "at revision %s", e.name,
202                                            e.new_revision)
203                    continue
204            elif e.action_kind == e.DELETED:
205                if not exists(join(root, e.name)):
206                    if logger: logger.debug("skipping '%s' since it's already "
207                                            "deleted", e.name)
208                    self.__maybeDeleteDirectory(root, split(e.name)[0],
209                                                changeset)
210                    continue
211            elif e.action_kind == e.ADDED and e.new_revision is None:
212                # This is a new directory entry, there is no need to update it
213                continue
214
215            # If this is a directory (CVS does not version directories,
216            # and thus new_revision is always None for them), and it's
217            # going to be deleted, do not execute a 'cvs update', that
218            # in some cases does not what one would expect. Instead,
219            # remove it with everything it contains (that should be
220            # just a single "CVS" subdir, btw)
221           
222            if e.action_kind == e.DELETED and e.new_revision is None:
223                assert listdir(join(root, e.name)) == ['CVS'], '%s should be empty' % e.name
224                rmtree(join(root, e.name))
225            else:
226                cmd = [CVS_CMD, "-q", "update", "-d", "-r", e.new_revision]
227                cvsup = ExternalCommand(cwd=root, command=cmd)
228                retry = 0
229                while True:
230                    cvsup.execute(e.name, stdout=PIPE)
231           
232                    if cvsup.exit_status:
233                        retry += 1
234                        if retry>3:
235                            break
236                        delay = 2**retry
237                        if logger:
238                            logger.warning("%s returned status %s, "
239                                           "retrying in %d seconds..." %
240                                           (str(cvsup), cvsup.exit_status,
241                                            delay))
242                        sleep(retry)
243                    else:
244                        break
245                   
246                if cvsup.exit_status:
247                    raise ChangesetApplicationFailure(
248                        "%s returned status %s" % (str(cvsup),
249                                                   cvsup.exit_status))
250
251                if logger: logger.info("%s updated to %s" % (e.name,
252                                                             e.new_revision))
253
254            if e.action_kind == e.DELETED:
255                self.__maybeDeleteDirectory(root, split(e.name)[0], changeset)
256
257    def _checkoutUpstreamRevision(self, basedir, repository, module, revision,
258                                  subdir=None, logger=None, **kwargs):
259        """
260        Concretely do the checkout of the upstream sources. Use `revision` as
261        the name of the tag to get, or as a date if it starts with a number.
262
263        Return the last applied changeset.
264        """
265
266        from os.path import join, exists
267        from cvs import CvsEntries, compare_cvs_revs
268
269        if not module:
270            raise InvocationError("Must specify a module name")
271
272        timestamp = None
273        if revision is not None:
274            # If the revision contains a space, assume it really
275            # specify a branch and a timestamp. If it starts with
276            # a digit, assume it's a timestamp. Otherwise, it must
277            # be a branch name
278            if revision[0] in '0123456789' or revision == 'INITIAL':
279                timestamp = revision
280                revision = None
281            elif ' ' in revision:
282                revision, timestamp = revision.split(' ', 1)
283
284        wdir = join(basedir, subdir)
285        csets = self._getUpstreamChangesets(wdir, repository, module,
286                                           branch=revision or 'HEAD')
287        csets.reverse()
288
289        if timestamp == 'INITIAL':
290            timestamp = csets[-1].date.isoformat(sep=' ')
291           
292        if not exists(join(wdir, 'CVS')):
293            cmd = [CVS_CMD, "-q", "-d", repository, "checkout",
294                   "-d", subdir]
295            if revision:
296                cmd.extend(["-r", revision])
297            if timestamp:
298                cmd.extend(["-D", "%s UTC" % timestamp])
299           
300            checkout = ExternalCommand(cwd=basedir, command=cmd)
301            checkout.execute(module)
302           
303            if checkout.exit_status:
304                raise TargetInitializationFailure(
305                    "%s returned status %s" % (str(checkout),
306                                               checkout.exit_status))
307        else:
308            if logger: logger.info("Using existing %s", wdir)
309           
310        self.__forceTagOnEachEntry(wdir)
311       
312        entries = CvsEntries(wdir)
313       
314        # update cvsps cache, then loop over the changesets and find the
315        # last applied, to find out the actual cvsps revision
316
317        found = False
318        for cset in csets:
319            for m in cset.entries:
320                info = entries.getFileInfo(m.name)
321                if info:
322                    actualversion = info.cvs_version
323                    found = compare_cvs_revs(actualversion,m.new_revision)>=0
324                    if not found:
325                        break
326               
327            if found:
328                last = cset
329                break
330
331        if not found:
332            raise TargetInitializationFailure(
333                "Something went wrong: unable to determine the exact upstream "
334                "revision of the checked out tree in '%s'" % wdir)
335        else:
336            if logger: logger.info("working copy up to cvsps revision %s",
337                                   last.revision)
338           
339        return last
340   
341    def _willApplyChangeset(self, root, changeset, applyable=None):
342        """
343        This gets called just before applying each changeset.
344       
345        Since CVS has no "createdir" event, we have to take care
346        of new directories, creating empty-but-reasonable CVS dirs.
347        """
348
349        if UpdatableSourceWorkingDir._willApplyChangeset(self, root, changeset,
350                                                         applyable):
351            for m in changeset.entries:
352                if m.action_kind == m.ADDED:
353                    self.__createParentCVSDirectories(changeset, root, m.name)
354           
355            return True
356        else:
357            return False
358       
359    def __createParentCVSDirectories(self, changeset, root, entry):
360        """
361        Verify that the hierarchy down to the entry is under CVS.
362
363        If the directory containing the entry does not exists,
364        create it and make it appear as under CVS so that succeding
365        'cvs update' will work.
366        """
367       
368        from os.path import split, join, exists
369        from os import mkdir
370
371        path = split(entry)[0]
372        if path:
373            basedir = join(root, path)
374        else:
375            basedir = root           
376        cvsarea = join(basedir, 'CVS')
377       
378        if path and not exists(cvsarea):
379            parentcvs = self.__createParentCVSDirectories(changeset,
380                                                          root, path)
381
382            assert exists(parentcvs), "Uhm, strange things happen"
383           
384            if not exists(basedir):
385                mkdir(basedir)
386
387            # Create fake CVS area
388            mkdir(cvsarea)
389
390            # Create an empty "Entries" file
391            entries = open(join(cvsarea, 'Entries'), 'w')
392            entries.close()
393
394            reposf = open(join(parentcvs, 'Repository'))
395            rep = reposf.readline()[:-1]
396            reposf.close()
397
398            reposf = open(join(cvsarea, 'Repository'), 'w')
399            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
400            reposf.close()
401
402            rootf = open(join(parentcvs, 'Root'))
403            root = rootf.readline()
404            rootf.close()
405
406            rootf = open(join(cvsarea, 'Root'), 'w')
407            rootf.write(root)
408            rootf.close()
409
410            # Add the "new" directory to the changeset, so that the
411            # replayer get its name
412
413            entry = changeset.addEntry(path, None)
414            entry.action_kind = entry.ADDED
415           
416        return cvsarea
417   
418    ## SyncronizableTargetWorkingDir
419
420    def _addPathnames(self, root, names):
421        """
422        Add some new filesystem objects.
423        """
424
425        cmd = [CVS_CMD, "-q", "add"]
426        ExternalCommand(cwd=root, command=cmd).execute(names)
427
428    def __forceTagOnEachEntry(self, root):
429        """
430        Massage each CVS/Entries file, locking (ie, tagging) each
431        entry to its current CVS version.
432
433        This is to prevent silly errors such those that could arise
434        after a manual ``cvs update`` in the working directory.
435        """
436       
437        from os import walk, rename
438        from os.path import join
439
440        for dir, subdirs, files in walk(root):
441            if dir[-3:] == 'CVS':
442                efn = join(dir, 'Entries')
443                f = open(efn)
444                entries = f.readlines()
445                f.close()
446                rename(efn, efn+'.old')
447               
448                newentries = []
449                for e in entries:
450                    if e.startswith('/'):
451                        fields = e.split('/')
452                        fields[-1] = "T%s\n" % fields[2]
453                        newe = '/'.join(fields)
454                        newentries.append(newe)
455                    else:
456                        newentries.append(e)
457
458                f = open(efn, 'w')
459                f.writelines(newentries)
460                f.close()
461   
462    def _getCommitEntries(self, changeset):
463        """
464        Extract the names of the entries for the commit phase.  Since CVS
465        does not have a "rename" operation, this is simulated by a
466        remove+add, and both entries must be committed.
467        """
468
469        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
470                                                                  changeset)
471        entries.extend([e.old_name for e in changeset.renamedEntries()])
472
473        return entries
474       
475    def _commit(self,root, date, author, remark, changelog=None, entries=None):
476        """
477        Commit the changeset.
478        """
479
480        from shwrap import ReopenableNamedTemporaryFile
481        from sys import getdefaultencoding
482       
483        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
484       
485        logmessage = []
486        if remark:
487            logmessage.append(remark.encode(encoding))
488        if changelog:
489            logmessage.append(changelog.replace('%', '%%').encode(encoding))
490        logmessage.append('')
491        logmessage.append('Original author: %s' % author.encode(encoding))
492        logmessage.append('Date: %s' % date)
493        logmessage.append('')
494
495        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
496        log = open(rontf.name, "w")
497        log.write('\n'.join(log))
498        log.close()           
499
500        cmd = [CVS_CMD, "-q", "ci", "-F", rontf.name]
501        if not entries:
502            entries = ['.']
503         
504        ExternalCommand(cwd=root, command=cmd).execute(entries)
505       
506    def _removePathnames(self, root, names):
507        """
508        Remove some filesystem objects.
509        """
510
511        cmd = [CVS_CMD, "-q", "remove"]
512        ExternalCommand(cwd=root, command=cmd).execute(names)
513
514    def _renamePathname(self, root, oldname, newname):
515        """
516        Rename a filesystem object.
517        """
518
519        self._removePathnames(root, [oldname])
520        self._addPathnames(root, [newname])
Note: See TracBrowser for help on using the repository browser.