1#!/usr/bin/env python3
2"""
3Build steps for the windows binary packages.
4
5The script is designed to be called by appveyor. Subcommands map the steps in
6'appveyor.yml'.
7
8"""
9
10import re
11import os
12import sys
13import json
14import shutil
15import logging
16import subprocess as sp
17from glob import glob
18from pathlib import Path
19from zipfile import ZipFile
20from argparse import ArgumentParser
21from tempfile import NamedTemporaryFile
22from urllib.request import urlopen
23
24opt = None
25STEP_PREFIX = 'step_'
26
27logger = logging.getLogger()
28logging.basicConfig(
29    level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s'
30)
31
32
33def main():
34    global opt
35    opt = parse_cmdline()
36    logger.setLevel(opt.loglevel)
37
38    cmd = globals()[STEP_PREFIX + opt.step]
39    cmd()
40
41
42def setup_build_env():
43    """
44    Set the environment variables according to the build environment
45    """
46    setenv('VS_VER', opt.vs_ver)
47
48    path = [
49        str(opt.py_dir),
50        str(opt.py_dir / 'Scripts'),
51        r'C:\Strawberry\Perl\bin',
52        r'C:\Program Files\Git\mingw64\bin',
53        str(opt.ssl_build_dir / 'bin'),
54        os.environ['PATH'],
55    ]
56    setenv('PATH', os.pathsep.join(path))
57
58    logger.info("Configuring compiler")
59    bat_call([opt.vc_dir / "vcvarsall.bat", 'x86' if opt.arch_32 else 'amd64'])
60
61
62def python_info():
63    logger.info("Python Information")
64    run_python(['--version'], stderr=sp.STDOUT)
65    run_python(
66        ['-c', "import sys; print('64bit: %s' % (sys.maxsize > 2**32))"]
67    )
68
69
70def step_install():
71    python_info()
72    configure_sdk()
73    configure_postgres()
74
75    if opt.is_wheel:
76        install_wheel_support()
77
78
79def install_wheel_support():
80    """
81    Install an up-to-date pip wheel package to build wheels.
82    """
83    run_python("-m pip install --upgrade pip".split())
84    run_python("-m pip install wheel".split())
85
86
87def configure_sdk():
88    # The program rc.exe on 64bit with some versions look in the wrong path
89    # location when building postgresql. This cheats by copying the x64 bit
90    # files to that location.
91    if opt.arch_64:
92        for fn in glob(
93            r'C:\Program Files\Microsoft SDKs\Windows\v7.0\Bin\x64\rc*'
94        ):
95            copy_file(
96                fn, r"C:\Program Files (x86)\Microsoft SDKs\Windows\v7.0A\Bin"
97            )
98
99
100def configure_postgres():
101    """
102    Set up PostgreSQL config before the service starts.
103    """
104    logger.info("Configuring Postgres")
105    with (opt.pg_data_dir / 'postgresql.conf').open('a') as f:
106        # allow > 1 prepared transactions for test cases
107        print("max_prepared_transactions = 10", file=f)
108        print("ssl = on", file=f)
109
110    # Create openssl certificate to allow ssl connection
111    cwd = os.getcwd()
112    os.chdir(opt.pg_data_dir)
113    run_openssl(
114        'req -new -x509 -days 365 -nodes -text '
115        '-out server.crt -keyout server.key -subj /CN=initd.org'.split()
116    )
117    run_openssl(
118        'req -new -nodes -text -out root.csr -keyout root.key '
119        '-subj /CN=initd.org'.split()
120    )
121
122    run_openssl(
123        'x509 -req -in root.csr -text -days 3650 -extensions v3_ca '
124        '-signkey root.key -out root.crt'.split()
125    )
126
127    run_openssl(
128        'req -new -nodes -text -out server.csr -keyout server.key '
129        '-subj /CN=initd.org'.split()
130    )
131
132    run_openssl(
133        'x509 -req -in server.csr -text -days 365 -CA root.crt '
134        '-CAkey root.key -CAcreateserial -out server.crt'.split()
135    )
136
137    os.chdir(cwd)
138
139
140def run_openssl(args):
141    """Run the appveyor-installed openssl with some args."""
142    # https://www.appveyor.com/docs/windows-images-software/
143    openssl = Path(r"C:\OpenSSL-v111-Win64") / 'bin' / 'openssl'
144    return run_command([openssl] + args)
145
146
147def step_build_script():
148    setup_build_env()
149    build_openssl()
150    build_libpq()
151    build_psycopg()
152
153    if opt.is_wheel:
154        build_binary_packages()
155
156
157def build_openssl():
158    top = opt.ssl_build_dir
159    if (top / 'lib' / 'libssl.lib').exists():
160        return
161
162    logger.info("Building OpenSSL")
163
164    # Setup directories for building OpenSSL libraries
165    ensure_dir(top / 'include' / 'openssl')
166    ensure_dir(top / 'lib')
167
168    # Setup OpenSSL Environment Variables based on processor architecture
169    if opt.arch_32:
170        target = 'VC-WIN32'
171        setenv('VCVARS_PLATFORM', 'x86')
172    else:
173        target = 'VC-WIN64A'
174        setenv('VCVARS_PLATFORM', 'amd64')
175        setenv('CPU', 'AMD64')
176
177    ver = os.environ['OPENSSL_VERSION']
178
179    # Download OpenSSL source
180    zipname = f'OpenSSL_{ver}.zip'
181    zipfile = opt.cache_dir / zipname
182    if not zipfile.exists():
183        download(
184            f"https://github.com/openssl/openssl/archive/{zipname}", zipfile
185        )
186
187    with ZipFile(zipfile) as z:
188        z.extractall(path=opt.build_dir)
189
190    sslbuild = opt.build_dir / f"openssl-OpenSSL_{ver}"
191    os.chdir(sslbuild)
192    run_command(
193        ['perl', 'Configure', target, 'no-asm']
194        + ['no-shared', 'no-zlib', f'--prefix={top}', f'--openssldir={top}']
195    )
196
197    run_command("nmake build_libs install_sw".split())
198
199    assert (top / 'lib' / 'libssl.lib').exists()
200
201    os.chdir(opt.clone_dir)
202    shutil.rmtree(sslbuild)
203
204
205def build_libpq():
206    top = opt.pg_build_dir
207    if (top / 'lib' / 'libpq.lib').exists():
208        return
209
210    logger.info("Building libpq")
211
212    # Setup directories for building PostgreSQL librarires
213    ensure_dir(top / 'include')
214    ensure_dir(top / 'lib')
215    ensure_dir(top / 'bin')
216
217    ver = os.environ['POSTGRES_VERSION']
218
219    # Download PostgreSQL source
220    zipname = f'postgres-REL_{ver}.zip'
221    zipfile = opt.cache_dir / zipname
222    if not zipfile.exists():
223        download(
224            f"https://github.com/postgres/postgres/archive/REL_{ver}.zip",
225            zipfile,
226        )
227
228    with ZipFile(zipfile) as z:
229        z.extractall(path=opt.build_dir)
230
231    pgbuild = opt.build_dir / f"postgres-REL_{ver}"
232    os.chdir(pgbuild)
233
234    # Setup build config file (config.pl)
235    os.chdir("src/tools/msvc")
236    with open("config.pl", 'w') as f:
237        print(
238            """\
239$config->{ldap} = 0;
240$config->{openssl} = "%s";
241
2421;
243"""
244            % str(opt.ssl_build_dir).replace('\\', '\\\\'),
245            file=f,
246        )
247
248    # Hack the Mkvcbuild.pm file so we build the lib version of libpq
249    file_replace('Mkvcbuild.pm', "'libpq', 'dll'", "'libpq', 'lib'")
250
251    # Build libpgport, libpgcommon, libpq
252    run_command([which("build"), "libpgport"])
253    run_command([which("build"), "libpgcommon"])
254    run_command([which("build"), "libpq"])
255
256    # Install includes
257    with (pgbuild / "src/backend/parser/gram.h").open("w") as f:
258        print("", file=f)
259
260    # Copy over built libraries
261    file_replace("Install.pm", "qw(Install)", "qw(Install CopyIncludeFiles)")
262    run_command(
263        ["perl", "-MInstall=CopyIncludeFiles", "-e"]
264        + [f"chdir('../../..'); CopyIncludeFiles('{top}')"]
265    )
266
267    for lib in ('libpgport', 'libpgcommon', 'libpq'):
268        copy_file(pgbuild / f'Release/{lib}/{lib}.lib', top / 'lib')
269
270    # Prepare local include directory for building from
271    for dir in ('win32', 'win32_msvc'):
272        merge_dir(pgbuild / f"src/include/port/{dir}", pgbuild / "src/include")
273
274    # Build pg_config in place
275    os.chdir(pgbuild / 'src/bin/pg_config')
276    run_command(
277        ['cl', 'pg_config.c', '/MT', '/nologo', fr'/I{pgbuild}\src\include']
278        + ['/link', fr'/LIBPATH:{top}\lib']
279        + ['libpgcommon.lib', 'libpgport.lib', 'advapi32.lib']
280        + ['/NODEFAULTLIB:libcmt.lib']
281        + [fr'/OUT:{top}\bin\pg_config.exe']
282    )
283
284    assert (top / 'lib' / 'libpq.lib').exists()
285    assert (top / 'bin' / 'pg_config.exe').exists()
286
287    os.chdir(opt.clone_dir)
288    shutil.rmtree(pgbuild)
289
290
291def build_psycopg():
292    os.chdir(opt.package_dir)
293    patch_package_name()
294    add_pg_config_path()
295    run_python(
296        ["setup.py", "build_ext", "--have-ssl"]
297        + ["-l", "libpgcommon libpgport"]
298        + ["-L", opt.ssl_build_dir / 'lib']
299        + ['-I', opt.ssl_build_dir / 'include']
300    )
301    run_python(["setup.py", "build_py"])
302
303
304def patch_package_name():
305    """Change the psycopg2 package name in the setup.py if required."""
306    if opt.package_name == 'psycopg2':
307        return
308
309    logger.info("changing package name to %s", opt.package_name)
310
311    with (opt.package_dir / 'setup.py').open() as f:
312        data = f.read()
313
314    # Replace the name of the package with what desired
315    rex = re.compile(r"""name=["']psycopg2["']""")
316    assert len(rex.findall(data)) == 1, rex.findall(data)
317    data = rex.sub(f'name="{opt.package_name}"', data)
318
319    with (opt.package_dir / 'setup.py').open('w') as f:
320        f.write(data)
321
322
323def build_binary_packages():
324    """Create wheel binary packages."""
325    os.chdir(opt.package_dir)
326
327    add_pg_config_path()
328
329    # Build .whl packages
330    run_python(['setup.py', 'bdist_wheel', "-d", opt.dist_dir])
331
332
333def step_after_build():
334    if not opt.is_wheel:
335        install_built_package()
336    else:
337        install_binary_package()
338
339
340def install_built_package():
341    """Install the package just built by setup build."""
342    os.chdir(opt.package_dir)
343
344    # Install the psycopg just built
345    add_pg_config_path()
346    run_python(["setup.py", "install"])
347    shutil.rmtree("psycopg2.egg-info")
348
349
350def install_binary_package():
351    """Install the package from a packaged wheel."""
352    run_python(
353        ['-m', 'pip', 'install', '--no-index', '-f', opt.dist_dir]
354        + [opt.package_name]
355    )
356
357
358def add_pg_config_path():
359    """Allow finding in the path the pg_config just built."""
360    pg_path = str(opt.pg_build_dir / 'bin')
361    if pg_path not in os.environ['PATH'].split(os.pathsep):
362        setenv('PATH', os.pathsep.join([pg_path, os.environ['PATH']]))
363
364
365def step_before_test():
366    print_psycopg2_version()
367
368    # Create and setup PostgreSQL database for the tests
369    run_command([opt.pg_bin_dir / 'createdb', os.environ['PSYCOPG2_TESTDB']])
370    run_command(
371        [opt.pg_bin_dir / 'psql', '-d', os.environ['PSYCOPG2_TESTDB']]
372        + ['-c', "CREATE EXTENSION hstore"]
373    )
374
375
376def print_psycopg2_version():
377    """Print psycopg2 and libpq versions installed."""
378    for expr in (
379        'psycopg2.__version__',
380        'psycopg2.__libpq_version__',
381        'psycopg2.extensions.libpq_version()',
382    ):
383        out = out_python(['-c', f"import psycopg2; print({expr})"])
384        logger.info("built %s: %s", expr, out.decode('ascii'))
385
386
387def step_test_script():
388    check_libpq_version()
389    run_test_suite()
390
391
392def check_libpq_version():
393    """
394    Fail if the package installed is not using the expected libpq version.
395    """
396    want_ver = tuple(map(int, os.environ['POSTGRES_VERSION'].split('_')))
397    want_ver = "%d%04d" % want_ver
398    got_ver = (
399        out_python(
400            ['-c']
401            + ["import psycopg2; print(psycopg2.extensions.libpq_version())"]
402        )
403        .decode('ascii')
404        .rstrip()
405    )
406    assert want_ver == got_ver, f"libpq version mismatch: {want_ver!r} != {got_ver!r}"
407
408
409def run_test_suite():
410    # Remove this var, which would make badly a configured OpenSSL 1.1 work
411    os.environ.pop('OPENSSL_CONF', None)
412
413    # Run the unit test
414    args = [
415        '-c',
416        "import tests; tests.unittest.main(defaultTest='tests.test_suite')",
417    ]
418
419    if opt.is_wheel:
420        os.environ['PSYCOPG2_TEST_FAST'] = '1'
421    else:
422        args.append('--verbose')
423
424    os.chdir(opt.package_dir)
425    run_python(args)
426
427
428def step_on_success():
429    print_sha1_hashes()
430    if setup_ssh():
431        upload_packages()
432
433
434def print_sha1_hashes():
435    """
436    Print the packages sha1 so their integrity can be checked upon signing.
437    """
438    logger.info("artifacts SHA1 hashes:")
439
440    os.chdir(opt.package_dir / 'dist')
441    run_command([which('sha1sum'), '-b', 'psycopg2-*/*'])
442
443
444def setup_ssh():
445    """
446    Configure ssh to upload built packages where they can be retrieved.
447
448    Return False if can't configure and upload shoould be skipped.
449    """
450    # If we are not on the psycopg AppVeyor account, the environment variable
451    # REMOTE_KEY will not be decrypted. In that case skip uploading.
452    if os.environ['APPVEYOR_ACCOUNT_NAME'] != 'psycopg':
453        logger.warn("skipping artifact upload: you are not psycopg")
454        return False
455
456    pkey = os.environ.get('REMOTE_KEY', None)
457    if not pkey:
458        logger.warn("skipping artifact upload: no remote key")
459        return False
460
461    # Write SSH Private Key file from environment variable
462    pkey = pkey.replace(' ', '\n')
463    with (opt.clone_dir / 'data/id_rsa-psycopg-upload').open('w') as f:
464        f.write(
465            f"""\
466-----BEGIN RSA PRIVATE KEY-----
467{pkey}
468-----END RSA PRIVATE KEY-----
469"""
470        )
471
472    # Make a directory to please MinGW's version of ssh
473    ensure_dir(r"C:\MinGW\msys\1.0\home\appveyor\.ssh")
474
475    return True
476
477
478def upload_packages():
479    # Upload built artifacts
480    logger.info("uploading artifacts")
481
482    os.chdir(opt.clone_dir)
483    run_command(
484        [r"C:\MinGW\msys\1.0\bin\rsync", "-avr"]
485        + ["-e", r"C:\MinGW\msys\1.0\bin\ssh -F data/ssh_config"]
486        + ["psycopg2/dist/", "upload:"]
487    )
488
489
490def download(url, fn):
491    """Download a file locally"""
492    logger.info("downloading %s", url)
493    with open(fn, 'wb') as fo, urlopen(url) as fi:
494        while 1:
495            data = fi.read(8192)
496            if not data:
497                break
498            fo.write(data)
499
500    logger.info("file downloaded: %s", fn)
501
502
503def file_replace(fn, s1, s2):
504    """
505    Replace all the occurrences of the string s1 into s2 in the file fn.
506    """
507    assert os.path.exists(fn)
508    with open(fn, 'r+') as f:
509        data = f.read()
510        f.seek(0)
511        f.write(data.replace(s1, s2))
512        f.truncate()
513
514
515def merge_dir(src, tgt):
516    """
517    Merge the content of the directory src into the directory tgt
518
519    Reproduce the semantic of "XCOPY /Y /S src/* tgt"
520    """
521    src = str(src)
522    for dp, _dns, fns in os.walk(src):
523        logger.debug("dirpath %s", dp)
524        if not fns:
525            continue
526        assert dp.startswith(src)
527        subdir = dp[len(src) :].lstrip(os.sep)
528        tgtdir = ensure_dir(os.path.join(tgt, subdir))
529        for fn in fns:
530            copy_file(os.path.join(dp, fn), tgtdir)
531
532
533def bat_call(cmdline):
534    """
535    Simulate 'CALL' from a batch file
536
537    Execute CALL *cmdline* and export the changed environment to the current
538    environment.
539
540    nana-nana-nana-nana...
541
542    """
543    if not isinstance(cmdline, str):
544        cmdline = map(str, cmdline)
545        cmdline = ' '.join(c if ' ' not in c else '"%s"' % c for c in cmdline)
546
547    data = f"""\
548CALL {cmdline}
549{opt.py_exe} -c "import os, sys, json; \
550json.dump(dict(os.environ), sys.stdout, indent=2)"
551"""
552
553    logger.debug("preparing file to batcall:\n\n%s", data)
554
555    with NamedTemporaryFile(suffix='.bat') as tmp:
556        fn = tmp.name
557
558    with open(fn, "w") as f:
559        f.write(data)
560
561    try:
562        out = out_command(fn)
563        # be vewwy vewwy caweful to print the env var as it might contain
564        # secwet things like your pwecious pwivate key.
565        # logger.debug("output of command:\n\n%s", out.decode('utf8', 'replace'))
566
567        # The output has some useless crap on stdout, because sure, and json
568        # indented so the last { on column 1 is where we have to start parsing
569
570        m = list(re.finditer(b'^{', out, re.MULTILINE))[-1]
571        out = out[m.start() :]
572        env = json.loads(out)
573        for k, v in env.items():
574            if os.environ.get(k) != v:
575                setenv(k, v)
576    finally:
577        os.remove(fn)
578
579
580def ensure_dir(dir):
581    if not isinstance(dir, Path):
582        dir = Path(dir)
583
584    if not dir.is_dir():
585        logger.info("creating directory %s", dir)
586        dir.mkdir(parents=True)
587
588    return dir
589
590
591def run_command(cmdline, **kwargs):
592    """Run a command, raise on error."""
593    if not isinstance(cmdline, str):
594        cmdline = list(map(str, cmdline))
595    logger.info("running command: %s", cmdline)
596    sp.check_call(cmdline, **kwargs)
597
598
599def out_command(cmdline, **kwargs):
600    """Run a command, return its output, raise on error."""
601    if not isinstance(cmdline, str):
602        cmdline = list(map(str, cmdline))
603    logger.info("running command: %s", cmdline)
604    data = sp.check_output(cmdline, **kwargs)
605    return data
606
607
608def run_python(args, **kwargs):
609    """
610    Run a script in the target Python.
611    """
612    return run_command([opt.py_exe] + args, **kwargs)
613
614
615def out_python(args, **kwargs):
616    """
617    Return the output of a script run in the target Python.
618    """
619    return out_command([opt.py_exe] + args, **kwargs)
620
621
622def copy_file(src, dst):
623    logger.info("copying file %s -> %s", src, dst)
624    shutil.copy(src, dst)
625
626
627def setenv(k, v):
628    logger.debug("setting %s=%s", k, v)
629    os.environ[k] = v
630
631
632def which(name):
633    """
634    Return the full path of a command found on the path
635    """
636    base, ext = os.path.splitext(name)
637    if not ext:
638        exts = ('.com', '.exe', '.bat', '.cmd')
639    else:
640        exts = (ext,)
641
642    for dir in ['.'] + os.environ['PATH'].split(os.pathsep):
643        for ext in exts:
644            fn = os.path.join(dir, base + ext)
645            if os.path.isfile(fn):
646                return fn
647
648    raise Exception(f"couldn't find program on path: {name}")
649
650
651class Options:
652    """
653    An object exposing the script configuration from env vars and command line.
654    """
655
656    @property
657    def py_ver(self):
658        """The Python version to build as 2 digits string."""
659        rv = os.environ['PY_VER']
660        assert rv in ('36', '37', '38', '39', '310'), rv
661        return rv
662
663    @property
664    def py_arch(self):
665        """The Python architecture to build, 32 or 64."""
666        rv = os.environ['PY_ARCH']
667        assert rv in ('32', '64'), rv
668        return int(rv)
669
670    @property
671    def arch_32(self):
672        """True if the Python architecture to build is 32 bits."""
673        return self.py_arch == 32
674
675    @property
676    def arch_64(self):
677        """True if the Python architecture to build is 64 bits."""
678        return self.py_arch == 64
679
680    @property
681    def package_name(self):
682        return os.environ.get('CONFIGURATION', 'psycopg2')
683
684    @property
685    def package_version(self):
686        """The psycopg2 version number to build."""
687        with (self.package_dir / 'setup.py').open() as f:
688            data = f.read()
689
690        m = re.search(
691            r"""^PSYCOPG_VERSION\s*=\s*['"](.*)['"]""", data, re.MULTILINE
692        )
693        return m.group(1)
694
695    @property
696    def is_wheel(self):
697        """Are we building the wheel packages or just the extension?"""
698        workflow = os.environ["WORKFLOW"]
699        return workflow == "packages"
700
701    @property
702    def py_dir(self):
703        """
704        The path to the target python binary to execute.
705        """
706        dirname = ''.join(
707            [r"C:\Python", self.py_ver, '-x64' if self.arch_64 else '']
708        )
709        return Path(dirname)
710
711    @property
712    def py_exe(self):
713        """
714        The full path of the target python executable.
715        """
716        return self.py_dir / 'python.exe'
717
718    @property
719    def vc_dir(self):
720        """
721        The path of the Visual C compiler.
722        """
723        if self.vs_ver == '16.0':
724            path = Path(
725                r"C:\Program Files (x86)\Microsoft Visual Studio\2019"
726                r"\Community\VC\Auxiliary\Build"
727            )
728        else:
729            path = Path(
730                r"C:\Program Files (x86)\Microsoft Visual Studio %s\VC"
731                % self.vs_ver
732            )
733        return path
734
735    @property
736    def vs_ver(self):
737        # https://wiki.python.org/moin/WindowsCompilers
738        # https://www.appveyor.com/docs/windows-images-software/#python
739        # Py 3.6--3.8 = VS Ver. 14.0 (VS 2015)
740        # Py 3.9 = VS Ver. 16.0 (VS 2019)
741        vsvers = {
742            '36': '14.0',
743            '37': '14.0',
744            '38': '14.0',
745            '39': '16.0',
746            '310': '16.0',
747        }
748        return vsvers[self.py_ver]
749
750    @property
751    def clone_dir(self):
752        """The directory where the repository is cloned."""
753        return Path(r"C:\Project")
754
755    @property
756    def appveyor_pg_dir(self):
757        """The directory of the postgres service made available by Appveyor."""
758        return Path(os.environ['POSTGRES_DIR'])
759
760    @property
761    def pg_data_dir(self):
762        """The data dir of the appveyor postgres service."""
763        return self.appveyor_pg_dir / 'data'
764
765    @property
766    def pg_bin_dir(self):
767        """The bin dir of the appveyor postgres service."""
768        return self.appveyor_pg_dir / 'bin'
769
770    @property
771    def pg_build_dir(self):
772        """The directory where to build the postgres libraries for psycopg."""
773        return self.cache_arch_dir / 'postgresql'
774
775    @property
776    def ssl_build_dir(self):
777        """The directory where to build the openssl libraries for psycopg."""
778        return self.cache_arch_dir / 'openssl'
779
780    @property
781    def cache_arch_dir(self):
782        rv = self.cache_dir / str(self.py_arch) / self.vs_ver
783        return ensure_dir(rv)
784
785    @property
786    def cache_dir(self):
787        return Path(r"C:\Others")
788
789    @property
790    def build_dir(self):
791        rv = self.cache_arch_dir / 'Builds'
792        return ensure_dir(rv)
793
794    @property
795    def package_dir(self):
796        return self.clone_dir
797
798    @property
799    def dist_dir(self):
800        """The directory where to build packages to distribute."""
801        return (
802            self.package_dir / 'dist' / (f'psycopg2-{self.package_version}')
803        )
804
805
806def parse_cmdline():
807    parser = ArgumentParser(description=__doc__)
808
809    g = parser.add_mutually_exclusive_group()
810    g.add_argument(
811        '-q',
812        '--quiet',
813        help="Talk less",
814        dest='loglevel',
815        action='store_const',
816        const=logging.WARN,
817        default=logging.INFO,
818    )
819    g.add_argument(
820        '-v',
821        '--verbose',
822        help="Talk more",
823        dest='loglevel',
824        action='store_const',
825        const=logging.DEBUG,
826        default=logging.INFO,
827    )
828
829    steps = [
830        n[len(STEP_PREFIX) :]
831        for n in globals()
832        if n.startswith(STEP_PREFIX) and callable(globals()[n])
833    ]
834
835    parser.add_argument(
836        'step', choices=steps, help="the appveyor step to execute"
837    )
838
839    opt = parser.parse_args(namespace=Options())
840
841    return opt
842
843
844if __name__ == '__main__':
845    sys.exit(main())
846