Skip to content

Commit 5aae098

Browse files
authored
Allow external dependencies (#61)
* Allow external dependencies Resolves python/typeshed#5768 In python/typeshed#5768 (comment) consensus was reached on Akuli's idea, that external dependencies for stub packages are fine, as long as we validate that the external dependency is a dependency of the upstream package. The most important part of the implementation is the validation we perform. This happens in `verify_typeshed_req` and `verify_external_req`. To help lock things down, Metadata does not expose access to elements of `requires` without validation. We rely on PyPI's API to find the dependencies of the upstream. I believe this might not work if our stub has external dependencies and the upstream does not publish wheels. This is not the case currently (as proven by test) and does not seem too likely, so will leave as a TODO for the future. We use uploaded_packages.txt as the source of truth for what is a typeshed dependency. This is important to avoid potential badness around addition and deletion, for instance, if something is added to typeshed, but the distribution is created by someone else before stub_uploader uploads it. The other set of changes is that I delete most of the graph code that existed previously. The graph code was added in #1 and was previously load bearing for security. The idea being to ensure that the graph of transitive dependencies was fully contained within typeshed. This is no longer the case, so we can remove most of it. I still have some graph code in here, but it's no longer load bearing for security. I keep it around to better preserve uploading semantics, since it seems like it could matter in some edge case scenarios (such as multiple packages being uploaded for the first time that depend on each other). Since we don't have custom needs, we can get away with using the new-ish graphlib from stdlib. While the graph code has been removed, note that we do still run validation on transitive dependencies for each package. This is accomplished by `recursive_verify`. I think the non-transitive validation is sufficient, but running this before uploading each package doesn't hurt. I added some special-casing for types-gdb. As Akuli pointed out, this avoids us accidentally trusting a gdb package on PyPI if ever someone attempts to add external dependencies to types-gdb. New code paths have tests. I audited test coverage to make sure of this. * error if distribution is not on pypi * use removeprefix * hoist packages out of loop * add comment about sdists * add allowlist * add comment about requires_dist * Update stub_uploader/metadata.py
1 parent 592c0a4 commit 5aae098

File tree

10 files changed

+385
-258
lines changed

10 files changed

+385
-258
lines changed

.github/workflows/force_update.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 stub_uploader.upload_some ../typeshed "${{ github.event.inputs.distribution }}" data/uploaded_packages.txt
37+
python -m stub_uploader.upload_some ../typeshed "${{ github.event.inputs.distribution }}"
3838
# If we are force uploading packages that were never uploaded, they are added to the list
3939
if [ -z "$(git status --porcelain)" ]; then
4040
exit 0;

.github/workflows/update_stubs.yml

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

stub_uploader/build_wheel.py

Lines changed: 6 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,11 @@
2020
import os
2121
import os.path
2222
import shutil
23-
import tempfile
2423
import subprocess
25-
from collections import defaultdict
24+
import tempfile
2625
from textwrap import dedent
27-
from typing import List, Dict, Set, Optional
26+
from typing import Dict, List, Optional
2827

29-
from stub_uploader import get_version
3028
from stub_uploader.const import *
3129
from stub_uploader.metadata import Metadata, read_metadata
3230

@@ -101,13 +99,6 @@ def __init__(self, typeshed_dir: str, distribution: str) -> None:
10199
self.stub_dir = os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE, distribution)
102100

103101

