Skip to content

Commit f43ef65

Browse files
authored
feat: allow publishing multiple packages in one PR (#1418)
1 parent 6a64b4c commit f43ef65

9 files changed

Lines changed: 505 additions & 70 deletions
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""Ensure every changed package has a version not yet published to PyPI.
3+
4+
For each package with file changes in the PR, we read its version from
5+
pyproject.toml and check whether that exact version exists on PyPI.
6+
If it does, the check fails — the developer must bump the version.
7+
8+
This catches two scenarios:
9+
1. Developer changed code but forgot to bump the version.
10+
2. Two PRs raced to the same version — after rebase the first PR's
11+
version is already on PyPI, so this check forces the second PR
12+
to pick a new one.
13+
"""
14+
15+
import os
16+
import subprocess
17+
import sys
18+
import urllib.error
19+
import urllib.request
20+
from pathlib import Path
21+
22+
try:
23+
import tomllib
24+
except ModuleNotFoundError:
25+
import tomli as tomllib # type: ignore[no-redef]
26+
27+
28+
def version_exists_on_pypi(package_name: str, version: str) -> bool | None:
29+
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
30+
try:
31+
req = urllib.request.Request(url, method="HEAD")
32+
with urllib.request.urlopen(req, timeout=10):
33+
return True
34+
except urllib.error.HTTPError as e:
35+
if e.code == 404:
36+
return False
37+
print(f"Warning: PyPI returned HTTP {e.code} for {package_name}=={version}", file=sys.stderr)
38+
return None
39+
except Exception as e:
40+
print(f"Warning: Could not reach PyPI for {package_name}=={version}: {e}", file=sys.stderr)
41+
return None
42+
43+
44+
def get_package_info(package_dir: str) -> tuple[str, str] | None:
45+
pyproject = Path("packages") / package_dir / "pyproject.toml"
46+
if not pyproject.exists():
47+
return None
48+
with open(pyproject, "rb") as f:
49+
data = tomllib.load(f)
50+
project = data.get("project", {})
51+
name = project.get("name")
52+
version = project.get("version")
53+
if name and version:
54+
return name, version
55+
return None
56+
57+
58+
def get_changed_packages() -> list[str]:
59+
base_sha = os.getenv("BASE_SHA", "")
60+
head_sha = os.getenv("HEAD_SHA", "")
61+
diff_spec = f"{base_sha}...{head_sha}" if base_sha and head_sha else "origin/main...HEAD"
62+
63+
try:
64+
result = subprocess.run(
65+
["git", "diff", "--name-only", diff_spec],
66+
capture_output=True,
67+
text=True,
68+
check=True,
69+
)
70+
except subprocess.CalledProcessError as e:
71+
print(f"Error running git diff: {e}", file=sys.stderr)
72+
return []
73+
74+
changed = set()
75+
for file_path in result.stdout.strip().split("\n"):
76+
if file_path.startswith("packages/"):
77+
parts = file_path.split("/")
78+
if len(parts) >= 2 and (Path("packages") / parts[1] / "pyproject.toml").exists():
79+
changed.add(parts[1])
80+
return sorted(changed)
81+
82+
83+
def main() -> int:
84+
changed = get_changed_packages()
85+
if not changed:
86+
print("No changed packages detected.")
87+
return 0
88+
89+
conflicts = []
90+
failures = []
91+
for pkg_dir in changed:
92+
info = get_package_info(pkg_dir)
93+
if not info:
94+
continue
95+
96+
name, version = info
97+
exists = version_exists_on_pypi(name, version)
98+
99+
if exists is True:
100+
print(f"FAIL: {name}=={version} already exists on PyPI")
101+
conflicts.append(f"{name}=={version}")
102+
elif exists is False:
103+
print(f"OK: {name}=={version} is available")
104+
else:
105+
print(f"FAIL: could not verify {name}=={version}")
106+
failures.append(f"{name}=={version}")
107+
108+
success = len(conflicts) + len(failures) == 0
109+
if not success:
110+
if conflicts:
111+
print(f"\nPlease bump the version in pyproject.toml for: {', '.join(conflicts)}", file=sys.stderr)
112+
if failures:
113+
print(f"\nError while trying to check the following packages on pypi index: {', '.join(failures)}. Please retry.", file=sys.stderr)
114+
return 1
115+
116+
return 0
117+
118+
119+
if __name__ == "__main__":
120+
sys.exit(main())
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
"""Tests for check_version_uniqueness.py."""
3+
4+
import os
5+
import urllib.error
6+
from unittest import mock
7+
8+
import pytest
9+
10+
from check_version_uniqueness import (
11+
get_package_info,
12+
main,
13+
version_exists_on_pypi,
14+
)
15+
16+
17+
class TestVersionExistsOnPypi:
18+
def test_returns_true_when_version_exists(self):
19+
mock_response = mock.MagicMock()
20+
mock_response.__enter__ = mock.MagicMock(return_value=mock_response)
21+
mock_response.__exit__ = mock.MagicMock(return_value=False)
22+
23+
with mock.patch("urllib.request.urlopen", return_value=mock_response):
24+
assert version_exists_on_pypi("some-package", "1.0.0") is True
25+
26+
def test_returns_false_when_404(self):
27+
error = urllib.error.HTTPError(
28+
url="", code=404, msg="Not Found", hdrs=None, fp=None # type: ignore[arg-type]
29+
)
30+
with mock.patch("urllib.request.urlopen", side_effect=error):
31+
assert version_exists_on_pypi("some-package", "9.9.9") is False
32+
33+
def test_returns_none_on_server_error(self):
34+
error = urllib.error.HTTPError(
35+
url="", code=500, msg="Server Error", hdrs=None, fp=None # type: ignore[arg-type]
36+
)
37+
with mock.patch("urllib.request.urlopen", side_effect=error):
38+
assert version_exists_on_pypi("some-package", "1.0.0") is None
39+
40+
def test_returns_none_on_network_error(self):
41+
with mock.patch(
42+
"urllib.request.urlopen", side_effect=ConnectionError("no network")
43+
):
44+
assert version_exists_on_pypi("some-package", "1.0.0") is None
45+
46+
47+
class TestGetPackageInfo:
48+
def test_reads_name_and_version(self, tmp_path):
49+
pkg = tmp_path / "packages" / "my-pkg"
50+
pkg.mkdir(parents=True)
51+
(pkg / "pyproject.toml").write_text(
52+
'[project]\nname = "my-pkg"\nversion = "1.2.3"\n'
53+
)
54+
with mock.patch(
55+
"check_version_uniqueness.Path",
56+
side_effect=lambda p: tmp_path / p if p == "packages" else __import__("pathlib").Path(p),
57+
):
58+
assert get_package_info("my-pkg") == ("my-pkg", "1.2.3")
59+
60+
def test_returns_none_for_missing_file(self):
61+
assert get_package_info("nonexistent-package-xyz") is None
62+
63+
64+
class TestMain:
65+
def _run(self, changed, package_info, pypi_result):
66+
patches = [
67+
mock.patch("check_version_uniqueness.get_changed_packages", return_value=changed),
68+
mock.patch("check_version_uniqueness.get_package_info", side_effect=lambda d: package_info.get(d)),
69+
mock.patch("check_version_uniqueness.version_exists_on_pypi", return_value=pypi_result),
70+
]
71+
with patches[0], patches[1], patches[2]:
72+
os.environ.pop("BASE_SHA", None)
73+
os.environ.pop("HEAD_SHA", None)
74+
return main()
75+
76+
def test_passes_when_version_not_on_pypi(self):
77+
assert self._run(["my-pkg"], {"my-pkg": ("my-pkg", "2.0.0")}, False) == 0
78+
79+
def test_fails_when_version_exists_on_pypi(self):
80+
assert self._run(["my-pkg"], {"my-pkg": ("my-pkg", "2.0.0")}, True) == 1
81+
82+
def test_fails_on_network_error(self):
83+
assert self._run(["my-pkg"], {"my-pkg": ("my-pkg", "2.0.0")}, None) == 1
84+
85+
def test_no_changed_packages(self):
86+
assert self._run([], {}, False) == 0
87+
88+
def test_fails_when_version_unchanged_but_on_pypi(self):
89+
"""The key scenario: code changed, version not bumped, already on PyPI."""
90+
assert self._run(["my-pkg"], {"my-pkg": ("my-pkg", "1.0.0")}, True) == 1
91+
92+
def test_multiple_packages_one_conflict(self):
93+
def pypi_check(name, version):
94+
return name == "pkg-a"
95+
96+
with (
97+
mock.patch("check_version_uniqueness.get_changed_packages", return_value=["pkg-a", "pkg-b"]),
98+
mock.patch(
99+
"check_version_uniqueness.get_package_info",
100+
side_effect=lambda d: {"pkg-a": ("pkg-a", "1.0.0"), "pkg-b": ("pkg-b", "2.0.0")}.get(d),
101+
),
102+
mock.patch("check_version_uniqueness.version_exists_on_pypi", side_effect=pypi_check),
103+
):
104+
os.environ.pop("BASE_SHA", None)
105+
os.environ.pop("HEAD_SHA", None)
106+
assert main() == 1

.github/scripts/wait_for_pypi.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
#!/usr/bin/env python3
2+
"""Wait for a package version to become available on PyPI.
3+
4+
Usage: python wait_for_pypi.py <package-directory-name>
5+
6+
Reads the package name and version from packages/<dir>/pyproject.toml,
7+
then polls PyPI until the version appears or a timeout is reached.
8+
"""
9+
10+
import sys
11+
import time
12+
import urllib.error
13+
import urllib.request
14+
from pathlib import Path
15+
16+
try:
17+
import tomllib
18+
except ModuleNotFoundError:
19+
import tomli as tomllib # type: ignore[no-redef]
20+
21+
MAX_WAIT = 300 # 5 minutes
22+
POLL_INTERVAL = 15 # seconds
23+
24+
25+
def version_exists_on_pypi(package_name: str, version: str) -> bool:
26+
"""Check if a specific version of a package exists on PyPI."""
27+
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
28+
try:
29+
req = urllib.request.Request(url, method="HEAD")
30+
with urllib.request.urlopen(req, timeout=10):
31+
return True
32+
except urllib.error.HTTPError as e:
33+
if e.code == 404:
34+
return False
35+
print(f" Warning: PyPI returned HTTP {e.code}", file=sys.stderr)
36+
return False
37+
except Exception as e:
38+
print(f" Warning: Could not reach PyPI: {e}", file=sys.stderr)
39+
return False
40+
41+
42+
def main() -> int:
43+
if len(sys.argv) != 2:
44+
print(f"Usage: {sys.argv[0]} <package-directory>", file=sys.stderr)
45+
return 1
46+
47+
directory = sys.argv[1]
48+
pyproject = Path("packages") / directory / "pyproject.toml"
49+
50+
if not pyproject.exists():
51+
print(f"ERROR: {pyproject} not found", file=sys.stderr)
52+
return 1
53+
54+
with open(pyproject, "rb") as f:
55+
data = tomllib.load(f)
56+
57+
name = data["project"]["name"]
58+
version = data["project"]["version"]
59+
60+
print(f"Waiting for {name}=={version} to appear on PyPI...")
61+
elapsed = 0
62+
while elapsed < MAX_WAIT:
63+
if version_exists_on_pypi(name, version):
64+
print(f"{name}=={version} is now available on PyPI.")
65+
return 0
66+
time.sleep(POLL_INTERVAL)
67+
elapsed += POLL_INTERVAL
68+
print(f" Still waiting... ({elapsed}s/{MAX_WAIT}s)")
69+
70+
print(
71+
f"ERROR: {name}=={version} did not appear on PyPI within {MAX_WAIT}s",
72+
file=sys.stderr,
73+
)
74+
return 1
75+
76+
77+
if __name__ == "__main__":
78+
sys.exit(main())

0 commit comments

Comments
 (0)