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