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