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