|
1 | 1 | #!/usr/bin/env python3
|
2 |
| -"""Build and upload mypy packages for Linux and macOS to PyPI. |
| 2 | +"""Upload mypy packages to PyPI. |
3 | 3 |
|
4 |
| -*** You must first tag the release and use `git push --tags`. *** |
5 |
| -
|
6 |
| -Note: This should be run on macOS using official python.org Python 3.6 or |
7 |
| - later, as this is the only tested configuration. Use --force to |
8 |
| - run anyway. |
9 |
| -
|
10 |
| -This uses a fresh repo clone and a fresh virtualenv to avoid depending on |
11 |
| -local state. |
12 |
| -
|
13 |
| -Ideas for improvements: |
14 |
| -
|
15 |
| -- also upload Windows wheels |
16 |
| -- try installing the generated packages and running mypy |
17 |
| -- try installing the uploaded packages and running mypy |
18 |
| -- run tests |
19 |
| -- verify that there is a green travis build |
| 4 | +You must first tag the release, use `git push --tags` and wait for the wheel build in CI to complete. |
20 | 5 |
|
21 | 6 | """
|
22 | 7 |
|
23 | 8 | import argparse
|
24 |
| -import getpass |
25 |
| -import os |
26 |
| -import os.path |
| 9 | +import contextlib |
| 10 | +import json |
27 | 11 | import re
|
| 12 | +import shutil |
28 | 13 | import subprocess
|
29 |
| -import sys |
| 14 | +import tarfile |
30 | 15 | import tempfile
|
31 |
| -from typing import Any |
32 |
| - |
33 |
| - |
34 |
| -class Builder: |
35 |
| - def __init__(self, version: str, force: bool, no_upload: bool) -> None: |
36 |
| - if not re.match(r'0\.[0-9]{3}$', version): |
37 |
| - sys.exit('Invalid version {!r} (expected form 0.123)'.format(version)) |
38 |
| - self.version = version |
39 |
| - self.force = force |
40 |
| - self.no_upload = no_upload |
41 |
| - self.target_dir = tempfile.mkdtemp() |
42 |
| - self.repo_dir = os.path.join(self.target_dir, 'mypy') |
43 |
| - |
44 |
| - def build_and_upload(self) -> None: |
45 |
| - self.prompt() |
46 |
| - self.run_sanity_checks() |
47 |
| - print('Temporary target directory: {}'.format(self.target_dir)) |
48 |
| - self.git_clone_repo() |
49 |
| - self.git_check_out_tag() |
50 |
| - self.verify_version() |
51 |
| - self.make_virtualenv() |
52 |
| - self.install_dependencies() |
53 |
| - self.make_wheel() |
54 |
| - self.make_sdist() |
55 |
| - self.download_compiled_wheels() |
56 |
| - if not self.no_upload: |
57 |
| - self.upload_wheels() |
58 |
| - self.upload_sdist() |
59 |
| - self.heading('Successfully uploaded wheel and sdist for mypy {}'.format(self.version)) |
60 |
| - print("<< All done! >>") |
| 16 | +import venv |
| 17 | +from concurrent.futures import ThreadPoolExecutor |
| 18 | +from pathlib import Path |
| 19 | +from typing import Any, Dict, Iterator, List |
| 20 | +from urllib.request import urlopen |
| 21 | + |
| 22 | +BASE = "https://api.github.com/repos" |
| 23 | +REPO = "hauntsaninja/mypy_mypyc-wheelsv2" |
| 24 | + |
| 25 | + |
| 26 | +def is_whl_or_tar(name: str) -> bool: |
| 27 | + return name.endswith(".tar.gz") or name.endswith(".whl") |
| 28 | + |
| 29 | + |
| 30 | +def get_release_for_tag(tag: str) -> Dict[str, Any]: |
| 31 | + with urlopen(f"{BASE}/{REPO}/releases/tags/{tag}") as f: |
| 32 | + data = json.load(f) |
| 33 | + assert data["tag_name"] == tag |
| 34 | + return data |
| 35 | + |
| 36 | + |
| 37 | +def download_asset(asset: Dict[str, Any], dst: Path) -> Path: |
| 38 | + name = asset["name"] |
| 39 | + download_url = asset["browser_download_url"] |
| 40 | + assert is_whl_or_tar(name) |
| 41 | + with urlopen(download_url) as src_file: |
| 42 | + with open(dst / name, "wb") as dst_file: |
| 43 | + shutil.copyfileobj(src_file, dst_file) |
| 44 | + return dst / name |
| 45 | + |
| 46 | + |
| 47 | +def download_all_release_assets(release: Dict[str, Any], dst: Path) -> None: |
| 48 | + print(f"Downloading assets...") |
| 49 | + with ThreadPoolExecutor() as e: |
| 50 | + for asset in e.map(lambda asset: download_asset(asset, dst), release["assets"]): |
| 51 | + print(f"Downloaded {asset}") |
| 52 | + |
| 53 | + |
| 54 | +def check_sdist(dist: Path, version: str) -> None: |
| 55 | + tarfiles = list(dist.glob("*.tar.gz")) |
| 56 | + assert len(tarfiles) == 1 |
| 57 | + sdist = tarfiles[0] |
| 58 | + assert version in sdist.name |
| 59 | + with tarfile.open(sdist) as f: |
| 60 | + version_py = f.extractfile(f"{sdist.name[:-len('.tar.gz')]}/mypy/version.py") |
| 61 | + assert version_py is not None |
| 62 | + assert f"'{version}'" in version_py.read().decode("utf-8") |
| 63 | + |
| 64 | + |
| 65 | +def spot_check_dist(dist: Path, version: str) -> None: |
| 66 | + items = [item for item in dist.iterdir() if is_whl_or_tar(item.name)] |
| 67 | + assert len(items) > 10 |
| 68 | + assert all(version in item.name for item in items) |
| 69 | + assert any(item.name.endswith("py3-none-any.whl") for item in items) |
| 70 | + |
| 71 | + |
| 72 | +@contextlib.contextmanager |
| 73 | +def tmp_twine() -> Iterator[Path]: |
| 74 | + with tempfile.TemporaryDirectory() as tmp_dir: |
| 75 | + tmp_venv_dir = Path(tmp_dir) / "venv" |
| 76 | + venv.create(tmp_venv_dir, with_pip=True) |
| 77 | + pip_exe = tmp_venv_dir / "bin" / "pip" |
| 78 | + subprocess.check_call([pip_exe, "install", "twine"]) |
| 79 | + yield tmp_venv_dir / "bin" / "twine" |
| 80 | + |
| 81 | + |
| 82 | +def upload_dist(dist: Path, dry_run: bool = True) -> None: |
| 83 | + with tmp_twine() as twine: |
| 84 | + files = [item for item in dist.iterdir() if is_whl_or_tar(item.name)] |
| 85 | + cmd: List[Any] = [twine, "upload"] |
| 86 | + cmd += files |
| 87 | + if dry_run: |
| 88 | + print("[dry run] " + " ".join(map(str, cmd))) |
61 | 89 | else:
|
62 |
| - self.heading('Successfully built wheel and sdist for mypy {}'.format(self.version)) |
63 |
| - dist_dir = os.path.join(self.repo_dir, 'dist') |
64 |
| - print('Generated packages:') |
65 |
| - for fnam in sorted(os.listdir(dist_dir)): |
66 |
| - print(' {}'.format(os.path.join(dist_dir, fnam))) |
67 |
| - |
68 |
| - def prompt(self) -> None: |
69 |
| - if self.force: |
70 |
| - return |
71 |
| - extra = '' if self.no_upload else ' and upload' |
72 |
| - print('This will build{} PyPI packages for mypy {}.'.format(extra, self.version)) |
73 |
| - response = input('Proceed? [yN] ') |
74 |
| - if response.lower() != 'y': |
75 |
| - sys.exit('Exiting') |
76 |
| - |
77 |
| - def verify_version(self) -> None: |
78 |
| - version_path = os.path.join(self.repo_dir, 'mypy', 'version.py') |
79 |
| - with open(version_path) as f: |
80 |
| - contents = f.read() |
81 |
| - if "'{}'".format(self.version) not in contents: |
82 |
| - sys.stderr.write( |
83 |
| - '\nError: Version {} does not match {}/mypy/version.py\n'.format( |
84 |
| - self.version, self.repo_dir)) |
85 |
| - sys.exit(2) |
86 |
| - |
87 |
| - def run_sanity_checks(self) -> None: |
88 |
| - if not sys.version_info >= (3, 6): |
89 |
| - sys.exit('You must use Python 3.6 or later to build mypy') |
90 |
| - if sys.platform != 'darwin' and not self.force: |
91 |
| - sys.exit('You should run this on macOS; use --force to go ahead anyway') |
92 |
| - os_file = os.path.realpath(os.__file__) |
93 |
| - if not os_file.startswith('/Library/Frameworks') and not self.force: |
94 |
| - # Be defensive -- Python from brew may produce bad packages, for example. |
95 |
| - sys.exit('Error -- run this script using an official Python build from python.org') |
96 |
| - if getpass.getuser() == 'root': |
97 |
| - sys.exit('This script must not be run as root') |
98 |
| - |
99 |
| - def git_clone_repo(self) -> None: |
100 |
| - self.heading('Cloning mypy git repository') |
101 |
| - self.run('git clone https://github.com/python/mypy') |
102 |
| - |
103 |
| - def git_check_out_tag(self) -> None: |
104 |
| - tag = 'v{}'.format(self.version) |
105 |
| - self.heading('Check out {}'.format(tag)) |
106 |
| - self.run('cd mypy && git checkout {}'.format(tag)) |
107 |
| - self.run('cd mypy && git submodule update --init') |
108 |
| - |
109 |
| - def make_virtualenv(self) -> None: |
110 |
| - self.heading('Creating a fresh virtualenv') |
111 |
| - self.run('python3 -m virtualenv -p {} mypy-venv'.format(sys.executable)) |
112 |
| - |
113 |
| - def install_dependencies(self) -> None: |
114 |
| - self.heading('Installing build dependencies') |
115 |
| - self.run_in_virtualenv('pip3 install wheel twine && pip3 install -U setuptools') |
116 |
| - |
117 |
| - def make_wheel(self) -> None: |
118 |
| - self.heading('Building wheel') |
119 |
| - self.run_in_virtualenv('python3 setup.py bdist_wheel') |
120 |
| - |
121 |
| - def make_sdist(self) -> None: |
122 |
| - self.heading('Building sdist') |
123 |
| - self.run_in_virtualenv('python3 setup.py sdist') |
124 |
| - |
125 |
| - def download_compiled_wheels(self) -> None: |
126 |
| - self.heading('Downloading wheels compiled with mypyc') |
127 |
| - # N.B: We run the version in the current checkout instead of |
128 |
| - # the one in the version we are releasing, in case we needed |
129 |
| - # to fix the script. |
130 |
| - self.run_in_virtualenv( |
131 |
| - '%s %s' % |
132 |
| - (os.path.abspath('misc/download-mypyc-wheels.py'), self.version)) |
133 |
| - |
134 |
| - def upload_wheels(self) -> None: |
135 |
| - self.heading('Uploading wheels') |
136 |
| - for name in os.listdir(os.path.join(self.target_dir, 'mypy', 'dist')): |
137 |
| - if name.startswith('mypy-{}-'.format(self.version)) and name.endswith('.whl'): |
138 |
| - self.run_in_virtualenv( |
139 |
| - 'twine upload dist/{}'.format(name)) |
140 |
| - |
141 |
| - def upload_sdist(self) -> None: |
142 |
| - self.heading('Uploading sdist') |
143 |
| - self.run_in_virtualenv('twine upload dist/mypy-{}.tar.gz'.format(self.version)) |
144 |
| - |
145 |
| - def run(self, cmd: str) -> None: |
146 |
| - try: |
147 |
| - subprocess.check_call(cmd, shell=True, cwd=self.target_dir) |
148 |
| - except subprocess.CalledProcessError: |
149 |
| - sys.stderr.write('Error: Command {!r} failed\n'.format(cmd)) |
150 |
| - sys.exit(1) |
151 |
| - |
152 |
| - def run_in_virtualenv(self, cmd: str) -> None: |
153 |
| - self.run('. mypy-venv/bin/activate && cd mypy &&' + cmd) |
154 |
| - |
155 |
| - def heading(self, heading: str) -> None: |
156 |
| - print() |
157 |
| - print('==== {} ===='.format(heading)) |
158 |
| - print() |
159 |
| - |
160 |
| - |
161 |
| -def parse_args() -> Any: |
162 |
| - parser = argparse.ArgumentParser( |
163 |
| - description='PyPI mypy package uploader (for non-Windows packages only)') |
164 |
| - parser.add_argument('--force', action='store_true', default=False, |
165 |
| - help='Skip prompts and sanity checks (be careful!)') |
166 |
| - parser.add_argument('--no-upload', action='store_true', default=False, |
167 |
| - help="Only build packages but don't upload") |
168 |
| - parser.add_argument('version', help='Mypy version to release') |
169 |
| - return parser.parse_args() |
170 |
| - |
171 |
| - |
172 |
| -if __name__ == '__main__': |
173 |
| - args = parse_args() |
174 |
| - builder = Builder(args.version, args.force, args.no_upload) |
175 |
| - builder.build_and_upload() |
| 90 | + print(" ".join(map(str, cmd))) |
| 91 | + subprocess.check_call(cmd) |
| 92 | + |
| 93 | + |
| 94 | +def upload_to_pypi(version: str, dry_run: bool = True) -> None: |
| 95 | + assert re.match(r"0\.[0-9]{3}$", version) |
| 96 | + |
| 97 | + target_dir = tempfile.mkdtemp() |
| 98 | + dist = Path(target_dir) / "dist" |
| 99 | + dist.mkdir() |
| 100 | + print(f"Temporary target directory: {target_dir}") |
| 101 | + |
| 102 | + release = get_release_for_tag(f"v{version}") |
| 103 | + download_all_release_assets(release, dist) |
| 104 | + |
| 105 | + spot_check_dist(dist, version) |
| 106 | + check_sdist(dist, version) |
| 107 | + upload_dist(dist, dry_run) |
| 108 | + print("<< All done! >>") |
| 109 | + |
| 110 | + |
| 111 | +def main() -> None: |
| 112 | + parser = argparse.ArgumentParser(description="PyPI mypy package uploader") |
| 113 | + parser.add_argument( |
| 114 | + "--dry-run", action="store_true", default=False, help="Don't actually upload packages" |
| 115 | + ) |
| 116 | + parser.add_argument("version", help="mypy version to release") |
| 117 | + args = parser.parse_args() |
| 118 | + |
| 119 | + upload_to_pypi(args.version, args.dry_run) |
| 120 | + |
| 121 | + |
| 122 | +if __name__ == "__main__": |
| 123 | + main() |
0 commit comments