Make it easier to tell osx and osx-64 apart.
[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         self.bits = None
736
737     def package(self, project, checkout, output_dir, options):
738
739         for b in [32, 64]:
740             target = OSXSingleTarget(b, os.path.join(self.directory, '%d' % b))
741             target.ccache = self.ccache
742             tree = globals.trees.get(project, checkout, target)
743             tree.build_dependencies(options)
744             tree.build(options)
745
746         tree = globals.trees.get(project, checkout, self)
747         with TreeDirectory(tree):
748             if len(inspect.getargspec(tree.cscript['package']).args) == 3:
749                 packages = tree.call('package', tree.version, options)
750             else:
751                 log("Deprecated cscript package() method with no options parameter")
752                 packages = tree.call('package', tree.version)
753             for p in packages:
754                 copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
755
756 class SourceTarget(Target):
757     """Build a source .tar.bz2"""
758     def __init__(self):
759         super(SourceTarget, self).__init__('source')
760
761     def command(self, c):
762         log('host -> %s' % c)
763         command('%s %s' % (self.variables_string(), c))
764
765     def cleanup(self):
766         rmtree(self.directory)
767
768     def package(self, project, checkout, output_dir, options):
769         tree = globals.trees.get(project, checkout, self)
770         with TreeDirectory(tree):
771             name = read_wscript_variable(os.getcwd(), 'APPNAME')
772             command('./waf dist')
773             p = os.path.abspath('%s-%s.tar.bz2' % (name, tree.version))
774             copyfile(p, os.path.join(output_dir, os.path.basename(devel_to_git(tree.git_commit, p))))
775
776 # @param s Target string:
777 #       windows-{32,64}
778 #    or ubuntu-version-{32,64}
779 #    or debian-version-{32,64}
780 #    or centos-version-{32,64}
781 #    or fedora-version-{32,64}
782 #    or mageia-version-{32,64}
783 #    or osx-{32,64}
784 #    or source
785 #    or flatpak
786 #    or appimage
787 # @param debug True to build with debugging symbols (where possible)
788 def target_factory(args):
789     s = args.target
790     target = None
791     if s.startswith('windows-'):
792         x = s.split('-')
793         if len(x) == 2:
794             target = WindowsTarget(None, int(x[1]), args.work)
795         elif len(x) == 3:
796             target = WindowsTarget(x[1], int(x[2]), args.work)
797         else:
798             raise Error("Bad Windows target name `%s'")
799     elif s.startswith('ubuntu-') or s.startswith('debian-') or s.startswith('centos-') or s.startswith('fedora-') or s.startswith('mageia-'):
800         p = s.split('-')
801         if len(p) != 3:
802             raise Error("Bad Linux target name `%s'; must be something like ubuntu-16.04-32 (i.e. distro-version-bits)" % s)
803         target = LinuxTarget(p[0], p[1], int(p[2]), args.work)
804     elif s.startswith('arch-'):
805         p = s.split('-')
806         if len(p) != 2:
807             raise Error("Bad Arch target name `%s'; must be arch-32 or arch-64")
808         target = LinuxTarget(p[0], None, int(p[1]), args.work)
809     elif s == 'raspbian':
810         target = LinuxTarget(s, None, None, args.work)
811     elif s.startswith('osx-'):
812         target = OSXSingleTarget(int(s.split('-')[1]), args.work)
813     elif s == 'osx':
814         if globals.command == 'build':
815             target = OSXSingleTarget(64, args.work)
816         else:
817             target = OSXUniversalTarget(args.work)
818     elif s == 'source':
819         target = SourceTarget()
820     elif s == 'flatpak':
821         target = FlatpakTarget(args.project, args.checkout)
822     elif s == 'appimage':
823         target = AppImageTarget(args.work)
824
825     if target is None:
826         raise Error("Bad target `%s'" % s)
827
828     target.debug = args.debug
829     target.ccache = args.ccache
830
831     if args.environment is not None:
832         for e in args.environment:
833             target.set(e, os.environ[e])
834
835     if args.mount is not None:
836         for m in args.mount:
837             target.mount(m)
838
839     target.setup()
840     return target
841
842
843 #
844 # Tree
845 #
846
847 class Tree(object):
848     """Description of a tree, which is a checkout of a project,
849        possibly built.  This class is never exposed to cscripts.
850        Attributes:
851            name -- name of git repository (without the .git)
852            specifier -- git tag or revision to use
853            target -- target object that we are using
854            version -- version from the wscript (if one is present)
855            git_commit -- git revision that is actually being used
856            built -- true if the tree has been built yet in this run
857            required_by -- name of the tree that requires this one
858     """
859
860     def __init__(self, name, specifier, target, required_by):
861         self.name = name
862         self.specifier = specifier
863         self.target = target
864         self.version = None
865         self.git_commit = None
866         self.built = False
867         self.required_by = required_by
868
869         cwd = os.getcwd()
870
871         flags = ''
872         redirect = ''
873         if globals.quiet:
874             flags = '-q'
875             redirect = '>/dev/null'
876         command('git clone %s %s/%s.git %s/src/%s' % (flags, config.get('git_prefix'), self.name, target.directory, self.name))
877         os.chdir('%s/src/%s' % (target.directory, self.name))
878
879         spec = self.specifier
880         if spec is None:
881             spec = 'master'
882
883         command('git checkout %s %s %s' % (flags, spec, redirect))
884         self.git_commit = command_and_read('git rev-parse --short=7 HEAD').readline().strip()
885         command('git submodule init --quiet')
886         command('git submodule update --quiet')
887
888         proj = '%s/src/%s' % (target.directory, self.name)
889
890         self.cscript = {}
891         exec(open('%s/cscript' % proj).read(), self.cscript)
892
893         if os.path.exists('%s/wscript' % proj):
894             v = read_wscript_variable(proj, "VERSION");
895             if v is not None:
896                 try:
897                     self.version = Version(v)
898                 except:
899                     tag = subprocess.Popen(shlex.split('git -C %s describe --tags' % proj), stdout=subprocess.PIPE).communicate()[0][1:]
900                     self.version = Version.from_git_tag(tag)
901
902         os.chdir(cwd)
903
904     def call(self, function, *args):
905         with TreeDirectory(self):
906             return self.cscript[function](self.target, *args)
907
908     def add_defaults(self, options):
909         """Add the defaults from this into a dict options"""
910         if 'option_defaults' in self.cscript:
911             for k, v in self.cscript['option_defaults']().items():
912                 if not k in options:
913                     options[k] = v
914
915     def dependencies(self, options):
916         if not 'dependencies' in self.cscript:
917             return
918
919         if len(inspect.getargspec(self.cscript['dependencies']).args) == 2:
920             deps = self.call('dependencies', options)
921         else:
922             log("Deprecated cscript dependencies() method with no options parameter")
923             deps = self.call('dependencies')
924
925         for d in deps:
926             dep = globals.trees.get(d[0], d[1], self.target, self.name)
927
928             # Start with the options passed in
929             dep_options = copy.copy(options)
930             # Add things specified by the parent
931             if len(d) > 2:
932                 for k, v in d[2].items():
933                     if not k in dep_options:
934                         dep_options[k] = v
935             # Then fill in the dependency's defaults
936             dep.add_defaults(dep_options)
937
938             for i in dep.dependencies(dep_options):
939                 yield i
940             yield (dep, dep_options)
941
942     def checkout_dependencies(self, options={}):
943         for i in self.dependencies(options):
944             pass
945
946     def build_dependencies(self, options):
947         for i in self.dependencies(options):
948             i[0].build(i[1])
949
950     def build(self, options):
951         if self.built:
952             return
953
954         variables = copy.copy(self.target.variables)
955
956         # Start with the options passed in
957         options = copy.copy(options)
958         # Fill in the defaults
959         self.add_defaults(options)
960
961         if not globals.dry_run:
962             if len(inspect.getargspec(self.cscript['build']).args) == 2:
963                 self.call('build', options)
964             else:
965                 self.call('build')
966
967         self.target.variables = variables
968         self.built = True
969
970 #
971 # Command-line parser
972 #
973
974 def main():
975
976     commands = {
977         "build": "build project",
978         "package": "package and build project",
979         "release": "release a project using its next version number (changing wscript and tagging)",
980         "pot": "build the project's .pot files",
981         "changelog": "generate a simple HTML changelog",
982         "manual": "build the project's manual",
983         "doxygen": "build the project's Doxygen documentation",
984         "latest": "print out the latest version",
985         "test": "run the project's unit tests",
986         "shell": "build the project then start a shell",
987         "checkout": "check out the project",
988         "revision": "print the head git revision number"
989     }
990
991     one_of = "Command is one of:\n"
992     summary = ""
993     for k, v in commands.items():
994         one_of += "\t%s\t%s\n" % (k, v)
995         summary += k + " "
996
997     parser = argparse.ArgumentParser()
998     parser.add_argument('command', help=summary)
999     parser.add_argument('-p', '--project', help='project name')
1000     parser.add_argument('--minor', help='minor version number bump', action='store_true')
1001     parser.add_argument('--micro', help='micro version number bump', action='store_true')
1002     parser.add_argument('--latest-major', help='major version to return with latest', type=int)
1003     parser.add_argument('--latest-minor', help='minor version to return with latest', type=int)
1004     parser.add_argument('-c', '--checkout', help='string to pass to git for checkout')
1005     parser.add_argument('-o', '--output', help='output directory', default='.')
1006     parser.add_argument('-q', '--quiet', help='be quiet', action='store_true')
1007     parser.add_argument('-t', '--target', help='target')
1008     parser.add_argument('-k', '--keep', help='keep working tree', action='store_true')
1009     parser.add_argument('--debug', help='build with debugging symbols where possible', action='store_true')
1010     parser.add_argument('-w', '--work', help='override default work directory')
1011     parser.add_argument('-g', '--git-prefix', help='override configured git prefix')
1012     parser.add_argument('--test', help="name of test to run (with `test'), defaults to all")
1013     parser.add_argument('-n', '--dry-run', help='run the process without building anything', action='store_true')
1014     parser.add_argument('-e', '--environment', help='pass the value of the named environment variable into the build', action='append')
1015     parser.add_argument('-m', '--mount', help='mount a given directory in the build environment', action='append')
1016     parser.add_argument('--no-version-commit', help="use just tags for versioning, don't modify wscript, ChangeLog etc.", action='store_true')
1017     parser.add_argument('--option', help='set an option for the build (use --option key:value)', action='append')
1018     parser.add_argument('--ccache', help='use ccache', action='store_true')
1019     args = parser.parse_args()
1020
1021     # Override configured stuff
1022     if args.git_prefix is not None:
1023         config.set('git_prefix', args.git_prefix)
1024
1025     if args.output.find(':') == -1:
1026         # This isn't of the form host:path so make it absolute
1027         args.output = os.path.abspath(args.output) + '/'
1028     else:
1029         if args.output[-1] != ':' and args.output[-1] != '/':
1030             args.output += '/'
1031
1032     # Now, args.output is 'host:', 'host:path/' or 'path/'
1033
1034     if args.work is not None:
1035         args.work = os.path.abspath(args.work)
1036
1037     if args.project is None and args.command != 'shell':
1038         raise Error('you must specify -p or --project')
1039
1040     globals.quiet = args.quiet
1041     globals.command = args.command
1042     globals.dry_run = args.dry_run
1043
1044     if not globals.command in commands:
1045         e = 'command must be one of:\n' + one_of
1046         raise Error('command must be one of:\n%s' % one_of)
1047
1048     if globals.command == 'build':
1049         if args.target is None:
1050             raise Error('you must specify -t or --target')
1051
1052         target = target_factory(args)
1053         target.build(args.project, args.checkout, argument_options(args))
1054         if not args.keep:
1055             target.cleanup()
1056
1057     elif globals.command == 'package':
1058         if args.target is None:
1059             raise Error('you must specify -t or --target')
1060
1061         target = None
1062         try:
1063             target = target_factory(args)
1064
1065             if target.platform == 'linux' and target.detail != "appimage":
1066                 if target.distro != 'arch':
1067                     output_dir = os.path.join(args.output, '%s-%s-%d' % (target.distro, target.version, target.bits))
1068                 else:
1069                     output_dir = os.path.join(args.output, '%s-%d' % (target.distro, target.bits))
1070             else:
1071                 output_dir = args.output
1072
1073             makedirs(output_dir)
1074
1075             # Start with the options passed on the command line
1076             options = copy.copy(argument_options(args))
1077             # Fill in the defaults
1078             tree = globals.trees.get(args.project, args.checkout, target)
1079             tree.add_defaults(options)
1080             target.package(args.project, args.checkout, output_dir, options)
1081         except Error as e:
1082             if target is not None and not args.keep:
1083                 target.cleanup()
1084             raise
1085
1086         if target is not None and not args.keep:
1087             target.cleanup()
1088
1089     elif globals.command == 'release':
1090         if args.minor is False and args.micro is False:
1091             raise Error('you must specify --minor or --micro')
1092
1093         target = SourceTarget()
1094         tree = globals.trees.get(args.project, args.checkout, target)
1095
1096         version = tree.version
1097         version.to_release()
1098         if args.minor:
1099             version.bump_minor()
1100         else:
1101             version.bump_micro()
1102
1103         with TreeDirectory(tree):
1104             if not args.no_version_commit:
1105                 set_version_in_wscript(version)
1106                 append_version_to_changelog(version)
1107                 append_version_to_debian_changelog(version)
1108                 command('git commit -a -m "Bump version"')
1109
1110             command('git tag -m "v%s" v%s' % (version, version))
1111
1112             if not args.no_version_commit:
1113                 version.to_devel()
1114                 set_version_in_wscript(version)
1115                 command('git commit -a -m "Bump version"')
1116                 command('git push')
1117
1118             command('git push --tags')
1119
1120         target.cleanup()
1121
1122     elif globals.command == 'pot':
1123         target = SourceTarget()
1124         tree = globals.trees.get(args.project, args.checkout, target)
1125
1126         pots = tree.call('make_pot')
1127         for p in pots:
1128             copyfile(p, '%s%s' % (args.output, os.path.basename(p)))
1129
1130         target.cleanup()
1131
1132     elif globals.command == 'changelog':
1133         target = SourceTarget()
1134         tree = globals.trees.get(args.project, args.checkout, target)
1135
1136         with TreeDirectory(tree):
1137             text = open('ChangeLog', 'r')
1138
1139         html = tempfile.NamedTemporaryFile()
1140         versions = 8
1141
1142         last = None
1143         changes = []
1144
1145         while True:
1146             l = text.readline()
1147             if l == '':
1148                 break
1149
1150             if len(l) > 0 and l[0] == "\t":
1151                 s = l.split()
1152                 if len(s) == 4 and s[1] == "Version" and s[3] == "released.":
1153                     v = Version(s[2])
1154                     if v.micro == 0:
1155                         if last is not None and len(changes) > 0:
1156                             print("<h2>Changes between version %s and %s</h2>" % (s[2], last), file=html)
1157                             print("<ul>", file=html)
1158                             for c in changes:
1159                                 print("<li>%s" % c, file=html)
1160                             print("</ul>", file=html)
1161                         last = s[2]
1162                         changes = []
1163                         versions -= 1
1164                         if versions < 0:
1165                             break
1166                 else:
1167                     c = l.strip()
1168                     if len(c) > 0:
1169                         if c[0] == '*':
1170                             changes.append(c[2:])
1171                         else:
1172                             changes[-1] += " " + c
1173
1174         copyfile(html.file, '%schangelog.html' % args.output)
1175         html.close()
1176         target.cleanup()
1177
1178     elif globals.command == 'manual':
1179         target = SourceTarget()
1180         tree = globals.trees.get(args.project, args.checkout, target)
1181
1182         outs = tree.call('make_manual')
1183         for o in outs:
1184             if os.path.isfile(o):
1185                 copyfile(o, '%s%s' % (args.output, os.path.basename(o)))
1186             else:
1187                 copytree(o, '%s%s' % (args.output, os.path.basename(o)))
1188
1189         target.cleanup()
1190
1191     elif globals.command == 'doxygen':
1192         target = SourceTarget()
1193         tree = globals.trees.get(args.project, args.checkout, target)
1194
1195         dirs = tree.call('make_doxygen')
1196         if hasattr(dirs, 'strip') or (not hasattr(dirs, '__getitem__') and not hasattr(dirs, '__iter__')):
1197             dirs = [dirs]
1198
1199         for d in dirs:
1200             copytree(d, args.output)
1201
1202         target.cleanup()
1203
1204     elif globals.command == 'latest':
1205         target = SourceTarget()
1206         tree = globals.trees.get(args.project, args.checkout, target)
1207
1208         with TreeDirectory(tree):
1209             f = command_and_read('git log --tags --simplify-by-decoration --pretty="%d"')
1210             latest = None
1211             while latest is None:
1212                 t = f.readline()
1213                 m = re.compile(".*\((.*)\).*").match(t)
1214                 if m:
1215                     tags = m.group(1).split(', ')
1216                     for t in tags:
1217                         s = t.split()
1218                         if len(s) > 1:
1219                             t = s[1]
1220                         if len(t) > 0 and t[0] == 'v':
1221                             v = Version(t[1:])
1222                             if (args.latest_major is None or v.major == args.latest_major) and (args.latest_minor is None or v.minor == args.latest_minor):
1223                                 latest = v
1224
1225         print(latest)
1226         target.cleanup()
1227
1228     elif globals.command == 'test':
1229         if args.target is None:
1230             raise Error('you must specify -t or --target')
1231
1232         target = None
1233         try:
1234             target = target_factory(args)
1235             tree = globals.trees.get(args.project, args.checkout, target)
1236             with TreeDirectory(tree):
1237                 target.test(tree, args.test, argument_options(args))
1238         except Error as e:
1239             if target is not None and not args.keep:
1240                 target.cleanup()
1241             raise
1242
1243         if target is not None and not args.keep:
1244             target.cleanup()
1245
1246     elif globals.command == 'shell':
1247         if args.target is None:
1248             raise Error('you must specify -t or --target')
1249
1250         target = target_factory(args)
1251         target.command('bash')
1252
1253     elif globals.command == 'revision':
1254
1255         target = SourceTarget()
1256         tree = globals.trees.get(args.project, args.checkout, target)
1257         with TreeDirectory(tree):
1258             print(command_and_read('git rev-parse HEAD').readline().strip()[:7])
1259         target.cleanup()
1260
1261     elif globals.command == 'checkout':
1262
1263         if args.output is None:
1264             raise Error('you must specify -o or --output')
1265
1266         target = SourceTarget()
1267         tree = globals.trees.get(args.project, args.checkout, target)
1268         with TreeDirectory(tree):
1269             shutil.copytree('.', args.output)
1270         target.cleanup()
1271
1272     else:
1273         raise Error('invalid command %s' % globals.command)
1274
1275 try:
1276     main()
1277 except Error as e:
1278     print('cdist: %s' % str(e), file=sys.stderr)
1279     sys.exit(1)