01e016cacff8ce80d917b5e6ec32c084834e1b8d
[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         return tree.call('test', test)
484
485     def set(self, a, b):
486         self.variables[a] = b
487
488     def unset(self, a):
489         del(self.variables[a])
490
491     def get(self, a):
492         return self.variables[a]
493
494     def append(self, k, v, s):
495         if (not k in self.variables) or len(self.variables[k]) == 0:
496             self.variables[k] = '"%s"' % v
497         else:
498             e = self.variables[k]
499             if e[0] == '"' and e[-1] == '"':
500                 self.variables[k] = '"%s%s%s"' % (e[1:-1], s, v)
501             else:
502                 self.variables[k] = '"%s%s%s"' % (e, s, v)
503
504     def append_with_space(self, k, v):
505         return self.append(k, v, ' ')
506
507     def append_with_colon(self, k, v):
508         return self.append(k, v, ':')
509
510     def variables_string(self, escaped_quotes=False):
511         e = ''
512         for k, v in self.variables.items():
513             if escaped_quotes:
514                 v = v.replace('"', '\\"')
515             e += '%s=%s ' % (k, v)
516         return e
517
518     def cleanup(self):
519         if self.rmdir:
520             rmtree(self.directory)
521
522     def mount(self, m):
523         pass
524
525     @property
526     def ccache(self):
527         return self._ccache
528
529     @ccache.setter
530     def ccache(self, v):
531         self._ccache = v
532
533
534 class DockerTarget(Target):
535     def __init__(self, platform, directory):
536         super(DockerTarget, self).__init__(platform, directory)
537         self.mounts = []
538         self.privileged = False
539
540     def _user_tag(self):
541         if config.get('docker_no_user'):
542             return ''
543         return '-u %s' % getpass.getuser()
544
545     def _mount_option(self, d):
546         return '-v %s:%s ' % (os.path.realpath(d), os.path.realpath(d))
547
548     def setup(self):
549         opts = self._mount_option(self.directory)
550         for m in self.mounts:
551             opts += self._mount_option(m)
552         if config.has('git_reference'):
553             opts += self._mount_option(config.get('git_reference'))
554         if self.privileged:
555             opts += '--privileged=true '
556         if self.ccache:
557             opts += "-e CCACHE_DIR=/ccache/%s-%d --mount source=ccache,target=/ccache" % (self.image, os.getuid())
558
559         tag = self.image
560         if config.has('docker_hub_repository'):
561             tag = '%s:%s' % (config.get('docker_hub_repository'), tag)
562
563         self.container = command_and_read('%s run %s %s -itd %s /bin/bash' % (config.docker(), self._user_tag(), opts, tag))[0].strip()
564
565     def command(self, cmd):
566         dir = os.path.join(self.directory, os.path.relpath(os.getcwd(), self.directory))
567         interactive_flag = '-i ' if sys.stdin.isatty() else ''
568         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))
569
570     def cleanup(self):
571         super(DockerTarget, self).cleanup()
572         command('%s kill %s' % (config.docker(), self.container))
573
574     def mount(self, m):
575         self.mounts.append(m)
576
577
578 class FlatpakTarget(Target):
579     def __init__(self, project, checkout):
580         super(FlatpakTarget, self).__init__('flatpak')
581         self.build_dependencies = False
582         self.project = project
583         self.checkout = checkout
584
585     def setup(self):
586         pass
587
588     def command(self, cmd):
589         command(cmd)
590
591     def checkout_dependencies(self):
592         tree = globals.trees.get(self.project, self.checkout, self)
593         return tree.checkout_dependencies()
594
595     def flatpak(self):
596         return 'flatpak'
597
598     def flatpak_builder(self):
599         b = 'flatpak-builder'
600         if config.has('flatpak_state_dir'):
601             b += ' --state-dir=%s' % config.get('flatpak_state_dir')
602         return b
603
604
605 class WindowsTarget(DockerTarget):
606     """
607     This target exposes the following additional API:
608
609     version: Windows version ('xp' or None)
610     bits: bitness of Windows (32 or 64)
611     name: name of our target e.g. x86_64-w64-mingw32.shared
612     environment_prefix: path to Windows environment for the appropriate target (libraries and some tools)
613     tool_path: path to 32- and 64-bit tools
614     """
615     def __init__(self, windows_version, bits, directory, environment_version):
616         super(WindowsTarget, self).__init__('windows', directory)
617         self.version = windows_version
618         self.bits = bits
619
620         self.tool_path = '%s/usr/bin' % config.get('mxe_prefix')
621         if self.bits == 32:
622             self.name = 'i686-w64-mingw32.shared'
623         else:
624             self.name = 'x86_64-w64-mingw32.shared'
625         self.environment_prefix = '%s/usr/%s' % (config.get('mxe_prefix'), self.name)
626
627         self.set('PKG_CONFIG_LIBDIR', '%s/lib/pkgconfig' % self.environment_prefix)
628         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/bin/pkgconfig' % (self.directory, self.directory))
629         self.set('PATH', '%s/bin:%s:%s' % (self.environment_prefix, self.tool_path, os.environ['PATH']))
630         self.set('LD', '%s-ld' % self.name)
631         self.set('RANLIB', '%s-ranlib' % self.name)
632         self.set('WINRC', '%s-windres' % self.name)
633         cxx = '-I%s/include -I%s/include' % (self.environment_prefix, self.directory)
634         link = '-L%s/lib -L%s/lib' % (self.environment_prefix, self.directory)
635         self.set('CXXFLAGS', '"%s"' % cxx)
636         self.set('CPPFLAGS', '')
637         self.set('LINKFLAGS', '"%s"' % link)
638         self.set('LDFLAGS', '"%s"' % link)
639
640         self.image = 'windows'
641         if environment_version is not None:
642             self.image += '_%s' % environment_version
643
644     def setup(self):
645         super().setup()
646         if self.ccache:
647             self.set('CC', '"ccache %s-gcc"' % self.name)
648             self.set('CXX', '"ccache %s-g++"' % self.name)
649         else:
650             self.set('CC', '%s-gcc' % self.name)
651             self.set('CXX', '%s-g++' % self.name)
652
653     @property
654     def library_prefix(self):
655         log_normal('Deprecated property library_prefix: use environment_prefix')
656         return self.environment_prefix
657
658     @property
659     def windows_prefix(self):
660         log_normal('Deprecated property windows_prefix: use environment_prefix')
661         return self.environment_prefix
662
663     @property
664     def mingw_prefixes(self):
665         log_normal('Deprecated property mingw_prefixes: use environment_prefix')
666         return [self.environment_prefix]
667
668     @property
669     def mingw_path(self):
670         log_normal('Deprecated property mingw_path: use tool_path')
671         return self.tool_path
672
673     @property
674     def mingw_name(self):
675         log_normal('Deprecated property mingw_name: use name')
676         return self.name
677
678
679 class LinuxTarget(DockerTarget):
680     """
681     Build for Linux in a docker container.
682     This target exposes the following additional API:
683
684     distro: distribution ('debian', 'ubuntu', 'centos' or 'fedora')
685     version: distribution version (e.g. '12.04', '8', '6.5')
686     bits: bitness of the distribution (32 or 64)
687     detail: None or 'appimage' if we are building for appimage
688     """
689
690     def __init__(self, distro, version, bits, directory=None):
691         super(LinuxTarget, self).__init__('linux', directory)
692         self.distro = distro
693         self.version = version
694         self.bits = bits
695         self.detail = None
696
697         self.set('CXXFLAGS', '-I%s/include' % self.directory)
698         self.set('CPPFLAGS', '')
699         self.set('LINKFLAGS', '-L%s/lib' % self.directory)
700         self.set('PKG_CONFIG_PATH',
701                  '%s/lib/pkgconfig:%s/lib64/pkgconfig:/usr/local/lib64/pkgconfig:/usr/local/lib/pkgconfig' % (self.directory, self.directory))
702         self.set('PATH', '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin')
703
704         if self.version is None:
705             self.image = '%s-%s' % (self.distro, self.bits)
706         else:
707             self.image = '%s-%s-%s' % (self.distro, self.version, self.bits)
708
709     def setup(self):
710         super(LinuxTarget, self).setup()
711         if self.ccache:
712             self.set('CC', '"ccache gcc"')
713             self.set('CXX', '"ccache g++"')
714
715     def test(self, tree, test, options):
716         self.append_with_colon('PATH', '%s/bin' % self.directory)
717         self.append_with_colon('LD_LIBRARY_PATH', '%s/lib' % self.directory)
718         super(LinuxTarget, self).test(tree, test, options)
719
720
721 class AppImageTarget(LinuxTarget):
722     def __init__(self, work):
723         super(AppImageTarget, self).__init__('ubuntu', '18.04', 64, work)
724         self.detail = 'appimage'
725         self.privileged = True
726
727
728 class OSXTarget(Target):
729     def __init__(self, directory=None):
730         super(OSXTarget, self).__init__('osx', directory)
731         self.sdk = config.get('osx_sdk')
732         self.sdk_prefix = config.get('osx_sdk_prefix')
733         self.environment_prefix = config.get('osx_environment_prefix')
734         self.apple_id = config.get('apple_id')
735         self.apple_password = config.get('apple_password')
736
737     def command(self, c):
738         command('%s %s' % (self.variables_string(False), c))
739
740
741 class OSXSingleTarget(OSXTarget):
742     def __init__(self, bits, directory=None):
743         super(OSXSingleTarget, self).__init__(directory)
744         self.bits = bits
745
746         if bits == 32:
747             arch = 'i386'
748         else:
749             arch = 'x86_64'
750
751         flags = '-isysroot %s/MacOSX%s.sdk -arch %s' % (self.sdk_prefix, self.sdk, arch)
752         enviro = '%s/%d' % (config.get('osx_environment_prefix'), bits)
753
754         # Environment variables
755         self.set('CFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
756         self.set('CPPFLAGS', '')
757         self.set('CXXFLAGS', '"-I%s/include -I%s/include %s"' % (self.directory, enviro, flags))
758         self.set('LDFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
759         self.set('LINKFLAGS', '"-L%s/lib -L%s/lib %s"' % (self.directory, enviro, flags))
760         self.set('PKG_CONFIG_PATH', '%s/lib/pkgconfig:%s/lib/pkgconfig:/usr/lib/pkgconfig' % (self.directory, enviro))
761         self.set('PATH', '$PATH:/usr/bin:/sbin:/usr/local/bin:%s/bin' % enviro)
762         self.set('MACOSX_DEPLOYMENT_TARGET', config.get('osx_sdk'))
763         self.set('CCACHE_BASEDIR', self.directory)
764
765     @Target.ccache.setter
766     def ccache(self, v):
767         Target.ccache.fset(self, v)
768         if v:
769             self.set('CC', '"ccache gcc"')
770             self.set('CXX', '"ccache g++"')
771
772
773 class OSXUniversalTarget(OSXTarget):
774     def __init__(self, directory=None):
775         super(OSXUniversalTarget, self).__init__(directory)
776         self.bits = None
777
778     def package(self, project, checkout, output_dir, options):
779
780         for b in [32, 64]:
781             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
782             target.ccache = self.ccache
783             tree = globals.trees.get(project, checkout, target)
784             tree.build_dependencies(options)
785             tree.build(options)
786
787         tree = globals.trees.get(project, checkout, self)
788         with TreeDirectory(tree):
789             if len(inspect.getfullargspec(tree.cscript['package']).args) == 3:
790                 packages = tree.call('package', tree.version, options)
791             else:
792                 log_normal("Deprecated cscript package() method with no options parameter")
793                 packages = tree.call('package', tree.version)
794             for p in packages:
795                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
796
797 class SourceTarget(Target):
798     """Build a source .tar.bz2"""
799     def __init__(self):
800         super(SourceTarget, self).__init__('source')
801
802     def command(self, c):
803         log_normal('host -> %s' % c)
804         command('%s %s' % (self.variables_string(), c))
805
806     def cleanup(self):
807         rmtree(self.directory)
808
809     def package(self, project, checkout, output_dir, options):
810         tree = globals.trees.get(project, checkout, self)
811         with TreeDirectory(tree):
812             name = read_wscript_variable(os.getcwd(), 'APPNAME')
813             command('./waf dist')
814             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
815             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
816
817 # @param s Target string:
818 #       windows-{32,64}
819 #    or ubuntu-version-{32,64}
820 #    or debian-version-{32,64}
821 #    or centos-version-{32,64}
822 #    or fedora-version-{32,64}
823 #    or mageia-version-{32,64}
824 #    or osx-{32,64}
825 #    or source
826 #    or flatpak
827 #    or appimage
828 # @param debug True to build with debugging symbols (where possible)
829 def target_factory(args):
830     s = args.target
831     target = None
832     if s.startswith('windows-'):
833         x = s.split('-')
834         if len(x) == 2:
835             target = WindowsTarget(None, int(x[1]), args.work, args.environment_version)
836         elif len(x) == 3:
837             target = WindowsTarget(x[1], int(x[2]), args.work, args.environment_version)
838         else:
839             raise Error("Bad Windows target name `%s'")
840     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
841         p = s.split('-')
842         if len(p) != 3:
843             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
844         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
845     elif s.startswith('arch-'):
846         p = s.split('-')
847         if len(p) != 2:
848             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
849         target = LinuxTarget(p[0], None, int(p[1]), args.work)
850     elif s == 'raspbian':
851         target = LinuxTarget(s, None, None, args.work)
852     elif s.startswith('osx-'):
853         target = OSXSingleTarget(int(s.split('-')[1]), args.work)
854     elif s == 'osx':
855         if globals.command == 'build':
856             target = OSXSingleTarget(64, args.work)
857         else:
858             target = OSXUniversalTarget(args.work)
859     elif s == 'source':
860         target = SourceTarget()
861     elif s == 'flatpak':
862         target = FlatpakTarget(args.project, args.checkout)
863     elif s == 'appimage':
864         target = AppImageTarget(args.work)
865
866     if target is None:
867         raise Error("Bad target `%s'" % s)
868
869     target.debug = args.debug
870     target.ccache = args.ccache
871
872     if args.environment is not None:
873         for e in args.environment:
874             target.set(e, os.environ[e])
875
876     if args.mount is not None:
877         for m in args.mount:
878             target.mount(m)
879
880     target.setup()
881     return target
882
883
884 #
885 # Tree
886 #
887
888 class Tree(object):
889     """Description of a tree, which is a checkout of a project,
890        possibly built.  This class is never exposed to cscripts.
891        Attributes:
892            name -- name of git repository (without the .git)
893            specifier -- git tag or revision to use
894            target -- target object that we are using
895            version -- version from the wscript (if one is present)
896            git_commit -- git revision that is actually being used
897            built -- true if the tree has been built yet in this run
898            required_by -- name of the tree that requires this one
899     """
900
901     def __init__(self, name, specifier, target, required_by):
902         self.name = name
903         self.specifier = specifier
904         self.target = target
905         self.version = None
906         self.git_commit = None
907         self.built = False
908         self.required_by = required_by
909
910         cwd = os.getcwd()
911
912         flags = ''
913         redirect = ''
914         if globals.quiet:
915             flags = '-q'
916             redirect = '>/dev/null'
917         if config.has('git_reference'):
918             ref = '--reference-if-able %s/%s.git' % (config.get('git_reference'), self.name)
919         else:
920             ref = ''
921         command('git clone %s %s %s/%s.git %s/src/%s' % (flags, ref, config.get('git_prefix'), self.name, target.directory, self.name))
922         os.chdir('%s/src/%s' % (target.directory, self.name))
923
924         spec = self.specifier
925         if spec is None:
926             spec = 'master'
927
928         command('git checkout %s %s %s' % (flags, spec, redirect))
929         self.git_commit = command_and_read('git rev-parse --short=7 HEAD')[0].strip()
930
931         proj = '%s/src/%s' % (target.directory, self.name)
932
933         self.cscript = {}
934         exec(open('%s/cscript' % proj).read(), self.cscript)
935
936         # cscript can include submodules = False to stop submodules being fetched
937         if (not 'submodules' in self.cscript or self.cscript['submodules'] == True) and os.path.exists('.gitmodules'):
938             command('git submodule --quiet init')
939             paths = command_and_read('git config --file .gitmodules --get-regexp path')
940             urls = command_and_read('git config --file .gitmodules --get-regexp url')
941             for path, url in zip(paths, urls):
942                 path = path.split(' ')[1]
943                 url = url.split(' ')[1]
944             ref = ''
945             if config.has('git_reference'):
946                 ref_path = os.path.join(config.get('git_reference'), os.path.basename(url))
947                 if os.path.exists(ref_path):
948                     ref = '--reference %s' % ref_path
949             command('git submodule --quiet update %s %s' % (ref, path))
950
951         if os.path.exists('%s/wscript' % proj):
952             v = read_wscript_variable(proj, "VERSION");
953             if v is not None:
954                 try:
955                     self.version = Version(v)
956                 except:
957                     tag = command_and_read('git -C %s describe --tags' % proj)[0][1:]
958                     self.version = Version.from_git_tag(tag)
959
960         os.chdir(cwd)
961
962     def call(self, function, *args):
963         with TreeDirectory(self):
964             return self.cscript[function](self.target, *args)
965
966     def add_defaults(self, options):
967         """Add the defaults from self into a dict options"""
968         if 'option_defaults' in self.cscript:
969             from_cscript = self.cscript['option_defaults']
970             if isinstance(from_cscript, dict):
971                 defaults_dict = from_cscript
972             else:
973                 log_normal("Deprecated cscript option_defaults method; replace with a dict")
974                 defaults_dict = from_cscript()
975             for k, v in defaults_dict.items():
976                 if not k in options:
977                     options[k] = v
978
979     def dependencies(self, options):
980         """
981         yield details of the dependencies of this tree.  Each dependency is returned
982         as a tuple of (tree, options).  The 'options' parameter are the options that
983         we want to force for 'self'.
984         """
985         if not 'dependencies' in self.cscript:
986             return
987
988         if len(inspect.getfullargspec(self.cscript['dependencies']).args) == 2:
989             self_options = copy.copy(options)
990             self.add_defaults(self_options)
991             deps = self.call('dependencies', self_options)
992         else:
993             log_normal("Deprecated cscript dependencies() method with no options parameter")
994             deps = self.call('dependencies')
995
996         # Loop over our immediate dependencies
997         for d in deps:
998             dep = globals.trees.get(d[0], d[1], self.target, self.name)
999
1000             # deps only get their options from the parent's cscript
1001             dep_options = d[2] if len(d) > 2 else {}
1002             for i in dep.dependencies(dep_options):
1003                 yield i
1004             yield (dep, dep_options)
1005
1006     def checkout_dependencies(self, options={}):
1007         for i in self.dependencies(options):
1008             pass
1009
1010     def build_dependencies(self, options):
1011         """
1012         Called on the 'main' project tree (-p on the command line) to build all dependencies.
1013         'options' will be the ones from the command line.
1014         """
1015         for i in self.dependencies(options):
1016             i[0].build(i[1])
1017
1018     def build(self, options):
1019         if self.built:
1020             return
1021
1022         log_verbose("Building %s %s %s with %s" % (self.name, self.specifier, self.version, options))
1023
1024         variables = copy.copy(self.target.variables)
1025
1026         options = copy.copy(options)
1027         self.add_defaults(options)
1028
1029         if not globals.dry_run:
1030             if len(inspect.getfullargspec(self.cscript['build']).args) == 2:
1031                 self.call('build', options)
1032             else:
1033                 self.call('build')
1034
1035         self.target.variables = variables
1036         self.built = True
1037
1038 #
1039 # Command-line parser
1040 #
1041
1042 def main():
1043
1044     commands = {
1045         "build": "build project",
1046         "package": "package and build project",
1047         "release": "release a project using its next version number (changing wscript and tagging)",
1048         "pot": "build the project's .pot files",
1049         "manual": "build the project's manual",
1050         "doxygen": "build the project's Doxygen documentation",
1051         "latest": "print out the latest version",
1052         "test": "run the project's unit tests",
1053         "shell": "build the project then start a shell",
1054         "checkout": "check out the project",
1055         "revision": "print the head git revision number"
1056     }
1057
1058     one_of = "Command is one of:\n"
1059     summary = ""
1060     for k, v in commands.items():
1061         one_of += "\t%s\t%s\n" % (k, v)
1062         summary += k + " "
1063
1064     parser = argparse.ArgumentParser()
1065     parser.add_argument('command', help=summary)
1066     parser.add_argument('-p', '--project', help='project name')
1067     parser.add_argument('--minor', help='minor version number bump', action='store_true')
1068     parser.add_argument('--micro', help='micro version number bump', action='store_true')
1069     parser.add_argument('--latest-major', help='major version to return with latest', type=int)
1070     parser.add_argument('--latest-minor', help='minor version to return with latest', type=int)
1071     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1072     parser.add_argument('-o', '--output', help='output directory', default='.')
1073     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1074     parser.add_argument('-t', '--target', help='target', action='append')
1075     parser.add_argument('--environment-version', help='version of environment to use')
1076     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1077     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1078     parser.add_argument('-w', '--work', help='override default work directory')
1079     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1080     parser.add_argument('--test', help="name of test to run (with `test'), defaults to all")
1081     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1082     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1083     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1084     parser.add_argument('--no-version-commit', help="use just tags for versioning, don't modify wscript, ChangeLog etc.", action='store_true')
1085     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1086     parser.add_argument('--ccache', help='use ccache', action='store_true')
1087     parser.add_argument('--verbose', help='be verbose', action='store_true')
1088     global args
1089     args = parser.parse_args()
1090
1091     # Check for incorrect multiple parameters
1092     if args.target is not None:
1093         if len(args.target) > 1:
1094             parser.error('multiple -t options specified')
1095             sys.exit(1)
1096         else:
1097             args.target = args.target[0]
1098
1099     # Override configured stuff
1100     if args.git_prefix is not None:
1101         config.set('git_prefix', args.git_prefix)
1102
1103     if args.output.find(':') == -1:
1104         # This isn't of the form host:path so make it absolute
1105         args.output = os.path.abspath(args.output) + '/'
1106     else:
1107         if args.output[-1] != ':' and args.output[-1] != '/':
1108             args.output += '/'
1109
1110     # Now, args.output is 'host:', 'host:path/' or 'path/'
1111
1112     if args.work is not None:
1113         args.work = os.path.abspath(args.work)
1114         if not os.path.exists(args.work):
1115             os.makedirs(args.work)
1116
1117     if args.project is None and args.command != 'shell':
1118         raise Error('you must specify -p or --project')
1119
1120     globals.quiet = args.quiet
1121     globals.verbose = args.verbose
1122     globals.command = args.command
1123     globals.dry_run = args.dry_run
1124
1125     if not globals.command in commands:
1126         e = 'command must be one of:\n' + one_of
1127         raise Error('command must be one of:\n%s' % one_of)
1128
1129     if globals.command == 'build':
1130         if args.target is None:
1131             raise Error('you must specify -t or --target')
1132
1133         target = target_factory(args)
1134         target.build(args.project, args.checkout, get_command_line_options(args))
1135         if not args.keep:
1136             target.cleanup()
1137
1138     elif globals.command == 'package':
1139         if args.target is None:
1140             raise Error('you must specify -t or --target')
1141
1142         target = None
1143         try:
1144             target = target_factory(args)
1145
1146             if target.platform == 'linux' and target.detail != "appimage":
1147                 if target.distro != 'arch':
1148                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1149                 else:
1150                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1151             else:
1152                 output_dir = args.output
1153
1154             makedirs(output_dir)
1155             target.package(args.project, args.checkout, output_dir, get_command_line_options(args))
1156         except Error as e:
1157             if target is not None and not args.keep:
1158                 target.cleanup()
1159             raise
1160
1161         if target is not None and not args.keep:
1162             target.cleanup()
1163
1164     elif globals.command == 'release':
1165         if args.minor is False and args.micro is False:
1166             raise Error('you must specify --minor or --micro')
1167
1168         target = SourceTarget()
1169         tree = globals.trees.get(args.project, args.checkout, target)
1170
1171         version = tree.version
1172         version.to_release()
1173         if args.minor:
1174             version.bump_minor()
1175         else:
1176             version.bump_micro()
1177
1178         with TreeDirectory(tree):
1179             if not args.no_version_commit:
1180                 set_version_in_wscript(version)
1181                 append_version_to_changelog(version)
1182                 append_version_to_debian_changelog(version)
1183                 command('git commit -a -m "Bump version"')
1184
1185             command('git tag -m "v%s" v%s' % (version, version))
1186
1187             if not args.no_version_commit:
1188                 version.to_devel()
1189                 set_version_in_wscript(version)
1190                 command('git commit -a -m "Bump version"')
1191                 command('git push')
1192
1193             command('git push --tags')
1194
1195         target.cleanup()
1196
1197     elif globals.command == 'pot':
1198         target = SourceTarget()
1199         tree = globals.trees.get(args.project, args.checkout, target)
1200
1201         pots = tree.call('make_pot')
1202         for p in pots:
1203             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1204
1205         target.cleanup()
1206
1207     elif globals.command == 'manual':
1208         target = SourceTarget()
1209         tree = globals.trees.get(args.project, args.checkout, target)
1210
1211         outs = tree.call('make_manual')
1212         for o in outs:
1213             if os.path.isfile(o):
1214                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1215             else:
1216                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1217
1218         target.cleanup()
1219
1220     elif globals.command == 'doxygen':
1221         target = SourceTarget()
1222         tree = globals.trees.get(args.project, args.checkout, target)
1223
1224         dirs = tree.call('make_doxygen')
1225         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1226             dirs = [dirs]
1227
1228         for d in dirs:
1229             copytree(d, args.output)
1230
1231         target.cleanup()
1232
1233     elif globals.command == 'latest':
1234         target = SourceTarget()
1235         tree = globals.trees.get(args.project, args.checkout, target)
1236
1237         with TreeDirectory(tree):
1238             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1239             latest = None
1240             line = 0
1241             while latest is None:
1242                 t = f[line]
1243                 line += 1
1244                 m = re.compile(".*\((.*)\).*").match(t)
1245                 if m:
1246                     tags = m.group(1).split(', ')
1247                     for t in tags:
1248                         s = t.split()
1249                         if len(s) > 1:
1250                             t = s[1]
1251                         if len(t) > 0 and t[0] == 'v':
1252                             v = Version(t[1:])
1253                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1254                                 latest = v
1255
1256         print(latest)
1257         target.cleanup()
1258
1259     elif globals.command == 'test':
1260         if args.target is None:
1261             raise Error('you must specify -t or --target')
1262
1263         target = None
1264         try:
1265             target = target_factory(args)
1266             tree = globals.trees.get(args.project, args.checkout, target)
1267             with TreeDirectory(tree):
1268                 target.test(tree, args.test, get_command_line_options(args))
1269         except Error as e:
1270             if target is not None and not args.keep:
1271                 target.cleanup()
1272             raise
1273
1274         if target is not None and not args.keep:
1275             target.cleanup()
1276
1277     elif globals.command == 'shell':
1278         if args.target is None:
1279             raise Error('you must specify -t or --target')
1280
1281         target = target_factory(args)
1282         target.command('bash')
1283
1284     elif globals.command == 'revision':
1285
1286         target = SourceTarget()
1287         tree = globals.trees.get(args.project, args.checkout, target)
1288         with TreeDirectory(tree):
1289             print(command_and_read('git rev-parse HEAD')[0].strip()[:7])
1290         target.cleanup()
1291
1292     elif globals.command == 'checkout':
1293
1294         if args.output is None:
1295             raise Error('you must specify -o or --output')
1296
1297         target = SourceTarget()
1298         tree = globals.trees.get(args.project, args.checkout, target)
1299         with TreeDirectory(tree):
1300             shutil.copytree('.', args.output)
1301         target.cleanup()
1302
1303     else:
1304         raise Error('invalid command %s' % globals.command)
1305
1306 try:
1307     main()
1308 except Error as e:
1309     print('cdist: %s' % str(e), file=sys.stderr)
1310     sys.exit(1)