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