Untested configuration of parallel jobs count.
[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('LINKFLAGS', '"%s"' % link)
327
328     def command(self, c):
329         log('host -> %s' % c)
330         command('%s %s' % (self.variables_string(), c))
331
332 #
333 # Linux
334 #
335
336 class LinuxTarget(Target):
337     def __init__(self, distro, version, bits, directory=None):
338         super(LinuxTarget, self).__init__('linux', directory)
339         self.distro = distro
340         self.version = version
341         self.bits = bits
342         # e.g. ubuntu-14.04-64
343         self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
344         # e.g. /home/carl/Environments/ubuntu-14.04-64
345         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
346
347         self.set('CXXFLAGS', '-I%s/include' % self.directory)
348         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
349         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:/usr/local/lib/pkgconfig' % self.directory)
350         self.set('PATH', '%s:/usr/local/bin' % (os.environ['PATH']))
351
352     def command(self, c):
353         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
354
355 #
356 # OS X
357 #
358
359 class OSXTarget(Target):
360     def __init__(self, directory=None):
361         super(OSXTarget, self).__init__('osx', directory)
362
363     def command(self, c):
364         command('%s %s' % (self.variables_string(False), c))
365
366
367 class OSXSingleTarget(OSXTarget):
368     def __init__(self, bits, directory=None):
369         super(OSXSingleTarget, self).__init__(directory)
370         self.bits = bits
371
372         if bits == 32:
373             arch = 'i386'
374         else:
375             arch = 'x86_64'
376
377         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (config.get('osx_sdk_prefix'), config.get('osx_sdk'), arch)
378         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
379
380         # Environment variables
381         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
382         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
383         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
384         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
385         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
386         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
387         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
388
389     def package(self, project):
390         raise Error('cannot package non-universal OS X versions')
391
392
393 class OSXUniversalTarget(OSXTarget):
394     def __init__(self, directory=None):
395         super(OSXUniversalTarget, self).__init__(directory)
396         self.parts = []
397         self.parts.append(OSXSingleTarget(32, os.path.join(self.directory, '32')))
398         self.parts.append(OSXSingleTarget(64, os.path.join(self.directory, '64')))
399
400     def package(self, project):
401         for p in self.parts:
402             project.checkout(p)
403             p.build_dependencies(project)
404             p.build(project)
405
406         return project.cscript['package'](self, project.version)
407     
408
409 #
410 # Source
411 #
412
413 class SourceTarget(Target):
414     def __init__(self):
415         super(SourceTarget, self).__init__('source')
416
417     def command(self, c):
418         log('host -> %s' % c)
419         command('%s %s' % (self.variables_string(), c))
420
421     def cleanup(self):
422         rmtree(self.directory)
423
424     def package(self, project):
425         project.checkout(self)
426         name = read_wscript_variable(os.getcwd(), 'APPNAME')
427         command('./waf dist')
428         return os.path.abspath('%s-%s.tar.bz2' % (name, project.version))
429
430
431 # @param s Target string:
432 #       windows-{32,64}
433 #    or ubuntu-version-{32,64}
434 #    or debian-version-{32,64}
435 #    or centos-version-{32,64}
436 #    or osx-{32,64}
437 #    or source      
438 # @param debug True to build with debugging symbols (where possible)
439 def target_factory(s, debug, work):
440     target = None
441     if s.startswith('windows-'):
442         target = WindowsTarget(int(s.split('-')[1]), work)
443     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
444         p = s.split('-')
445         if len(p) != 3:
446             print >>sys.stderr,"Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s
447             sys.exit(1)
448         target = LinuxTarget(p[0], p[1], int(p[2]), work)
449     elif s.startswith('osx-'):
450         target = OSXSingleTarget(int(s.split('-')[1]), work)
451     elif s == 'osx':
452         if args.command == 'build':
453             target = OSXSingleTarget(64, work)
454         else:
455             target = OSXUniversalTarget(work)
456     elif s == 'source':
457         target = SourceTarget()
458
459     if target is not None:
460         target.debug = debug
461
462     return target
463
464
465 #
466 # Project
467 #
468  
469 class Project(object):
470     def __init__(self, name, directory, specifier=None):
471         self.name = name
472         self.directory = directory
473         self.version = None
474         self.specifier = specifier
475         self.git_commit = None
476         if self.specifier is None:
477             self.specifier = 'master'
478
479     def checkout(self, target):
480         flags = ''
481         redirect = ''
482         if args.quiet:
483             flags = '-q'
484             redirect = '>/dev/null'
485         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
486         os.chdir('%s/src/%s' % (target.directory, self.name))
487         command('git checkout %s %s %s' % (flags, self.specifier, redirect))
488         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
489         command('git submodule init --quiet')
490         command('git submodule update --quiet')
491         os.chdir(self.directory)
492
493         proj = '%s/src/%s/%s' % (target.directory, self.name, self.directory)
494
495         self.read_cscript('%s/cscript' % proj)
496         
497         if os.path.exists('%s/wscript' % proj):
498             v = read_wscript_variable(proj, "VERSION");
499             if v is not None:
500                 self.version = Version(v)
501
502     def read_cscript(self, s):
503         self.cscript = {}
504         execfile(s, self.cscript)
505
506 def set_version_in_wscript(version):
507     f = open('wscript', 'rw')
508     o = open('wscript.tmp', 'w')
509     while 1:
510         l = f.readline()
511         if l == '':
512             break
513
514         s = l.split()
515         if len(s) == 3 and s[0] == "VERSION":
516             print "Writing %s" % version
517             print >>o,"VERSION = '%s'" % version
518         else:
519             print >>o,l,
520     f.close()
521     o.close()
522
523     os.rename('wscript.tmp', 'wscript')
524
525 def append_version_to_changelog(version):
526     try:
527         f = open('ChangeLog', 'r')
528     except:
529         log('Could not open ChangeLog')
530         return
531
532     c = f.read()
533     f.close()
534
535     f = open('ChangeLog', 'w')
536     now = datetime.datetime.now()
537     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))
538     f.write(c)
539
540 def append_version_to_debian_changelog(version):
541     if not os.path.exists('debian'):
542         log('Could not find debian directory')
543         return
544
545     command('dch -b -v %s-1 "New upstream release."' % version)
546
547 def devel_to_git(project, filename):
548     if project.git_commit is not None:
549         filename = filename.replace('devel', '-%s' % project.git_commit)
550     return filename
551
552 #
553 # Command-line parser
554 #
555
556 parser = argparse.ArgumentParser()
557 parser.add_argument('command')
558 parser.add_argument('-p', '--project', help='project name')
559 parser.add_argument('-d', '--directory', help='directory within project repo', default='.')
560 parser.add_argument('--minor', help='minor version number bump', action='store_true')
561 parser.add_argument('--micro', help='micro version number bump', action='store_true')
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     t = f.readline()
729     m = re.compile(".*\((.*)\).*").match(t)
730     latest = None
731     if m:
732         tags = m.group(1).split(', ')
733         for t in tags:
734             s = t.split()
735             if len(s) > 1:
736                 t = s[1]
737             if len(t) > 0 and t[0] == 'v':
738                 latest = t[1:]
739
740     print latest
741     target.cleanup()
742
743 elif args.command == 'test':
744     if args.target is None:
745         raise Error('you must specify -t or --target')
746
747     target = None
748     try:
749         target = target_factory(args.target, args.debug, args.work)
750         target.test(project)
751     except Error as e:
752         if target is not None:
753             target.cleanup()
754         raise
755         
756     if target is not None:
757         target.cleanup()
758
759 elif args.command == 'shell':
760     if args.target is None:
761         raise Error('you must specify -t or --target')
762
763     target = target_factory(args.target, args.debug, args.work)
764     target.command('bash')
765
766 else:
767     raise Error('invalid command %s' % args.command)