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