Allow extraction of latest versions of different major releases.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012-2014 Carl Hetherington <cth@carlh.net>
4 #
5 #    This program is free software; you can redistribute it and/or modify
6 #    it under the terms of the GNU General Public License as published by
7 #    the Free Software Foundation; either version 2 of the License, or
8 #    (at your option) any later version.
9 #
10 #    This program is distributed in the hope that it will be useful,
11 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
12 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 #    GNU General Public License for more details.
14
15 #    You should have received a copy of the GNU General Public License
16 #    along with this program; if not, write to the Free Software
17 #    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
18
19 import os
20 import sys
21 import shutil
22 import glob
23 import tempfile
24 import argparse
25 import datetime
26 import subprocess
27 import re
28 import copy
29 import inspect
30
31 TEMPORARY_DIRECTORY = '/tmp'
32
33 class Error(Exception):
34     def __init__(self, value):
35         self.value = value
36     def __str__(self):
37         return '\x1b[31m%s\x1b[0m' % repr(self.value)
38     def __repr__(self):
39         return str(self)
40
41 #
42 # Configuration
43 #
44
45 class Option(object):
46     def __init__(self, key):
47         self.key = key
48         self.value = None
49
50     def offer(self, key, value):
51         if key == self.key:
52             self.value = value
53
54 class BoolOption(object):
55     def __init__(self, key):
56         self.key = key
57         self.value = False
58
59     def offer(self, key, value):
60         if key == self.key:
61             self.value = (value == 'yes' or value == '1' or value == 'true')
62
63 class Config:
64     def __init__(self):
65         self.options = [ Option('linux_chroot_prefix'),
66                          Option('windows_environment_prefix'),
67                          Option('mingw_prefix'),
68                          Option('git_prefix'),
69                          Option('osx_build_host'),
70                          Option('osx_environment_prefix'),
71                          Option('osx_sdk_prefix'),
72                          Option('osx_sdk') ]
73
74         try:
75             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
76             while 1:
77                 l = f.readline()
78                 if l == '':
79                     break
80
81                 if len(l) > 0 and l[0] == '#':
82                     continue
83
84                 s = l.strip().split()
85                 if len(s) == 2:
86                     for k in self.options:
87                         k.offer(s[0], s[1])
88         except:
89             raise
90
91     def get(self, k):
92         for o in self.options:
93             if o.key == k:
94                 return o.value
95
96         raise Error('Required setting %s not found' % k)
97
98 config = Config()
99
100 #
101 # Utility bits
102
103
104 def log(m):
105     if not args.quiet:
106         print '\x1b[33m* %s\x1b[0m' % m
107
108 def copytree(a, b):
109     log('copy %s -> %s' % (a, b))
110     shutil.copytree(a, b)
111
112 def copyfile(a, b):
113     log('copy %s -> %s' % (a, b))
114     shutil.copyfile(a, b)
115
116 def rmdir(a):
117     log('remove %s' % a)
118     os.rmdir(a)
119
120 def rmtree(a):
121     log('remove %s' % a)
122     shutil.rmtree(a, ignore_errors=True)
123
124 def command(c, can_fail=False):
125     log(c)
126     r = os.system(c)
127     if (r >> 8) and not can_fail:
128         raise Error('command %s failed' % c)
129
130 def command_and_read(c):
131     log(c)
132     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
133     f = os.fdopen(os.dup(p.stdout.fileno()))
134     return f
135
136 def read_wscript_variable(directory, variable):
137     f = open('%s/wscript' % directory, 'r')
138     while 1:
139         l = f.readline()
140         if l == '':
141             break
142         
143         s = l.split()
144         if len(s) == 3 and s[0] == variable:
145             f.close()
146             return s[2][1:-1]
147
148     f.close()
149     return None
150
151 #
152 # Version
153 #
154
155 class Version:
156     def __init__(self, s):
157         self.devel = False
158
159         if s.startswith("'"):
160             s = s[1:]
161         if s.endswith("'"):
162             s = s[0:-1]
163         
164         if s.endswith('devel'):
165             s = s[0:-5]
166             self.devel = True
167
168         if s.endswith('pre'):
169             s = s[0:-3]
170
171         p = s.split('.')
172         self.major = int(p[0])
173         self.minor = int(p[1])
174         if len(p) == 3:
175             self.micro = int(p[2])
176         else:
177             self.micro = 0
178
179     def bump_minor(self):
180         self.minor += 1
181         self.micro = 0
182
183     def bump_micro(self):
184         self.micro += 1
185
186     def to_devel(self):
187         self.devel = True
188
189     def to_release(self):
190         self.devel = False
191
192     def __str__(self):
193         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
194         if self.devel:
195             s += 'devel'
196
197         return s
198
199 #
200 # Targets
201 #
202
203 class Target(object):
204     # @param directory directory to work in; if None we will use a temporary directory
205     # Temporary directories will be removed after use; specified directories will not
206     def __init__(self, platform, parallel, directory=None):
207         self.platform = platform
208         self.parallel = parallel
209
210         if directory is None:
211             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
212             self.rmdir = True
213         else:
214             self.directory = directory
215             self.rmdir = False
216
217         # Environment variables that we will use when we call cscripts
218         self.variables = {}
219         self.debug = False
220
221     def build_dependencies(self, project):
222         cwd = os.getcwd()
223         if 'dependencies' in project.cscript:
224             for d in project.cscript['dependencies'](self):
225                 log('Building dependency %s %s of %s' % (d[0], d[1], project.name))
226                 dep = Project(d[0], '.', d[1])
227                 dep.checkout(self)
228                 self.build_dependencies(dep)
229
230                 # Make the options to pass in from the option_defaults of the thing
231                 # we are building and any options specified by the parent.
232                 options = {}
233                 if 'option_defaults' in dep.cscript:
234                     options = dep.cscript['option_defaults']()
235                     if len(d) > 2:
236                         for k, v in d[2].iteritems():
237                             options[k] = v
238
239                 self.build(dep, options)
240
241         os.chdir(cwd)
242
243     def build(self, project, options=None):
244         variables = copy.copy(self.variables)
245         if len(inspect.getargspec(project.cscript['build']).args) == 2:
246             project.cscript['build'](self, options)
247         else:
248             project.cscript['build'](self)
249         self.variables = variables
250
251     def package(self, project):
252         project.checkout(self)
253         self.build_dependencies(project)
254         self.build(project)
255         return project.cscript['package'](self, project.version)
256
257     def test(self, project):
258         project.checkout(self)
259         self.build_dependencies(project)
260         self.build(project)
261         project.cscript['test'](self)
262
263     def set(self, a, b):
264         self.variables[a] = b
265
266     def unset(self, a):
267         del(self.variables[a])
268
269     def get(self, a):
270         return self.variables[a]
271
272     def append_with_space(self, k, v):
273         if not k in self.variables:
274             self.variables[k] = v
275         else:
276             self.variables[k] = '%s %s' % (self.variables[k], v)
277
278     def variables_string(self, escaped_quotes=False):
279         e = ''
280         for k, v in self.variables.iteritems():
281             if escaped_quotes:
282                 v = v.replace('"', '\\"')
283             e += '%s=%s ' % (k, v)
284         return e
285
286     def cleanup(self):
287         if self.rmdir:
288             rmtree(self.directory)
289
290
291 # Windows
292 #
293
294 class WindowsTarget(Target):
295     def __init__(self, bits, directory=None):
296         super(WindowsTarget, self).__init__('windows', 2, directory)
297         self.bits = bits
298
299         self.windows_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
300         if not os.path.exists(self.windows_prefix):
301             raise Error('windows prefix %s does not exist' % self.windows_prefix)
302             
303         if self.bits == 32:
304             self.mingw_name = 'i686'
305         else:
306             self.mingw_name = 'x86_64'
307
308         mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
309         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
310
311         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.windows_prefix)
312         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
313         self.set('PATH', '%s/bin:%s:%s' % (self.windows_prefix, mingw_path, os.environ['PATH']))
314         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
315         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
316         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
317         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
318         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
319         cxx = '-I%s/include -I%s/include' % (self.windows_prefix, self.directory)
320         link = '-L%s/lib -L%s/lib' % (self.windows_prefix, self.directory)
321         for p in self.mingw_prefixes:
322             cxx += ' -I%s/include' % p
323             link += ' -L%s/lib' % p
324         self.set('CXXFLAGS', '"%s"' % cxx)
325         self.set('LINKFLAGS', '"%s"' % link)
326
327     def command(self, c):
328         log('host -> %s' % c)
329         command('%s %s' % (self.variables_string(), c))
330
331 #
332 # Linux
333 #
334
335 class LinuxTarget(Target):
336     def __init__(self, distro, version, bits, directory=None):
337         super(LinuxTarget, self).__init__('linux', 2, directory)
338         self.distro = distro
339         self.version = version
340         self.bits = bits
341         # e.g. ubuntu-14.04-64
342         self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
343         # e.g. /home/carl/Environments/ubuntu-14.04-64
344         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
345
346         self.set('CXXFLAGS', '-I%s/include' % self.directory)
347         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
348         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:/usr/local/lib/pkgconfig' % self.directory)
349         self.set('PATH', '%s:/usr/local/bin' % (os.environ['PATH']))
350
351     def command(self, c):
352         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
353
354 #
355 # OS X
356 #
357
358 class OSXTarget(Target):
359     def __init__(self, directory=None):
360         super(OSXTarget, self).__init__('osx', 4, directory)
361
362     def command(self, c):
363         command('%s %s' % (self.variables_string(False), c))
364
365
366 class OSXSingleTarget(OSXTarget):
367     def __init__(self, bits, directory=None):
368         super(OSXSingleTarget, self).__init__(directory)
369         self.bits = bits
370
371         if bits == 32:
372             arch = 'i386'
373         else:
374             arch = 'x86_64'
375
376         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (config.get('osx_sdk_prefix'), config.get('osx_sdk'), arch)
377         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
378
379         # Environment variables
380         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
381         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
382         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
383         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
384         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
385         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
386         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
387
388     def package(self, project):
389         raise Error('cannot package non-universal OS X versions')
390
391
392 class OSXUniversalTarget(OSXTarget):
393     def __init__(self, directory=None):
394         super(OSXUniversalTarget, self).__init__(directory)
395         self.parts = []
396         self.parts.append(OSXSingleTarget(32, os.path.join(self.directory, '32')))
397         self.parts.append(OSXSingleTarget(64, os.path.join(self.directory, '64')))
398
399     def package(self, project):
400         for p in self.parts:
401             project.checkout(p)
402             p.build_dependencies(project)
403             p.build(project)
404
405         return project.cscript['package'](self, project.version)
406     
407
408 #
409 # Source
410 #
411
412 class SourceTarget(Target):
413     def __init__(self):
414         super(SourceTarget, self).__init__('source', 2)
415
416     def command(self, c):
417         log('host -> %s' % c)
418         command('%s %s' % (self.variables_string(), c))
419
420     def cleanup(self):
421         rmtree(self.directory)
422
423     def package(self, project):
424         project.checkout(self)
425         name = read_wscript_variable(os.getcwd(), 'APPNAME')
426         command('./waf dist')
427         return os.path.abspath('%s-%s.tar.bz2' % (name, project.version))
428
429
430 # @param s Target string:
431 #       windows-{32,64}
432 #    or ubuntu-version-{32,64}
433 #    or debian-version-{32,64}
434 #    or centos-version-{32,64}
435 #    or osx-{32,64}
436 #    or source      
437 # @param debug True to build with debugging symbols (where possible)
438 def target_factory(s, debug, work):
439     target = None
440     if s.startswith('windows-'):
441         target = WindowsTarget(int(s.split('-')[1]), work)
442     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
443         p = s.split('-')
444         if len(p) != 3:
445             print >>sys.stderr,"Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s
446             sys.exit(1)
447         target = LinuxTarget(p[0], p[1], int(p[2]), work)
448     elif s.startswith('osx-'):
449         target = OSXSingleTarget(int(s.split('-')[1]), work)
450     elif s == 'osx':
451         if args.command == 'build':
452             target = OSXSingleTarget(64, work)
453         else:
454             target = OSXUniversalTarget(work)
455     elif s == 'source':
456         target = SourceTarget()
457
458     if target is not None:
459         target.debug = debug
460
461     return target
462
463
464 #
465 # Project
466 #
467  
468 class Project(object):
469     def __init__(self, name, directory, specifier=None):
470         self.name = name
471         self.directory = directory
472         self.version = None
473         self.specifier = specifier
474         self.git_commit = None
475         if self.specifier is None:
476             self.specifier = 'master'
477
478     def checkout(self, target):
479         flags = ''
480         redirect = ''
481         if args.quiet:
482             flags = '-q'
483             redirect = '>/dev/null'
484         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
485         os.chdir('%s/src/%s' % (target.directory, self.name))
486         command('git checkout %s %s %s' % (flags, self.specifier, redirect))
487         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
488         command('git submodule init --quiet')
489         command('git submodule update --quiet')
490         os.chdir(self.directory)
491
492         proj = '%s/src/%s/%s' % (target.directory, self.name, self.directory)
493
494         self.read_cscript('%s/cscript' % proj)
495         
496         if os.path.exists('%s/wscript' % proj):
497             v = read_wscript_variable(proj, "VERSION");
498             if v is not None:
499                 self.version = Version(v)
500
501     def read_cscript(self, s):
502         self.cscript = {}
503         execfile(s, self.cscript)
504
505 def set_version_in_wscript(version):
506     f = open('wscript', 'rw')
507     o = open('wscript.tmp', 'w')
508     while 1:
509         l = f.readline()
510         if l == '':
511             break
512
513         s = l.split()
514         if len(s) == 3 and s[0] == "VERSION":
515             print "Writing %s" % version
516             print >>o,"VERSION = '%s'" % version
517         else:
518             print >>o,l,
519     f.close()
520     o.close()
521
522     os.rename('wscript.tmp', 'wscript')
523
524 def append_version_to_changelog(version):
525     try:
526         f = open('ChangeLog', 'r')
527     except:
528         log('Could not open ChangeLog')
529         return
530
531     c = f.read()
532     f.close()
533
534     f = open('ChangeLog', 'w')
535     now = datetime.datetime.now()
536     f.write('%d-%02d-%02d  Carl Hetherington  <cth@carlh.net>\n\n\t* Version %s released.\n\n' % (now.year, now.month, now.day, version))
537     f.write(c)
538
539 def append_version_to_debian_changelog(version):
540     if not os.path.exists('debian'):
541         log('Could not find debian directory')
542         return
543
544     command('dch -b -v %s-1 "New upstream release."' % version)
545
546 def devel_to_git(project, filename):
547     if project.git_commit is not None:
548         filename = filename.replace('devel', '-%s' % project.git_commit)
549     return filename
550
551 #
552 # Command-line parser
553 #
554
555 parser = argparse.ArgumentParser()
556 parser.add_argument('command')
557 parser.add_argument('-p', '--project', help='project name')
558 parser.add_argument('-d', '--directory', help='directory within project repo', default='.')
559 parser.add_argument('--minor', help='minor version number bump', action='store_true')
560 parser.add_argument('--micro', help='micro version number bump', action='store_true')
561 parser.add_argument('--major', help='major version to return with latest', type=int)
562 parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
563 parser.add_argument('-o', '--output', help='output directory', default='.')
564 parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
565 parser.add_argument('-t', '--target', help='target')
566 parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
567 parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
568 parser.add_argument('-w', '--work', help='override default work directory')
569 args = parser.parse_args()
570
571 args.output = os.path.abspath(args.output)
572 if args.work is not None:
573     args.work = os.path.abspath(args.work)
574
575 if args.project is None and args.command != 'shell':
576     raise Error('you must specify -p or --project')
577
578 project = Project(args.project, args.directory, args.checkout)
579
580 if args.command == 'build':
581     if args.target is None:
582         raise Error('you must specify -t or --target')
583
584     target = target_factory(args.target, args.debug, args.work)
585     project.checkout(target)
586     target.build_dependencies(project)
587     target.build(project)
588     if not args.keep:
589         target.cleanup()
590
591 elif args.command == 'package':
592     if args.target is None:
593         raise Error('you must specify -t or --target')
594         
595     target = target_factory(args.target, args.debug, args.work)
596
597     packages = target.package(project)
598     if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
599         packages = [packages]
600
601     if target.platform == 'linux':
602         out = '%s/%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
603         try:
604             os.makedirs(out)
605         except:
606             pass
607         for p in packages:
608             copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(project, p))))
609     else:
610         for p in packages:
611             copyfile(p, '%s/%s' % (args.output, os.path.basename(devel_to_git(project, p))))
612
613     if not args.keep:
614         target.cleanup()
615
616 elif args.command == 'release':
617     if args.minor is False and args.micro is False:
618         raise Error('you must specify --minor or --micro')
619
620     target = SourceTarget()
621     project.checkout(target)
622
623     version = project.version
624     version.to_release()
625     if args.minor:
626         version.bump_minor()
627     else:
628         version.bump_micro()
629
630     set_version_in_wscript(version)
631     append_version_to_changelog(version)
632     append_version_to_debian_changelog(version)
633
634     command('git commit -a -m "Bump version"')
635     command('git tag -m "v%s" v%s' % (version, version))
636
637     version.to_devel()
638     set_version_in_wscript(version)
639     command('git commit -a -m "Bump version"')
640     command('git push')
641     command('git push --tags')
642
643     target.cleanup()
644
645 elif args.command == 'pot':
646     target = SourceTarget()
647     project.checkout(target)
648
649     pots = project.cscript['make_pot'](target)
650     for p in pots:
651         copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
652
653     target.cleanup()
654
655 elif args.command == 'changelog':
656     target = SourceTarget()
657     project.checkout(target)
658
659     text = open('ChangeLog', 'r')
660     html = open('%s/changelog.html' % args.output, 'w')
661     versions = 8
662     
663     last = None
664     changes = []
665     
666     while 1:
667         l = text.readline()
668         if l == '':
669             break
670     
671         if len(l) > 0 and l[0] == "\t":
672             s = l.split()
673             if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
674                 v = Version(s[2])
675                 if v.micro == 0:
676                     if last is not None and len(changes) > 0:
677                         print >>html,"<h2>Changes between version %s and %s</h2>" % (s[2], last)
678                         print >>html,"<ul>"
679                         for c in changes:
680                             print >>html,"<li>%s" % c
681                         print >>html,"</ul>"
682                     last = s[2]
683                     changes = []
684                     versions -= 1
685                     if versions < 0:
686                         break
687             else:
688                 c = l.strip()
689                 if len(c) > 0:
690                     if c[0] == '*':
691                         changes.append(c[2:])
692                     else:
693                         changes[-1] += " " + c
694
695     target.cleanup()
696
697 elif args.command == 'manual':
698     target = SourceTarget()
699     project.checkout(target)
700
701     outs = project.cscript['make_manual'](target)
702     for o in outs:
703         if os.path.isfile(o):
704             copyfile(o, '%s/%s' % (args.output, os.path.basename(o)))
705         else:
706             copytree(o, '%s/%s' % (args.output, os.path.basename(o)))
707
708     target.cleanup()
709
710 elif args.command == 'doxygen':
711     target = SourceTarget()
712     project.checkout(target)
713
714     dirs = project.cscript['make_doxygen'](target)
715     if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
716         dirs = [dirs]
717
718     for d in dirs:
719         copytree(d, '%s/%s' % (args.output, 'doc'))
720
721     target.cleanup()
722
723 elif args.command == 'latest':
724     target = SourceTarget()
725     project.checkout(target)
726
727     f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
728     latest = None
729     while latest is None:
730         t = f.readline()
731         m = re.compile(".*\((.*)\).*").match(t)
732         if m:
733             tags = m.group(1).split(', ')
734             for t in tags:
735                 s = t.split()
736                 if len(s) > 1:
737                     t = s[1]
738                 if len(t) > 0 and t[0] == 'v':
739                     v = Version(t[1:])
740                     if args.major is None or v.major == args.major:
741                         latest = v
742
743     print latest
744     target.cleanup()
745
746 elif args.command == 'test':
747     if args.target is None:
748         raise Error('you must specify -t or --target')
749
750     target = None
751     try:
752         target = target_factory(args.target, args.debug, args.work)
753         target.test(project)
754     except Error as e:
755         if target is not None:
756             target.cleanup()
757         raise
758         
759     if target is not None:
760         target.cleanup()
761
762 elif args.command == 'shell':
763     if args.target is None:
764         raise Error('you must specify -t or --target')
765
766     target = target_factory(args.target, args.debug, args.work)
767     target.command('bash')
768
769 else:
770     raise Error('invalid command %s' % args.command)