Skip to content

Commit ee3a48e

Browse files
ilevkivskyiIvan Levkivskyi
and
Ivan Levkivskyi
authored
Verify dependencies before uploading a package (#1)
Co-authored-by: Ivan Levkivskyi <[email protected]>
1 parent 146feb8 commit ee3a48e

File tree

12 files changed

+252
-24
lines changed

12 files changed

+252
-24
lines changed

.github/workflows/check_scripts.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
- uses: actions/checkout@v2
1414
- run: |
1515
pip install mypy
16-
mypy -p scripts
16+
mypy -p scripts -p tests
1717
1818
tests:
1919
name: Run integration and unit tests
@@ -36,4 +36,4 @@ jobs:
3636
run: |
3737
pip install pytest toml requests setuptools wheel
3838
cd main
39-
python -m pytest tests/*
39+
python -m pytest tests

.github/workflows/force_update.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@ jobs:
3434
TWINE_PASSWORD: ${{ secrets.TYPESHED_BOT_API_TOKEN }}
3535
run: |
3636
cd main
37-
python -m scripts.upload_some ../typeshed ${{ github.event.inputs.distribution }}
37+
python -m scripts.upload_some ../typeshed ${{ github.event.inputs.distribution }} data/uploaded_packages.txt

.github/workflows/update_stubs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
TWINE_PASSWORD: ${{ secrets.TYPESHED_BOT_API_TOKEN }}
3535
run: |
3636
cd main
37-
python -m scripts.upload_changed ../typeshed $(cat data/last_typeshed_commit.sha1) --dry-run
37+
python -m scripts.upload_changed ../typeshed $(cat data/last_typeshed_commit.sha1) data/uploaded_packages.txt --dry-run
3838
(cd ../typeshed; git rev-parse HEAD) > data/last_typeshed_commit.sha1
3939
if [ -z "$(git status --porcelain)" ]; then
4040
exit 0;

data/uploaded_packages.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
types-Routes
2+
types-attrs
3+
types-enum34
4+
types-first
5+
types-mypy-extensions
6+
types-six
7+
types-toml
8+
types-typed-ast
9+
types-typing-extensions

scripts/build_wheel.py

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
The types stubs live in https://github.com/python/typeshed/tree/master/stubs,
1313
all fixes for types and metadata should be contributed there, see
1414
https://github.com/python/typeshed/blob/master/CONTRIBUTING.md for more details.
15+
16+
This file also contains some helper functions related to wheel validation and upload.
1517
"""
1618

1719
import argparse
@@ -20,8 +22,12 @@
2022
import shutil
2123
import tempfile
2224
import subprocess
25+
from collections import defaultdict
26+
from functools import cmp_to_key
2327
from textwrap import dedent
24-
from typing import List, Dict, Any, Tuple
28+
from typing import List, Dict, Any, Tuple, Set
29+
30+
from scripts import get_version
2531

2632
import toml
2733

@@ -66,6 +72,11 @@
6672
""").lstrip()
6773

6874

75+
def strip_types_prefix(dependency: str) -> str:
76+
assert dependency.startswith("types-"), "Currently only dependencies on stub packages are supported"
77+
return dependency[len("types-"):]
78+
79+
6980
def find_stub_files(top: str) -> List[str]:
7081
"""Find all stub files for a given package, relative to package root.
7182
@@ -165,6 +176,82 @@ def collect_setup_entries(
165176
return packages, package_data
166177

167178

179+
def verify_dependency(typeshed_dir: str, dependency: str, uploaded: str) -> None:
180+
"""Verify this is a valid dependency, i.e. a stub package uploaded by us."""
181+
known_distributions = set(os.listdir(os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE)))
182+
assert ";" not in dependency, "Semicolons in dependencies are not supported"
183+
dependency = get_version.strip_dep_version(dependency)
184+
assert strip_types_prefix(dependency) in known_distributions, "Only dependencies on typeshed stubs are allowed"
185+
with open(uploaded) as f:
186+
uploaded_distributions = set(f.read().splitlines())
187+
188+
msg = f"{dependency} looks like a foreign distribution."
189+
uploaded_distributions_lower = [d.lower() for d in uploaded_distributions]
190+
if dependency not in uploaded_distributions and dependency.lower() in uploaded_distributions_lower:
191+
msg += " Note: list is case sensitive"
192+
assert dependency in uploaded_distributions, msg
193+
194+
195+
def update_uploaded(uploaded: str, distribution: str) -> None:
196+
with open(uploaded) as f:
197+
current = set(f.read().splitlines())
198+
if f"types-{distribution}" not in current:
199+
with open(uploaded, "w") as f:
200+
f.writelines(sorted(current | {f"types-{distribution}"}))
201+
202+
203+
def make_dependency_map(typeshed_dir: str, distributions: List[str]) -> Dict[str, Set[str]]:
204+
"""Return relative dependency map among distributions.
205+
206+
Important: this only includes dependencies *within* the given
207+
list of distributions.
208+
"""
209+
result: Dict[str, Set[str]] = {d: set() for d in distributions}
210+
for distribution in distributions:
211+
data = read_matadata(
212+
os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE, distribution, META)
213+
)
214+
for dependency in data.get("requires", []):
215+
dependency = strip_types_prefix(get_version.strip_dep_version(dependency))
216+
if dependency in distributions:
217+
result[distribution].add(dependency)
218+
return result
219+
220+
221+
def transitive_deps(dep_map: Dict[str, Set[str]]) -> Dict[str, Set[str]]:
222+
"""Propagate dependencies to compute a transitive dependency map.
223+
224+
Note: this algorithm is O(N**2) in general case, but we don't worry,
225+
because N is small (less than 1000). So it will take few seconds at worst,
226+
while building/uploading 1000 packages will take minutes.
227+
"""
228+
transitive: Dict[str, Set[str]] = defaultdict(set)
229+
for distribution in dep_map:
230+
to_add = {distribution}
231+
while to_add:
232+
new = to_add.pop()
233+
extra = dep_map[new]
234+
transitive[distribution] |= extra
235+
assert distribution not in transitive[distribution], f"Cyclic dependency {distribution} -> {distribution}"
236+
to_add |= extra
237+
return transitive
238+
239+
240+
def sort_by_dependency(dep_map: Dict[str, Set[str]]) -> List[str]:
241+
"""Sort distributions by dependency order (those depending on nothing appear first)."""
242+
trans_map = transitive_deps(dep_map)
243+
244+
def compare(d1: str, d2: str) -> int:
245+
if d1 in trans_map[d2]:
246+
return -1
247+
if d2 in trans_map[d1]:
248+
return 1
249+
return 0
250+
251+
# Return independent packages sorted by name for stability.
252+
return sorted(sorted(dep_map), key=cmp_to_key(compare))
253+
254+
168255
def generate_setup_file(typeshed_dir: str, distribution: str, increment: str) -> str:
169256
"""Auto-generate a setup.py file for given distribution using a template."""
170257
base_dir = os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE, distribution)
@@ -178,17 +265,11 @@ def generate_setup_file(typeshed_dir: str, distribution: str, increment: str) ->
178265
packages += py2_packages
179266
package_data.update(py2_package_data)
180267
version = metadata["version"]
181-
requires = metadata.get("requires", [])
182-
known_distributions = set(os.listdir(os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE)))
183-
for dependency in requires:
184-
assert dependency.startswith("types-"), "Only dependencies on stub packages are allowed"
185-
dep_name = dependency[len("types-"):]
186-
assert dep_name in known_distributions, "Only dependencies on typeshed stubs are allowed"
187268
assert version.count(".") == 1, f"Version must be major.minor, not {version}"
188269
return SETUP_TEMPLATE.format(
189270
distribution=distribution,
190271
version=f"{version}.{increment}",
191-
requires=requires,
272+
requires=metadata.get("requires", []),
192273
packages=packages,
193274
package_data=package_data,
194275
)

scripts/get_version.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
If the given version was never uploaded, this will return -1. See
66
https://github.com/python/typeshed/blob/master/README.md for details
77
on stub versioning.
8+
9+
This file also contains some helper functions related to querying
10+
distribution information.
811
"""
912

1013
import argparse
@@ -34,6 +37,30 @@ def read_base_version(typeshed_dir: str, distribution: str) -> str:
3437
return data["version"]
3538

3639

40+
def strip_dep_version(dependency: str) -> str:
41+
"""Strip a possible version suffix, e.g. types-six>=0.1.4 -> types-six."""
42+
dep_version_pos = len(dependency)
43+
for pos, c in enumerate(dependency):
44+
if c in "=<>":
45+
dep_version_pos = pos
46+
break
47+
return dependency[:dep_version_pos]
48+
49+
50+
def check_exists(distribution: str) -> bool:
51+
"""Check if any version of this *stub* distribution has ben ever uploaded."""
52+
url = URL_TEMPLATE.format(distribution)
53+
retry_strategy = Retry(total=RETRIES, status_forcelist=RETRY_ON)
54+
with requests.Session() as session:
55+
session.mount("https://", HTTPAdapter(max_retries=retry_strategy))
56+
resp = session.get(url, timeout=TIMEOUT)
57+
if resp.ok:
58+
return True
59+
if resp.status_code == 404:
60+
return False
61+
raise ValueError("Error while verifying existence")
62+
63+
3764
def main(typeshed_dir: str, distribution: str, version: Optional[str]) -> int:
3865
"""A simple function to get version increment of a third-party stub package.
3966

scripts/upload_changed.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""
22
Entry point for scheduled GitHub auto-upload action.
33
4-
This does three things:
4+
This does following things:
55
* Reads the list of stub packages modified since last commit in typeshed
66
* Checks what is the current stub version increment for each package on PyPI
77
* Bumps the increment, builds and uploads (unless run with --dry-run) each
88
new package to PyPI
9+
* Verifies validity of stub dependencies, and updates known dependencies if needed
910
"""
1011

1112
import argparse
@@ -17,24 +18,34 @@
1718
from scripts import get_changed
1819

1920

20-
def main(typeshed_dir: str, commit: str, dry_run: bool = False) -> None:
21+
def main(typeshed_dir: str, commit: str, uploaded: str, dry_run: bool = False) -> None:
2122
"""Upload stub typeshed packages modified since commit."""
2223
changed = get_changed.main(typeshed_dir, commit)
23-
for distribution in changed:
24+
# Sort by dependency to prevent depending on foreign distributions.
25+
to_upload = build_wheel.sort_by_dependency(
26+
build_wheel.make_dependency_map(typeshed_dir, changed)
27+
)
28+
for distribution in to_upload:
2429
# Setting base version to None, so it will be read from current METADATA.toml.
2530
increment = get_version.main(typeshed_dir, distribution, None)
2631
increment += 1
2732
temp_dir = build_wheel.main(typeshed_dir, distribution, increment)
2833
if dry_run:
2934
print(f"Would upload: {distribution}, increment {increment}")
3035
continue
36+
for dependency in build_wheel.read_matadata(
37+
os.path.join(typeshed_dir, build_wheel.THIRD_PARTY_NAMESPACE, distribution)
38+
).get("requires", []):
39+
build_wheel.verify_dependency(typeshed_dir, dependency, uploaded)
3140
subprocess.run(["twine", "upload", os.path.join(temp_dir, "*")], check=True)
41+
build_wheel.update_uploaded(uploaded, distribution)
3242

3343

3444
if __name__ == "__main__":
3545
parser = argparse.ArgumentParser()
3646
parser.add_argument("typeshed_dir", help="Path to typeshed checkout directory")
3747
parser.add_argument("previous_commit", help="Previous typeshed commit for which we performed upload")
48+
parser.add_argument("uploaded", help="Previously uploaded packages to validate dependencies")
3849
parser.add_argument("--dry-run", action="store_true", help="Should we perform a dry run (don't actually upload)")
3950
args = parser.parse_args()
40-
main(args.typeshed_dir, args.previous_commit, args.dry_run)
51+
main(args.typeshed_dir, args.previous_commit, args.uploaded, args.dry_run)

scripts/upload_some.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""
22
Entry point for manual GitHub upload action.
33
4-
This does three things:
4+
This does following things:
55
* Finds all distributions with names that match the pattern provided
66
* Checks what is the current stub version increment for each package on PyPI
77
* Bumps the increment, builds and uploads the each new package to PyPI
8+
* Verifies validity of stub dependencies, and updates known dependencies if needed
89
"""
910

1011
import argparse
@@ -16,22 +17,33 @@
1617
from scripts import build_wheel
1718

1819

19-
def main(typeshed_dir: str, pattern: str) -> None:
20+
def main(typeshed_dir: str, pattern: str, uploaded: str) -> None:
2021
"""Force upload typeshed stub packages to PyPI."""
2122
compiled = re.compile(f"^{pattern}$") # force exact matches
22-
for distribution in os.listdir(os.path.join(typeshed_dir, "stubs")):
23-
if not re.match(compiled, distribution):
24-
continue
23+
matching = [
24+
d for d in os.listdir(os.path.join(typeshed_dir, "stubs")) if re.match(compiled, d)
25+
]
26+
# Sort by dependency to prevent depending on foreign distributions.
27+
to_upload = build_wheel.sort_by_dependency(
28+
build_wheel.make_dependency_map(typeshed_dir, matching)
29+
)
30+
for distribution in to_upload:
2531
# Setting base version to None, so it will be read from current METADATA.toml.
2632
increment = get_version.main(typeshed_dir, distribution, version=None)
2733
increment += 1
34+
for dependency in build_wheel.read_matadata(
35+
os.path.join(typeshed_dir, build_wheel.THIRD_PARTY_NAMESPACE, distribution)
36+
).get("requires", []):
37+
build_wheel.verify_dependency(typeshed_dir, dependency, uploaded)
2838
temp_dir = build_wheel.main(typeshed_dir, distribution, increment)
2939
subprocess.run(["twine", "upload", os.path.join(temp_dir, "*")], check=True)
40+
build_wheel.update_uploaded(uploaded, distribution)
3041

3142

3243
if __name__ == "__main__":
3344
parser = argparse.ArgumentParser()
3445
parser.add_argument("typeshed_dir", help="Path to typeshed checkout directory")
35-
parser.add_argument("pattern", help="Pattern to select distributions for upload")
46+
parser.add_argument("pattern", help="Regular expression to select distributions for upload")
47+
parser.add_argument("uploaded", help="Previously uploaded packages to validate dependencies")
3648
args = parser.parse_args()
37-
main(args.typeshed_dir, args.pattern)
49+
main(args.typeshed_dir, args.pattern, args.uploaded)

tests/__init__.py

Whitespace-only changes.

tests/integration.py renamed to tests/test_integration.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
a typeshed checkout side by side.
55
"""
66
import os
7+
import pytest # type: ignore[import]
78
from scripts import get_version, build_wheel
89

910
TYPESHED = "../typeshed"
11+
UPLOADED = "data/uploaded_packages.txt"
1012

1113

1214
def test_version() -> None:
@@ -17,9 +19,27 @@ def test_version() -> None:
1719
assert get_version.main(TYPESHED, "typing-extensions", None) >= 0
1820

1921

22+
def test_check_exists() -> None:
23+
assert get_version.check_exists("six")
24+
assert not get_version.check_exists("nonexistent-distribution")
25+
26+
2027
def test_build_wheel() -> None:
2128
# Check that we can build wheels for all distributions.
2229
for distribution in os.listdir(os.path.join(TYPESHED, "stubs")):
2330
tmp_dir = build_wheel.main(TYPESHED, distribution, increment=1)
2431
assert tmp_dir.endswith("/dist")
2532
assert list(os.listdir(tmp_dir)) # check it is not empty
33+
34+
35+
def test_verify_dependency() -> None:
36+
# Check some known dependencies that they verify as valid.
37+
build_wheel.verify_dependency(TYPESHED, "types-six", UPLOADED)
38+
build_wheel.verify_dependency(TYPESHED, "types-six==0.1.1", UPLOADED)
39+
build_wheel.verify_dependency(TYPESHED, "types-typing-extensions", UPLOADED)
40+
build_wheel.verify_dependency(TYPESHED, "types-typing-extensions>=3.7", UPLOADED)
41+
# Also check couple errors.
42+
with pytest.raises(AssertionError):
43+
build_wheel.verify_dependency(TYPESHED, "unsupported", UPLOADED)
44+
with pytest.raises(AssertionError):
45+
build_wheel.verify_dependency(TYPESHED, "types-unknown-xxx", UPLOADED)

0 commit comments

Comments
 (0)