More dry-run stuff.
[cdist.git] / cdist
1 #!/usr/bin/python
2
3 #    Copyright (C) 2012-2015 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 from __future__ import print_function
20 import os
21 import sys
22 import shutil
23 import glob
24 import tempfile
25 import argparse
26 import datetime
27 import subprocess
28 import re
29 import copy
30 import inspect
31
32 TEMPORARY_DIRECTORY = '/var/tmp'
33
34 class Error(Exception):
35     def __init__(self, value):
36         self.value = value
37     def __str__(self):
38         return self.value
39     def __repr__(self):
40         return str(self)
41
42 class Trees:
43     """
44     Store for Tree objects which re-uses already-created objects
45     and checks for requests for different versions of the same thing.
46     """
47
48     def __init__(self):
49         self.trees = []
50
51     def get(self, name, specifier, target):
52         for t in self.trees:
53             if t.name == name and t.specifier == specifier and t.target == target:
54                 return t
55             elif t.name == name and t.specifier != specifier:
56                 raise Error('conflicting versions of %s requested (%s and %s)' % (name, specifier, t.specifier))
57
58         nt = Tree(name, specifier, target)
59         self.trees.append(nt)
60         return nt
61
62 class Globals:
63     quiet = False
64     command = None
65     trees = Trees()
66
67 globals = Globals()
68
69
70 #
71 # Configuration
72 #
73
74 class Option(object):
75     def __init__(self, key, default=None):
76         self.key = key
77         self.value = default
78
79     def offer(self, key, value):
80         if key == self.key:
81             self.value = value
82
83 class BoolOption(object):
84     def __init__(self, key):
85         self.key = key
86         self.value = False
87
88     def offer(self, key, value):
89         if key == self.key:
90             self.value = (value == 'yes' or value == '1' or value == 'true')
91
92 class Config:
93     def __init__(self):
94         self.options = [ Option('linux_chroot_prefix'),
95                          Option('windows_environment_prefix'),
96                          Option('mingw_prefix'),
97                          Option('git_prefix'),
98                          Option('osx_build_host'),
99                          Option('osx_environment_prefix'),
100                          Option('osx_sdk_prefix'),
101                          Option('osx_sdk'),
102                          Option('parallel', 4) ]
103
104         try:
105             f = open('%s/.config/cdist' % os.path.expanduser('~'), 'r')
106             while True:
107                 l = f.readline()
108                 if l == '':
109                     break
110
111                 if len(l) > 0 and l[0] == '#':
112                     continue
113
114                 s = l.strip().split()
115                 if len(s) == 2:
116                     for k in self.options:
117                         k.offer(s[0], s[1])
118         except:
119             raise
120
121     def get(self, k):
122         for o in self.options:
123             if o.key == k:
124                 if o.value is None:
125                     raise Error('Required setting %s not found' % k)
126                 return o.value
127
128     def set(self, k, v):
129         for o in self.options:
130             o.offer(k, v)
131
132 config = Config()
133
134 #
135 # Utility bits
136 #
137
138 def log(m):
139     if not globals.quiet:
140         print('\x1b[33m* %s\x1b[0m' % m)
141
142 def scp_escape(n):
143     s = n.split(':')
144     assert(len(s) == 1 or len(s) == 2)
145     if len(s) == 2:
146         return '%s:"\'%s\'"' % (s[0], s[1])
147     else:
148         return '\"%s\"' % s[0]
149
150 def copytree(a, b):
151     log('copy %s -> %s' % (scp_escape(b), scp_escape(b)))
152     command('scp -r %s %s' % (scp_escape(a), scp_escape(b)))
153
154 def copyfile(a, b):
155     log('copy %s -> %s' % (scp_escape(a), scp_escape(b)))
156     command('scp %s %s' % (scp_escape(a), scp_escape(b)))
157
158 def makedirs(d):
159     if d.find(':') == -1:
160         os.makedirs(d)
161     else:
162         s = d.split(':')
163         command('ssh %s -- mkdir -p %s' % (s[0], s[1]))
164
165 def rmdir(a):
166     log('remove %s' % a)
167     os.rmdir(a)
168
169 def rmtree(a):
170     log('remove %s' % a)
171     shutil.rmtree(a, ignore_errors=True)
172
173 def command(c):
174     log(c)
175     r = os.system(c)
176     if (r >> 8):
177         raise Error('command %s failed' % c)
178
179 def command_and_read(c):
180     log(c)
181     p = subprocess.Popen(c.split(), stdout=subprocess.PIPE)
182     f = os.fdopen(os.dup(p.stdout.fileno()))
183     return f
184
185 def read_wscript_variable(directory, variable):
186     f = open('%s/wscript' % directory, 'r')
187     while True:
188         l = f.readline()
189         if l == '':
190             break
191
192         s = l.split()
193         if len(s) == 3 and s[0] == variable:
194             f.close()
195             return s[2][1:-1]
196
197     f.close()
198     return None
199
200 def set_version_in_wscript(version):
201     f = open('wscript', 'rw')
202     o = open('wscript.tmp', 'w')
203     while True:
204         l = f.readline()
205         if l == '':
206             break
207
208         s = l.split()
209         if len(s) == 3 and s[0] == "VERSION":
210             print("Writing %s" % version)
211             print("VERSION = '%s'" % version, file=o)
212         else:
213             print(l, file=o, end="")
214     f.close()
215     o.close()
216
217     os.rename('wscript.tmp', 'wscript')
218
219 def append_version_to_changelog(version):
220     try:
221         f = open('ChangeLog', 'r')
222     except:
223         log('Could not open ChangeLog')
224         return
225
226     c = f.read()
227     f.close()
228
229     f = open('ChangeLog', 'w')
230     now = datetime.datetime.now()
231     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))
232     f.write(c)
233
234 def append_version_to_debian_changelog(version):
235     if not os.path.exists('debian'):
236         log('Could not find debian directory')
237         return
238
239     command('dch -b -v %s-1 "New upstream release."' % version)
240
241 def devel_to_git(git_commit, filename):
242     if git_commit is not None:
243         filename = filename.replace('devel', '-%s' % git_commit)
244     return filename
245
246 class TreeDirectory:
247     def __init__(self, tree):
248         self.tree = tree
249     def __enter__(self):
250         self.cwd = os.getcwd()
251         os.chdir('%s/src/%s' % (self.tree.target.directory, self.tree.name))
252     def __exit__(self, type, value, traceback):
253         os.chdir(self.cwd)
254
255 #
256 # Version
257 #
258
259 class Version:
260     def __init__(self, s):
261         self.devel = False
262
263         if s.startswith("'"):
264             s = s[1:]
265         if s.endswith("'"):
266             s = s[0:-1]
267
268         if s.endswith('devel'):
269             s = s[0:-5]
270             self.devel = True
271
272         if s.endswith('pre'):
273             s = s[0:-3]
274
275         p = s.split('.')
276         self.major = int(p[0])
277         self.minor = int(p[1])
278         if len(p) == 3:
279             self.micro = int(p[2])
280         else:
281             self.micro = 0
282
283     def bump_minor(self):
284         self.minor += 1
285         self.micro = 0
286
287     def bump_micro(self):
288         self.micro += 1
289
290     def to_devel(self):
291         self.devel = True
292
293     def to_release(self):
294         self.devel = False
295
296     def __str__(self):
297         s = '%d.%d.%d' % (self.major, self.minor, self.micro)
298         if self.devel:
299             s += 'devel'
300
301         return s
302
303 #
304 # Targets
305 #
306
307 class Target(object):
308     """
309     Class representing the target that we are building for.  This is exposed to cscripts,
310     though not all of it is guaranteed 'API'.  cscripts may expect:
311
312     platform: platform string (e.g. 'windows', 'linux', 'osx')
313     parallel: number of parallel jobs to run
314     directory: directory to work in
315     variables: dict of environment variables
316     debug: True to build a debug version, otherwise False
317     set(a, b): set the value of variable 'a' to 'b'
318     unset(a): unset the value of variable 'a'
319     command(c): run the command 'c' in the build environment
320
321     """
322
323     def __init__(self, platform, directory=None):
324         """
325         platform -- platform string (e.g. 'windows', 'linux', 'osx')
326         directory -- directory to work in; if None we will use a temporary directory
327         Temporary directories will be removed after use; specified directories will not.
328         """
329         self.platform = platform
330         self.parallel = int(config.get('parallel'))
331
332         # self.directory is the working directory
333         if directory is None:
334             self.directory = tempfile.mkdtemp('', 'tmp', TEMPORARY_DIRECTORY)
335             self.rmdir = True
336         else:
337             self.directory = directory
338             self.rmdir = False
339
340         # Environment variables that we will use when we call cscripts
341         self.variables = {}
342         self.debug = False
343
344     def package(self, project, checkout, dry_run):
345         tree = globals.trees.get(project, checkout, self)
346         tree.build_dependencies(dry_run)
347         tree.build(dry_run)
348         return tree.call('package', tree.version), tree.git_commit
349
350     def test(self, tree, test):
351         """test is the test case to run, or None"""
352         tree.build_dependencies(args.dry_run)
353         tree.build(args.dry_run)
354         return tree.call('test', test)
355
356     def set(self, a, b):
357         self.variables[a] = b
358
359     def unset(self, a):
360         del(self.variables[a])
361
362     def get(self, a):
363         return self.variables[a]
364
365     def append_with_space(self, k, v):
366         if (not k in self.variables) or len(self.variables[k]) == 0:
367             self.variables[k] = '"%s"' % v
368         else:
369             e = self.variables[k]
370             if e[0] == '"' and e[-1] == '"':
371                 self.variables[k] = '"%s %s"' % (e[1:-1], v)
372             else:
373                 self.variables[k] = '"%s %s"' % (e, v)
374
375     def variables_string(self, escaped_quotes=False):
376         e = ''
377         for k, v in self.variables.items():
378             if escaped_quotes:
379                 v = v.replace('"', '\\"')
380             e += '%s=%s ' % (k, v)
381         return e
382
383     def cleanup(self):
384         if self.rmdir:
385             rmtree(self.directory)
386
387
388 class WindowsTarget(Target):
389     """
390     This target exposes the following additional API:
391
392     version: Windows version ('xp' or None)
393     bits: bitness of Windows (32 or 64)
394     environment_prefix: path to Windows environment
395     mingw_path: path to mingw binaries
396     """
397     def __init__(self, version, bits, directory=None):
398         super(WindowsTarget, self).__init__('windows', directory)
399         self.version = version
400         self.bits = bits
401
402         self.environment_prefix = '%s/%d' % (config.get('windows_environment_prefix'), self.bits)
403         if not os.path.exists(self.environment_prefix):
404             raise Error('environment prefix %s does not exist' % self.environment_prefix)
405
406         if self.bits == 32:
407             self.mingw_name = 'i686'
408         else:
409             self.mingw_name = 'x86_64'
410
411         self.mingw_path = '%s/%d/bin' % (config.get('mingw_prefix'), self.bits)
412         self.mingw_prefixes = ['/%s/%d' % (config.get('mingw_prefix'), self.bits), '%s/%d/%s-w64-mingw32' % (config.get('mingw_prefix'), bits, self.mingw_name)]
413
414         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
415         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
416         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.mingw_path, os.environ['PATH']))
417         self.set('CC', '%s-w64-mingw32-gcc' % self.mingw_name)
418         self.set('CXX', '%s-w64-mingw32-g++' % self.mingw_name)
419         self.set('LD', '%s-w64-mingw32-ld' % self.mingw_name)
420         self.set('RANLIB', '%s-w64-mingw32-ranlib' % self.mingw_name)
421         self.set('WINRC', '%s-w64-mingw32-windres' % self.mingw_name)
422         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
423         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
424         for p in self.mingw_prefixes:
425             cxx += ' -I%s/include' % p
426             link += ' -L%s/lib' % p
427         self.set('CXXFLAGS', '"%s"' % cxx)
428         self.set('CPPFLAGS', '')
429         self.set('LINKFLAGS', '"%s"' % link)
430         self.set('LDFLAGS', '"%s"' % link)
431
432         # This is for backwards-compatibility
433         self.windows_prefix = self.environment_prefix
434
435     def command(self, c):
436         log('host -> %s' % c)
437         command('%s %s' % (self.variables_string(), c))
438
439 class LinuxTarget(Target):
440     """
441     Parent for Linux targets.
442     This target exposes the following additional API:
443
444     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
445     version: distribution version (e.g. '12.04', '8', '6.5')
446     bits: bitness of the distribution (32 or 64)
447     """
448
449     def __init__(self, distro, version, bits, directory=None):
450         super(LinuxTarget, self).__init__('linux', directory)
451         self.distro = distro
452         self.version = version
453         self.bits = bits
454
455         self.set('CXXFLAGS', '-I%s/include' % self.directory)
456         self.set('CPPFLAGS', '')
457         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
458         self.set('PKG_CONFIG_PATH',
459                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
460         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
461
462 class ChrootTarget(LinuxTarget):
463     """Build in a chroot"""
464     def __init__(self, distro, version, bits, directory=None):
465         super(ChrootTarget, self).__init__(distro, version, bits, directory)
466         # e.g. ubuntu-14.04-64
467         if self.version is not None and self.bits is not None:
468             self.chroot = '%s-%s-%d' % (self.distro, self.version, self.bits)
469         else:
470             self.chroot = self.distro
471         # e.g. /home/carl/Environments/ubuntu-14.04-64
472         self.chroot_prefix = '%s/%s' % (config.get('linux_chroot_prefix'), self.chroot)
473
474     def command(self, c):
475         command('%s schroot -c %s -p -- %s' % (self.variables_string(), self.chroot, c))
476
477
478 class HostTarget(LinuxTarget):
479     """Build directly on the host"""
480     def __init__(self, distro, version, bits, directory=None):
481         super(HostTarget, self).__init__(distro, version, bits, directory)
482
483     def command(self, c):
484         command('%s %s' % (self.variables_string(), c))
485
486
487 class DockerTarget(Target):
488     """
489     Build a Docker image.
490
491     This target exposes the following additional API:
492
493     deb: path to Debian 8 .deb
494     """
495     def __init__(self, directory=None):
496         super(DockerTarget, self).__init__('docker', directory)
497         self.debian = ChrootTarget('debian', '8', 64, directory)
498
499     def command(self, c):
500         log('host -> %s' % c)
501         command('%s %s' % (self.variables_string(), c))
502
503     def package(self, project, checkout, dry_run):
504         self.deb = self.debian.package(project, checkout, dry_run)
505         return globals.trees.get(project, checkout, self).call('package', tree.version), tree.git_commit
506
507
508 class OSXTarget(Target):
509     def __init__(self, directory=None):
510         super(OSXTarget, self).__init__('osx', directory)
511         self.sdk = config.get('osx_sdk')
512         self.sdk_prefix = config.get('osx_sdk_prefix')
513         self.environment_prefix = config.get('osx_environment_prefix')
514
515     def command(self, c):
516         command('%s %s' % (self.variables_string(False), c))
517
518
519 class OSXSingleTarget(OSXTarget):
520     def __init__(self, bits, directory=None):
521         super(OSXSingleTarget, self).__init__(directory)
522         self.bits = bits
523
524         if bits == 32:
525             arch = 'i386'
526         else:
527             arch = 'x86_64'
528
529         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
530         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
531
532         # Environment variables
533         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
534         self.set('CPPFLAGS', '')
535         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
536         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
537         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
538         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
539         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
540         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
541
542     def package(self, project, checkout, dry_run):
543         raise Error('cannot package non-universal OS X versions')
544
545
546 class OSXUniversalTarget(OSXTarget):
547     def __init__(self, directory=None):
548         super(OSXUniversalTarget, self).__init__(directory)
549
550     def package(self, project, checkout, dry_run):
551
552         for b in [32, 64]:
553             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
554             tree = globals.trees.get(project, checkout, target)
555             tree.build_dependencies(dry_run)
556             tree.build(dry_run)
557
558         tree = globals.trees.get(project, checkout, self)
559         with TreeDirectory(tree):
560             return tree.call('package', tree.version), tree.git_commit
561
562 class SourceTarget(Target):
563     """Build a source .tar.bz2"""
564     def __init__(self):
565         super(SourceTarget, self).__init__('source')
566
567     def command(self, c):
568         log('host -> %s' % c)
569         command('%s %s' % (self.variables_string(), c))
570
571     def cleanup(self):
572         rmtree(self.directory)
573
574     def package(self, project, checkout, dry_run):
575         tree = globals.trees.get(project, checkout, self)
576         with TreeDirectory(tree):
577             name = read_wscript_variable(os.getcwd(), 'APPNAME')
578             command('./waf dist')
579             return os.path.abspath('%s-%s.tar.bz2' % (name, tree.version)), tree.git_commit
580
581
582 # @param s Target string:
583 #       windows-{32,64}
584 #    or ubuntu-version-{32,64}
585 #    or debian-version-{32,64}
586 #    or centos-version-{32,64}
587 #    or osx-{32,64}
588 #    or source
589 # @param debug True to build with debugging symbols (where possible)
590 def target_factory(s, debug, work):
591     target = None
592     if s.startswith('windows-'):
593         x = s.split('-')
594         if len(x) == 2:
595             target = WindowsTarget(None, int(x[1]), work)
596         elif len(x) == 3:
597             target = WindowsTarget(x[1], int(x[2]), work)
598         else:
599             raise Error("Bad Windows target name `%s'")
600     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-'):
601         p = s.split('-')
602         if len(p) != 3:
603             raise Error("Bad Linux target name `%s'; must be something like ubuntu-12.04-32 (i.e. distro-version-bits)" % s)
604         target = ChrootTarget(p[0], p[1], int(p[2]), work)
605     elif s.startswith('arch-'):
606         p = s.split('-')
607         if len(p) != 2:
608             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
609         target = ChrootTarget(p[0], None, p[1], work)
610     elif s == 'raspbian':
611         target = ChrootTarget(s, None, None, work)
612     elif s == 'host':
613         if command_and_read('uname -m').read().strip() == 'x86_64':
614             bits = 64
615         else:
616             bits = 32
617         try:
618             f = open('/etc/fedora-release', 'r')
619             l = f.readline().strip().split()
620             target = HostTarget("fedora", l[2], bits, work)
621         except Exception as e:
622             if os.path.exists('/etc/arch-release'):
623                 target = HostTarget("arch", None, bits, work)
624             else:
625                 raise Error("could not identify distribution for `host' target (%s)" % e)
626     elif s.startswith('osx-'):
627         target = OSXSingleTarget(int(s.split('-')[1]), work)
628     elif s == 'osx':
629         if globals.command == 'build':
630             target = OSXSingleTarget(64, work)
631         else:
632             target = OSXUniversalTarget(work)
633     elif s == 'source':
634         target = SourceTarget()
635     elif s == 'docker':
636         target = DockerTarget()
637
638     if target is None:
639         raise Error("Bad target `%s'" % s)
640
641     target.debug = debug
642     return target
643
644
645 #
646 # Tree
647 #
648
649 class Tree(object):
650     """Description of a tree, which is a checkout of a project,
651        possibly built.  This class is never exposed to cscripts.
652        Attributes:
653            name -- name of git repository (without the .git)
654            specifier -- git tag or revision to use
655            target --- target object that we are using
656            version --- version from the wscript (if one is present)
657            git_commit -- git revision that is actually being used
658            built --- true if the tree has been built yet in this run
659     """
660
661     def __init__(self, name, specifier, target):
662         self.name = name
663         self.specifier = specifier
664         self.target = target
665         self.version = None
666         self.git_commit = None
667         self.built = False
668
669         cwd = os.getcwd()
670
671         flags = ''
672         redirect = ''
673         if globals.quiet:
674             flags = '-q'
675             redirect = '>/dev/null'
676         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
677         os.chdir('%s/src/%s' % (target.directory, self.name))
678
679         spec = self.specifier
680         if spec is None:
681             spec = 'master'
682
683         command('git checkout %s %s %s' % (flags, spec, redirect))
684         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
685         command('git submodule init --quiet')
686         command('git submodule update --quiet')
687
688         proj = '%s/src/%s' % (target.directory, self.name)
689
690         self.cscript = {}
691         exec(open('%s/cscript' % proj).read(), self.cscript)
692
693         if os.path.exists('%s/wscript' % proj):
694             v = read_wscript_variable(proj, "VERSION");
695             if v is not None:
696                 self.version = Version(v)
697
698         os.chdir(cwd)
699
700     def call(self, function, *args):
701         with TreeDirectory(self):
702             return self.cscript[function](self.target, *args)
703
704     def build_dependencies(self, dry_run, options=None):
705         if 'dependencies' in self.cscript:
706             if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
707                 deps = self.call('dependencies', options)
708             else:
709                 deps = self.call('dependencies')
710
711             for d in deps:
712                 log('Building dependency %s %s of %s' % (d[0], d[1], self.name))
713                 dep = globals.trees.get(d[0], d[1], self.target)
714
715                 # Make the options to pass in from the option_defaults of the thing
716                 # we are building and any options specified by the parent.
717
718                 if 'option_defaults' in dep.cscript:
719                     for k, v in dep.cscript['option_defaults'].items():
720                         options[k] = v
721
722                 if len(d) > 2:
723                     for k, v in d[2].items():
724                         options[k] = v
725
726                 dep.build_dependencies(dry_run, options)
727                 dep.build(dry_run, options)
728
729     def build(self, dry_run, options=None):
730         if self.built:
731             return
732
733         variables = copy.copy(self.target.variables)
734
735         if not dry_run:
736             if len(inspect.getargspec(self.cscript['build']).args) == 2:
737                 self.call('build', options)
738             else:
739                 self.call('build')
740
741         self.target.variables = variables
742         self.built = True
743
744 #
745 # Command-line parser
746 #
747
748 def main():
749
750     commands = {
751         "build": "build project",
752         "package": "package and build project",
753         "release": "release a project using its next version number (changing wscript and tagging)",
754         "pot": "build the project's .pot files",
755         "changelog": "generate a simple HTML changelog",
756         "manual": "build the project's manual",
757         "doxygen": "build the project's Doxygen documentation",
758         "latest": "print out the latest version",
759         "test": "run the project's unit tests",
760         "shell": "build the project then start a shell in its chroot",
761         "checkout": "check out the project",
762         "revision": "print the head git revision number"
763     }
764
765     one_of = "Command is one of:\n"
766     summary = ""
767     for k, v in commands.items():
768         one_of += "\t%s\t%s\n" % (k, v)
769         summary += k + " "
770
771     parser = argparse.ArgumentParser()
772     parser.add_argument('command', help=summary)
773     parser.add_argument('-p', '--project', help='project name')
774     parser.add_argument('--minor', help='minor version number bump', action='store_true')
775     parser.add_argument('--micro', help='micro version number bump', action='store_true')
776     parser.add_argument('--major', help='major version to return with latest', type=int)
777     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
778     parser.add_argument('-o', '--output', help='output directory', default='.')
779     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
780     parser.add_argument('-t', '--target', help='target')
781     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
782     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
783     parser.add_argument('-w', '--work', help='override default work directory')
784     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
785     parser.add_argument('--test', help='name of test to run (with `test''), defaults to all')
786     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
787     args = parser.parse_args()
788
789     # Override configured stuff
790     if args.git_prefix is not None:
791         config.set('git_prefix', args.git_prefix)
792
793     if args.output.find(':') == -1:
794         # This isn't of the form host:path so make it absolute
795         args.output = os.path.abspath(args.output) + '/'
796     else:
797         if args.output[-1] != ':' and args.output[-1] != '/':
798             args.output += '/'
799
800     # Now, args.output is 'host:', 'host:path/' or 'path/'
801
802     if args.work is not None:
803         args.work = os.path.abspath(args.work)
804
805     if args.project is None and args.command != 'shell':
806         raise Error('you must specify -p or --project')
807
808     globals.quiet = args.quiet
809     globals.command = args.command
810
811     if not globals.command in commands:
812         e = 'command must be one of:\n' + one_of
813         raise Error('command must be one of:\n%s' % one_of)
814
815     if globals.command == 'build':
816         if args.target is None:
817             raise Error('you must specify -t or --target')
818
819         target = target_factory(args.target, args.debug, args.work)
820         tree = globals.trees.get(args.project, args.checkout, target)
821         tree.build_dependencies(dry_run)
822         tree.build(dry_run)
823         if not args.keep:
824             target.cleanup()
825
826     elif globals.command == 'package':
827         if args.target is None:
828             raise Error('you must specify -t or --target')
829
830         target = target_factory(args.target, args.debug, args.work)
831         packages, git_commit = target.package(args.project, args.checkout, args.dry_run)
832         if hasattr(packages, 'strip') or (not hasattr(packages, '__getitem__') and not hasattr(packages, '__iter__')):
833             packages = [packages]
834
835         if target.platform == 'linux':
836             out = '%s%s-%s-%d' % (args.output, target.distro, target.version, target.bits)
837             try:
838                 makedirs(out)
839             except:
840                 pass
841             for p in packages:
842                 copyfile(p, '%s/%s' % (out, os.path.basename(devel_to_git(git_commit, p))))
843         else:
844             try:
845                 makedirs(args.output)
846             except:
847                 pass
848             for p in packages:
849                 copyfile(p, '%s%s' % (args.output, os.path.basename(devel_to_git(git_commit, p))))
850
851         if not args.keep:
852             target.cleanup()
853
854     elif globals.command == 'release':
855         if args.minor is False and args.micro is False:
856             raise Error('you must specify --minor or --micro')
857
858         target = SourceTarget()
859         tree = globals.trees.get(args.project, args.checkout, target)
860
861         version = tree.version
862         version.to_release()
863         if args.minor:
864             version.bump_minor()
865         else:
866             version.bump_micro()
867
868         set_version_in_wscript(version)
869         append_version_to_changelog(version)
870         append_version_to_debian_changelog(version)
871
872         command('git commit -a -m "Bump version"')
873         command('git tag -m "v%s" v%s' % (version, version))
874
875         version.to_devel()
876         set_version_in_wscript(version)
877         command('git commit -a -m "Bump version"')
878         command('git push')
879         command('git push --tags')
880
881         target.cleanup()
882
883     elif globals.command == 'pot':
884         target = SourceTarget()
885         tree = globals.trees.get(args.project, args.checkout, target)
886
887         pots = tree.call('make_pot')
888         for p in pots:
889             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
890
891         target.cleanup()
892
893     elif globals.command == 'changelog':
894         target = SourceTarget()
895         tree = globals.trees.get(args.project, args.checkout, target)
896
897         with TreeDirectory(tree):
898             text = open('ChangeLog', 'r')
899
900         html = tempfile.NamedTemporaryFile()
901         versions = 8
902
903         last = None
904         changes = []
905
906         while True:
907             l = text.readline()
908             if l == '':
909                 break
910
911             if len(l) > 0 and l[0] == "\t":
912                 s = l.split()
913                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
914                     v = Version(s[2])
915                     if v.micro == 0:
916                         if last is not None and len(changes) > 0:
917                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
918                             print("<ul>", file=html)
919                             for c in changes:
920                                 print("<li>%s" % c, file=html)
921                             print("</ul>", file=html)
922                         last = s[2]
923                         changes = []
924                         versions -= 1
925                         if versions < 0:
926                             break
927                 else:
928                     c = l.strip()
929                     if len(c) > 0:
930                         if c[0] == '*':
931                             changes.append(c[2:])
932                         else:
933                             changes[-1] += " " + c
934
935         copyfile(html.file, '%schangelog.html' % args.output)
936         html.close()
937         target.cleanup()
938
939     elif globals.command == 'manual':
940         target = SourceTarget()
941         tree = globals.trees.get(args.project, args.checkout, target)
942
943         outs = tree.call('make_manual')
944         for o in outs:
945             if os.path.isfile(o):
946                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
947             else:
948                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
949
950         target.cleanup()
951
952     elif globals.command == 'doxygen':
953         target = SourceTarget()
954         tree = globals.trees.get(args.project, args.checkout, target)
955
956         dirs = tree.call('make_doxygen')
957         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
958             dirs = [dirs]
959
960         for d in dirs:
961             copytree(d, args.output)
962
963         target.cleanup()
964
965     elif globals.command == 'latest':
966         target = SourceTarget()
967         tree = globals.trees.get(args.project, args.checkout, target)
968
969         with TreeDirectory(tree):
970             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
971             latest = None
972             while latest is None:
973                 t = f.readline()
974                 m = re.compile(".*\((.*)\).*").match(t)
975                 if m:
976                     tags = m.group(1).split(', ')
977                     for t in tags:
978                         s = t.split()
979                         if len(s) > 1:
980                             t = s[1]
981                         if len(t) > 0 and t[0] == 'v':
982                             v = Version(t[1:])
983                             if args.major is None or v.major == args.major:
984                                 latest = v
985
986         print(latest)
987         target.cleanup()
988
989     elif globals.command == 'test':
990         if args.target is None:
991             raise Error('you must specify -t or --target')
992
993         target = None
994         try:
995             target = target_factory(args.target, args.debug, args.work)
996             tree = globals.trees.get(args.project, args.checkout, target)
997             with TreeDirectory(tree):
998                 target.test(tree, args.test)
999         except Error as e:
1000             if target is not None:
1001                 target.cleanup()
1002             raise
1003
1004         if target is not None:
1005             target.cleanup()
1006
1007     elif globals.command == 'shell':
1008         if args.target is None:
1009             raise Error('you must specify -t or --target')
1010
1011         target = target_factory(args.target, args.debug, args.work)
1012         target.command('bash')
1013
1014     elif globals.command == 'revision':
1015
1016         target = SourceTarget()
1017         tree = globals.trees.get(args.project, args.checkout, target)
1018         with TreeDirectory(tree):
1019             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1020         target.cleanup()
1021
1022     elif globals.command == 'checkout':
1023
1024         if args.output is None:
1025             raise Error('you must specify -o or --output')
1026
1027         target = SourceTarget()
1028         tree = globals.trees.get(args.project, args.checkout, target)
1029         with TreeDirectory(tree):
1030             shutil.copytree('.', args.output)
1031         target.cleanup()
1032
1033     else:
1034         raise Error('invalid command %s' % globals.command)
1035
1036 try:
1037     main()
1038 except Error as e:
1039     print('cdist: %s' % str(e), file=sys.stderr)
1040     sys.exit(1)