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