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