104-
def strip_types_prefix(dependency: str) -> str:
105-
assert dependency.startswith(
106-
TYPES_PREFIX
107-
), "Currently only dependencies on stub packages are supported"
108-
return dependency[len(TYPES_PREFIX) :]
109-
110-
111102
def find_stub_files(top: str) -> List[str]:
112103
"""Find all stub files for a given package, relative to package root.
113104
@@ -214,106 +205,21 @@ def collect_setup_entries(base_dir: str) -> Dict[str, List[str]]:
214205
return package_data
215206

216207

217-
def verify_dependency(typeshed_dir: str, dependency: str, uploaded: str) -> None:
218-
"""Verify this is a valid dependency, i.e. a stub package uploaded by us."""
219-
known_distributions = set(
220-
os.listdir(os.path.join(typeshed_dir, THIRD_PARTY_NAMESPACE))
221-
)
222-
assert ";" not in dependency, "Semicolons in dependencies are not supported"
223-
dependency = get_version.strip_dep_version(dependency)
224-
assert (
225-
strip_types_prefix(dependency) in known_distributions
226-
), "Only dependencies on typeshed stubs are allowed"
227-
with open(uploaded) as f:
228-
uploaded_distributions = set(f.read().splitlines())
229-
230-
msg = f"{dependency} looks like a foreign distribution."
231-
uploaded_distributions_lower = [d.lower() for d in uploaded_distributions]
232-
if (
233-
dependency not in uploaded_distributions
234-
and dependency.lower() in uploaded_distributions_lower
235-
):
236-
msg += " Note: list is case sensitive"
237-
assert dependency in uploaded_distributions, msg
238-
239-
240-
def update_uploaded(uploaded: str, distribution: str) -> None:
241-
with open(uploaded) as f:
242-
current = set(f.read().splitlines())
243-
if f"types-{distribution}" not in current:
244-
with open(uploaded, "w") as f:
245-
f.write("\n".join(sorted(current | {f"types-{distribution}"})))
246-
247-
248-
def make_dependency_map(
249-
typeshed_dir: str, distributions: List[str]
250-
) -> Dict[str, Set[str]]:
251-
"""Return relative dependency map among distributions.
252-
253-
Important: this only includes dependencies *within* the given
254-
list of distributions.
255-
"""
256-
result: Dict[str, Set[str]] = {d: set() for d in distributions}
257-
for distribution in distributions:
258-
data = read_metadata(typeshed_dir, distribution)
259-
for dependency in data.requires:
260-
dependency = strip_types_prefix(get_version.strip_dep_version(dependency))
261-
if dependency in distributions:
262-
result[distribution].add(dependency)
263-
return result
264-
265-
266-
def transitive_deps(dep_map: Dict[str, Set[str]]) -> Dict[str, Set[str]]:
267-
"""Propagate dependencies to compute a transitive dependency map.
268-
269-
Note: this algorithm is O(N**2) in general case, but we don't worry,
270-
because N is small (less than 1000). So it will take few seconds at worst,
271-
while building/uploading 1000 packages will take minutes.
272-
"""
273-
transitive: Dict[str, Set[str]] = defaultdict(set)
274-
for distribution in dep_map:
275-
to_add = {distribution}
276-
while to_add:
277-
new = to_add.pop()
278-
extra = dep_map[new]
279-
transitive[distribution] |= extra
280-
assert (
281-
distribution not in transitive[distribution]
282-
), f"Cyclic dependency {distribution} -> {distribution}"
283-
to_add |= extra
284-
return transitive
285-
286-
287-
def sort_by_dependency(dep_map: Dict[str, Set[str]]) -> List[str]:
288-
"""Sort distributions by dependency order (those depending on nothing appear first)."""
289-
trans_map = transitive_deps(dep_map)
290-
291-
# We can't use builtin sort w.r.t. trans_map because it makes various assumptions
292-
# about properties of equality and order (like their mutual transitivity).
293-
def sort(ds: List[str]) -> List[str]:
294-
if not ds:
295-
return []
296-
pivot = ds.pop()
297-
not_dependent = [d for d in ds if pivot not in trans_map[d]]
298-
dependent = [d for d in ds if pivot in trans_map[d]]
299-
return sort(not_dependent) + [pivot] + sort(dependent)
300-
301-
# Return independent packages sorted by name for stability.
302-
return sort(sorted(dep_map))
303-
304-
305208
def generate_setup_file(
306209
build_data: BuildData, metadata: Metadata, version: str, commit: str
307210
) -> str:
308211
"""Auto-generate a setup.py file for given distribution using a template."""
212+
all_requirements = [
213+
str(req) for req in metadata.requires_typeshed + metadata.requires_external
214+
]
309215
package_data = collect_setup_entries(build_data.stub_dir)
310216
return SETUP_TEMPLATE.format(
311217
distribution=build_data.distribution,
312218
long_description=generate_long_description(
313219
build_data.distribution, commit, metadata
314220
),
315221
version=version,
316-
requires=metadata.requires,
222+
requires=all_requirements,
317223
packages=list(package_data.keys()),
318224
package_data=package_data,
319225
)

stub_uploader/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
TYPES_PREFIX = "types-"
55

66
CHANGELOG_PATH = "data/changelogs"
7+
UPLOADED_PATH = "data/uploaded_packages.txt"

stub_uploader/get_version.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
from typing import Any, Union
1616

1717
import requests
18-
from packaging.requirements import Requirement
1918
from packaging.specifiers import SpecifierSet
2019
from packaging.version import Version
2120
from requests.adapters import HTTPAdapter
@@ -129,11 +128,6 @@ def compute_incremented_version(
129128
return incremented_version
130129

131130

132-
def strip_dep_version(dependency: str) -> str:
133-
"""Strip a possible version suffix, e.g. types-six>=0.1.4 -> types-six."""
134-
return Requirement(dependency).name
135-
136-
137131
def determine_incremented_version(metadata: Metadata) -> str:
138132
published_stub_versions = fetch_pypi_versions(metadata.stub_distribution)
139133
version = compute_incremented_version(

0 commit comments

Comments
 (0)