|
1 | 1 | import compileall
|
2 | 2 | import fnmatch
|
| 3 | +import http.server |
3 | 4 | import io
|
4 | 5 | import os
|
5 | 6 | import re
|
6 | 7 | import shutil
|
7 | 8 | import subprocess
|
8 | 9 | import sys
|
| 10 | +import threading |
9 | 11 | from contextlib import ExitStack, contextmanager
|
| 12 | +from dataclasses import dataclass |
| 13 | +from enum import Enum |
| 14 | +from hashlib import sha256 |
10 | 15 | from pathlib import Path
|
| 16 | +from textwrap import dedent |
11 | 17 | from typing import (
|
12 | 18 | TYPE_CHECKING,
|
| 19 | + Any, |
13 | 20 | AnyStr,
|
14 | 21 | Callable,
|
| 22 | + ClassVar, |
15 | 23 | Dict,
|
16 | 24 | Iterable,
|
17 | 25 | Iterator,
|
18 | 26 | List,
|
19 | 27 | Optional,
|
| 28 | + Set, |
| 29 | + Tuple, |
20 | 30 | Union,
|
21 | 31 | )
|
22 | 32 | from unittest.mock import patch
|
@@ -750,3 +760,235 @@ def proxy(request: pytest.FixtureRequest) -> str:
|
750 | 760 | @pytest.fixture
|
751 | 761 | def enable_user_site(virtualenv: VirtualEnvironment) -> None:
|
752 | 762 | virtualenv.user_site_packages = True
|
| 763 | + |
| 764 | + |
| 765 | +class MetadataKind(Enum): |
| 766 | + """All the types of values we might be provided for the data-dist-info-metadata |
| 767 | + attribute from PEP 658.""" |
| 768 | + |
| 769 | + # Valid: will read metadata from the dist instead. |
| 770 | + No = "none" |
| 771 | + # Valid: will read the .metadata file, but won't check its hash. |
| 772 | + Unhashed = "unhashed" |
| 773 | + # Valid: will read the .metadata file and check its hash matches. |
| 774 | + Sha256 = "sha256" |
| 775 | + # Invalid: will error out after checking the hash. |
| 776 | + WrongHash = "wrong-hash" |
| 777 | + # Invalid: will error out after failing to fetch the .metadata file. |
| 778 | + NoFile = "no-file" |
| 779 | + |
| 780 | + |
| 781 | +@dataclass(frozen=True) |
| 782 | +class FakePackage: |
| 783 | + """Mock package structure used to generate a PyPI repository. |
| 784 | +
|
| 785 | + FakePackage name and version should correspond to sdists (.tar.gz files) in our test |
| 786 | + data.""" |
| 787 | + |
| 788 | + name: str |
| 789 | + version: str |
| 790 | + filename: str |
| 791 | + metadata: MetadataKind |
| 792 | + # This will override any dependencies specified in the actual dist's METADATA. |
| 793 | + requires_dist: Tuple[str, ...] = () |
| 794 | + # This will override the Name specified in the actual dist's METADATA. |
| 795 | + metadata_name: Optional[str] = None |
| 796 | + |
| 797 | + def metadata_filename(self) -> str: |
| 798 | + """This is specified by PEP 658.""" |
| 799 | + return f"{self.filename}.metadata" |
| 800 | + |
| 801 | + def generate_additional_tag(self) -> str: |
| 802 | + """This gets injected into the <a> tag in the generated PyPI index page for this |
| 803 | + package.""" |
| 804 | + if self.metadata == MetadataKind.No: |
| 805 | + return "" |
| 806 | + if self.metadata in [MetadataKind.Unhashed, MetadataKind.NoFile]: |
| 807 | + return 'data-dist-info-metadata="true"' |
| 808 | + if self.metadata == MetadataKind.WrongHash: |
| 809 | + return 'data-dist-info-metadata="sha256=WRONG-HASH"' |
| 810 | + assert self.metadata == MetadataKind.Sha256 |
| 811 | + checksum = sha256(self.generate_metadata()).hexdigest() |
| 812 | + return f'data-dist-info-metadata="sha256={checksum}"' |
| 813 | + |
| 814 | + def requires_str(self) -> str: |
| 815 | + if not self.requires_dist: |
| 816 | + return "" |
| 817 | + joined = " and ".join(self.requires_dist) |
| 818 | + return f"Requires-Dist: {joined}" |
| 819 | + |
| 820 | + def generate_metadata(self) -> bytes: |
| 821 | + """This is written to `self.metadata_filename()` and will override the actual |
| 822 | + dist's METADATA, unless `self.metadata == MetadataKind.NoFile`.""" |
| 823 | + return dedent( |
| 824 | + f"""\ |
| 825 | + Metadata-Version: 2.1 |
| 826 | + Name: {self.metadata_name or self.name} |
| 827 | + Version: {self.version} |
| 828 | + {self.requires_str()} |
| 829 | + """ |
| 830 | + ).encode("utf-8") |
| 831 | + |
| 832 | + |
| 833 | +@pytest.fixture(scope="session") |
| 834 | +def fake_packages() -> Dict[str, List[FakePackage]]: |
| 835 | + """The package database we generate for testing PEP 658 support.""" |
| 836 | + return { |
| 837 | + "simple": [ |
| 838 | + FakePackage("simple", "1.0", "simple-1.0.tar.gz", MetadataKind.Sha256), |
| 839 | + FakePackage("simple", "2.0", "simple-2.0.tar.gz", MetadataKind.No), |
| 840 | + # This will raise a hashing error. |
| 841 | + FakePackage("simple", "3.0", "simple-3.0.tar.gz", MetadataKind.WrongHash), |
| 842 | + ], |
| 843 | + "simple2": [ |
| 844 | + # Override the dependencies here in order to force pip to download |
| 845 | + # simple-1.0.tar.gz as well. |
| 846 | + FakePackage( |
| 847 | + "simple2", |
| 848 | + "1.0", |
| 849 | + "simple2-1.0.tar.gz", |
| 850 | + MetadataKind.Unhashed, |
| 851 | + ("simple==1.0",), |
| 852 | + ), |
| 853 | + # This will raise an error when pip attempts to fetch the metadata file. |
| 854 | + FakePackage("simple2", "2.0", "simple2-2.0.tar.gz", MetadataKind.NoFile), |
| 855 | + # This has a METADATA file with a mismatched name. |
| 856 | + FakePackage( |
| 857 | + "simple2", |
| 858 | + "3.0", |
| 859 | + "simple2-3.0.tar.gz", |
| 860 | + MetadataKind.Sha256, |
| 861 | + metadata_name="not-simple2", |
| 862 | + ), |
| 863 | + ], |
| 864 | + "colander": [ |
| 865 | + # Ensure we can read the dependencies from a metadata file within a wheel |
| 866 | + # *without* PEP 658 metadata. |
| 867 | + FakePackage( |
| 868 | + "colander", |
| 869 | + "0.9.9", |
| 870 | + "colander-0.9.9-py2.py3-none-any.whl", |
| 871 | + MetadataKind.No, |
| 872 | + ), |
| 873 | + ], |
| 874 | + "compilewheel": [ |
| 875 | + # Ensure we can override the dependencies of a wheel file by injecting PEP |
| 876 | + # 658 metadata. |
| 877 | + FakePackage( |
| 878 | + "compilewheel", |
| 879 | + "1.0", |
| 880 | + "compilewheel-1.0-py2.py3-none-any.whl", |
| 881 | + MetadataKind.Unhashed, |
| 882 | + ("simple==1.0",), |
| 883 | + ), |
| 884 | + ], |
| 885 | + "has-script": [ |
| 886 | + # Ensure we check PEP 658 metadata hashing errors for wheel files. |
| 887 | + FakePackage( |
| 888 | + "has-script", |
| 889 | + "1.0", |
| 890 | + "has.script-1.0-py2.py3-none-any.whl", |
| 891 | + MetadataKind.WrongHash, |
| 892 | + ), |
| 893 | + ], |
| 894 | + "translationstring": [ |
| 895 | + FakePackage( |
| 896 | + "translationstring", |
| 897 | + "1.1", |
| 898 | + "translationstring-1.1.tar.gz", |
| 899 | + MetadataKind.No, |
| 900 | + ), |
| 901 | + ], |
| 902 | + "priority": [ |
| 903 | + # Ensure we check for a missing metadata file for wheels. |
| 904 | + FakePackage( |
| 905 | + "priority", |
| 906 | + "1.0", |
| 907 | + "priority-1.0-py2.py3-none-any.whl", |
| 908 | + MetadataKind.NoFile, |
| 909 | + ), |
| 910 | + ], |
| 911 | + "requires-simple-extra": [ |
| 912 | + # Metadata name is not canonicalized. |
| 913 | + FakePackage( |
| 914 | + "requires-simple-extra", |
| 915 | + "0.1", |
| 916 | + "requires_simple_extra-0.1-py2.py3-none-any.whl", |
| 917 | + MetadataKind.Sha256, |
| 918 | + metadata_name="Requires_Simple.Extra", |
| 919 | + ), |
| 920 | + ], |
| 921 | + } |
| 922 | + |
| 923 | + |
| 924 | +@pytest.fixture(scope="session") |
| 925 | +def html_index_for_packages( |
| 926 | + shared_data: TestData, |
| 927 | + fake_packages: Dict[str, List[FakePackage]], |
| 928 | + tmpdir_factory: pytest.TempPathFactory, |
| 929 | +) -> Path: |
| 930 | + """Generate a PyPI HTML package index within a local directory pointing to |
| 931 | + synthetic test data.""" |
| 932 | + html_dir = tmpdir_factory.mktemp("fake_index_html_content") |
| 933 | + |
| 934 | + # (1) Generate the content for a PyPI index.html. |
| 935 | + pkg_links = "\n".join( |
| 936 | + f' <a href="{pkg}/index.html">{pkg}</a>' for pkg in fake_packages.keys() |
| 937 | + ) |
| 938 | + index_html = f"""\ |
| 939 | +<!DOCTYPE html> |
| 940 | +<html> |
| 941 | + <head> |
| 942 | + <meta name="pypi:repository-version" content="1.0"> |
| 943 | + <title>Simple index</title> |
| 944 | + </head> |
| 945 | + <body> |
| 946 | +{pkg_links} |
| 947 | + </body> |
| 948 | +</html>""" |
| 949 | + # (2) Generate the index.html in a new subdirectory of the temp directory. |
| 950 | + (html_dir / "index.html").write_text(index_html) |
| 951 | + |
| 952 | + # (3) Generate subdirectories for individual packages, each with their own |
| 953 | + # index.html. |
| 954 | + for pkg, links in fake_packages.items(): |
| 955 | + pkg_subdir = html_dir / pkg |
| 956 | + pkg_subdir.mkdir() |
| 957 | + |
| 958 | + download_links: List[str] = [] |
| 959 | + for package_link in links: |
| 960 | + # (3.1) Generate the <a> tag which pip can crawl pointing to this |
| 961 | + # specific package version. |
| 962 | + download_links.append( |
| 963 | + f' <a href="{package_link.filename}" {package_link.generate_additional_tag()}>{package_link.filename}</a><br/>' # noqa: E501 |
| 964 | + ) |
| 965 | + # (3.2) Copy over the corresponding file in `shared_data.packages`. |
| 966 | + shutil.copy( |
| 967 | + shared_data.packages / package_link.filename, |
| 968 | + pkg_subdir / package_link.filename, |
| 969 | + ) |
| 970 | + # (3.3) Write a metadata file, if applicable. |
| 971 | + if package_link.metadata != MetadataKind.NoFile: |
| 972 | + with open(pkg_subdir / package_link.metadata_filename(), "wb") as f: |
| 973 | + f.write(package_link.generate_metadata()) |
| 974 | + |
| 975 | + # (3.4) After collating all the download links and copying over the files, |
| 976 | + # write an index.html with the generated download links for each |
| 977 | + # copied file for this specific package name. |
| 978 | + download_links_str = "\n".join(download_links) |
| 979 | + pkg_index_content = f"""\ |
| 980 | +<!DOCTYPE html> |
| 981 | +<html> |
| 982 | + <head> |
| 983 | + <meta name="pypi:repository-version" content="1.0"> |
| 984 | + <title>Links for {pkg}</title> |
| 985 | + </head> |
| 986 | + <body> |
| 987 | + <h1>Links for {pkg}</h1> |
| 988 | +{download_links_str} |
| 989 | + </body> |
| 990 | +</html>""" |
| 991 | + with open(pkg_subdir / "index.html", "w") as f: |
| 992 | + f.write(pkg_index_content) |
| 993 | + |
| 994 | + return html_dir |
0 commit comments