From 035ac2f47eefa668e152dec0bb39ba1dc219f006 Mon Sep 17 00:00:00 2001 From: hauntsaninja <> Date: Fri, 9 Oct 2020 00:45:45 -0700 Subject: [PATCH 1/4] new upload pypi script CI now builds the pure Python wheel and sdist. Use the Github API to pull down all assets associated with the CI build. Because this script now does very little, it's a lot simpler. Since it's no longer building things, I assume we can be less picky about what Python we use. The version check now runs against the sdist, which is nice. --- misc/upload-pypi.py | 278 ++++++++++++++++++-------------------------- 1 file changed, 113 insertions(+), 165 deletions(-) diff --git a/misc/upload-pypi.py b/misc/upload-pypi.py index 886af9139560..b122ea1eb38c 100644 --- a/misc/upload-pypi.py +++ b/misc/upload-pypi.py @@ -1,175 +1,123 @@ #!/usr/bin/env python3 -"""Build and upload mypy packages for Linux and macOS to PyPI. +"""Upload mypy packages to PyPI. -*** You must first tag the release and use `git push --tags`. *** - -Note: This should be run on macOS using official python.org Python 3.6 or - later, as this is the only tested configuration. Use --force to - run anyway. - -This uses a fresh repo clone and a fresh virtualenv to avoid depending on -local state. - -Ideas for improvements: - -- also upload Windows wheels -- try installing the generated packages and running mypy -- try installing the uploaded packages and running mypy -- run tests -- verify that there is a green travis build +You must first tag the release, use `git push --tags` and wait for the wheel build in CI to complete. """ import argparse -import getpass -import os -import os.path +import contextlib +import json import re +import shutil import subprocess -import sys +import tarfile import tempfile -from typing import Any - - -class Builder: - def __init__(self, version: str, force: bool, no_upload: bool) -> None: - if not re.match(r'0\.[0-9]{3}$', version): - sys.exit('Invalid version {!r} (expected form 0.123)'.format(version)) - self.version = version - self.force = force - self.no_upload = no_upload - self.target_dir = tempfile.mkdtemp() - self.repo_dir = os.path.join(self.target_dir, 'mypy') - - def build_and_upload(self) -> None: - self.prompt() - self.run_sanity_checks() - print('Temporary target directory: {}'.format(self.target_dir)) - self.git_clone_repo() - self.git_check_out_tag() - self.verify_version() - self.make_virtualenv() - self.install_dependencies() - self.make_wheel() - self.make_sdist() - self.download_compiled_wheels() - if not self.no_upload: - self.upload_wheels() - self.upload_sdist() - self.heading('Successfully uploaded wheel and sdist for mypy {}'.format(self.version)) - print("<< All done! >>") +import venv +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, Dict, Iterator, List +from urllib.request import urlopen + +BASE = "https://api.github.com/repos" +REPO = "hauntsaninja/mypy_mypyc-wheelsv2" + + +def is_whl_or_tar(name: str) -> bool: + return name.endswith(".tar.gz") or name.endswith(".whl") + + +def get_release_for_tag(tag: str) -> Dict[str, Any]: + with urlopen(f"{BASE}/{REPO}/releases/tags/{tag}") as f: + data = json.load(f) + assert data["tag_name"] == tag + return data + + +def download_asset(asset: Dict[str, Any], dst: Path) -> Path: + name = asset["name"] + download_url = asset["browser_download_url"] + assert is_whl_or_tar(name) + with urlopen(download_url) as src_file: + with open(dst / name, "wb") as dst_file: + shutil.copyfileobj(src_file, dst_file) + return dst / name + + +def download_all_release_assets(release: Dict[str, Any], dst: Path) -> None: + print(f"Downloading assets...") + with ThreadPoolExecutor() as e: + for asset in e.map(lambda asset: download_asset(asset, dst), release["assets"]): + print(f"Downloaded {asset}") + + +def check_sdist(dist: Path, version: str) -> None: + tarfiles = list(dist.glob("*.tar.gz")) + assert len(tarfiles) == 1 + sdist = tarfiles[0] + assert version in sdist.name + with tarfile.open(sdist) as f: + version_py = f.extractfile(f"{sdist.name[:-len('.tar.gz')]}/mypy/version.py") + assert version_py is not None + assert f"'{version}'" in version_py.read().decode("utf-8") + + +def spot_check_dist(dist: Path, version: str) -> None: + items = [item for item in dist.iterdir() if is_whl_or_tar(item.name)] + assert len(items) > 10 + assert all(version in item.name for item in items) + assert any(item.name.endswith("py3-none-any.whl") for item in items) + + +@contextlib.contextmanager +def tmp_twine() -> Iterator[Path]: + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_venv_dir = Path(tmp_dir) / "venv" + venv.create(tmp_venv_dir, with_pip=True) + pip_exe = tmp_venv_dir / "bin" / "pip" + subprocess.check_call([pip_exe, "install", "twine"]) + yield tmp_venv_dir / "bin" / "twine" + + +def upload_dist(dist: Path, dry_run: bool = True) -> None: + with tmp_twine() as twine: + files = [item for item in dist.iterdir() if is_whl_or_tar(item.name)] + cmd: List[Any] = [twine, "upload"] + cmd += files + if dry_run: + print("[dry run] " + " ".join(map(str, cmd))) else: - self.heading('Successfully built wheel and sdist for mypy {}'.format(self.version)) - dist_dir = os.path.join(self.repo_dir, 'dist') - print('Generated packages:') - for fnam in sorted(os.listdir(dist_dir)): - print(' {}'.format(os.path.join(dist_dir, fnam))) - - def prompt(self) -> None: - if self.force: - return - extra = '' if self.no_upload else ' and upload' - print('This will build{} PyPI packages for mypy {}.'.format(extra, self.version)) - response = input('Proceed? [yN] ') - if response.lower() != 'y': - sys.exit('Exiting') - - def verify_version(self) -> None: - version_path = os.path.join(self.repo_dir, 'mypy', 'version.py') - with open(version_path) as f: - contents = f.read() - if "'{}'".format(self.version) not in contents: - sys.stderr.write( - '\nError: Version {} does not match {}/mypy/version.py\n'.format( - self.version, self.repo_dir)) - sys.exit(2) - - def run_sanity_checks(self) -> None: - if not sys.version_info >= (3, 6): - sys.exit('You must use Python 3.6 or later to build mypy') - if sys.platform != 'darwin' and not self.force: - sys.exit('You should run this on macOS; use --force to go ahead anyway') - os_file = os.path.realpath(os.__file__) - if not os_file.startswith('/Library/Frameworks') and not self.force: - # Be defensive -- Python from brew may produce bad packages, for example. - sys.exit('Error -- run this script using an official Python build from python.org') - if getpass.getuser() == 'root': - sys.exit('This script must not be run as root') - - def git_clone_repo(self) -> None: - self.heading('Cloning mypy git repository') - self.run('git clone https://github.com/python/mypy') - - def git_check_out_tag(self) -> None: - tag = 'v{}'.format(self.version) - self.heading('Check out {}'.format(tag)) - self.run('cd mypy && git checkout {}'.format(tag)) - self.run('cd mypy && git submodule update --init') - - def make_virtualenv(self) -> None: - self.heading('Creating a fresh virtualenv') - self.run('python3 -m virtualenv -p {} mypy-venv'.format(sys.executable)) - - def install_dependencies(self) -> None: - self.heading('Installing build dependencies') - self.run_in_virtualenv('pip3 install wheel twine && pip3 install -U setuptools') - - def make_wheel(self) -> None: - self.heading('Building wheel') - self.run_in_virtualenv('python3 setup.py bdist_wheel') - - def make_sdist(self) -> None: - self.heading('Building sdist') - self.run_in_virtualenv('python3 setup.py sdist') - - def download_compiled_wheels(self) -> None: - self.heading('Downloading wheels compiled with mypyc') - # N.B: We run the version in the current checkout instead of - # the one in the version we are releasing, in case we needed - # to fix the script. - self.run_in_virtualenv( - '%s %s' % - (os.path.abspath('misc/download-mypyc-wheels.py'), self.version)) - - def upload_wheels(self) -> None: - self.heading('Uploading wheels') - for name in os.listdir(os.path.join(self.target_dir, 'mypy', 'dist')): - if name.startswith('mypy-{}-'.format(self.version)) and name.endswith('.whl'): - self.run_in_virtualenv( - 'twine upload dist/{}'.format(name)) - - def upload_sdist(self) -> None: - self.heading('Uploading sdist') - self.run_in_virtualenv('twine upload dist/mypy-{}.tar.gz'.format(self.version)) - - def run(self, cmd: str) -> None: - try: - subprocess.check_call(cmd, shell=True, cwd=self.target_dir) - except subprocess.CalledProcessError: - sys.stderr.write('Error: Command {!r} failed\n'.format(cmd)) - sys.exit(1) - - def run_in_virtualenv(self, cmd: str) -> None: - self.run('. mypy-venv/bin/activate && cd mypy &&' + cmd) - - def heading(self, heading: str) -> None: - print() - print('==== {} ===='.format(heading)) - print() - - -def parse_args() -> Any: - parser = argparse.ArgumentParser( - description='PyPI mypy package uploader (for non-Windows packages only)') - parser.add_argument('--force', action='store_true', default=False, - help='Skip prompts and sanity checks (be careful!)') - parser.add_argument('--no-upload', action='store_true', default=False, - help="Only build packages but don't upload") - parser.add_argument('version', help='Mypy version to release') - return parser.parse_args() - - -if __name__ == '__main__': - args = parse_args() - builder = Builder(args.version, args.force, args.no_upload) - builder.build_and_upload() + print(" ".join(map(str, cmd))) + subprocess.check_call(cmd) + + +def upload_to_pypi(version: str, dry_run: bool = True) -> None: + assert re.match(r"0\.[0-9]{3}$", version) + + target_dir = tempfile.mkdtemp() + dist = Path(target_dir) / "dist" + dist.mkdir() + print(f"Temporary target directory: {target_dir}") + + release = get_release_for_tag(f"v{version}") + download_all_release_assets(release, dist) + + spot_check_dist(dist, version) + check_sdist(dist, version) + upload_dist(dist, dry_run) + print("<< All done! >>") + + +def main() -> None: + parser = argparse.ArgumentParser(description="PyPI mypy package uploader") + parser.add_argument( + "--dry-run", action="store_true", default=False, help="Don't actually upload packages" + ) + parser.add_argument("version", help="mypy version to release") + args = parser.parse_args() + + upload_to_pypi(args.version, args.dry_run) + + +if __name__ == "__main__": + main() From 2d647e2c0604a9f86a7cd3faf6115e1521067efa Mon Sep 17 00:00:00 2001 From: hauntsaninja <> Date: Fri, 9 Oct 2020 00:53:46 -0700 Subject: [PATCH 2/4] update trigger wheel build for new wheel building --- misc/trigger_wheel_build.sh | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/misc/trigger_wheel_build.sh b/misc/trigger_wheel_build.sh index 411030a5d6f4..469064fe8133 100755 --- a/misc/trigger_wheel_build.sh +++ b/misc/trigger_wheel_build.sh @@ -5,20 +5,18 @@ # $WHEELS_PUSH_TOKEN is stored in travis and is an API token for the # mypy-build-bot account. -git clone --recurse-submodules https://${WHEELS_PUSH_TOKEN}@github.com/mypyc/mypy_mypyc-wheels.git build git config --global user.email "nobody" git config --global user.name "mypy wheels autopush" COMMIT=$(git rev-parse HEAD) -cd build/mypy -git fetch -git checkout $COMMIT -git submodule update -pip install -r test-requirements.txt +pip install -r mypy-requirements.txt V=$(python3 -m mypy --version) V=$(echo "$V" | cut -d" " -f2) -cd .. + +git clone https://${WHEELS_PUSH_TOKEN}@github.com/mypyc/mypy_mypyc-wheels.git build +cd build +echo $COMMIT > mypy_commit git commit -am "Build wheels for mypy $V" git tag v$V # Push a tag, but no need to push the change to master From b53e37c1be88556886c25af6e222a8b393f2490f Mon Sep 17 00:00:00 2001 From: hauntsaninja <> Date: Fri, 9 Oct 2020 00:54:10 -0700 Subject: [PATCH 3/4] remove more scripts download-mypyc-wheels is now subsumed by upload-pypi, which uses the Github API instead of hardcoding filenames. test_installed_version is part of mypyc_mypy-wheels and some of what it does is taken care of by cibuildwheel --- misc/download-mypyc-wheels.py | 56 ---------------------------------- misc/test_installed_version.sh | 42 ------------------------- 2 files changed, 98 deletions(-) delete mode 100755 misc/download-mypyc-wheels.py delete mode 100755 misc/test_installed_version.sh diff --git a/misc/download-mypyc-wheels.py b/misc/download-mypyc-wheels.py deleted file mode 100755 index 0b9722cabd57..000000000000 --- a/misc/download-mypyc-wheels.py +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env python3 -# Script for downloading mypyc-compiled mypy wheels in preparation for a release - -import os -import os.path -import sys -from urllib.request import urlopen - - -PLATFORMS = [ - 'macosx_10_{macos_ver}_x86_64', - 'manylinux1_x86_64', - 'win_amd64', -] -MIN_VER = 5 -MAX_VER = 8 -BASE_URL = "https://github.com/mypyc/mypy_mypyc-wheels/releases/download" -URL = "{base}/v{version}/mypy-{version}-cp3{pyver}-cp3{pyver}{abi_tag}-{platform}.whl" - -def download(url): - print('Downloading', url) - name = os.path.join('dist', os.path.split(url)[1]) - with urlopen(url) as f: - data = f.read() - with open(name, 'wb') as f: - f.write(data) - -def download_files(version): - for pyver in range(MIN_VER, MAX_VER + 1): - for platform in PLATFORMS: - abi_tag = "" if pyver >= 8 else "m" - macos_ver = 9 if pyver >= 6 else 6 - url = URL.format( - base=BASE_URL, - version=version, - pyver=pyver, - abi_tag=abi_tag, - platform=platform.format(macos_ver=macos_ver) - ) - # argh, there is an inconsistency here and I don't know why - if 'win_' in platform: - parts = url.rsplit('/', 1) - parts[1] = parts[1].replace("+dev", ".dev") - url = '/'.join(parts) - - download(url) - -def main(argv): - if len(argv) != 2: - sys.exit("Usage: download-mypy-wheels.py version") - - os.makedirs('dist', exist_ok=True) - download_files(argv[1]) - -if __name__ == '__main__': - main(sys.argv) diff --git a/misc/test_installed_version.sh b/misc/test_installed_version.sh deleted file mode 100755 index 7182c9556a12..000000000000 --- a/misc/test_installed_version.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/bin/bash -ex - -# Usage: misc/test_installed_version.sh [wheel] [python command] -# Installs a version of mypy into a virtualenv and tests it. - -# A bunch of stuff about mypy's code organization and test setup makes -# it annoying to test an installed version of mypy. If somebody has a -# better way please let me know. - -function abspath { - python3 -c "import os.path; print(os.path.abspath('$1'))" -} - -TO_INSTALL="${1-.}" -PYTHON="${2-python3}" -VENV="$(mktemp -d -t mypy-test-venv.XXXXXXXXXX)" -trap "rm -rf '$VENV'" EXIT - -"$PYTHON" -m virtualenv "$VENV" -source "$VENV/bin/activate" - -ROOT="$PWD" -TO_INSTALL="$(abspath "$TO_INSTALL")" - -# Change directory so we can't pick up any of the stuff in the root. -# We need to do this before installing things too because I was having -# the current mypy directory getting picked up as satisfying the -# requirement (argh!) -cd "$VENV" - -pip install -r "$ROOT/test-requirements.txt" -pip install $TO_INSTALL - -# pytest looks for configuration files in the parent directories of -# where the tests live. Since we are trying to run the tests from -# their installed location, we copy those into the venv. Ew ew ew. -cp "$ROOT/pytest.ini" "$ROOT/conftest.py" "$VENV/" - -# Find the directory that mypy tests were installed into -MYPY_TEST_DIR="$(python3 -c 'import mypy.test; print(mypy.test.__path__[0])')" -# Run the mypy tests -MYPY_TEST_PREFIX="$ROOT" python3 -m pytest "$MYPY_TEST_DIR"/test*.py From 042975e6991c93b36a6cdb648fad4bc17ab71702 Mon Sep 17 00:00:00 2001 From: hauntsaninja <> Date: Fri, 9 Oct 2020 14:33:52 -0700 Subject: [PATCH 4/4] fix repo :-) --- misc/upload-pypi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/upload-pypi.py b/misc/upload-pypi.py index b122ea1eb38c..7aa9553cf4f8 100644 --- a/misc/upload-pypi.py +++ b/misc/upload-pypi.py @@ -20,7 +20,7 @@ from urllib.request import urlopen BASE = "https://api.github.com/repos" -REPO = "hauntsaninja/mypy_mypyc-wheelsv2" +REPO = "mypyc/mypy_mypyc-wheels" def is_whl_or_tar(name: str) -> bool: