Skip to content

Commit d394386

Browse files
mayeuthenryiiijoerick
authored
feat: update python versions as part of update_dependencies (#496)
* Update python versions as part of update_dependencies * refactor: pulling out to update_pythons * refactor: rewrite and expect install * feat: support macos too, logging output * WIP: update * refactor: drive from original file * Remove unused variable * Output a diff instead of the result file, to review changes more easily * fix: minor cleanup Co-authored-by: Henry Fredrick Schreiner <henry.fredrick.schreiner@cern.ch> Co-authored-by: Henry Schreiner <henryschreineriii@gmail.com> Co-authored-by: Joe Rickerby <joerick@mac.com>
1 parent ca7871c commit d394386

8 files changed

+384
-20
lines changed

.github/workflows/update-dependencies.yml

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ jobs:
1717
python-version: 3.9
1818
architecture: x64
1919
- name: Install dependencies
20-
run: python -m pip install requests pip-tools
21-
- name: Run update
20+
run: python -m pip install ".[dev]"
21+
- name: "Run update: dependencies"
2222
run: python ./bin/update_dependencies.py
23+
- name: "Run update: python configs"
24+
run: python ./bin/update_pythons.py --force
2325
- name: Create Pull Request
2426
if: github.ref == 'refs/heads/master'
2527
uses: peter-evans/create-pull-request@v3

.pre-commit-config.yaml

+9-1
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,20 @@ repos:
1616
hooks:
1717
- id: isort
1818

19+
- repo: https://github.com/psf/black
20+
rev: 20.8b1
21+
hooks:
22+
- id: black
23+
files: ^bin/update_pythons.py$
24+
args: ["--line-length=120"]
25+
1926
- repo: https://github.com/pre-commit/mirrors-mypy
2027
rev: v0.790
2128
hooks:
2229
- id: mypy
23-
files: ^(cibuildwheel/|test/|bin/projects.py|unit_test/)
30+
files: ^(cibuildwheel/|test/|bin/projects.py|bin/update_pythons.py|unit_test/)
2431
pass_filenames: false
32+
additional_dependencies: [packaging, click]
2533

2634
- repo: https://github.com/asottile/pyupgrade
2735
rev: v2.7.4

bin/update_dependencies.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@
2828
'--output-file', f'cibuildwheel/resources/constraints-python{python_version}.txt'
2929
])
3030
else:
31-
image = 'quay.io/pypa/manylinux2010_x86_64:latest'
32-
subprocess.check_call(['docker', 'pull', image])
31+
image_runner = 'quay.io/pypa/manylinux2010_x86_64:latest'
32+
subprocess.check_call(['docker', 'pull', image_runner])
3333
for python_version in PYTHON_VERSIONS:
3434
abi_flags = '' if int(python_version) >= 38 else 'm'
3535
python_path = f'/opt/python/cp{python_version}-cp{python_version}{abi_flags}/bin/'
3636
subprocess.check_call([
3737
'docker', 'run', '--rm',
3838
'-e', 'CUSTOM_COMPILE_COMMAND',
3939
'-v', f'{os.getcwd()}:/volume',
40-
'--workdir', '/volume', image,
40+
'--workdir', '/volume', image_runner,
4141
'bash', '-c',
4242
f'{python_path}pip install pip-tools &&'
4343
f'{python_path}pip-compile --allow-unsafe --upgrade '

bin/update_pythons.py

+310
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
#!/usr/bin/env python3
2+
3+
import copy
4+
import difflib
5+
import logging
6+
from pathlib import Path
7+
from typing import Dict, Optional, Union
8+
9+
import click
10+
import requests
11+
import rich
12+
import toml
13+
from packaging.specifiers import Specifier
14+
from packaging.version import Version
15+
from rich.logging import RichHandler
16+
from rich.syntax import Syntax
17+
18+
from cibuildwheel.extra import InlineArrayDictEncoder
19+
from cibuildwheel.typing import Final, Literal, TypedDict
20+
21+
log = logging.getLogger("cibw")
22+
23+
# Looking up the dir instead of using utils.resources_dir
24+
# since we want to write to it.
25+
DIR: Final[Path] = Path(__file__).parent.parent.resolve()
26+
RESOURCES_DIR: Final[Path] = DIR / "cibuildwheel/resources"
27+
28+
29+
ArchStr = Literal["32", "64"]
30+
31+
32+
class ConfigWinCP(TypedDict):
33+
identifier: str
34+
version: str
35+
arch: str
36+
37+
38+
class ConfigWinPP(TypedDict):
39+
identifier: str
40+
version: str
41+
arch: str
42+
url: str
43+
44+
45+
class ConfigMacOS(TypedDict):
46+
identifier: str
47+
version: str
48+
url: str
49+
50+
51+
AnyConfig = Union[ConfigWinCP, ConfigWinPP, ConfigMacOS]
52+
53+
54+
# The following set of "Versions" classes allow the initial call to the APIs to
55+
# be cached and reused in the `update_version_*` methods.
56+
57+
58+
class WindowsVersions:
59+
def __init__(self, arch_str: ArchStr) -> None:
60+
61+
response = requests.get("https://api.nuget.org/v3/index.json")
62+
response.raise_for_status()
63+
api_info = response.json()
64+
65+
for resource in api_info["resources"]:
66+
if resource["@type"] == "PackageBaseAddress/3.0.0":
67+
endpoint = resource["@id"]
68+
69+
ARCH_DICT = {"32": "win32", "64": "win_amd64"}
70+
PACKAGE_DICT = {"32": "pythonx86", "64": "python"}
71+
72+
self.arch_str = arch_str
73+
self.arch = ARCH_DICT[arch_str]
74+
package = PACKAGE_DICT[arch_str]
75+
76+
response = requests.get(f"{endpoint}{package}/index.json")
77+
response.raise_for_status()
78+
cp_info = response.json()
79+
80+
versions = (Version(v) for v in cp_info["versions"])
81+
self.versions = sorted(v for v in versions if not v.is_devrelease)
82+
83+
def update_version_windows(self, spec: Specifier) -> Optional[ConfigWinCP]:
84+
versions = sorted(v for v in self.versions if spec.contains(v))
85+
if not all(v.is_prerelease for v in versions):
86+
versions = [v for v in versions if not v.is_prerelease]
87+
log.debug(f"Windows {self.arch} {spec} has {', '.join(str(v) for v in versions)}")
88+
89+
if not versions:
90+
return None
91+
92+
version = versions[-1]
93+
identifier = f"cp{version.major}{version.minor}-{self.arch}"
94+
result = ConfigWinCP(
95+
identifier=identifier,
96+
version=str(version),
97+
arch=self.arch_str,
98+
)
99+
return result
100+
101+
102+
class PyPyVersions:
103+
def __init__(self, arch_str: ArchStr):
104+
105+
response = requests.get("https://downloads.python.org/pypy/versions.json")
106+
response.raise_for_status()
107+
108+
releases = [r for r in response.json() if r["pypy_version"] != "nightly"]
109+
for release in releases:
110+
release["pypy_version"] = Version(release["pypy_version"])
111+
release["python_version"] = Version(release["python_version"])
112+
113+
self.releases = [
114+
r for r in releases if not r["pypy_version"].is_prerelease and not r["pypy_version"].is_devrelease
115+
]
116+
self.arch = arch_str
117+
118+
def update_version_windows(self, spec: Specifier) -> ConfigWinCP:
119+
if self.arch != "32":
120+
raise RuntimeError("64 bit releases not supported yet on Windows")
121+
122+
releases = [r for r in self.releases if spec.contains(r["python_version"])]
123+
releases = sorted(releases, key=lambda r: r["pypy_version"])
124+
125+
if not releases:
126+
raise RuntimeError(f"PyPy Win {self.arch} not found for {spec}! {self.releases}")
127+
128+
release = releases[-1]
129+
version = release["python_version"]
130+
identifier = f"pp{version.major}{version.minor}-win32"
131+
132+
(url,) = [rf["download_url"] for rf in release["files"] if "" in rf["platform"] == "win32"]
133+
134+
return ConfigWinPP(
135+
identifier=identifier,
136+
version=f"{version.major}.{version.minor}",
137+
arch="32",
138+
url=url,
139+
)
140+
141+
def update_version_macos(self, spec: Specifier) -> ConfigMacOS:
142+
if self.arch != "64":
143+
raise RuntimeError("Other archs not supported yet on macOS")
144+
145+
releases = [r for r in self.releases if spec.contains(r["python_version"])]
146+
releases = sorted(releases, key=lambda r: r["pypy_version"])
147+
148+
if not releases:
149+
raise RuntimeError(f"PyPy macOS {self.arch} not found for {spec}!")
150+
151+
release = releases[-1]
152+
version = release["python_version"]
153+
identifier = f"pp{version.major}{version.minor}-macosx_x86_64"
154+
155+
(url,) = [
156+
rf["download_url"] for rf in release["files"] if "" in rf["platform"] == "darwin" and rf["arch"] == "x64"
157+
]
158+
159+
return ConfigMacOS(
160+
identifier=identifier,
161+
version=f"{version.major}.{version.minor}",
162+
url=url,
163+
)
164+
165+
166+
class CPythonVersions:
167+
def __init__(self, plat_arch: str, file_ident: str) -> None:
168+
169+
response = requests.get("https://www.python.org/api/v2/downloads/release/?is_published=true")
170+
response.raise_for_status()
171+
172+
releases_info = response.json()
173+
174+
self.versions_dict: Dict[Version, int] = {}
175+
for release in releases_info:
176+
# Removing the prefix, Python 3.9 would use: release["name"].removeprefix("Python ")
177+
version = Version(release["name"][7:])
178+
179+
if not version.is_prerelease and not version.is_devrelease:
180+
uri = int(release["resource_uri"].rstrip("/").split("/")[-1])
181+
self.versions_dict[version] = uri
182+
183+
self.file_ident = file_ident
184+
self.plat_arch = plat_arch
185+
186+
def update_version_macos(self, spec: Specifier) -> Optional[ConfigMacOS]:
187+
188+
sorted_versions = sorted(v for v in self.versions_dict if spec.contains(v))
189+
190+
for version in reversed(sorted_versions):
191+
# Find the first patch version that contains the requested file
192+
uri = self.versions_dict[version]
193+
response = requests.get(f"https://www.python.org/api/v2/downloads/release_file/?release={uri}")
194+
response.raise_for_status()
195+
file_info = response.json()
196+
197+
urls = [rf["url"] for rf in file_info if self.file_ident in rf["url"]]
198+
if urls:
199+
return ConfigMacOS(
200+
identifier=f"cp{version.major}{version.minor}-{self.plat_arch}",
201+
version=f"{version.major}.{version.minor}",
202+
url=urls[0],
203+
)
204+
205+
return None
206+
207+
208+
# This is a universal interface to all the above Versions classes. Given an
209+
# identifier, it updates a config dict.
210+
211+
212+
class AllVersions:
213+
def __init__(self) -> None:
214+
self.windows_32 = WindowsVersions("32")
215+
self.windows_64 = WindowsVersions("64")
216+
self.windows_pypy = PyPyVersions("32")
217+
218+
self.macos_6 = CPythonVersions(plat_arch="macosx_x86_64", file_ident="macosx10.6.pkg")
219+
self.macos_9 = CPythonVersions(plat_arch="macosx_x86_64", file_ident="macosx10.9.pkg")
220+
self.macos_u2 = CPythonVersions(plat_arch="macosx_universal2", file_ident="macos11.0.pkg")
221+
self.macos_pypy = PyPyVersions("64")
222+
223+
def update_config(self, config: Dict[str, str]) -> None:
224+
identifier = config["identifier"]
225+
version = Version(config["version"])
226+
spec = Specifier(f"=={version.major}.{version.minor}.*")
227+
log.info(f"Reading in '{identifier}' -> {spec} @ {version}")
228+
orig_config = copy.copy(config)
229+
config_update: Optional[AnyConfig]
230+
231+
# We need to use ** in update due to MyPy (probably a bug)
232+
if "macosx_x86_64" in identifier:
233+
if identifier.startswith("pp"):
234+
config_update = self.macos_pypy.update_version_macos(spec)
235+
else:
236+
config_update = self.macos_9.update_version_macos(spec) or self.macos_6.update_version_macos(spec)
237+
assert config_update is not None, f"MacOS {spec} not found!"
238+
config.update(**config_update)
239+
elif "win32" in identifier:
240+
if identifier.startswith("pp"):
241+
config.update(**self.windows_pypy.update_version_windows(spec))
242+
else:
243+
config_update = self.windows_32.update_version_windows(spec)
244+
if config_update:
245+
config.update(**config_update)
246+
elif "win_amd64" in identifier:
247+
config_update = self.windows_64.update_version_windows(spec)
248+
if config_update:
249+
config.update(**config_update)
250+
251+
if config != orig_config:
252+
log.info(f" Updated {orig_config} to {config}")
253+
254+
255+
@click.command()
256+
@click.option("--force", is_flag=True)
257+
@click.option("--level", default="INFO", type=click.Choice(["INFO", "DEBUG", "TRACE"], case_sensitive=False))
258+
def update_pythons(force: bool, level: str) -> None:
259+
260+
logging.basicConfig(
261+
level="INFO",
262+
format="%(message)s",
263+
datefmt="[%X]",
264+
handlers=[RichHandler(rich_tracebacks=True, markup=True)],
265+
)
266+
log.setLevel(level)
267+
268+
all_versions = AllVersions()
269+
toml_file_path = RESOURCES_DIR / "build-platforms.toml"
270+
271+
original_toml = toml_file_path.read_text()
272+
configs = toml.loads(original_toml)
273+
274+
for config in configs["windows"]["python_configurations"]:
275+
all_versions.update_config(config)
276+
277+
for config in configs["macos"]["python_configurations"]:
278+
all_versions.update_config(config)
279+
280+
result_toml = toml.dumps(configs, encoder=InlineArrayDictEncoder()) # type: ignore
281+
282+
rich.print() # spacer
283+
284+
if original_toml == result_toml:
285+
rich.print("[green]Check complete, Python configurations unchanged.")
286+
return
287+
288+
rich.print("Python configurations updated.")
289+
rich.print("Changes:")
290+
rich.print()
291+
292+
toml_relpath = toml_file_path.relative_to(DIR).as_posix()
293+
diff_lines = difflib.unified_diff(
294+
original_toml.splitlines(keepends=True),
295+
result_toml.splitlines(keepends=True),
296+
fromfile=toml_relpath,
297+
tofile=toml_relpath,
298+
)
299+
rich.print(Syntax("".join(diff_lines), "diff", theme="ansi_light"))
300+
rich.print()
301+
302+
if force:
303+
toml_file_path.write_text(result_toml)
304+
rich.print("[green]TOML file updated.")
305+
else:
306+
rich.print("[yellow]File left unchanged. Use --force flag to update.")
307+
308+
309+
if __name__ == "__main__":
310+
update_pythons()

cibuildwheel/extra.py

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
These are utilities for the `/bin` scripts, not for the `cibuildwheel` program.
3+
"""
4+
5+
from typing import Any, Dict
6+
7+
import toml.encoder
8+
from packaging.version import Version
9+
10+
11+
class InlineArrayDictEncoder(toml.encoder.TomlEncoder): # type: ignore
12+
def __init__(self) -> None:
13+
super().__init__()
14+
self.dump_funcs[Version] = lambda v: f'"{v}"'
15+
16+
def dump_sections(self, o: Dict[str, Any], sup: str) -> Any:
17+
if all(isinstance(a, list) for a in o.values()):
18+
val = ""
19+
for k, v in o.items():
20+
inner = ",\n ".join(self.dump_inline_table(d_i).strip() for d_i in v)
21+
val += f"{k} = [\n {inner},\n]\n"
22+
return val, self._dict()
23+
else:
24+
return super().dump_sections(o, sup)

0 commit comments

Comments
 (0)