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