268a6f49f3d7f0d092000d6a8f56f3e69a3a9b29
[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         print 'Working in %s' % self.directory
218
219         # Environment variables that we will use when we call cscripts
220         self.variables = {}
221         self.debug = False
222
223     def build_dependencies(self, project):
224         cwd = os.getcwd()
225         if 'dependencies' in project.cscript:
226             for d in project.cscript['dependencies'](self):
227                 log('Building dependency %s %s of %s' % (d[0], d[1], project.name))
228                 dep = Project(d[0], '.', d[1])
229                 dep.checkout(self)
230                 self.build_dependencies(dep)
231
232                 # Make the options to pass in from the option_defaults of the thing
233                 # we are building and any options specified by the parent.
234                 options = {}
235                 if 'option_defaults' in dep.cscript:
236                     options = dep.cscript['option_defaults']()
237                     if len(d) > 2:
238                         for k, v in d[2].iteritems():
239                             options[k] = v
240
241                 self.build(dep, options)
242
243         os.chdir(cwd)
244
245     def build(self, project, options=None):
246         variables = copy.copy(self.variables)
247         if len(inspect.getargspec(project.cscript['build']).args) == 2:
248             project.cscript['build'](self, options)
249         else:
250             project.cscript['build'](self)
251         self.variables = variables
252
253     def package(self, project):
254         project.checkout(self)
255         self.build_dependencies(project)
256         self.build(project)
257         return project.cscript['package'](self, project.version)
258
259     def test(self, project):
260         project.checkout(self)
261         self.build_dependencies(project)
262         self.build(project)
263         project.cscript['test'](self)
264
265     def set(self, a, b):
266         self.variables[a] = b
267
268     def unset(self, a):
269         del(self.variables[a])
270
271     def get(self, a):
272         return self.variables[a]
273
274     def append_with_space(self, k, v):
275         if not k in self.variables:
276             self.variables[k] = v
277         else:
278             self.variables[k] = '%s %s' % (self.variables[k], v)
279
280     def variables_string(self, escaped_quotes=False):
281         e = ''
282         for k, v in self.variables.iteritems():
283             if escaped_quotes:
284                 v = v.replace('"', '\\"')
285             e += '%s=%s ' % (k, v)
286         return e
287
288     def cleanup(self):
289         if self.rmdir:
290             rmtree(self.directory)
291
292
293 # Windows
294 #
295
296 class WindowsTarget(Target):
297     def __init__(self, bits, directory=None):
298         super(WindowsTarget, self).__init__('windows', 2, directory)
299         self.bits = bits
300
301         self.windows_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
302         if not os.path.exists(self.windows_prefix):
303             raise Error('windows prefix %s does not exist' % self.windows_prefix)
304             
305         if self.bits == 32:
306             self.mingw_name = 'i686'
307         else:
308             self.mingw_name = 'x86_64'
309
310         mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
311         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
312
313         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.windows_prefix)
314         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
315         self.set('PATH', '%s/bin:%s:%s' % (self.windows_prefix, mingw_path, os.environ['PATH']))
316         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
317         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
318         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
319         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
320         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
321         cxx = '-I%s/include -I%s/include' % (self.windows_prefix, self.directory)
322         link = '-L%s/lib -L%s/lib' % (self.windows_prefix, self.directory)
323         for p in self.mingw_prefixes:
324             cxx += ' -I%s/include' % p
325             link += ' -L%s/lib' % p
326         self.set('CXXFLAGS', '"%s"' % cxx)
327         self.set('LINKFLAGS', '"%s"' % link)
328
329     def command(self, c):
330         log('host -> %s' % c)
331         command('%s %s' % (self.variables_string(), c))
332
333 #
334 # Linux
335 #
336
337 class LinuxTarget(Target):
338     def __init__(self, distro, version, bits, directory=None):
339         super(LinuxTarget, self).__init__('linux', 2, directory)
340         self.distro = distro
341         self.version = version
342         self.bits = bits
343         # e.g. ubuntu-14.04-64
344         self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
345         # e.g. /home/carl/Environments/ubuntu-14.04-64
346         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
347
348         self.set('CXXFLAGS', '-I%s/include' % self.directory)
349         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
350         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:/usr/local/lib/pkgconfig' % self.directory)
351         self.set('PATH', '%s:/usr/local/bin' % (os.environ['PATH']))
352
353     def command(self, c):
354         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
355
356 #
357 # OS X
358 #
359
360 class OSXTarget(Target):
361     def __init__(self, directory=None):
362         super(OSXTarget, self).__init__('osx', 4, directory)
363
364     def command(self, c):
365         command('%s %s' % (self.variables_string(False), c))
366
367
368 class OSXSingleTarget(OSXTarget):
369     def __init__(self, bits, directory=None):
370         super(OSXSingleTarget, self).__init__(directory)
371         self.bits = bits
372
373         if bits == 32:
374             arch = 'i386'
375         else:
376             arch = 'x86_64'
377
378         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (config.get('osx_sdk_prefix'), config.get('osx_sdk'), arch)
379         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
380
381         # Environment variables
382         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
383         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
384         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
385         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
386         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
387         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
388         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
389
390     def package(self, project):
391         raise Error('cannot package non-universal OS X versions')
392
393
394 class OSXUniversalTarget(OSXTarget):
395     def __init__(self, directory=None):
396         super(OSXUniversalTarget, self).__init__(directory)
397         self.parts = []
398         self.parts.append(OSXSingleTarget(32, os.path.join(self.directory, '32')))
399         self.parts.append(OSXSingleTarget(64, os.path.join(self.directory, '64')))
400
401     def package(self, project):
402         for p in self.parts:
403             project.checkout(p)
404             p.build_dependencies(project)
405             p.build(project)
406
407         return project.cscript['package'](self, project.version)
408     
409
410 #
411 # Source
412 #
413
414 class SourceTarget(Target):
415     def __init__(self):
416         super(SourceTarget, self).__init__('source', 2)
417
418     def command(self, c):
419         log('host -> %s' % c)
420         command('%s %s' % (self.variables_string(), c))
421
422     def cleanup(self):
423         rmtree(self.directory)
424
425     def package(self, project):
426         project.checkout(self)
427         name = read_wscript_variable(os.getcwd(), 'APPNAME')
428         command('./waf dist')
429         return os.path.abspath('%s-%s.tar.bz2' % (name, project.version))
430
431
432 # @param s Target string:
433 #       windows-{32,64}
434 #    or ubuntu-version-{32,64}
435 #    or debian-version-{32,64}
436 #    or centos-version-{32,64}
437 #    or osx-{32,64}
438 #    or source      
439 # @param debug True to build with debugging symbols (where possible)
440 def target_factory(s, debug, work):
441     target = None
442     if s.startswith('windows-'):
443         target = WindowsTarget(int(s.split('-')[1]), work)
444     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
445         p = s.split('-')
446         if len(p) != 3:
447             print >>sys.stderr,"Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s
448             sys.exit(1)
449         target = LinuxTarget(p[0], p[1], int(p[2]), work)
450     elif s.startswith('osx-'):
451         target = OSXSingleTarget(int(s.split('-')[1]), work)
452     elif s == 'osx':
453         if args.command == 'build':
454             target = OSXSingleTarget(64, work)
455         else:
456             target = OSXUniversalTarget(work)
457     elif s == 'source':
458         target = SourceTarget()
459
460     if target is not None:
461         target.debug = debug
462
463     return target
464
465
466 #
467 # Project
468 #
469  
470 class Project(object):
471     def __init__(self, name, directory, specifier=None):
472         self.name = name
473         self.directory = directory
474         self.version = None
475         self.specifier = specifier
476         self.git_commit = None
477         if self.specifier is None:
478             self.specifier = 'master'
479
480     def checkout(self, target):
481         flags = ''
482         redirect = ''
483         if args.quiet:
484             flags = '-q'
485             redirect = '>/dev/null'
486         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
487         os.chdir('%s/src/%s' % (target.directory, self.name))
488         command('git checkout %s %s %s' % (flags, self.specifier, redirect))
489         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
490         command('git submodule init --quiet')
491         command('git submodule update --quiet')
492         os.chdir(self.directory)
493
494         proj = '%s/src/%s/%s' % (target.directory, self.name, self.directory)
495
496         self.read_cscript('%s/cscript' % proj)
497         
498         if os.path.exists('%s/wscript' % proj):
499             v = read_wscript_variable(proj, "VERSION");
500             if v is not None:
501                 self.version = Version(v)
502
503     def read_cscript(self, s):
504         self.cscript = {}
505         execfile(s, self.cscript)
506
507 def set_version_in_wscript(version):
508     f = open('wscript', 'rw')
509     o = open('wscript.tmp', 'w')
510     while 1:
511         l = f.readline()
512         if l == '':
513             break
514
515         s = l.split()
516         if len(s) == 3 and s[0] == "VERSION":
517             print "Writing %s" % version
518             print >>o,"VERSION = '%s'" % version
519         else:
520             print >>o,l,
521     f.close()
522     o.close()
523
524     os.rename('wscript.tmp', 'wscript')
525
526 def append_version_to_changelog(version):
527     try:
528         f = open('ChangeLog', 'r')
529     except:
530         log('Could not open ChangeLog')
531         return
532
533     c = f.read()
534     f.close()
535
536     f = open('ChangeLog', 'w')
537     now = datetime.datetime.now()
538     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))
539     f.write(c)
540
541 def append_version_to_debian_changelog(version):
542     if not os.path.exists('debian'):
543         log('Could not find debian directory')
544         return
545
546     command('dch -b -v %s-1 "New upstream release."' % version)
547
548 def devel_to_git(project, filename):
549     if project.git_commit is not None:
550         filename = filename.replace('devel', '-%s' % project.git_commit)
551     return filename
552
553 #
554 # Command-line parser
555 #
556
557 parser = argparse.ArgumentParser()
558 parser.add_argument('command')
559 parser.add_argument('-p', '--project', help='project name')
560 parser.add_argument('-d', '--directory', help='directory within project repo', default='.')
561 parser.add_argument('--minor', help='minor version number bump', action='store_true')
562 parser.add_argument('--micro', help='micro version number bump', action='store_true')
563 parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
564 parser.add_argument('-o', '--output', help='output directory', default='.')
565 parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
566 parser.add_argument('-t', '--target', help='target')
567 parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
568 parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
569 parser.add_argument('-w', '--work', help='override default work directory')
570 args = parser.parse_args()
571
572 args.output = os.path.abspath(args.output)
573 if args.work is not None:
574     args.work = os.path.abspath(args.work)
575
576 if args.project is None and args.command != 'shell':
577     raise Error('you must specify -p or --project')
578
579 project = Project(args.project, args.directory, args.checkout)
580
581 if args.command == 'build':
582     if args.target is None:
583         raise Error('you must specify -t or --target')
584
585     target = target_factory(args.target, args.debug, args.work)
586     project.checkout(target)
587     target.build_dependencies(project)
588     target.build(project)
589     if not args.keep:
590         target.cleanup()
591
592 elif args.command == 'package':
593     if args.target is None:
594         raise Error('you must specify -t or --target')
595         
596     target = target_factory(args.target, args.debug, args.work)
597
598     packages = target.package(project)
599     if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
600         packages = [packages]
601
602     if target.platform == 'linux':
603         out = '%s/%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
604         try:
605             os.makedirs(out)
606         except:
607             pass
608         for p in packages:
609             copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(project, p))))
610     else:
611         for p in packages:
612             copyfile(p, '%s/%s' % (args.output, os.path.basename(devel_to_git(project, p))))
613
614     if not args.keep:
615         target.cleanup()
616
617 elif args.command == 'release':
618     if args.minor is False and args.micro is False:
619         raise Error('you must specify --minor or --micro')
620
621     target = SourceTarget()
622     project.checkout(target)
623
624     version = project.version
625     version.to_release()
626     if args.minor:
627         version.bump_minor()
628     else:
629         version.bump_micro()
630
631     set_version_in_wscript(version)
632     append_version_to_changelog(version)
633     append_version_to_debian_changelog(version)
634
635     command('git commit -a -m "Bump version"')
636     command('git tag -m "v%s" v%s' % (version, version))
637
638     version.to_devel()
639     set_version_in_wscript(version)
640     command('git commit -a -m "Bump version"')
641     command('git push')
642     command('git push --tags')
643
644     target.cleanup()
645
646 elif args.command == 'pot':
647     target = SourceTarget()
648     project.checkout(target)
649
650     pots = project.cscript['make_pot'](target)
651     for p in pots:
652         copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
653
654     target.cleanup()
655
656 elif args.command == 'changelog':
657     target = SourceTarget()
658     project.checkout(target)
659
660     text = open('ChangeLog', 'r')
661     html = open('%s/changelog.html' % args.output, 'w')
662     versions = 8
663     
664     last = None
665     changes = []
666     
667     while 1:
668         l = text.readline()
669         if l == '':
670             break
671     
672         if len(l) > 0 and l[0] == "\t":
673             s = l.split()
674             if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
675                 v = Version(s[2])
676                 if v.micro == 0:
677                     if last is not None and len(changes) > 0:
678                         print >>html,"<h2>Changes between version %s and %s</h2>" % (s[2], last)
679                         print >>html,"<ul>"
680                         for c in changes:
681                             print >>html,"<li>%s" % c
682                         print >>html,"</ul>"
683                     last = s[2]
684                     changes = []
685                     versions -= 1
686                     if versions < 0:
687                         break
688             else:
689                 c = l.strip()
690                 if len(c) > 0:
691                     if c[0] == '*':
692                         changes.append(c[2:])
693                     else:
694                         changes[-1] += " " + c
695
696     target.cleanup()
697
698 elif args.command == 'manual':
699     target = SourceTarget()
700     project.checkout(target)
701
702     outs = project.cscript['make_manual'](target)
703     for o in outs:
704         if os.path.isfile(o):
705             copyfile(o, '%s/%s' % (args.output, os.path.basename(o)))
706         else:
707             copytree(o, '%s/%s' % (args.output, os.path.basename(o)))
708
709     target.cleanup()
710
711 elif args.command == 'doxygen':
712     target = SourceTarget()
713     project.checkout(target)
714
715     dirs = project.cscript['make_doxygen'](target)
716     if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
717         dirs = [dirs]
718
719     for d in dirs:
720         copytree(d, '%s/%s' % (args.output, 'doc'))
721
722     target.cleanup()
723
724 elif args.command == 'latest':
725     target = SourceTarget()
726     project.checkout(target)
727
728     f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
729     t = f.readline()
730     m = re.compile(".*\((.*)\).*").match(t)
731     latest = None
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                 latest = t[1:]
740
741     print latest
742     target.cleanup()
743
744 elif args.command == 'test':
745     if args.target is None:
746         raise Error('you must specify -t or --target')
747
748     target = None
749     try:
750         target = target_factory(args.target, args.debug, args.work)
751         target.test(project)
752     except Error as e:
753         if target is not None:
754             target.cleanup()
755         raise
756         
757     if target is not None:
758         target.cleanup()
759
760 elif args.command == 'shell':
761     if args.target is None:
762         raise Error('you must specify -t or --target')
763
764     target = target_factory(args.target, args.debug, args.work)
765     target.command('bash')
766
767 else:
768     raise Error('invalid command %s' % args.command)