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