source: tailor/vcpx/tailor.py @ 452

Revision 452, 26.9 KB checked in by lele@…, 8 years ago (diff)

Added the infrastructure to allow easier bootstraps

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Frontend capabilities
3# :Creato:   dom 04 lug 2004 00:40:54 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9Implement the basic capabilities of the frontend.
10
11This implementation stores the relevant project information, needed to
12keep the whole thing going on, such as the last synced revision, in a
13unversioned file named ``tailor.info`` at the root.
14"""
15
16__docformat__ = 'reStructuredText'
17
18__version__ = '0.9.0'
19
20from optparse import OptionParser, OptionGroup, make_option
21from dualwd import DualWorkingDir
22from source import InvocationError
23from session import interactive
24from svn import SvnWorkingDir
25
26STATUS_FILENAME = 'tailor.info'
27LOG_FILENAME = 'tailor.log'
28
29def relpathto(source, dest):
30    """
31    Compute the relative path needed to point ``source`` from ``dest``.
32
33    Warning: ``dest`` is assumed to be a directory.
34    """
35
36    from os.path import abspath, split, commonprefix
37
38    source = abspath(source)
39    dest = abspath(dest)
40
41    if source.startswith(dest):
42        return source[len(dest)+1:]
43
44    prefix = commonprefix([source, dest])
45
46    source = source[len(prefix):]
47    dest = dest[len(prefix):]
48
49    return '../' * len(dest.split('/')) + source
50
51
52class TailorConfig(object):
53    """
54    Configuration of a set of tailorized projects.
55
56    The configuration is stored in a persistent dictionary keyed on the
57    relative path of each project. The information about a single project
58    is another dictionary.
59    """
60
61    def __init__(self, options):
62        from os.path import abspath, split
63
64        self.options = options
65        self.configfile = abspath(options.configfile)
66        self.basedir = split(self.configfile)[0]
67
68    def __call__(self, args):
69        from os.path import join, exists, split
70        from source import ChangesetApplicationFailure
71
72        self.__load()
73
74        if len(args) == 0:
75            fromconfig = True
76            if self.options.bootstrap:
77                f = lambda x: not exists(x)
78            else:
79                f = exists
80
81            args = [p for p in [join(self.basedir, r)
82                                for r in self.config.keys()] if f(p)]
83            args.sort()
84        else:
85            fromconfig = False
86
87        try:
88            for root in args:
89                if self.options.bootstrap:
90                    if not (fromconfig or self.options.source_repository):
91                        raise InvocationError('Need a repository to bootstrap '
92                                              '%r' % root, '--bootstrap')
93                else:
94                    if not self.config.has_key(relpathto(root, self.basedir)):
95                        raise UnknownProjectError("Project %r does not exist" %
96                                                  root)
97
98                tailored = TailorizedProject(root, self.options.verbose, self)
99
100                if self.options.bootstrap:
101                    if fromconfig:
102                        info = self.loadProject(root=root)
103                        self.options.source_kind = info['source_kind']
104                        self.options.target_kind = info['target_kind']
105                        self.options.source_repository = info['upstream_repos']
106                        self.options.source_module = info['module']
107                        self.options.subdir = info.get('subdir',
108                                                       split(info['module'])[1])
109                        self.options.revision = info['upstream_revision']
110
111                    tailored.bootstrap(self.options.source_kind,
112                                       self.options.target_kind,
113                                       self.options.source_repository,
114                                       self.options.source_module,
115                                       self.options.revision,
116                                       self.options.target_repository,
117                                       self.options.target_module,
118                                       self.options.subdir)
119                elif self.options.migrate:
120                    tailored.migrateConfiguration()
121                elif self.options.update:
122                    try:
123                        tailored.update(self.options.single_commit,
124                                        self.options.concatenate_logs)
125                    except ChangesetApplicationFailure, e:
126                        print "Skipping '%s' because of errors:" % root, e
127        finally:
128            self.__save()
129
130    def __save(self):
131        from pprint import pprint
132
133        configfile = open(self.configfile, 'w')
134        pprint(self.config, configfile)
135        configfile.close()
136
137    def __load(self):
138        from os.path import exists
139
140        if exists(self.options.configfile):
141            configfile = open(self.configfile)
142            self.config = eval(configfile.read())
143            configfile.close()
144        else:
145            self.config = {}
146
147    def loadProject(self, project=None, root=None):
148        from os.path import split
149
150        relpath = relpathto(project and project.root or root, self.basedir)
151
152        info = self.config.get(relpath)
153        if info and project:
154            project.source_kind = info['source_kind']
155            project.target_kind = info['target_kind']
156            project.upstream_module = info['module']
157            project.subdir = info.get('subdir',
158                                      split(project.upstream_module)[1])
159            project.upstream_repos = info['upstream_repos']
160            project.upstream_revision = info['upstream_revision']
161
162        return info
163
164    def saveProject(self, project):
165        relpath = relpathto(project.root, self.basedir)
166
167        self.config[relpath] = {
168            'source_kind': project.source_kind,
169            'target_kind': project.target_kind,
170            'module': project.upstream_module,
171            'subdir': project.subdir,
172            'upstream_repos': project.upstream_repos,
173            'upstream_revision': project.upstream_revision,
174            }
175
176
177class TailorizedProject(object):
178    """
179    A TailorizedProject has two main capabilities: it may be bootstrapped
180    from an upstream repository or brought in sync with current upstream
181    revision.
182    """
183
184    def __init__(self, root, verbose=False, config=None):
185        import logging
186        from os import makedirs
187        from os.path import join, exists, split
188
189        self.root = root
190        if not exists(root):
191            makedirs(root)
192
193        self.verbose = verbose
194        self.logger = logging.getLogger('tailor.%s' % split(root)[1])
195        hdlr = logging.FileHandler(join(root, LOG_FILENAME))
196        formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
197        hdlr.setFormatter(formatter)
198        self.logger.addHandler(hdlr)
199        self.logger.setLevel(logging.INFO)
200
201        self.source_kind = self.target_kind = None
202
203        self.config = config
204
205    def migrateConfiguration(self):
206        self.__loadOldStatus()
207        self.__saveStatus()
208
209    def __saveOldStatus(self):
210        from os.path import join
211
212        statusfilename = join(self.root, STATUS_FILENAME)
213        f = open(statusfilename, 'w')
214        print >>f, self.source_kind
215        print >>f, self.target_kind
216        print >>f, self.upstream_module
217        print >>f, self.upstream_repos
218        print >>f, self.upstream_revision
219        print >>f, self.subdir
220        f.close()
221
222    def __saveStatus(self):
223        """
224        Save relevant project information in a persistent way.
225        """
226
227        if self.config:
228            self.config.saveProject(self)
229        else:
230            self.__saveOldStatus()
231
232    def __loadOldStatus(self):
233        from os.path import join, split
234
235        statusfilename = join(self.root, STATUS_FILENAME)
236        f = open(statusfilename)
237        self.source_kind = f.readline()[:-1]
238        self.target_kind = f.readline()[:-1]
239        self.upstream_module = f.readline()[:-1]
240        self.upstream_repos = f.readline()[:-1]
241        self.upstream_revision = f.readline()[:-1]
242        subdir = f.readline()
243        if subdir:
244            self.subdir = subdir[:-1]
245        else:
246            self.subdir = split(self.upstream_module)[1]
247        f.close()
248
249    def __loadStatus(self):
250        """
251        Load relevant project information.
252        """
253
254        if self.config:
255            self.config.loadProject(self)
256        else:
257            self.__loadOldStatus()
258
259        # Fix old configs
260
261        if self.source_kind == 'svn' and not '/' in self.upstream_module:
262            self.logger.warning('OLD config values for SVN')
263            print "The project at '%s' contains old values for" % self.root
264            print "the upstream repository (%s)" % self.upstream_repos
265            print "and module (%s)." % self.upstream_module
266            print "Please correct them, specifying the exact URL of the"
267            print "root of the SVN repository and then the prefix path up"
268            print "to the point you want, that must start with a slash."
269            print "This usually means splitting the repository URL above in"
270            print "two parts. For example, that could be"
271
272            crepo = self.upstream_repos
273            example_split = crepo.rfind('/', 6, crepo.rfind('/'))
274            if example_split > 0:
275                example_repo = crepo[:example_split]
276                example_module = crepo[example_split:]
277            else:
278                example_repo = 'http://svn.plone.org/collective'
279                example_module = '/ATContentTypes/trunk'
280
281            print "  Repository=%s" % example_repo
282            print "  Module=%s" % example_module
283            print "but your situation may vary, that's just an example!"
284            print
285            try:
286                self.repository = raw_input('Repository: ')
287                self.upstream_module = raw_input('Module/prefix: ')
288            except KeyboardInterrupt:
289                self.logger.warning("Leaving old config values, stopped by user")
290                raise
291
292    def bootstrap(self, source_kind, target_kind,
293                  source_repository, source_module, revision,
294                  target_repository, target_module, subdir):
295        """
296        Bootstrap a new tailorized module.
297
298        First of all prepare the target system working directory such that
299        it can host the upstream source tree. This is backend specific.
300
301        Extract a copy of the ``repository`` at given ``revision`` in the
302        ``root`` directory and initialize a target repository with its content.
303
304        The actual information on the project are stored in a text file.
305        """
306
307        from os.path import split
308
309        if source_kind == 'svn':
310            if not (source_module and source_module.startswith('/')):
311                raise InvocationError('With SVN the module argument is '
312                                      'mandatory and must start with a "/"')
313
314        if source_repository.endswith('/'):
315            source_repository = source_repository[:-1]
316
317        if source_module and source_module.endswith('/'):
318            source_module = source_module[:-1]
319
320        if not subdir:
321            subdir = split(source_module or source_repository)[1] or ''
322
323        self.logger.info("Bootstrapping '%s'" % (self.root,))
324
325        dwd = DualWorkingDir(source_kind, target_kind)
326        self.logger.info("getting %s revision '%s' of '%s' from '%s'" % (
327            source_kind, revision, source_module, source_repository))
328
329        try:
330            dwd.prepareWorkingDirectory(self.root,
331                                        target_repository, target_module)
332        except:
333            self.logger.exception('Cannot prepare working directory!')
334            raise
335
336        try:
337            actual = dwd.checkoutUpstreamRevision(self.root, source_repository,
338                                                  source_module, revision,
339                                                  subdir=subdir,
340                                                  logger=self.logger)
341        except:
342            self.logger.exception('Checkout failed!')
343            raise
344
345        # the above machinery checked out a copy under of the wc
346        # in the directory named as the last component of the module's name
347
348        if not source_module:
349            source_module = split(repository)[1]
350
351        try:
352            dwd.initializeNewWorkingDir(self.root, source_repository,
353                                        source_module, subdir,
354                                        actual, revision=='INITIAL')
355        except:
356            self.logger.exception('Working copy initialization failed!')
357            raise
358
359        self.source_kind = source_kind
360        self.target_kind = target_kind
361        self.upstream_repos = source_repository
362        self.upstream_module = source_module
363        self.subdir = subdir
364        self.upstream_revision = actual.revision
365
366        self.__saveStatus()
367
368        self.logger.info("Bootstrap completed")
369
370    def applyable(self, root, changeset):
371        """
372        Print the changeset being applied.
373        """
374
375        if self.verbose:
376            print "Changeset %s:" % changeset.revision
377            try:
378                print changeset.log
379            except UnicodeEncodeError:
380                print ">>> Non-printable changelog <<<"
381
382        return True
383
384    def applied(self, root, changeset):
385        """
386        Save current status.
387        """
388
389        self.upstream_revision = changeset.revision
390        self.__saveStatus()
391        if self.verbose:
392            print
393
394    def update(self, single_commit, concatenate_logs):
395        """
396        Update an existing tailorized project.
397
398        Fetch the upstream changesets and apply them to the working copy.
399        Use the information stored in the ``tailor.info`` file to ask just
400        the new changeset since last bootstrap/synchronization.
401        """
402
403        from os.path import join
404
405        self.__loadStatus()
406        proj = join(self.root, self.subdir)
407
408        self.logger.info("Updating '%s' from revision '%s'" % (
409            self.upstream_module, self.upstream_revision))
410
411        if self.verbose:
412            print "\nUpdating '%s' from revision '%s'" % (
413                self.upstream_module, self.upstream_revision)
414
415        try:
416            dwd = DualWorkingDir(self.source_kind, self.target_kind)
417            changesets = dwd.getUpstreamChangesets(proj,
418                                                   self.upstream_repos,
419                                                   self.upstream_module,
420                                                   self.upstream_revision)
421        except KeyboardInterrupt:
422            print "Leaving '%s' unchanged" % proj
423            self.logger.info("Leaving '%s' unchanged, stopped by user" % proj)
424            return
425        except:
426            self.logger.exception("Unable to get changes for '%s'" % proj)
427            raise
428
429        nchanges = len(changesets)
430        if nchanges:
431            if self.verbose:
432                print "Applying %d upstream changesets" % nchanges
433
434            try:
435                last, conflicts = dwd.applyUpstreamChangesets(
436                    proj, self.upstream_module, changesets,
437                    applyable=self.applyable,
438                    applied=self.applied, logger=self.logger,
439                    delayed_commit=single_commit)
440            except:
441                self.logger.exception('Upstream change application failed')
442                raise
443
444            if last:
445                if single_commit:
446                    dwd.commitDelayedChangesets(proj, concatenate_logs)
447
448                self.logger.info("Update completed, now at revision '%s'" % (
449                    self.upstream_revision,))
450        else:
451            self.logger.info("Update completed with no upstream changes")
452
453
454GENERAL_OPTIONS = [
455    make_option("-i", "--interactive", default=False, action="store_true",
456                help="Start an interactive session."),
457    make_option("-D", "--debug", dest="debug",
458                action="store_true", default=False,
459                help="Print each executed command. This also keeps "
460                     "temporary files with the upstream logs, that are "
461                     "otherwise removed after use."),
462    make_option("-v", "--verbose", dest="verbose",
463                action="store_true", default=False,
464                help="Be verbose, echoing the changelog of each applied "
465                     "changeset to stdout."),
466    make_option("--configfile", metavar="CONFNAME",
467                help="Centralized storage of projects info.  With this "
468                     "option and no other arguments tailor will update "
469                     "every project found in the config file."),
470    make_option("--migrate-config", dest="migrate",
471                action="store_true", default=False,
472                help="Migrate old configuration to new centralized storage."),
473    make_option("--encoding", metavar="CHARSET", default=None,
474                help="Force the output encoding to given CHARSET, rather "
475                     "then using the user default settings specified in the "
476                     "environment."),
477]
478
479UPDATE_OPTIONS = [
480    make_option("--update", action="store_true", default=True,
481                help="Update the given repositories, fetching upstream "
482                     "changesets, applying and re-registering each one. "
483                     "This is the default behaviour."),
484    make_option("-S", "--single-commit", action="store_true", default=False,
485                help="Do a single, final commit on the target VC, effectively "
486                     "grouping together all upstream changeset into a single "
487                     "one, from the target VC point of view."),
488    make_option("-C", "--concatenate-logs", action="store_true", default=False,
489                help="With --single-commit, concatenate each changeset "
490                     "message log to the final changelog, instead of just "
491                     "the name of the patch."),
492    make_option("-F", "--patch-name-format", metavar="FORMAT",
493                help="Specify the prototype that will be used "
494                     "to compute the patch name.  The prototype may contain "
495                     "%(keyword)s such as 'module', 'author', 'date', "
496                     "'revision', 'firstlogline', 'remaininglog' for normal "
497                     "updates, otherwise 'module', 'authors', 'nchangesets', "
498                     "'mindate' and 'maxdate' when using --single-commit. It "
499                     "defaults to '%(module)s: changeset %(revision)s'; "
500                     "setting it to the empty string means that tailor will "
501                     "simply use the original changelog."),
502    make_option("-1", "--remove-first-log-line", action="store_true",
503                default=False,
504                help="Remove the first line of the upstream changelog. This "
505                     "is intended to go in pair with --patch-name-format, "
506                     "when using it's 'firstlogline' variable to build the "
507                     "name of the patch."),
508    make_option("-N", "--dont-refill-changelogs", action="store_true",
509                default=False,
510                help="Do not refill every changelog, but keep them as is. "
511                     "This is usefull when using --patch-name-format, or "
512                     "when upstream developers are already formatting their "
513                     "notes with a consistent layout."),
514]
515
516BOOTSTRAP_OPTIONS = [
517    make_option("-b", "--bootstrap", action="store_true", default=False,
518                help="Bootstrap mode, that is the initial copy of the "
519                     "upstream tree, given as an URI (see -R) and maybe "
520                     "a revision (-r).  This overrides --update."),
521    make_option("-s", "--source-kind", dest="source_kind", metavar="VC-KIND",
522                help="Select the backend for the upstream source "
523                     "version control VC-KIND. Default is 'cvs'.",
524                default="cvs"),
525    make_option("-t", "--target-kind", dest="target_kind", metavar="VC-KIND",
526                help="Select VC-KIND as backend for the shadow repository, "
527                     "with 'darcs' as default.",
528                default="darcs"),
529    make_option("-R", "--repository", "--source-repository",
530                dest="source_repository", metavar="REPOS",
531                help="Specify the upstream repository, from where bootstrap "
532                     "will checkout the module.  REPOS syntax depends on "
533                     "the source version control kind."),
534    make_option("-m", "--module", "--source-module", dest="source_module",
535                metavar="MODULE",
536                help="Specify the module to checkout at bootstrap time. "
537                     "This has different meanings under the various upstream "
538                     "systems: with CVS it indicates the module, while under "
539                     "SVN it's the prefix of the tree you want and must begin "
540                     "with a slash. Since it's used in the description of the "
541                     "target repository, you may want to give it a value with "
542                     "darcs too even if it is otherwise ignored."),
543    make_option("-r", "--revision", dest="revision", metavar="REV",
544                help="Specify the revision bootstrap should checkout.  REV "
545                     "must be a valid 'name' for a revision in the upstream "
546                     "version control kind. For CVS it may be either a branch "
547                     "name, a timestamp or both separated by a space, and "
548                     "timestamp may be 'INITIAL' to denote the beginning of "
549                     "time for the given branch. Under Darcs, INITIAL is a "
550                     "shortcut for the name of the first patch in the upstream "
551                     "repository, otherwise it is interpreted as the name of "
552                     "a tag. Under Subversion, 'INITIAL' is the first patch "
553                     "that touches given repos/module, otherwise it must be "
554                     "an integer revision number. "
555                     "'HEAD', the default, means the latest version in all "
556                     "backends.",
557                default="HEAD"),
558    make_option("-T", "--target-repository",
559                dest="target_repository", metavar="REPOS", default=None,
560                help="Specify the target repository, the one that will "
561                     "receive the patches coming from the source one."),
562    make_option("-M", "--target-module", dest="target_module",
563                metavar="MODULE",
564                help="Specify the module on the target repository that will "
565                     "actually contain the upstream source tree."),
566    make_option("--subdir", metavar="DIR",
567                help="Force the subdirectory where the checkout will happen, "
568                     "by default it's the tail part of the module name."),
569]
570
571VC_SPECIFIC_OPTIONS = [
572    make_option("--use-svn-propset", action="store_true", default=False,
573                help="Use 'svn propset' to set the real date and author of "
574                     "each commit, instead of appending these information to "
575                     "the changelog. This requires some tweaks on the SVN "
576                     "repository to enable revision propchanges."),
577]
578
579class ExistingProjectError(Exception):
580    "Project seems already tailored"
581
582class UnknownProjectError(Exception):
583    "Project does not exist"
584
585class ProjectNotTailored(Exception):
586    "Not a tailored project"
587
588def main():
589    """
590    Script entry point.
591
592    Parse the command line options and arguments, and for each
593    specified working copy directory (the current working directory by
594    default) execute the tailorization steps.
595    """
596
597    from os import getcwd, chdir
598    from os.path import abspath, exists, join
599    from shwrap import ExternalCommand
600    from target import SyncronizableTargetWorkingDir
601    from changes import Changeset
602
603    parser = OptionParser(usage='%prog [options] [project ...]',
604                          version=__version__,
605                          option_list=GENERAL_OPTIONS)
606
607    bsoptions = OptionGroup(parser, "Bootstrap options")
608    bsoptions.add_options(BOOTSTRAP_OPTIONS)
609
610    upoptions = OptionGroup(parser, "Update options")
611    upoptions.add_options(UPDATE_OPTIONS)
612
613    vcoptions = OptionGroup(parser, "VC specific options")
614    vcoptions.add_options(VC_SPECIFIC_OPTIONS)
615
616    parser.add_option_group(bsoptions)
617    parser.add_option_group(upoptions)
618    parser.add_option_group(vcoptions)
619
620    options, args = parser.parse_args()
621
622    ExternalCommand.VERBOSE = options.debug
623    if options.encoding:
624        ExternalCommand.FORCE_ENCODING = options.encoding
625
626        # Make printouts be encoded as well. A better solution would be
627        # using the replace mechanism of the encoder, and keep printing
628        # in the user LC_CTYPE/LANG setting.
629
630        import codecs, sys
631        sys.stdout = codecs.getwriter(options.encoding)(sys.stdout)
632
633    if options.patch_name_format is not None:
634        SyncronizableTargetWorkingDir.PATCH_NAME_FORMAT = options.patch_name_format
635    SyncronizableTargetWorkingDir.REMOVE_FIRST_LOG_LINE = options.remove_first_log_line
636    Changeset.REFILL_MESSAGE = not options.dont_refill_changelogs
637
638    SvnWorkingDir.USE_PROPSET = options.use_svn_propset
639
640    if options.interactive:
641        interactive(options, args)
642    elif options.configfile:
643        config = TailorConfig(options)
644
645        config(map(abspath, args))
646    else:
647        # Good (?) old way
648
649        config = None
650
651        base = getcwd()
652
653        if len(args) == 0:
654            args.append(base)
655
656        while args:
657            chdir(base)
658
659            proj = args.pop(0)
660            root = abspath(proj)
661
662            if options.bootstrap:
663                if exists(join(root, STATUS_FILENAME)):
664                    raise ExistingProjectError(
665                        "Project %r cannot be bootstrapped twice" % proj)
666
667                if not options.source_repository:
668                    raise InvocationError('Need a repository to bootstrap %r' %
669                                          proj)
670            else:
671                if not exists(proj):
672                    raise UnknownProjectError("Project %r does not exist" %
673                                              proj)
674
675                if not exists(join(root, STATUS_FILENAME)):
676                    raise UnknownProjectError(
677                        "%r is not a tailorized project" % proj)
678
679            tailored = TailorizedProject(root, options.verbose, config)
680
681            if options.bootstrap:
682                tailored.bootstrap(options.source_kind, options.target_kind,
683                                   options.source_repository,
684                                   options.source_module,
685                                   options.revision,
686                                   options.target_repository,
687                                   options.target_module,
688                                   options.subdir)
689            elif options.update:
690                tailored.update(options.single_commit,
691                                options.concatenate_logs)
Note: See TracBrowser for help on using the repository browser.