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