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