Skip to content

Commit 035ac2f

Browse files
author
hauntsaninja
committed
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.
1 parent 952ca23 commit 035ac2f

File tree

1 file changed

+113
-165
lines changed

1 file changed

+113
-165
lines changed

misc/upload-pypi.py

Lines changed: 113 additions & 165 deletions
Original file line numberDiff line numberDiff line change
@@ -1,175 +1,123 @@
11
#!/usr/bin/env python3
2-
"""Build and upload mypy packages for Linux and macOS to PyPI.
2+
"""Upload mypy packages to PyPI.
33
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.
205
216
"""
227

238
import argparse
24-
import getpass
25-
import os
26-
import os.path
9+
import contextlib
10+
import json
2711
import re
12+
import shutil
2813
import subprocess
29-
import sys
14+
import tarfile
3015
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)))
6189
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

Comments
 (0)