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