diff --git a/CHANGES/1054.feature b/CHANGES/1054.feature new file mode 100644 index 00000000..143a07b7 --- /dev/null +++ b/CHANGES/1054.feature @@ -0,0 +1 @@ +Added data-requires-python to Simple HTML API. diff --git a/pulp_python/app/utils.py b/pulp_python/app/utils.py index 5781ac7f..ce8cac6e 100644 --- a/pulp_python/app/utils.py +++ b/pulp_python/app/utils.py @@ -47,7 +47,7 @@ """ -# TODO in the future: data-requires-python (PEP 503) +# TODO in the future: data-yanked (not implemented yet because it is mutable) simple_detail_template = """ @@ -58,6 +58,7 @@

Links for {{ project_name }}

{%- for pkg in project_packages %} {{ pkg.filename }}
@@ -501,7 +502,7 @@ def write_simple_index(project_names, streamed=False): def write_simple_detail(project_name, project_packages, streamed=False): """Writes the simple detail page of a package.""" - detail = Template(simple_detail_template) + detail = Template(simple_detail_template, autoescape=True) context = { "SIMPLE_API_VERSION": SIMPLE_API_VERSION, "project_name": project_name, diff --git a/pulp_python/tests/functional/api/test_pypi_simple_api.py b/pulp_python/tests/functional/api/test_pypi_simple_api.py index 57b640d9..621ed9a6 100644 --- a/pulp_python/tests/functional/api/test_pypi_simple_api.py +++ b/pulp_python/tests/functional/api/test_pypi_simple_api.py @@ -5,17 +5,23 @@ from pulp_python.tests.functional.constants import ( PYPI_SERIAL_CONSTANT, - PYTHON_EGG_FILENAME, - PYTHON_EGG_SHA256, - PYTHON_EGG_URL, PYTHON_SM_FIXTURE_CHECKSUMS, PYTHON_SM_FIXTURE_RELEASES, PYTHON_SM_PROJECT_SPECIFIER, - PYTHON_WHEEL_FILENAME, - PYTHON_WHEEL_METADATA_SHA256, - PYTHON_WHEEL_SHA256, - PYTHON_WHEEL_URL, - PYTHON_XS_FIXTURE_CHECKSUMS, + TWINE_EGG_FILENAME, + TWINE_EGG_REQUIRES_PYTHON, + TWINE_EGG_SHA256, + TWINE_EGG_SIZE, + TWINE_EGG_URL, + TWINE_FIXTURE_CHECKSUMS, + TWINE_FIXTURE_METADATA_SHA256, + TWINE_FIXTURE_REQUIRES_PYTHON, + TWINE_WHEEL_FILENAME, + TWINE_WHEEL_METADATA_SHA256, + TWINE_WHEEL_REQUIRES_PYTHON, + TWINE_WHEEL_SHA256, + TWINE_WHEEL_SIZE, + TWINE_WHEEL_URL, ) from pulp_python.tests.functional.utils import ensure_simple @@ -55,30 +61,27 @@ def test_simple_html_detail_api( python_distribution_factory, python_repo_factory, ): - content_1 = python_content_factory(PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL) - content_2 = python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL) + content_1 = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL) + content_2 = python_content_factory(TWINE_EGG_FILENAME, url=TWINE_EGG_URL) body = {"add_content_units": [content_1.pulp_href, content_2.pulp_href]} repo = python_repo_factory() monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task) distro = python_distribution_factory(repository=repo) - url = f'{urljoin(distro.base_url, "simple/")}shelf-reader' + url = f'{urljoin(distro.base_url, "simple/")}twine' headers = {"Accept": PYPI_SIMPLE_V1_HTML} response = requests.get(url, headers=headers) assert response.headers["Content-Type"] == PYPI_SIMPLE_V1_HTML assert response.headers["X-PyPI-Last-Serial"] == str(PYPI_SERIAL_CONSTANT) - metadata_sha_digests = { - PYTHON_WHEEL_FILENAME: PYTHON_WHEEL_METADATA_SHA256, - PYTHON_EGG_FILENAME: None, # egg files should not have metadata - } proper, msgs = ensure_simple( urljoin(distro.base_url, "simple/"), - {"shelf-reader": [PYTHON_WHEEL_FILENAME, PYTHON_EGG_FILENAME]}, - sha_digests=PYTHON_XS_FIXTURE_CHECKSUMS, - metadata_sha_digests=metadata_sha_digests, + {"twine": [TWINE_WHEEL_FILENAME, TWINE_EGG_FILENAME]}, + sha_digests=TWINE_FIXTURE_CHECKSUMS, + metadata_sha_digests=TWINE_FIXTURE_METADATA_SHA256, + requires_python=TWINE_FIXTURE_REQUIRES_PYTHON, ) assert proper, f"Simple API validation failed: {msgs}" @@ -114,15 +117,15 @@ def test_simple_json_detail_api( python_distribution_factory, python_repo_factory, ): - content_1 = python_content_factory(PYTHON_WHEEL_FILENAME, url=PYTHON_WHEEL_URL) - content_2 = python_content_factory(PYTHON_EGG_FILENAME, url=PYTHON_EGG_URL) + content_1 = python_content_factory(TWINE_WHEEL_FILENAME, url=TWINE_WHEEL_URL) + content_2 = python_content_factory(TWINE_EGG_FILENAME, url=TWINE_EGG_URL) body = {"add_content_units": [content_1.pulp_href, content_2.pulp_href]} repo = python_repo_factory() monitor_task(python_bindings.RepositoriesPythonApi.modify(repo.pulp_href, body).task) distro = python_distribution_factory(repository=repo) - url = f'{urljoin(distro.base_url, "simple/")}shelf-reader' + url = f'{urljoin(distro.base_url, "simple/")}twine' headers = {"Accept": PYPI_SIMPLE_V1_JSON} response = requests.get(url, headers=headers) @@ -131,29 +134,31 @@ def test_simple_json_detail_api( data = response.json() assert data["meta"] == {"api-version": API_VERSION, "_last-serial": PYPI_SERIAL_CONSTANT} - assert data["name"] == "shelf-reader" + assert data["name"] == "twine" assert data["files"] - assert data["versions"] == ["0.1"] + assert data["versions"] == ["5.1.0"] # Check data of a wheel - file_whl = next((i for i in data["files"] if i["filename"] == PYTHON_WHEEL_FILENAME), None) + file_whl = next((i for i in data["files"] if i["filename"] == TWINE_WHEEL_FILENAME), None) assert file_whl is not None, "wheel file not found" assert file_whl["url"] - assert file_whl["hashes"] == {"sha256": PYTHON_WHEEL_SHA256} - assert file_whl["requires-python"] is None - assert file_whl["data-dist-info-metadata"] == {"sha256": PYTHON_WHEEL_METADATA_SHA256} - assert file_whl["core-metadata"] == {"sha256": PYTHON_WHEEL_METADATA_SHA256} - assert file_whl["size"] == 22455 + assert file_whl["hashes"] == {"sha256": TWINE_WHEEL_SHA256} + assert file_whl["requires-python"] == TWINE_WHEEL_REQUIRES_PYTHON + assert file_whl["data-dist-info-metadata"] == {"sha256": TWINE_WHEEL_METADATA_SHA256} + assert file_whl["core-metadata"] == {"sha256": TWINE_WHEEL_METADATA_SHA256} + assert file_whl["size"] == TWINE_WHEEL_SIZE assert file_whl["upload-time"] is not None + assert file_whl["provenance"] is None + # Check data of a tarball - file_tar = next((i for i in data["files"] if i["filename"] == PYTHON_EGG_FILENAME), None) + file_tar = next((i for i in data["files"] if i["filename"] == TWINE_EGG_FILENAME), None) assert file_tar is not None, "tar file not found" assert file_tar["url"] - assert file_tar["hashes"] == {"sha256": PYTHON_EGG_SHA256} - assert file_tar["requires-python"] is None + assert file_tar["hashes"] == {"sha256": TWINE_EGG_SHA256} + assert file_tar["requires-python"] == TWINE_EGG_REQUIRES_PYTHON assert file_tar["data-dist-info-metadata"] is False assert file_tar["core-metadata"] is False - assert file_tar["size"] == 19097 + assert file_tar["size"] == TWINE_EGG_SIZE assert file_tar["upload-time"] is not None assert file_tar["provenance"] is None diff --git a/pulp_python/tests/functional/constants.py b/pulp_python/tests/functional/constants.py index 4150720f..af5744b2 100644 --- a/pulp_python/tests/functional/constants.py +++ b/pulp_python/tests/functional/constants.py @@ -226,6 +226,32 @@ # maybe add description, license is long for this one } +# twine pkg data for PyPI Simple API +TWINE_EGG_FILENAME = "twine-5.1.0.tar.gz" +TWINE_EGG_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_EGG_FILENAME) +TWINE_EGG_SHA256 = "4d74770c88c4fcaf8134d2a6a9d863e40f08255ff7d8e2acb3cbbd57d25f6e9d" +TWINE_EGG_SIZE = 224997 +TWINE_EGG_REQUIRES_PYTHON = ">=3.8" + +TWINE_WHEEL_FILENAME = "twine-5.1.0-py3-none-any.whl" +TWINE_WHEEL_URL = urljoin(urljoin(PYTHON_FIXTURES_URL, "packages/"), TWINE_WHEEL_FILENAME) +TWINE_WHEEL_SHA256 = "fe1d814395bfe50cfbe27783cb74efe93abeac3f66deaeb6c8390e4e92bacb43" +TWINE_WHEEL_SIZE = 38563 +TWINE_WHEEL_REQUIRES_PYTHON = ">=3.8" +TWINE_WHEEL_METADATA_SHA256 = "0ac5cf457bd47512b3477949ff6274cc2258414f3e1f136e049585aac92e4ddb" + +TWINE_FIXTURE_CHECKSUMS = { + TWINE_EGG_FILENAME: TWINE_EGG_SHA256, + TWINE_WHEEL_FILENAME: TWINE_WHEEL_SHA256, +} +TWINE_FIXTURE_METADATA_SHA256 = { + TWINE_WHEEL_FILENAME: TWINE_WHEEL_METADATA_SHA256, + TWINE_EGG_FILENAME: None, # egg files should not have metadata +} +TWINE_FIXTURE_REQUIRES_PYTHON = { + TWINE_WHEEL_FILENAME: TWINE_EGG_REQUIRES_PYTHON, + TWINE_EGG_FILENAME: TWINE_WHEEL_REQUIRES_PYTHON, +} # Current tests use PYTHON_FIXTURES_URL with an 'S', remove after adding api tests PYTHON_FIXTURE_URL = urljoin(PULP_FIXTURES_BASE_URL, "python-pypi/") diff --git a/pulp_python/tests/functional/utils.py b/pulp_python/tests/functional/utils.py index 2e6d1f40..b47215aa 100644 --- a/pulp_python/tests/functional/utils.py +++ b/pulp_python/tests/functional/utils.py @@ -25,7 +25,36 @@ def _validate_metadata_sha_digest(link, filename, metadata_sha_digests): return msgs -def ensure_simple(simple_url, packages, sha_digests=None, metadata_sha_digests=None): +def _validate_requires_python(link, filename, requires_python, page_content): + """ + Validate data-requires-python attribute for a release link. + """ + expected_requires_python = requires_python.get(filename) if requires_python else None + attr_value = link.get("data-requires-python") + + msgs = "" + if attr_value != expected_requires_python: + if expected_requires_python: + msgs += ( + f"\nFile {filename} has incorrect data-requires-python: " + f"expected '{expected_requires_python}', got '{attr_value}'" + ) + else: + msgs += f"\nFile {filename} should not have data-requires-python but has '{attr_value}'" + + # Check HTML escaping + if expected_requires_python and any(char in expected_requires_python for char in [">", "<"]): + escaped_value = expected_requires_python.replace(">", ">").replace("<", "<") + escaped_attr = f'data-requires-python="{escaped_value}"' + if escaped_attr not in page_content: + msgs += f"\nFile {filename} has unescaped < or > in data-requires-python attribute" + + return msgs + + +def ensure_simple( + simple_url, packages, sha_digests=None, metadata_sha_digests=None, requires_python=None +): """ Tests that the simple api at `url` matches the packages supplied. `packages`: dictionary of form {package_name: [release_filenames]} @@ -40,7 +69,8 @@ def ensure_simple(simple_url, packages, sha_digests=None, metadata_sha_digests=N def explore_links(page_url, page_name, links_found, msgs): legit_found_links = [] - page = html.fromstring(requests.get(page_url).text) + page_content = requests.get(page_url).text + page = html.fromstring(page_content) page_links = page.xpath("/html/body/a") for link in page_links: if link.text in links_found: @@ -52,6 +82,11 @@ def explore_links(page_url, page_name, links_found, msgs): # Check metadata SHA digest if provided if metadata_sha_digests and page_name == "release": msgs += _validate_metadata_sha_digest(link, link.text, metadata_sha_digests) + # Check requires-python if provided + if requires_python and page_name == "release": + msgs += _validate_requires_python( + link, link.text, requires_python, page_content + ) else: msgs += f"\nFound {page_name} link without href {link.text}" else: