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