All patches and comments are welcome. Please squash your changes to logical
commits before using git-format-patch and git-send-email to
patches@git.madduck.net.
If you'd read over the Git project's submission guidelines and adhered to them,
I'd be especially grateful.
9 from argparse import ArgumentParser, Namespace
10 from concurrent.futures import ThreadPoolExecutor
11 from functools import lru_cache, partial
12 from pathlib import Path
13 from typing import Generator, List, NamedTuple, Optional, Tuple, Union, cast
14 from urllib.request import urlopen, urlretrieve
16 PYPI_INSTANCE = "https://pypi.org/pypi"
18 "https://hugovk.github.io/top-pypi-packages/top-pypi-packages-30-days.min.json"
20 INTERNAL_BLACK_REPO = f"{tempfile.gettempdir()}/__black"
22 ArchiveKind = Union[tarfile.TarFile, zipfile.ZipFile]
24 subprocess.run = partial(subprocess.run, check=True) # type: ignore
25 # https://github.com/python/mypy/issues/1484
28 class BlackVersion(NamedTuple):
30 config: Optional[str] = None
33 def get_pypi_download_url(package: str, version: Optional[str]) -> str:
34 with urlopen(PYPI_INSTANCE + f"/{package}/json") as page:
35 metadata = json.load(page)
38 sources = metadata["urls"]
40 if version in metadata["releases"]:
41 sources = metadata["releases"][version]
44 f"No releases found with version ('{version}') tag. "
45 f"Found releases: {metadata['releases'].keys()}"
48 for source in sources:
49 if source["python_version"] == "source":
52 raise ValueError(f"Couldn't find any sources for {package}")
54 return cast(str, source["url"])
57 def get_top_packages() -> List[str]:
58 with urlopen(PYPI_TOP_PACKAGES) as page:
59 result = json.load(page)
61 return [package["project"] for package in result["rows"]]
64 def get_package_source(package: str, version: Optional[str]) -> str:
65 if package == "cpython":
68 return f"https://github.com/python/cpython/archive/{version}.zip"
69 elif package == "pypy":
71 version = "branch/default"
73 f"https://foss.heptapod.net/pypy/pypy/repository/{version}/archive.tar.bz2"
76 return get_pypi_download_url(package, version)
79 def get_archive_manager(local_file: str) -> ArchiveKind:
80 if tarfile.is_tarfile(local_file):
81 return tarfile.open(local_file)
82 elif zipfile.is_zipfile(local_file):
83 return zipfile.ZipFile(local_file)
85 raise ValueError("Unknown archive kind.")
88 def get_first_archive_member(archive: ArchiveKind) -> str:
89 if isinstance(archive, tarfile.TarFile):
90 return archive.getnames()[0]
91 elif isinstance(archive, zipfile.ZipFile):
92 return archive.namelist()[0]
95 def download_and_extract(package: str, version: Optional[str], directory: Path) -> Path:
96 source = get_package_source(package, version)
98 local_file, _ = urlretrieve(source, directory / f"{package}-src")
99 with get_archive_manager(local_file) as archive:
100 archive.extractall(path=directory)
101 result_dir = get_first_archive_member(archive)
102 return directory / result_dir
106 package: str, version: Optional[str], directory: Path
109 return download_and_extract(package, version, directory)
111 print(f"Caught an exception while downloading {package}.")
112 traceback.print_exc()
116 DEFAULT_SLICE = slice(None) # for flake8
119 def download_and_extract_top_packages(
122 limit: slice = DEFAULT_SLICE,
123 ) -> Generator[Path, None, None]:
124 with ThreadPoolExecutor(max_workers=workers) as executor:
125 bound_downloader = partial(get_package, version=None, directory=directory)
126 for package in executor.map(bound_downloader, get_top_packages()[limit]):
127 if package is not None:
131 def git_create_repository(repo: Path) -> None:
132 subprocess.run(["git", "init"], cwd=repo)
133 git_add_and_commit(msg="Initial commit", repo=repo)
136 def git_add_and_commit(msg: str, repo: Path) -> None:
137 subprocess.run(["git", "add", "."], cwd=repo)
138 subprocess.run(["git", "commit", "-m", msg, "--allow-empty"], cwd=repo)
141 def git_switch_branch(
142 branch: str, repo: Path, new: bool = False, from_branch: Optional[str] = None
144 args = ["git", "checkout"]
149 args.append(from_branch)
150 subprocess.run(args, cwd=repo)
153 def init_repos(options: Namespace) -> Tuple[Path, ...]:
154 options.output.mkdir(exist_ok=True)
156 if options.top_packages:
157 source_directories = tuple(
158 download_and_extract_top_packages(
159 directory=options.output,
160 workers=options.workers,
161 limit=slice(None, options.top_packages),
165 source_directories = (
166 download_and_extract(
167 package=options.pypi_package,
168 version=options.version,
169 directory=options.output,
173 for source_directory in source_directories:
174 git_create_repository(source_directory)
176 if options.black_repo is None:
178 ["git", "clone", "https://github.com/psf/black.git", INTERNAL_BLACK_REPO],
181 options.black_repo = options.output / INTERNAL_BLACK_REPO
183 return source_directories
187 def black_runner(version: str, black_repo: Path) -> Path:
188 directory = tempfile.TemporaryDirectory()
189 venv.create(directory.name, with_pip=True)
191 python = Path(directory.name) / "bin" / "python"
192 subprocess.run([python, "-m", "pip", "install", "-e", black_repo])
194 atexit.register(directory.cleanup)
198 def format_repo_with_version(
200 from_branch: Optional[str],
202 black_version: BlackVersion,
203 input_directory: Path,
205 current_branch = f"black-{black_version.version}"
206 git_switch_branch(black_version.version, repo=black_repo)
207 git_switch_branch(current_branch, repo=repo, new=True, from_branch=from_branch)
209 format_cmd: List[Union[Path, str]] = [
210 black_runner(black_version.version, black_repo),
211 (black_repo / "black.py").resolve(),
214 if black_version.config:
215 format_cmd.extend(["--config", input_directory / black_version.config])
217 subprocess.run(format_cmd, cwd=repo, check=False) # ensure the process
218 # continuess to run even it can't format some files. Reporting those
220 git_add_and_commit(f"Format with black:{black_version.version}", repo=repo)
222 return current_branch
225 def format_repos(repos: Tuple[Path, ...], options: Namespace) -> None:
226 black_versions = tuple(
227 BlackVersion(*version.split(":")) for version in options.versions
232 for black_version in black_versions:
233 from_branch = format_repo_with_version(
235 from_branch=from_branch,
236 black_repo=options.black_repo,
237 black_version=black_version,
238 input_directory=options.input,
240 git_switch_branch("main", repo=repo)
242 git_switch_branch("main", repo=options.black_repo)
246 parser = ArgumentParser(description="""Black Gallery is a script that
247 automates the process of applying different Black versions to a selected
248 PyPI package and seeing the results between versions.""")
250 group = parser.add_mutually_exclusive_group(required=True)
251 group.add_argument("-p", "--pypi-package", help="PyPI package to download.")
253 "-t", "--top-packages", help="Top n PyPI packages to download.", type=int
256 parser.add_argument("-b", "--black-repo", help="Black's Git repository.", type=Path)
261 "Version for given PyPI package. Will be discarded if used with -t option."
268 "Maximum number of threads to download with at the same time. "
269 "Will be discarded if used with -p option."
275 default=Path("/input"),
277 help="Input directory to read configuration.",
282 default=Path("/output"),
284 help="Output directory to download and put result artifacts.",
286 parser.add_argument("versions", nargs="*", default=("main",), help="")
288 options = parser.parse_args()
289 repos = init_repos(options)
290 format_repos(repos, options)
293 if __name__ == "__main__":