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