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