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