Merge branch 'working-dir-changes'
[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('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', 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')
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('--major', help='major version to return with latest', type=int)
564 parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
565 parser.add_argument('-o', '--output', help='output directory', default='.')
566 parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
567 parser.add_argument('-t', '--target', help='target')
568 parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
569 parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
570 parser.add_argument('-w', '--work', help='override default work directory')
571 args = parser.parse_args()
572
573 args.output = os.path.abspath(args.output)
574 if args.work is not None:
575     args.work = os.path.abspath(args.work)
576
577 if args.project is None and args.command != 'shell':
578     raise Error('you must specify -p or --project')
579
580 project = Project(args.project, args.directory, args.checkout)
581
582 if args.command == 'build':
583     if args.target is None:
584         raise Error('you must specify -t or --target')
585
586     target = target_factory(args.target, args.debug, args.work)
587     project.checkout(target)
588     target.build_dependencies(project)
589     target.build(project)
590     if not args.keep:
591         target.cleanup()
592
593 elif args.command == 'package':
594     if args.target is None:
595         raise Error('you must specify -t or --target')
596         
597     target = target_factory(args.target, args.debug, args.work)
598
599     packages = target.package(project)
600     if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
601         packages = [packages]
602
603     if target.platform == 'linux':
604         out = '%s/%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
605         try:
606             os.makedirs(out)
607         except:
608             pass
609         for p in packages:
610             copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(project, p))))
611     else:
612         for p in packages:
613             copyfile(p, '%s/%s' % (args.output, os.path.basename(devel_to_git(project, p))))
614
615     if not args.keep:
616         target.cleanup()
617
618 elif args.command == 'release':
619     if args.minor is False and args.micro is False:
620         raise Error('you must specify --minor or --micro')
621
622     target = SourceTarget()
623     project.checkout(target)
624
625     version = project.version
626     version.to_release()
627     if args.minor:
628         version.bump_minor()
629     else:
630         version.bump_micro()
631
632     set_version_in_wscript(version)
633     append_version_to_changelog(version)
634     append_version_to_debian_changelog(version)
635
636     command('git commit -a -m "Bump version"')
637     command('git tag -m "v%s" v%s' % (version, version))
638
639     version.to_devel()
640     set_version_in_wscript(version)
641     command('git commit -a -m "Bump version"')
642     command('git push')
643     command('git push --tags')
644
645     target.cleanup()
646
647 elif args.command == 'pot':
648     target = SourceTarget()
649     project.checkout(target)
650
651     pots = project.cscript['make_pot'](target)
652     for p in pots:
653         copyfile(p, '%s/%s' % (args.output, os.path.basename(p)))
654
655     target.cleanup()
656
657 elif args.command == 'changelog':
658     target = SourceTarget()
659     project.checkout(target)
660
661     text = open('ChangeLog', 'r')
662     html = open('%s/changelog.html' % args.output, 'w')
663     versions = 8
664     
665     last = None
666     changes = []
667     
668     while 1:
669         l = text.readline()
670         if l == '':
671             break
672     
673         if len(l) > 0 and l[0] == "\t":
674             s = l.split()
675             if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
676                 v = Version(s[2])
677                 if v.micro == 0:
678                     if last is not None and len(changes) > 0:
679                         print >>html,"<h2>Changes between version %s and %s</h2>" % (s[2], last)
680                         print >>html,"<ul>"
681                         for c in changes:
682                             print >>html,"<li>%s" % c
683                         print >>html,"</ul>"
684                     last = s[2]
685                     changes = []
686                     versions -= 1
687                     if versions < 0:
688                         break
689             else:
690                 c = l.strip()
691                 if len(c) > 0:
692                     if c[0] == '*':
693                         changes.append(c[2:])
694                     else:
695                         changes[-1] += " " + c
696
697     target.cleanup()
698
699 elif args.command == 'manual':
700     target = SourceTarget()
701     project.checkout(target)
702
703     outs = project.cscript['make_manual'](target)
704     for o in outs:
705         if os.path.isfile(o):
706             copyfile(o, '%s/%s' % (args.output, os.path.basename(o)))
707         else:
708             copytree(o, '%s/%s' % (args.output, os.path.basename(o)))
709
710     target.cleanup()
711
712 elif args.command == 'doxygen':
713     target = SourceTarget()
714     project.checkout(target)
715
716     dirs = project.cscript['make_doxygen'](target)
717     if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
718         dirs = [dirs]
719
720     for d in dirs:
721         copytree(d, '%s/%s' % (args.output, 'doc'))
722
723     target.cleanup()
724
725 elif args.command == 'latest':
726     target = SourceTarget()
727     project.checkout(target)
728
729     f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
730     latest = None
731     while latest is None:
732         t = f.readline()
733         m = re.compile(".*\((.*)\).*").match(t)
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                     v = Version(t[1:])
742                     if args.major is None or v.major == args.major:
743                         latest = v
744
745     print latest
746     target.cleanup()
747
748 elif args.command == 'test':
749     if args.target is None:
750         raise Error('you must specify -t or --target')
751
752     target = None
753     try:
754         target = target_factory(args.target, args.debug, args.work)
755         target.test(project)
756     except Error as e:
757         if target is not None:
758             target.cleanup()
759         raise
760         
761     if target is not None:
762         target.cleanup()
763
764 elif args.command == 'shell':
765     if args.target is None:
766         raise Error('you must specify -t or --target')
767
768     target = target_factory(args.target, args.debug, args.work)
769     target.command('bash')
770
771 else:
772     raise Error('invalid command %s' % args.command)