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