source: tailor/vcpx/tailor.py @ 446

Revision 446, 25.7 KB checked in by lele@…, 8 years ago (diff)

Introduced --source-repository and --source-module as aliases for -R and -m
This is to allow --target-repository and --target-module options, needed
to create the target working copy at bootstrap time.

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