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