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