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