diff --git a/tests/frontend/filter_list_controller_test.js b/tests/frontend/filter_list_controller_test.js index ad9a00554a7e..42849c568958 100644 --- a/tests/frontend/filter_list_controller_test.js +++ b/tests/frontend/filter_list_controller_test.js @@ -15,23 +15,56 @@ const testFixtureHTMLShowing = `

`; const testFixtureHTMLFilters = ` - - + + `; const testFixtureHTMLItems = ` -
Item 1
-
Item 2
-
Item 3
+ Show all files +
Item 1
+
Item 2
+
Item 3
`; describe("Filter list controller", () => { + const setFilterSelectValue = function(value) { + const elFilter = document.getElementById("filter-select"); + const dispatchEventSpy = jest.spyOn(elFilter, "dispatchEvent"); + + elFilter.value = value; + + // Manually trigger the 'input' event to get the MutationObserver that Stimulus uses to be updated. + // Also ensure the event has been dispatched. + const event = new Event("change"); + elFilter.dispatchEvent(event); + expect(dispatchEventSpy).toHaveBeenCalledWith(event); + return elFilter; + }; + const setFilterInputValue = function(value) { + const elFilter = document.getElementById("filter-input"); + const dispatchEventSpy = jest.spyOn(elFilter, "dispatchEvent"); + + elFilter.value = value; + + // Manually trigger the 'input' event to get the MutationObserver that Stimulus uses to be updated. + // Also ensure the event has been dispatched. + const event = new Event("input"); + elFilter.dispatchEvent(event); + expect(dispatchEventSpy).toHaveBeenCalledWith(event); + }; + const clearFilters = function() { + const elUrl = document.getElementById("filter-clear"); + const dispatchEventSpy = jest.spyOn(elUrl, "dispatchEvent"); + const event = new Event("click"); + elUrl.dispatchEvent(event); + expect(dispatchEventSpy).toHaveBeenCalledWith(event); + }; describe("is initialized as expected", () => { describe("makes expected elements visible", () => { let application; @@ -93,16 +126,16 @@ describe("Filter list controller", () => { expect(Object.keys(controller.mappingItemFilterData)).toHaveLength(3); expect(controller.mappingItemFilterData["0"]).toEqual({ - "contentType": ["contentType1", "contentType1a"], - "myattr": ["myattr1"], + "contentType": ["contentType1","Content Type 1", "contentType1a", "Content Type 1a"], + "myattr":["myattr1", "My Attr 1"], }); expect(controller.mappingItemFilterData["1"]).toEqual({ - "contentType": ["contentType2", "contentType2a"], - "myattr": ["myattr2"], + "contentType": ["contentType2", "Content Type 2", "contentType2a", "Content Type 2a"], + "myattr": ["myattr2", "My Attr 2"], }); expect(controller.mappingItemFilterData["2"]).toEqual({ - "contentType": ["contentType3", "contentType3a"], - "myattr": ["myattr3"], + "contentType": ["contentType3", "Content Type 3", "contentType3a", "Content Type 3a"], + "myattr": ["myattr3", "My Attr 3"], }); const elP = document.getElementById("url-update"); @@ -133,16 +166,37 @@ describe("Filter list controller", () => { it("all items begin shown", () => { const elP = document.getElementById("shown-and-total"); expect(elP.textContent).toEqual("Showing 3 of 3 files."); + expect(document.getElementsByClassName("my-item").length).toEqual(3); + + const elUrl = document.getElementById("url-update"); + expect(elUrl.href).toEqual("https://example.com/#testing"); + }); + it("shows message when all items are hidden", () => { + setFilterInputValue("lizards"); + + const elItem1 = document.getElementById("item-1"); + expect(elItem1.classList).toContainEqual("hidden"); + + const elItem2 = document.getElementById("item-2"); + expect(elItem2.classList).toContainEqual("hidden"); + + const elItem3 = document.getElementById("item-3"); + expect(elItem3.classList).toContainEqual("hidden"); + + const elP = document.getElementById("shown-and-total"); + expect(elP.textContent).toEqual("No files match the current filters. Showing 0 of 3 files."); }); }); describe("allows filtering", () => { + describe("input text filters the items", () => { let application; beforeEach(() => { document.body.innerHTML = `
+ ${testFixtureHTMLShowing} ${testFixtureHTMLFilters} ${testFixtureHTMLItems}
@@ -157,17 +211,13 @@ describe("Filter list controller", () => { }); it("the item classes are updated", () => { + // Set select to no filter + setFilterSelectValue(""); - const elFilter = document.getElementById("filter-input"); - const dispatchEventSpy = jest.spyOn(elFilter, "dispatchEvent"); - - elFilter.value = "2"; + setFilterInputValue("2"); - // Manually trigger the 'input' event to get the MutationObserver that Stimulus uses to be updated. - // Also ensure the event has been dispatched. - const event = new Event("input"); - elFilter.dispatchEvent(event); - expect(dispatchEventSpy).toHaveBeenCalledWith(event); + const elP = document.getElementById("url-update"); + expect(elP.textContent).toEqual("https://example.com/?contentType=2#testing"); const elItem1 = document.getElementById("item-1"); expect(elItem1.classList).toContainEqual("hidden"); @@ -177,9 +227,27 @@ describe("Filter list controller", () => { const elItem3 = document.getElementById("item-3"); expect(elItem3.classList).toContainEqual("hidden"); + }); + it("shows all items after clearing the filters", () => { + setFilterInputValue("lizards"); - const elP = document.getElementById("url-update"); - expect(elP.textContent).toEqual("https://example.com/?contentType=2#testing"); + const elItem1 = document.getElementById("item-1"); + expect(elItem1.classList).toContainEqual("hidden"); + + const elItem2 = document.getElementById("item-2"); + expect(elItem2.classList).toContainEqual("hidden"); + + const elItem3 = document.getElementById("item-3"); + expect(elItem3.classList).toContainEqual("hidden"); + + clearFilters(); + + const elP = document.getElementById("shown-and-total"); + expect(elP.textContent).toEqual("Showing 3 of 3 files."); + + expect(elItem1.classList).not.toContainEqual("hidden"); + expect(elItem2.classList).not.toContainEqual("hidden"); + expect(elItem3.classList).not.toContainEqual("hidden"); }); }); @@ -202,16 +270,7 @@ describe("Filter list controller", () => { }); it("the item classes are updated", () => { - const elFilter = document.getElementById("filter-select"); - const dispatchEventSpy = jest.spyOn(elFilter, "dispatchEvent"); - - elFilter.value = "myattr3"; - - // Manually trigger the 'input' event to get the MutationObserver that Stimulus uses to be updated. - // Also ensure the event has been dispatched. - const event = new Event("change"); - elFilter.dispatchEvent(event); - expect(dispatchEventSpy).toHaveBeenCalledWith(event); + setFilterSelectValue("myattr3"); const elItem1 = document.getElementById("item-1"); expect(elItem1.classList).toContainEqual("hidden"); diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index 1a18503a2e54..bd6749ea8902 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -215,12 +215,11 @@ def test_detail_rendered(self, db_request): "maintainers": sorted(users, key=lambda u: u.username.lower()), "license": None, "PEP740AttestationViewer": views.PEP740AttestationViewer, - "wheel_filters_all": {"interpreters": [], "abis": [], "platforms": []}, - "wheel_filters_params": { - "filename": "", - "interpreters": "", - "abis": "", - "platforms": "", + "wheel_filters_all": { + "interpreter": {}, + "abi": {}, + "platform": {}, + "other": {}, }, } @@ -249,9 +248,24 @@ def test_detail_renders_files_natural_sort(self, db_request): assert result["files"] == sorted_files assert [file.wheel_filters for file in result["files"]] == [ - {"interpreters": ["cp310"], "abis": ["none"], "platforms": ["any"]}, - {"interpreters": ["cp39"], "abis": ["none"], "platforms": ["any"]}, - {"interpreters": ["cp27"], "abis": ["none"], "platforms": ["any"]}, + { + "interpreter": {"cp310": "CPython 3.10"}, + "abi": {"none": "(none)"}, + "platform": {"any": "(any)"}, + "other": {}, + }, + { + "interpreter": {"cp39": "CPython 3.9"}, + "abi": {"none": "(none)"}, + "platform": {"any": "(any)"}, + "other": {}, + }, + { + "interpreter": {"cp27": "CPython 2.7"}, + "abi": {"none": "(none)"}, + "platform": {"any": "(any)"}, + "other": {}, + }, ] def test_license_from_classifier(self, db_request): diff --git a/tests/unit/utils/test_wheel.py b/tests/unit/utils/test_wheel.py index 5f1d5a00ebbb..660d4342d913 100644 --- a/tests/unit/utils/test_wheel.py +++ b/tests/unit/utils/test_wheel.py @@ -5,138 +5,378 @@ from warehouse.utils import wheel +def _build(**kwargs): + grouped_labels = { + "interpreter": {}, + "abi": {}, + "platform": {}, + "other": {}, + } + for key, value in kwargs.items(): + if key.startswith("interp_"): + grouped_labels["interpreter"][key.removeprefix("interp_")] = value + elif key.startswith("abi_"): + grouped_labels["abi"][key.removeprefix("abi_")] = value + elif key.startswith("plat_"): + grouped_labels["platform"][key.removeprefix("plat_")] = value + else: + grouped_labels["other"][key.removeprefix("other_")] = value + return grouped_labels + + @pytest.mark.parametrize( ("filename", "expected_tags"), [ - ("cryptography-42.0.5.tar.gz", ["Source"]), - ("Pillow-2.5.0-py3.4-win-amd64.egg", ["Egg"]), - ("Pillow-2.5.0-py3.4-win32.egg", ["Egg"]), + ("cryptography-42.0.5.tar.gz", _build(other_source="Source")), + ("Pillow-2.5.0-py3.4-win-amd64.egg", _build(other_egg="Egg")), + ("Pillow-2.5.0-py3.4-win32.egg", _build(other_egg="Egg")), ( "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", - ["PyPy", "Windows x86-64"], + _build( + interp_pp310="PyPy 310", + abi_pypy310_pp73="PyPy 310 pp73", + plat_win_amd64="Windows x86-64", + ), ), ( "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", - ["PyPy", "manylinux: glibc 2.28+ x86-64"], + _build( + interp_pp310="PyPy 310", + abi_pypy310_pp73="PyPy 310 pp73", + plat_manylinux_2_28_x86_64="linux glibc 2.28+ x86-64", + ), ), ( "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", - ["CPython 3.7+", "musllinux: musl 1.2+ x86-64"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_musllinux_1_2_x86_64="linux musl 1.2+ x86-64", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_5_intel.whl", - ["CPython 3.7+", "macOS 10.5+ Intel (x86-64, i386)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_5_intel="macOS 10.5+ Intel (x86-64, i386)", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_5_fat.whl", - ["CPython 3.7+", "macOS 10.5+ fat (i386, PPC)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_5_fat="macOS 10.5+ fat (i386, PPC)", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_5_fat3.whl", - ["CPython 3.7+", "macOS 10.5+ fat3 (x86-64, i386, PPC)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_5_fat3="macOS 10.5+ fat3 (x86-64, i386, PPC)", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_5_fat64.whl", - ["CPython 3.7+", "macOS 10.5+ fat64 (x86-64, PPC64)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_5_fat64="macOS 10.5+ fat64 (x86-64, PPC64)", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_5_universal.whl", - ["CPython 3.7+", "macOS 10.5+ universal (x86-64, i386, PPC64, PPC)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_5_universal="macOS 10.5+ " + "universal (x86-64, i386, PPC64, PPC)", + ), ), ( "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", - ["CPython 3.7+", "macOS 10.12+ universal2 (ARM64, x86-64)"], + _build( + interp_cp37="CPython 3.7", + abi_abi3="CPython abi3", + plat_macosx_10_12_universal2="macOS 10.12+ universal2 (ARM64, x86-64)", + ), ), ( "cryptography-42.0.5-cp313-cp313-android_27_armeabi_v7a.whl", - ["Android API level 27+ ARM EABI v7a", "CPython 3.13"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_android_27_armeabi_v7a="Android API level 27+ ARM EABI v7a", + ), ), ( "cryptography-42.0.5-cp313-cp313-android_27_arm64_v8a.whl", - ["Android API level 27+ ARM64 v8a", "CPython 3.13"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_android_27_arm64_v8a="Android API level 27+ ARM64 v8a", + ), ), ( "cryptography-42.0.5-cp313-cp313-android_27_x86.whl", - ["Android API level 27+ x86", "CPython 3.13"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_android_27_x86="Android API level 27+ x86", + ), ), ( "cryptography-42.0.5-cp313-cp313-android_27_x86_64.whl", - ["Android API level 27+ x86-64", "CPython 3.13"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_android_27_x86_64="Android API level 27+ x86-64", + ), ), ( "cryptography-42.0.5-cp313-abi3-android_16_armeabi_v7a.whl", - ["Android API level 16+ ARM EABI v7a", "CPython 3.13+"], + _build( + interp_cp313="CPython 3.13", + abi_abi3="CPython abi3", + plat_android_16_armeabi_v7a="Android API level 16+ ARM EABI v7a", + ), ), ( "cryptography-42.0.5-cp313-cp313-iOS_15_6_arm64_iphoneos.whl", - ["CPython 3.13", "iOS 15.6+ ARM64 Device"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_ios_15_6_arm64_iphoneos="iOS 15.6+ ARM64 Device", + ), ), ( "cryptography-42.0.5-cp313-cp313-iOS_15_6_arm64_iphonesimulator.whl", - ["CPython 3.13", "iOS 15.6+ ARM64 Simulator"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_ios_15_6_arm64_iphonesimulator="iOS 15.6+ ARM64 Simulator", + ), ), ( "cryptography-42.0.5-cp313-cp313-iOS_15_6_x86_64_iphonesimulator.whl", - ["CPython 3.13", "iOS 15.6+ x86-64 Simulator"], + _build( + interp_cp313="CPython 3.13", + abi_cp313="CPython 3.13", + plat_ios_15_6_x86_64_iphonesimulator="iOS 15.6+ x86-64 Simulator", + ), ), ( "cryptography-42.0.5-cp313-abi3-iOS_13_0_arm64_iphoneos.whl", - ["CPython 3.13+", "iOS 13.0+ ARM64 Device"], + _build( + interp_cp313="CPython 3.13", + abi_abi3="CPython abi3", + plat_ios_13_0_arm64_iphoneos="iOS 13.0+ ARM64 Device", + ), ), ( "cryptography-42.0.5-cp313-abi3-iOS_13_0_arm64_iphonesimulator.whl", - ["CPython 3.13+", "iOS 13.0+ ARM64 Simulator"], + _build( + interp_cp313="CPython 3.13", + abi_abi3="CPython abi3", + plat_ios_13_0_arm64_iphonesimulator="iOS 13.0+ ARM64 Simulator", + ), ), ( "pgf-1.0-pp27-pypy_73-manylinux2010_x86_64.whl", - ["PyPy", "manylinux: glibc 2.12+ x86-64"], + _build( + interp_pp27="PyPy 27", + abi_pypy_73="PyPy 73", + plat_manylinux2010_x86_64="linux glibc 2.12+ x86-64", + ), + ), + # Cannot parse 'pdfcomparator-0_2_0-py2-none-any.whl' - invalid version? + ( + "pdfcomparator-0_2_0-py2-none-any.whl", + # _build(interp_py2="Python 2", abi_none="(none)", plat_any="(any)")), + _build(), ), - ("pdfcomparator-0_2_0-py2-none-any.whl", []), ( "mclbn256-0.6.0-py3-abi3-macosx_12_0_arm64.whl", - ["Python 3", "macOS 12.0+ ARM64"], + _build( + interp_py3="Python 3", + abi_abi3="CPython abi3", + plat_macosx_12_0_arm64="macOS 12.0+ ARM64", + ), ), ( "pep272_encryption-0.4-py2.pp35.pp36.pp37.pp38.pp39-none-any.whl", - ["PyPy", "Python 2"], + _build( + interp_py2="Python 2", + interp_pp35="PyPy 35", + interp_pp36="PyPy 36", + interp_pp37="PyPy 37", + interp_pp38="PyPy 38", + interp_pp39="PyPy 39", + abi_none="(none)", + plat_any="(any)", + ), ), ( "ruff-0.3.2-py3-none-musllinux_1_2_armv7l.whl", - ["Python 3", "musllinux: musl 1.2+ ARMv7l"], + _build( + interp_py3="Python 3", + abi_none="(none)", + plat_musllinux_1_2_armv7l="linux musl 1.2+ ARMv7l", + ), ), ( "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", - ["CPython 3.12", "musllinux: musl 1.1+ x86-64"], + _build( + interp_cp312="CPython 3.12", + abi_cp312="CPython 3.12", + plat_musllinux_1_1_x86_64="linux musl 1.1+ x86-64", + ), + ), + ( + "numpy-1.26.4-lol_interpreter-lol_abi-lol_platform.whl", + _build( + interp_lol_interpreter="lol interpreter", + abi_lol_abi="lol abi", + plat_lol_platform="lol platform", + ), ), ( - "numpy-1.26.4-lolinterpreter-lolabi-musllinux_1_1_x86_64.whl", - ["lolinterpreter", "musllinux: musl 1.1+ x86-64"], + "pydantic_core-2.16.2-pp39-pypy39_pp73-" + "manylinux_2_17_aarch64.manylinux2014_aarch64.whl", + _build( + interp_pp39="PyPy 39", + abi_pypy39_pp73="PyPy 39 pp73", + plat_manylinux_2_17_aarch64="linux glibc 2.17+ ARM64", + plat_manylinux2014_aarch64="linux glibc 2.17+ ARM64", + ), + ), + ( + "numpy-1.13.1-cp36-none-win_amd64.whl", + _build( + interp_cp36="CPython 3.6", + abi_none="(none)", + plat_win_amd64="Windows x86-64", + ), ), ( - ( - "pydantic_core-2.16.2-pp39-pypy39_pp73-manylinux_2_17_aarch64." - "manylinux2014_aarch64.whl" + "cryptography-38.0.2-cp36-abi3-win32.whl", + _build( + interp_cp36="CPython 3.6", + abi_abi3="CPython abi3", + plat_win32="Windows x86", ), - ["PyPy", "manylinux: glibc 2.17+ ARM64"], ), - ("numpy-1.13.1-cp36-none-win_amd64.whl", ["CPython 3.6", "Windows x86-64"]), - ("cryptography-38.0.2-cp36-abi3-win32.whl", ["CPython 3.6+", "Windows x86"]), ( "plato_learn-0.4.7-py36.py37.py38.py39-none-any.whl", - [ - "Python 3.6", - "Python 3.7", - "Python 3.8", - "Python 3.9", - ], - ), - ("juriscraper-1.1.11-py27-none-any.whl", ["Python 2.7"]), - ("OZI-0.0.291-py312-none-any.whl", ["Python 3.12"]), - ("foo-0.0.0-ip27-none-any.whl", ["IronPython 2.7"]), - ("foo-0.0.0-jy38-none-any.whl", ["Jython 3.8"]), - ("foo-0.0.0-garbage-none-any.whl", ["garbage"]), - ("foo-0.0.0-69-none-any.whl", []), + _build( + interp_py36="Python 3.6", + interp_py37="Python 3.7", + interp_py38="Python 3.8", + interp_py39="Python 3.9", + abi_none="(none)", + plat_any="(any)", + ), + ), + ( + "juriscraper-1.1.11-py27-none-any.whl", + _build(interp_py27="Python 2.7", abi_none="(none)", plat_any="(any)"), + ), + ( + "OZI-0.0.291-py312-none-any.whl", + _build(interp_py312="Python 3.12", abi_none="(none)", plat_any="(any)"), + ), + ( + "foo-0.0.0-ip27-none-any.whl", + _build(interp_ip27="IronPython 2.7", abi_none="(none)", plat_any="(any)"), + ), + ( + "foo-0.0.0-jy38-none-any.whl", + _build(interp_jy38="Jython 3.8", abi_none="(none)", plat_any="(any)"), + ), + ( + "foo-0.0.0-garbage-none-any.whl", + _build(interp_garbage="garbage", abi_none="(none)", plat_any="(any)"), + ), + ( + "foo-0.0.0-69-none-any.whl", + _build(interp_69="69", abi_none="(none)", plat_any="(any)"), + ), + ( + "aiohttp-3.13.2-cp314-cp314udmtz-" + "manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", + _build( + interp_cp314="CPython 3.14", + abi_cp314udmtz="CPython 3.14 " + "debug free-threading pymalloc wide-unicode z", + plat_manylinux_2_31_riscv64="linux glibc 2.31+ RISC-V 64", + plat_manylinux_2_39_riscv64="linux glibc 2.39+ RISC-V 64", + ), + ), + ( + "aiohttp-3.13.2-cp314-cp314t-" + "manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", + _build( + interp_cp314="CPython 3.14", + abi_cp314t="CPython 3.14 free-threading", + plat_manylinux2014_s390x="linux glibc 2.17+ IBM System/390x", + plat_manylinux_2_17_s390x="linux glibc 2.17+ IBM System/390x", + plat_manylinux_2_28_s390x="linux glibc 2.28+ IBM System/390x", + ), + ), + ( + "aiohttp-3.13.2-cp39-cp39-" + "manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", + _build( + interp_cp39="CPython 3.9", + abi_cp39="CPython 3.9", + plat_manylinux2014_ppc64le="linux glibc 2.17+ PowerPC 64-le", + plat_manylinux_2_17_ppc64le="linux glibc 2.17+ PowerPC 64-le", + plat_manylinux_2_28_ppc64le="linux glibc 2.28+ PowerPC 64-le", + ), + ), + ( + "numpy-2.3.4-pp311-pypy311_pp73-" + "manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + _build( + interp_pp311="PyPy 311", + abi_pypy311_pp73="PyPy 311 pp73", + plat_manylinux_2_27_aarch64="linux glibc 2.27+ ARM64", + plat_manylinux_2_28_aarch64="linux glibc 2.28+ ARM64", + ), + ), + ( + "numpy-2.3.4-pp311-pp73_pypy311-" + "manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + _build( + interp_pp311="PyPy 311", + abi_pp73_pypy311="PyPy 73 pypy311", + plat_manylinux_2_27_aarch64="linux glibc 2.27+ ARM64", + plat_manylinux_2_28_aarch64="linux glibc 2.28+ ARM64", + ), + ), + ( + "numpy-2.3.4-pp311-ip27-" + "manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + _build( + interp_pp311="PyPy 311", + abi_ip27="IronPython 2.7", + plat_manylinux_2_27_aarch64="linux glibc 2.27+ ARM64", + plat_manylinux_2_28_aarch64="linux glibc 2.28+ ARM64", + ), + ), + ( + "numpy-2.3.4-pp311-jy38-" + "manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", + _build( + interp_pp311="PyPy 311", + abi_jy38="Jython 3.8", + plat_manylinux_2_27_aarch64="linux glibc 2.27+ ARM64", + plat_manylinux_2_28_aarch64="linux glibc 2.28+ ARM64", + ), + ), ], ) -def test_wheel_to_pretty_tags(filename, expected_tags): - assert wheel.filename_to_pretty_tags(filename) == expected_tags +def test_wheel_to_groups_labels(filename, expected_tags): + assert wheel.filename_to_grouped_labels(filename) == expected_tags diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 8fdbd270e5e3..2c10ac038c73 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -865,7 +865,7 @@ msgstr "" msgid "Provide an Inspector link to specific lines of code." msgstr "" -#: warehouse/packaging/views.py:346 +#: warehouse/packaging/views.py:339 msgid "Your report has been recorded. Thank you for your help." msgstr "" @@ -873,7 +873,11 @@ msgstr "" msgid "Copied" msgstr "" -#: warehouse/static/js/warehouse/controllers/filter_list_controller.js:69 +#: warehouse/static/js/warehouse/controllers/filter_list_controller.js:99 +msgid "No files match the current filters." +msgstr "" + +#: warehouse/static/js/warehouse/controllers/filter_list_controller.js:101 msgid "Showing %1 of %2 file." msgid_plural "Showing %1 of %2 files." msgstr[0] "" @@ -1000,9 +1004,8 @@ msgstr "" #: warehouse/templates/manage/project/release.html:226 #: warehouse/templates/manage/project/releases.html:137 #: warehouse/templates/manage/project/releases.html:190 -#: warehouse/templates/packaging/detail.html:444 -#: warehouse/templates/packaging/detail.html:462 -#: warehouse/templates/packaging/detail.html:478 +#: warehouse/templates/packaging/detail.html:539 +#: warehouse/templates/packaging/detail.html:554 #: warehouse/templates/pages/classifiers.html:17 #: warehouse/templates/pages/help.html:7 #: warehouse/templates/pages/help.html:441 @@ -1270,6 +1273,7 @@ msgstr "" #: warehouse/templates/base.html:291 warehouse/templates/base.html:320 #: warehouse/templates/error-base-with-search.html:19 #: warehouse/templates/index.html:44 +#: warehouse/templates/packaging/detail.html:575 msgid "Search" msgstr "" @@ -2992,8 +2996,7 @@ msgstr "" #: warehouse/templates/manage/account/recovery_codes-provision.html:53 #: warehouse/templates/manage/account/totp-provision.html:46 #: warehouse/templates/manage/unverified-account.html:200 -#: warehouse/templates/packaging/detail.html:148 -#: warehouse/templates/packaging/detail.html:498 +#: warehouse/templates/packaging/detail.html:243 #: warehouse/templates/pages/classifiers.html:42 msgid "Copy to clipboard" msgstr "" @@ -3005,7 +3008,6 @@ msgstr "" #: warehouse/templates/manage/account/recovery_codes-provision.html:54 #: warehouse/templates/manage/account/totp-provision.html:47 #: warehouse/templates/manage/unverified-account.html:201 -#: warehouse/templates/packaging/detail.html:499 #: warehouse/templates/pages/classifiers.html:43 msgid "Copy" msgstr "" @@ -5042,6 +5044,10 @@ msgstr "" #: warehouse/templates/manage/account/publishing.html:418 #: warehouse/templates/manage/project/publishing.html:376 +#: warehouse/templates/packaging/detail.html:81 +#: warehouse/templates/packaging/detail.html:106 +#: warehouse/templates/packaging/detail.html:120 +#: warehouse/templates/packaging/detail.html:183 msgid "Details" msgstr "" @@ -6502,12 +6508,12 @@ msgid "Back to projects" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:51 -#: warehouse/templates/packaging/detail.html:327 +#: warehouse/templates/packaging/detail.html:422 msgid "This project has been quarantined." msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:53 -#: warehouse/templates/packaging/detail.html:329 +#: warehouse/templates/packaging/detail.html:424 msgid "" "PyPI Admins need to review this project before it can be restored. While " "in quarantine, the project is not installable by clients, and cannot be " @@ -6515,7 +6521,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:60 -#: warehouse/templates/packaging/detail.html:336 +#: warehouse/templates/packaging/detail.html:431 #, python-format msgid "" "Read more in the project in quarantine help " @@ -6523,7 +6529,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:67 -#: warehouse/templates/packaging/detail.html:343 +#: warehouse/templates/packaging/detail.html:438 msgid "This project has been archived." msgstr "" @@ -6630,6 +6636,8 @@ msgstr "" #: warehouse/templates/manage/project/release.html:40 #: warehouse/templates/manage/project/release.html:56 +#: warehouse/templates/packaging/detail.html:80 +#: warehouse/templates/packaging/detail.html:99 msgid "Type" msgstr "" @@ -7449,205 +7457,247 @@ msgstr "" msgid "%(org)s has not uploaded any projects to PyPI, yet" msgstr "" +#: warehouse/templates/packaging/detail.html:75 +#, python-format +msgid "Source distribution for %(name)s %(version)s" +msgstr "" + +#: warehouse/templates/packaging/detail.html:77 #: warehouse/templates/packaging/detail.html:86 -msgid "view details" +#: warehouse/templates/packaging/detail.html:116 +#: warehouse/templates/packaging/detail.html:126 +msgid "File" +msgstr "" + +#: warehouse/templates/packaging/detail.html:78 +#: warehouse/templates/packaging/detail.html:91 +msgid "Uploaded" +msgstr "" + +#: warehouse/templates/packaging/detail.html:79 +#: warehouse/templates/packaging/detail.html:95 +msgid "Size" +msgstr "" + +#: warehouse/templates/packaging/detail.html:92 +#: warehouse/templates/packaging/detail.html:132 +#, python-format +msgid "Uploaded %(upload_time)s" +msgstr "" + +#: warehouse/templates/packaging/detail.html:100 +msgid "Source" msgstr "" -#: warehouse/templates/packaging/detail.html:96 #: warehouse/templates/packaging/detail.html:103 +#: warehouse/templates/packaging/detail.html:180 +msgid "View details" +msgstr "" + +#: warehouse/templates/packaging/detail.html:114 +#, python-format +msgid "Table of built distributions (wheels) for %(name)s %(release)s" +msgstr "" + +#: warehouse/templates/packaging/detail.html:117 +#: warehouse/templates/packaging/detail.html:146 +msgid "Interpreter" +msgstr "" + +#: warehouse/templates/packaging/detail.html:118 +#: warehouse/templates/packaging/detail.html:157 +msgid "ABI" +msgstr "" + +#: warehouse/templates/packaging/detail.html:119 +#: warehouse/templates/packaging/detail.html:168 +msgid "Platform" +msgstr "" + +#: warehouse/templates/packaging/detail.html:190 +#: warehouse/templates/packaging/detail.html:199 #, python-format msgid "%(title)s" msgstr "" -#: warehouse/templates/packaging/detail.html:115 +#: warehouse/templates/packaging/detail.html:210 #, python-format msgid "RSS: latest releases for %(project_name)s" msgstr "" -#: warehouse/templates/packaging/detail.html:150 +#: warehouse/templates/packaging/detail.html:245 msgid "Copy PIP instructions" msgstr "" -#: warehouse/templates/packaging/detail.html:160 +#: warehouse/templates/packaging/detail.html:255 msgid "This project has been quarantined" msgstr "" -#: warehouse/templates/packaging/detail.html:166 +#: warehouse/templates/packaging/detail.html:261 msgid "This release has been yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:174 +#: warehouse/templates/packaging/detail.html:269 #, python-format msgid "Stable version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:179 +#: warehouse/templates/packaging/detail.html:274 #, python-format msgid "Newer version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:184 +#: warehouse/templates/packaging/detail.html:279 msgid "Latest version" msgstr "" -#: warehouse/templates/packaging/detail.html:189 +#: warehouse/templates/packaging/detail.html:284 #, python-format msgid "Released: %(release_date)s" msgstr "" -#: warehouse/templates/packaging/detail.html:202 +#: warehouse/templates/packaging/detail.html:297 msgid "No project description provided" msgstr "" -#: warehouse/templates/packaging/detail.html:214 +#: warehouse/templates/packaging/detail.html:309 msgid "Navigation" msgstr "" -#: warehouse/templates/packaging/detail.html:215 -#: warehouse/templates/packaging/detail.html:267 +#: warehouse/templates/packaging/detail.html:310 +#: warehouse/templates/packaging/detail.html:362 #, python-format msgid "Navigation for %(project)s" msgstr "" -#: warehouse/templates/packaging/detail.html:224 -#: warehouse/templates/packaging/detail.html:276 +#: warehouse/templates/packaging/detail.html:319 +#: warehouse/templates/packaging/detail.html:371 msgid "Project description. Focus will be moved to the description." msgstr "" -#: warehouse/templates/packaging/detail.html:226 -#: warehouse/templates/packaging/detail.html:278 -#: warehouse/templates/packaging/detail.html:357 +#: warehouse/templates/packaging/detail.html:321 +#: warehouse/templates/packaging/detail.html:373 +#: warehouse/templates/packaging/detail.html:452 msgid "Project description" msgstr "" -#: warehouse/templates/packaging/detail.html:235 -#: warehouse/templates/packaging/detail.html:298 +#: warehouse/templates/packaging/detail.html:330 +#: warehouse/templates/packaging/detail.html:393 msgid "Release history. Focus will be moved to the history panel." msgstr "" -#: warehouse/templates/packaging/detail.html:237 -#: warehouse/templates/packaging/detail.html:300 -#: warehouse/templates/packaging/detail.html:385 +#: warehouse/templates/packaging/detail.html:332 +#: warehouse/templates/packaging/detail.html:395 +#: warehouse/templates/packaging/detail.html:480 msgid "Release history" msgstr "" -#: warehouse/templates/packaging/detail.html:247 -#: warehouse/templates/packaging/detail.html:310 +#: warehouse/templates/packaging/detail.html:342 +#: warehouse/templates/packaging/detail.html:405 msgid "Download files. Focus will be moved to the project files." msgstr "" -#: warehouse/templates/packaging/detail.html:249 -#: warehouse/templates/packaging/detail.html:312 -#: warehouse/templates/packaging/detail.html:442 +#: warehouse/templates/packaging/detail.html:344 +#: warehouse/templates/packaging/detail.html:407 +#: warehouse/templates/packaging/detail.html:537 msgid "Download files" msgstr "" -#: warehouse/templates/packaging/detail.html:262 +#: warehouse/templates/packaging/detail.html:357 msgid "Report project as malware" msgstr "" -#: warehouse/templates/packaging/detail.html:287 +#: warehouse/templates/packaging/detail.html:382 msgid "Project details. Focus will be moved to the project details." msgstr "" -#: warehouse/templates/packaging/detail.html:289 -#: warehouse/templates/packaging/detail.html:373 +#: warehouse/templates/packaging/detail.html:384 +#: warehouse/templates/packaging/detail.html:468 msgid "Project details" msgstr "" -#: warehouse/templates/packaging/detail.html:345 +#: warehouse/templates/packaging/detail.html:440 msgid "" "The maintainers of this project have marked this project as archived. No " "new releases are expected." msgstr "" -#: warehouse/templates/packaging/detail.html:353 -#: warehouse/templates/packaging/detail.html:424 +#: warehouse/templates/packaging/detail.html:448 +#: warehouse/templates/packaging/detail.html:519 msgid "Reason this release was yanked:" msgstr "" -#: warehouse/templates/packaging/detail.html:362 +#: warehouse/templates/packaging/detail.html:457 msgid "The author of this package has not provided a project description" msgstr "" -#: warehouse/templates/packaging/detail.html:387 +#: warehouse/templates/packaging/detail.html:482 msgid "Release notifications" msgstr "" -#: warehouse/templates/packaging/detail.html:388 +#: warehouse/templates/packaging/detail.html:483 msgid "RSS feed" msgstr "" -#: warehouse/templates/packaging/detail.html:399 +#: warehouse/templates/packaging/detail.html:494 msgid "This version" msgstr "" -#: warehouse/templates/packaging/detail.html:415 +#: warehouse/templates/packaging/detail.html:510 msgid "pre-release" msgstr "" -#: warehouse/templates/packaging/detail.html:418 +#: warehouse/templates/packaging/detail.html:513 msgid "yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:444 +#: warehouse/templates/packaging/detail.html:539 #, python-format msgid "" -"Download the file for your platform. If you're not sure which to choose, " -"learn more about installing packages." +"For a detailed explanation of source distributions (sdists) and built " +"distributions (wheels), please see the package formats " +"documentation." msgstr "" -#: warehouse/templates/packaging/detail.html:447 -msgid "Source Distribution" -msgid_plural "Source Distributions" +#: warehouse/templates/packaging/detail.html:542 +msgid "Source distribution (sdist)" +msgid_plural "Source distributions (sdists)" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/packaging/detail.html:461 +#: warehouse/templates/packaging/detail.html:553 msgid "No source distribution files available for this release." msgstr "" -#: warehouse/templates/packaging/detail.html:462 +#: warehouse/templates/packaging/detail.html:554 #, python-format msgid "" "See tutorial on generating distribution archives." msgstr "" -#: warehouse/templates/packaging/detail.html:468 -msgid "Built Distribution" -msgid_plural "Built Distributions" +#: warehouse/templates/packaging/detail.html:560 +msgid "Built distribution (wheel)" +msgid_plural "Built distributions (wheels)" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/packaging/detail.html:475 -msgid "Filter files by name, interpreter, ABI, and platform." -msgstr "" - -#: warehouse/templates/packaging/detail.html:478 -#, python-format -msgid "" -"If you're not sure about the file name format, learn more about wheel file names." -msgstr "" - -#: warehouse/templates/packaging/detail.html:482 -msgid "The dropdown lists show the available interpreters, ABIs, and platforms." +#: warehouse/templates/packaging/detail.html:569 +msgid "Enable javascript to be able to filter the list of wheel files." msgstr "" -#: warehouse/templates/packaging/detail.html:485 -msgid "Enable javascript to be able to filter the list of wheel files." +#: warehouse/templates/packaging/detail.html:580 +msgid "Search by file name" msgstr "" -#: warehouse/templates/packaging/detail.html:489 -msgid "Copy a direct link to the current filters" +#: warehouse/templates/packaging/detail.html:593 +msgid "Filters" msgstr "" -#: warehouse/templates/packaging/detail.html:507 -#: warehouse/templates/packaging/detail.html:512 -msgid "File name" +#: warehouse/templates/packaging/detail.html:603 +#: warehouse/templates/packaging/detail.html:605 +msgid "Copy link to filters" msgstr "" #: warehouse/templates/packaging/submit-malware-observation.html:20 diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 49ad4cd1e792..7fe2ff6cab8f 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -1040,7 +1040,7 @@ def pretty_wheel_tags(self) -> list[str]: @property def wheel_filters(self): - return wheel.filename_to_filters(self.filename) + return wheel.filename_to_grouped_labels(self.filename) class Filename(db.ModelBase): diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 30859ec2db70..52c243212b35 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -268,15 +268,9 @@ def release_detail(release, request): ) # Collect all the available bdist details to enable building filters. - wheel_filters_all = wheel.filenames_to_filters([bdist.filename for bdist in bdists]) - - # Get the querystring to load any pre-set parameters - wheel_filters_params = { - "filename": request.params.get("filename", ""), - "interpreters": request.params.get("interpreters", ""), - "abis": request.params.get("abis", ""), - "platforms": request.params.get("platforms", ""), - } + wheel_filters_all = wheel.filenames_to_grouped_labels( + [bdist.filename for bdist in bdists] + ) return { "project": project, @@ -292,7 +286,6 @@ def release_detail(release, request): # Additional function to format the attestations "PEP740AttestationViewer": PEP740AttestationViewer, "wheel_filters_all": wheel_filters_all, - "wheel_filters_params": wheel_filters_params, } diff --git a/warehouse/static/js/warehouse/controllers/filter_list_controller.js b/warehouse/static/js/warehouse/controllers/filter_list_controller.js index ce062b0f7418..a6a94aaa5db3 100644 --- a/warehouse/static/js/warehouse/controllers/filter_list_controller.js +++ b/warehouse/static/js/warehouse/controllers/filter_list_controller.js @@ -15,7 +15,7 @@ * - data-filtered-target-[name of filter group in kebab-case e.g. content-type]='(stringify-ed JSON)' (zero or more) */ import {Controller} from "@hotwired/stimulus"; -import {ngettext} from "../utils/messages-access"; +import {gettext, ngettext} from "../utils/messages-access"; export default class extends Controller { static targets = ["item", "filter", "summary", "url"]; @@ -24,11 +24,28 @@ export default class extends Controller { }; mappingItemFilterData = {}; + initialSelectOptions = {}; connect() { this._populateMappings(); this._initVisibility(); + // Capture the initial select element values, so they can be restored. + this._getFilterTargets().forEach(filterTarget => { + if (filterTarget.nodeName === "SELECT") { + const key = filterTarget.dataset.filteredSource; + if (!this.initialSelectOptions[key]) { + this.initialSelectOptions[key] = []; + } + for (const option of filterTarget.options) { + this.initialSelectOptions[key].push([option.value, option.label]); + } + } + }); + + const urlFilters = this._getFiltersUrlSearch(); + this._setFiltersHtmlElements(urlFilters); + this.filter(); } @@ -46,18 +63,29 @@ export default class extends Controller { let total = 0; let shown = 0; + const groupedLabels = {}; + this.itemTargets.forEach((item, index) => { total += 1; const itemData = this.mappingItemFilterData[index]; - const compareResult = this._compare(itemData, filterData); + const isShow = this._compare(itemData, filterData); // Should the item be displayed or not? - // Show if there are no filters, or if there are filters and at least one match. - const isShow = !compareResult.hasFilter || (compareResult.hasFilter && compareResult.isMatch); if (isShow) { // match: show item item.classList.remove("hidden"); shown += 1; + // store the matched items to update the select options later + Object.entries(itemData).forEach(([key, values]) => { + if (!groupedLabels[key]) { + groupedLabels[key] = []; + } + values.forEach(value => { + if (!groupedLabels[key].includes(value)) { + groupedLabels[key].push(value); + } + }); + }); } else { // no match: hide item item.classList.add("hidden"); @@ -66,32 +94,80 @@ export default class extends Controller { // show the number of matches and the total number of items if (this.hasSummaryTarget) { - this.summaryTarget.textContent = ngettext( + let messages = []; + if (shown === 0) { + messages.push(gettext("No files match the current filters.")); + } + messages.push(ngettext( "Showing %1 of %2 file.", "Showing %1 of %2 files.", total, shown, - total); + total)); + this.summaryTarget.textContent = messages.join(" "); } - // Update the direct url to this filter + // Update the current url to include the filters + const htmlElementFilters = this._getFiltersHtmlElements(); + this._setFiltersUrlSearch(htmlElementFilters); + + // Update the direct url to these filters if (this.hasUrlTarget && this.urlTarget) { - const searchParams = new URLSearchParams(); - for (const key in filterData) { - for (const value of filterData[key]) { - if (value && value.trim()) { - searchParams.set(key, [...searchParams.getAll(key), value]); - } + const urlTargetUrl = new URL(this.urlTarget.href); + Object.entries(htmlElementFilters ?? {}).forEach(([key, value]) => { + if (value) { + urlTargetUrl.searchParams.set(key, value); + } else { + urlTargetUrl.searchParams.delete(key); } - } + }); + this.urlTarget.textContent = urlTargetUrl.toString(); + } - const qs = searchParams.toString(); - const baseUrl = new URL(this.urlTarget.href); - if (qs) { - baseUrl.search = "?" + qs; + // Update the dropdowns to reflect the currently displayed items. + const filterTargets = this._getFilterTargets(); + const selected = {}; + filterTargets.forEach(filterTarget => { + if (filterTarget.nodeName === "SELECT") { + const key = filterTarget.dataset.filteredSource; + // Store which option is selected. + for (const selectedOption of filterTarget.selectedOptions) { + selected[key] = selectedOption.value; + } + // Remove all existing options. + for (let index = filterTarget.options.length - 1; index >= 0; index--) { + filterTarget.options.remove(index); + } + // Add the options reflecting the currently displayed items. + const valuesToKeep = groupedLabels[key] ?? []; + this.initialSelectOptions[key].forEach(option => { + const initialOptionValue = option[0]; + const initialOptionLabel = option[1]; + if (initialOptionValue === "" || valuesToKeep.includes(initialOptionValue)) { + const isSelected = selected[key] === initialOptionValue; + filterTarget.options.add(new Option(initialOptionLabel, initialOptionValue, null, isSelected)); + } + }); } - this.urlTarget.textContent = baseUrl.toString(); - } + }); + } + + /** + * Show all files by clearing the filters. + * @param event + */ + filterClear(event) { + // don't follow the url + event.preventDefault(); + + // set the html elements to no filter + const filterTargets = this._getFilterTargets(); + filterTargets.forEach(filterTarget => { + filterTarget.value = ""; + }); + + // update the list of files + this.filter(); } /** @@ -142,79 +218,145 @@ export default class extends Controller { /** * Build a mapping of filteredSource names to array of values. - * @returns {{}} + * @returns {{[key: string]: {values: string[], comparison: "exact"|"includes"}}} * @private */ _buildFilterData() { const filterData = {}; - if (this.hasFilterTarget) { - this.filterTargets.forEach(filterTarget => { - const key = filterTarget.dataset.filteredSource; - const value = filterTarget.value; - if (!Object.hasOwn(filterData, key)) { - filterData[key] = []; - } - filterData[key].push(value); - }); - } + const filterTargets = this._getFilterTargets(); + filterTargets.forEach(filterTarget => { + const key = filterTarget.dataset.filteredSource; + const value = filterTarget.value; + if (!Object.hasOwn(filterData, key)) { + filterData[key] = {values: [], comparison: "exact"}; + } + filterData[key].values.push(value); + + const comparison = filterTarget.dataset.comparisonType; + if (comparison) { + filterData[key].comparison = comparison; + } + }); return filterData; } /** - * Compare an item's tags to all filter values and find matches. - * @param itemData The item mapping. - * @param filterData The filter mapping. - * @returns {{hasFilter: boolean, isMatch: boolean, matches: *[]}} + * Compare an item's data to all filter values and find matches. + * Filters are processed as 'AND' - the item data must match all the filters. + * @param itemData {{[key: string]:string[]}} The item mapping. + * @param filterData {{[key: string]: {values: string[], comparison: "exact"|"includes"}}} The filter mapping. + * @returns {boolean} * @private */ _compare(itemData, filterData) { - // The overall match will be true when, - // for every filter key that has at least one value, - // at least one item value for the same key includes any filter value. - - const isMatch = []; - const matches = []; - const misses = []; - let hasFilter = false; - for (const itemKey in itemData) { - const itemValues = itemData[itemKey]; - const filterValues = filterData[itemKey]; - - let isKeyMatch = false; - let hasKeyFilter = false; - - for (const itemValue of itemValues) { - for (const filterItemValue of filterValues) { - - if (filterItemValue && !hasKeyFilter) { - // Record whether there are any filter values in any filter key. - hasFilter = true; - } + for (const [filterKey, filterInfo] of Object.entries(filterData)) { + const comparison = filterInfo.comparison; + const filterValues = Array.from(new Set((filterInfo.values ?? []).map(i => i?.toString()?.trim() ?? "").filter(i => !!i))); + const itemValues = Array.from(new Set((itemData[filterKey] ?? []).map(i => i?.toString()?.trim() ?? "").filter(i => !!i))); - if (filterItemValue && !hasKeyFilter) { - // Record whether there are any filter values in *this* filter key. - hasKeyFilter = true; - } + // Not a match if the item values and filter values contain different values. + if (filterValues.length > 0 && comparison === "exact") { + if (!filterValues.every(filterValue => itemValues.includes(filterValue))) { + return false; + } + } - // There could be two types of comparisons - exact match for tags, contains for filename. - // Using: for each named group, does any item value include any filter value? - if (filterItemValue && itemValue.includes(filterItemValue)) { - isKeyMatch = true; - matches.push({"key": itemKey, "filter": filterItemValue, "item": itemValue}); - } - if (filterItemValue && !itemValue.includes(filterItemValue)) { - misses.push({"key": itemKey, "filter": filterItemValue, "item": itemValue}); - } + if (filterValues.length > 0 && comparison === "includes") { + if (!filterValues.every(filterValue => itemValues.some(itemValue => itemValue.includes(filterValue)))) { + return false; } } - isMatch.push(!hasKeyFilter || (isKeyMatch && hasKeyFilter)); } + return true; + } + + _getFilterTargets() { + return this.hasFilterTarget ? (this.filterTargets ?? []) : []; + } + + /** + * Get the filters from the url query string. + * @returns {{[key: string]: string}} + * @private + */ + _getFiltersUrlSearch() { + const enabledFilterTargets = this._getAutoUpdateUrlQuerystringFilters(); + const currentSearchParams = new URLSearchParams(document.location.search); + const filterTargets = this._getFilterTargets(); + return Object.fromEntries(filterTargets.map(filterTarget => { + const key = filterTarget.dataset.filteredSource; + const value = currentSearchParams.get(key); + return [key, value]; + }).filter(i => enabledFilterTargets.includes(i[0]))); + } + + /** + * Set the filters to the url query string. + * @param filters The filters to set. + * @private + */ + _setFiltersUrlSearch(filters) { + const enabledFilterTargets = this._getAutoUpdateUrlQuerystringFilters(); + const currentUrl = new URL(document.location.href); + const filterTargets = this._getFilterTargets(); + filterTargets.forEach(filterTarget => { + const key = filterTarget.dataset.filteredSource; + if (!enabledFilterTargets.includes(key)) { + return; + } + const value = filters[key] ?? null; + if (value) { + currentUrl.searchParams.set(key, value); + } else { + currentUrl.searchParams.delete(key); + } + }); + window.history.replaceState(null, "", currentUrl); + } + + /** + * Get the filters from the HTML element values. + * @returns {{[key: string]: string}} + * @private + */ + _getFiltersHtmlElements() { + const filterTargets = this._getFilterTargets(); + return Object.fromEntries(filterTargets.map(filterTarget => { + const key = filterTarget.dataset.filteredSource; + const value = filterTarget.value; + return [key, value]; + })); + } - return { - "isMatch": isMatch.every(value => value), - "hasFilter": hasFilter, - "matches": matches, - "misses": misses, - }; + /** + * Set the filters to the HTML element values. + * @param filters + * @private + */ + _setFiltersHtmlElements(filters) { + const filterTargets = this._getFilterTargets(); + filterTargets.forEach(filterTarget => { + const key = filterTarget.dataset.filteredSource; + if (filters[key] !== undefined) { + filterTarget.value = filters[key] ?? ""; + } + }); + } + + /** + * Get a map of the filters and whether they participate in the automatic url querystring update. + * @returns {string[]} + * @private + */ + _getAutoUpdateUrlQuerystringFilters() { + const filterTargets = this._getFilterTargets(); + return filterTargets + .map(filterTarget => { + const key = filterTarget.dataset.filteredSource; + const value = filterTarget.dataset.autoUpdateUrlQuerystring; + return [key, value]; + }) + .filter(i => i[1] === "true") + .map(i => i[0]); } } diff --git a/warehouse/static/sass/base/_forms.scss b/warehouse/static/sass/base/_forms.scss index ef0d1fb075b1..5a542619f0dc 100644 --- a/warehouse/static/sass/base/_forms.scss +++ b/warehouse/static/sass/base/_forms.scss @@ -48,6 +48,10 @@ } } +select { + height: 40px; +} + input[type="checkbox"] { &:focus, &:hover, diff --git a/warehouse/static/sass/blocks/_button.scss b/warehouse/static/sass/blocks/_button.scss index a0d3859a3784..e8bbbc8d7383 100644 --- a/warehouse/static/sass/blocks/_button.scss +++ b/warehouse/static/sass/blocks/_button.scss @@ -13,6 +13,7 @@ - primary: Makes button bright blue. - danger: Makes button red. - warning: Makes button brown. + - link: styles button like a link (removes all padding, border, etc.) - disabled: Styles for when the button cannot be clicked. - switch-to-desktop: Switch to desktop button found in site footer. - switch-to-mobile: Switch to mobile button found in site header. @@ -126,6 +127,20 @@ } } + &--link { + padding: 0; + background-color: transparent; + border: 0; + color: $text-color; + display: inline-block; + + &:focus, + &:hover, + &:active { + color: $primary-color; + } + } + &[disabled], &--disabled { cursor: not-allowed; diff --git a/warehouse/static/sass/blocks/_filter-wheels.scss b/warehouse/static/sass/blocks/_filter-wheels.scss new file mode 100644 index 000000000000..d1100a7bec62 --- /dev/null +++ b/warehouse/static/sass/blocks/_filter-wheels.scss @@ -0,0 +1,56 @@ +/* SPDX-License-Identifier: Apache-2.0 */ + +/* + Search and filter wheels + +
+ +
+ Filters here +
+
+*/ + +.filter-wheels { + display: flex; + gap: $spacing-unit / 2; + justify-content: space-between; + + @media only screen and (max-width: $tablet) { + flex-wrap: wrap; + } + + &__search { + flex-grow: 1; + + .form-group input { + height: 40px; + } + + @media only screen and (max-width: $tablet) { + width: 100%; + } + } + + &__filters { + flex: 0 1 auto; + flex-shrink: 0; + display: flex; + gap: $spacing-unit / 3; + align-items: flex-end; + + div { + flex: 0 1 auto; + } + + @media only screen and (max-width: $tablet) { + width: 100%; + } + + @media only screen and (max-width: $small-tablet) { + display: block; + } + } +} diff --git a/warehouse/static/sass/blocks/_form-group.scss b/warehouse/static/sass/blocks/_form-group.scss index ce936206cc8e..a29c399b7f22 100644 --- a/warehouse/static/sass/blocks/_form-group.scss +++ b/warehouse/static/sass/blocks/_form-group.scss @@ -12,6 +12,10 @@

When a field is not editable, you can use me instead

Some help text here

+ + + Modifiers: + - flex-width: makes fields inside the group flexible width (instead of 350px wide) */ .form-group { @@ -126,6 +130,39 @@ } } + &--flex-width { + max-width: unset; + + :where( + input:not([type]), + select, + textarea, + [type="color"], + [type="date"], + [type="datetime"], + [type="datetime-local"], + [type="email"], + [type="month"], + [type="month"], + [type="number"], + [type="password"], + [type="search"], + [type="tel"], + [type="text"], + [type="time"], + [type="url"], + [type="week"] + ).form-group__field, + select.form-group__field { + width: 100%; + max-width: 100%; + } + + select.form-group__field { + padding-right: $spacing-unit; + } + } + // Specific cases for input validation using `pattern` attribute /* stylelint-disable-next-line selector-id-pattern -- Form sets name */ diff --git a/warehouse/static/sass/blocks/_table.scss b/warehouse/static/sass/blocks/_table.scss index 10df7f4217ef..bfab883edf76 100644 --- a/warehouse/static/sass/blocks/_table.scss +++ b/warehouse/static/sass/blocks/_table.scss @@ -31,8 +31,9 @@ Modifiers: - information: table for displaying table data - ideally with row level headings - downloads: specific styles for downloads table on project detail page + - files: specific styles for files table on project detail page - releases: specific styles for releases table on manage project page - - files: specific styles for files table on releases tab + - manage-files: specific styles for files table on the releases tab - history: specific styles for project journals - hashes: specific styles for the hashes table on an individual file - collaborators: specific styles for managing a project's collaborators @@ -59,6 +60,7 @@ tbody tr td:last-child { display: block; width: 100%; + max-width: 100%; text-align: left; border-bottom: 0; padding: 2px 0; @@ -169,8 +171,8 @@ &--information { th { - width: 1%; - white-space: nowrap; + width: 1%; + white-space: nowrap; } td, @@ -197,6 +199,19 @@ } } + &--files { + margin-bottom: $half-spacing-unit; + + td.file-name { + word-wrap: break-word; + max-width: 250px; + } + + @media only screen and (max-width: $desktop) { + @include mobile-friendly-table; + } + } + &--releases { word-wrap: break-word; margin-bottom: $spacing-unit; @@ -207,7 +222,7 @@ } } - &--files, + &--manage-files, &--history { margin-top: $half-spacing-unit; diff --git a/warehouse/static/sass/warehouse.scss b/warehouse/static/sass/warehouse.scss index 7c3200981c1e..684e91ada890 100644 --- a/warehouse/static/sass/warehouse.scss +++ b/warehouse/static/sass/warehouse.scss @@ -96,6 +96,7 @@ @import "blocks/project-description"; /*rtl:end:ignore*/ @import "blocks/files"; +@import "blocks/filter-wheels"; @import "blocks/radio-toggle-form"; @import "blocks/release"; @import "blocks/release-timeline"; diff --git a/warehouse/templates/manage/project/release.html b/warehouse/templates/manage/project/release.html index 6f3277ed9997..ec37bfbf1c0e 100644 --- a/warehouse/templates/manage/project/release.html +++ b/warehouse/templates/manage/project/release.html @@ -32,7 +32,7 @@

{% if files %} - +
diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index b29903e561b7..298456cd3777 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -70,40 +70,135 @@

pip install{{ index_url }} {{ release.project.name }}{{ project_version }} {%- endif -%} {%- endmacro -%} -{%- macro file_table(files) -%} - {% for file in files %} -
-
- -
-
- {{ file.filename }} - ({{ file.size|filesizeformat() if file.size else 0|filesizeformat() }} - {%- trans -%}view details{%- endtrans -%}) -

- Uploaded {{ humanize(file.upload_time) }} - {% for tag in file.pretty_wheel_tags %}{{ tag }}{% endfor %} -

-
-
- {% endfor %} +{%- macro sdists_table(files) -%} +

{% trans version=release.version, project_name=project.name %}Files for release {{ version }} of {{ project_name }}{% endtrans %}
+ + + + + + + + + {% for file in files %} + + + + + + + + {% endfor %} +
{% trans name=release.project.name, release=release.version %}Source distribution for {{ name }} {{ version }}{% endtrans %}
{% trans %}File{% endtrans %}{% trans %}Uploaded{% endtrans %}{% trans %}Size{% endtrans %}{% trans %}Type{% endtrans %}{% trans %}Details{% endtrans %}
+ {% trans %}File{% endtrans %} +   + {{ file.filename }} + + {% trans %}Uploaded{% endtrans %} + {% trans upload_time=humanize(file.upload_time) %}Uploaded {{ upload_time }}{% endtrans %} + + {% trans %}Size{% endtrans %} + {{ file.size|filesizeformat() if file.size else 0|filesizeformat() }} + + {% trans %}Type{% endtrans %} + {% trans %}Source{% endtrans %} + + {% trans %}View details{% endtrans %} + {% trans %}Details{% endtrans %} +
+{%- endmacro -%} +{%- macro bdists_table(files) -%} + + + + + + + + + + {% for file in files %} + + + {% if 'Egg' in file.pretty_wheel_tags %} + + {% else %} + + + + {% endif %} + + + {% endfor %} +
{% trans name=release.project.name, release=release.version %}Table of built distributions (wheels) for {{ name }} {{ release }}{% endtrans %}
{% trans %}File{% endtrans %}{% trans %}Interpreter{% endtrans %}{% trans %}ABI{% endtrans %}{% trans %}Platform{% endtrans %}{% trans %}Details{% endtrans %}
+ {% trans %}File{% endtrans %} +   + Download file +
+ + {{ file.size|filesizeformat() if file.size else 0|filesizeformat() }}.  + {% trans upload_time=humanize(file.upload_time) %}Uploaded {{ upload_time }}{% endtrans %}. + +
+ {{ file.filename }} + {% for tag in file.pretty_wheel_tags %} + {% if loop.first %}-{% endif %} + {{ tag }} + {% if not loop.last %} {% endif %} + {% endfor %} + + {% trans %}Interpreter{% endtrans %} + {% for key, value in file.wheel_filters.items() %} + {% if key == 'interpreter' %} + {% for interpreter_value, interpreter_label in value.items() %} + {{ interpreter_label }} + {% if not loop.last %}
{% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+ {% trans %}ABI{% endtrans %} + {% for key, value in file.wheel_filters.items() %} + {% if key == 'abi' %} + {% for abi_value, abi_label in value.items() %} + {{ abi_label }} + {% if not loop.last %}
{% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+ {% trans %}Platform{% endtrans %} + {% for key, value in file.wheel_filters.items() %} + {% if key == 'platform' %} + {% for platform_value, platform_label in value.items() %} + {{ platform_label }} + {% if not loop.last %}
{% endif %} + {% endfor %} + {% endif %} + {% endfor %} +
+ {% trans %}View details{% endtrans %} + {% trans %}Details{% endtrans %} +
{%- endmacro -%} -{% macro filter_select(name, title, selected) %} +{% macro filter_select(name, title) %} {% endmacro %} @@ -441,91 +536,94 @@

data-controller="filter-list">

{% trans %}Download files{% endtrans %}

- {% trans href='https://packaging.python.org/tutorials/installing-packages/', title=gettext('External link') %}Download the file for your platform. If you're not sure which to choose, learn more about installing packages.{% endtrans %} + {% trans href='https://packaging.python.org/en/latest/discussions/package-formats/#package-formats', title=gettext('External link') %}For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.{% endtrans %}

{% trans count=sdists|length %} - Source Distribution + Source distribution (sdist) {% pluralize %} - Source Distributions + Source distributions (sdists) {% endtrans %}

{% if sdists %} - {{ file_table(sdists) }} + {{ sdists_table(sdists) }} {% else %} -
-
- -
-
- {% trans %}No source distribution files available for this release.{% endtrans %} - {% trans href='https://packaging.python.org/tutorials/packaging-projects/#generating-distribution-archives', title=gettext('External link') %}See tutorial on generating distribution archives.{% endtrans %} -
+
+   + {% trans %}No source distribution files available for this release.{% endtrans %}  + {% trans href='https://packaging.python.org/tutorials/packaging-projects/#generating-distribution-archives', title=gettext('External link') %}See tutorial on generating distribution archives.{% endtrans %}
{% endif %} {% if bdists %} + {% set bdist_count = bdists|length %}

- {% trans count=bdists|length %} - Built Distribution + {% trans count=bdist_count %} + Built distribution (wheel) {% pluralize %} - Built Distributions + Built distributions (wheels) {% endtrans %}

- -

- {% trans href='https://packaging.python.org/en/latest/specifications/binary-distribution-format/', title=gettext('External link') %}If you're not sure about the file name format, learn more about wheel file names.{% endtrans %} -

- - - -
- -
-
{{ filter_select('interpreters', 'Interpreter', wheel_filters_params) }}
-
{{ filter_select('abis', 'ABI', wheel_filters_params) }}
-
{{ filter_select('platforms', 'Platform', wheel_filters_params) }}
+ {% if bdist_count > 3 %} + +
+ +
+
+ {% trans %}Filters{% endtrans %} + + {{ filter_select('interpreter', 'Interpreter') }} +
+
{{ filter_select('abi', 'ABI') }}
+
+ Show all files + + {{ filter_select('platform', 'Platform') }}
- {{ file_table(bdists) }} - {% endif %} +
+{% endif %} +{{ bdists_table(bdists) }} +{% if bdist_count > 3 %} + +{% endif %} +{% endif %}
{# Tabs: file details #} {% for file in files %} diff --git a/warehouse/utils/wheel.py b/warehouse/utils/wheel.py index db8065ab9a17..b16710d3b281 100644 --- a/warehouse/utils/wheel.py +++ b/warehouse/utils/wheel.py @@ -5,47 +5,67 @@ import packaging.tags import packaging.utils -# import sentry_sdk - -_PLATFORMS = [ - (re.compile(r"^win_(.*?)$"), lambda m: f"Windows {_normalize_arch(m.group(1))}"), - (re.compile(r"^win32$"), lambda m: "Windows x86"), - ( - re.compile(r"^manylinux2010_(.*?)$"), - lambda m: f"manylinux: glibc 2.12+ {_normalize_arch(m.group(1))}", - ), - ( - re.compile(r"^manylinux_(\d+)_(\d+)_(.*?)$"), - lambda m: ( - f"manylinux: glibc " - f"{m.group(1)}.{m.group(2)}+ {_normalize_arch(m.group(3))}" +# Map known Python tags, ABI tags, Platform tags to labels. +_PLATFORM_MAP = { + "win": [ + (re.compile(r"^win_(.*?)$"), lambda m: f"Windows {_norm_arch(m.group(1))}") + ], + "win32": [(re.compile(r"^win32$"), lambda m: "Windows x86")], + "manylinux": [ + ( + re.compile(r"^manylinux_(\d+)_(\d+)_(.*?)$"), + lambda m: f"linux glibc {m.group(1)}.{m.group(2)}+ " + f"{_norm_arch(m.group(3))}", + ) + ], + "manylinux2014": [ + ( + re.compile(r"^manylinux2014_(.*?)$"), + lambda m: f"linux glibc 2.17+ {_norm_arch(m.group(1))}", + ) + ], + "manylinux2010": [ + ( + re.compile(r"^manylinux2010_(.*?)$"), + lambda m: f"linux glibc 2.12+ {_norm_arch(m.group(1))}", + ) + ], + "manylinux1": [ + ( + re.compile(r"^manylinux1_(.*?)$"), + lambda m: f"linux glibc 2.5+ {_norm_arch(m.group(1))}", + ) + ], + "musllinux": [ + ( + re.compile(r"^musllinux_(\d+)_(\d+)_(.*?)$"), + lambda m: f"linux musl {m.group(1)}.{m.group(2)}+ {_norm_arch(m.group(3))}", + ) + ], + "macosx": [ + ( + re.compile(r"^macosx_(\d+)_(\d+)_(.*?)$"), + lambda m: f"macOS {m.group(1)}.{m.group(2)}+ {_norm_arch(m.group(3))}", + ) + ], + "ios": [ + ( + re.compile(r"^ios_(\d+)_(\d+)_(.*?)_iphoneos$"), + lambda m: f"iOS {m.group(1)}.{m.group(2)}+ {_norm_arch(m.group(3))} Device", # noqa: E501 ), - ), - ( - re.compile(r"^musllinux_(\d+)_(\d+)_(.*?)$"), - lambda m: ( - f"musllinux: musl {m.group(1)}.{m.group(2)}+ {_normalize_arch(m.group(3))}" + ( + re.compile(r"^ios_(\d+)_(\d+)_(.*?)_iphonesimulator$"), + lambda m: f"iOS {m.group(1)}.{m.group(2)}+ {_norm_arch(m.group(3))} Simulator", # noqa: E501 ), - ), - ( - re.compile(r"^macosx_(\d+)_(\d+)_(.*?)$"), - lambda m: f"macOS {m.group(1)}.{m.group(2)}+ {_normalize_arch(m.group(3))}", - ), - ( - re.compile(r"^android_(\d+)_(.*?)$"), - lambda m: f"Android API level {m.group(1)}+ {_normalize_arch(m.group(2))}", - ), - ( - re.compile(r"^ios_(\d+)_(\d+)_(.*?)_iphoneos$"), - lambda m: f"iOS {m.group(1)}.{m.group(2)}+ {_normalize_arch(m.group(3))} Device", # noqa: E501 - ), - ( - re.compile(r"^ios_(\d+)_(\d+)_(.*?)_iphonesimulator$"), - lambda m: f"iOS {m.group(1)}.{m.group(2)}+ {_normalize_arch(m.group(3))} Simulator", # noqa: E501 - ), -] - -_ARCHS = { + ], + "android": [ + ( + re.compile(r"^android_(\d+)_(.*?)$"), + lambda m: f"Android API level {m.group(1)}+ {_norm_arch(m.group(2))}", + ) + ], +} +_ARCH_MAP = { "amd64": "x86-64", "aarch64": "ARM64", "armeabi_v7a": "ARM EABI v7a", @@ -59,17 +79,121 @@ "universal2": "universal2 (ARM64, x86-64)", "arm64": "ARM64", "armv7l": "ARMv7l", + "i686": "x86-32", + "ppc64": "PowerPC 64-be", + "ppc64le": "PowerPC 64-le", + "s390x": "IBM System/390x", + "riscv64": "RISC-V 64", +} +_CPYTHON_SUFFIX_MAP = { + "d": "debug", + "m": "pymalloc", + "t": "free-threading", + "u": "wide-unicode", } - - -def _normalize_arch(a: str) -> str: - return _ARCHS.get(a, a) def _format_version(s: str) -> str: return f"{s[0]}.{s[1:]}" +def _norm_arch(a: str) -> str: + return _ARCH_MAP.get(a, a) + + +def _norm_str(s: str) -> str: + return (s or "").replace("_", " ").strip() + + +def _implementation_to_label(raw: str) -> str: + if raw.startswith("pypy"): + version = _norm_str(raw.removeprefix("pypy")) + return f"PyPy {version}" + elif raw.startswith("py"): + major, minor = raw[2:3], raw[3:] + return f"Python {major}{'.' if minor else ''}{minor}" + elif raw.startswith("cp"): + version, suffixes = _format_cpython(raw.removeprefix("cp")) + return f"CPython {version} {suffixes}".strip() + elif raw.startswith("pp"): + version = _norm_str(raw.removeprefix("pp")) + return f"PyPy {version}" + elif raw.startswith("ip"): + major, minor = raw[2:3], raw[3:] + return f"IronPython {major}{'.' if minor else ''}{minor}" + elif raw.startswith("jy"): + major, minor = raw[2:3], raw[3:] + version = f"{major}{'.' if minor else ''}{minor}" + return f"Jython {version}" + else: + # Unknown format. Normalise and return it. + return _norm_str(raw) + + +def _format_cpython(s: str) -> tuple[str, str]: + suffixes = [] + raw = (s or "").strip() + while raw[-1].isalpha(): + last_char = raw[-1] + name = _CPYTHON_SUFFIX_MAP.get(last_char) + if not name: + # Unknown CPython abi suffix. Just include it. + name = last_char + suffixes.append(name) + raw = raw[0:-1] + version = _format_version(raw) + return version, " ".join(sorted(suffixes)) + + +def _interpreter_to_label(tag: packaging.tags.Tag) -> str: + return _implementation_to_label(tag.interpreter) + + +def _abi_to_label(tag: packaging.tags.Tag) -> str: + if tag.abi == "none": + return "(none)" + elif tag.abi == "abi3": + # NOTE: CPython abi3 should have a CPython interpreter. + # if not tag.interpreter.startswith("cp"): + # A non- CPython interpreter with CPython abi3. + # Should this be possible? + # pass + return "CPython abi3" + elif tag.abi.startswith("cp"): + return _implementation_to_label(tag.abi) + elif tag.abi.startswith("pypy"): + return _implementation_to_label(tag.abi) + elif tag.abi.startswith("pp"): + return _implementation_to_label(tag.abi) + elif tag.abi.startswith("ip"): + return _implementation_to_label(tag.abi) + elif tag.abi.startswith("jy"): + return _implementation_to_label(tag.abi) + else: + # Unknown abi. Just return it. + return _norm_str(tag.abi) + + +def _platform_to_label(tag: packaging.tags.Tag) -> str: + if tag.platform == "any": + return "(any)" + + value = tag.platform + key = value.split("_", maxsplit=1)[0] if "_" in value else value + + patterns = _PLATFORM_MAP.get(key, []) + for prefix_re, tmpl in patterns: + if match := prefix_re.match(value): + return tmpl(match) + + # Unknown platform. Just return it + return _norm_str(value) + + +def _add_group_label(container: dict, group: str, value: str, label: str) -> None: + container[group][value] = label + + def filename_to_tags(filename: str) -> set[packaging.tags.Tag]: """Parse a wheel file name to extract the tags.""" try: @@ -80,76 +204,52 @@ def filename_to_tags(filename: str) -> set[packaging.tags.Tag]: def filename_to_pretty_tags(filename: str) -> list[str]: - if filename.endswith(".egg"): - return ["Egg"] - elif not filename.endswith(".whl"): - return ["Source"] - - tags = filename_to_tags(filename) - + grouped_labels = filename_to_grouped_labels(filename) pretty_tags = set() - for tag in tags: - if tag.platform != "any": - for prefix_re, tmpl in _PLATFORMS: - if match := prefix_re.match(tag.platform): - pretty_tags.add(tmpl(match)) - - if len(tag.interpreter) < 3 or not tag.interpreter[:2].isalpha(): - # This tag doesn't fit our format, give up - pass - elif tag.interpreter.startswith("pp"): - # PyPy tags are a disaster, give up. - pretty_tags.add("PyPy") - elif tag.interpreter.startswith("py"): - major, minor = tag.interpreter[2:3], tag.interpreter[3:] - pretty_tags.add(f"Python {major}{'.' if minor else ''}{minor}") - elif tag.interpreter.startswith("ip"): - major, minor = tag.interpreter[2:3], tag.interpreter[3:] - pretty_tags.add(f"IronPython {major}{'.' if minor else ''}{minor}") - elif tag.interpreter.startswith("jy"): - major, minor = tag.interpreter[2:3], tag.interpreter[3:] - pretty_tags.add(f"Jython {major}{'.' if minor else ''}{minor}") - elif tag.abi == "abi3": - assert tag.interpreter.startswith("cp") - version = _format_version(tag.interpreter.removeprefix("cp")) - pretty_tags.add(f"CPython {version}+") - elif tag.abi.startswith("cp"): - version = _format_version(tag.abi.removeprefix("cp")) - pretty_tags.add(f"CPython {version}") - elif tag.interpreter.startswith("cp"): - version = _format_version(tag.interpreter.removeprefix("cp")) - pretty_tags.add(f"CPython {version}") - else: - # There's a lot of cruft from over the years. If we can't identify - # the interpreter tag, just add it directly. - pretty_tags.add(tag.interpreter) - + for kind, kind_items in grouped_labels.items(): + for value, label in kind_items.items(): + pretty_tags.add(label) return sorted(pretty_tags) -def filenames_to_filters(filenames: list[str]) -> dict[str, list[str]]: - tags = set() - for filename in filenames: - tags.update(filename_to_tags(filename)) - return tags_to_filters(tags) +def filename_to_grouped_labels(filename: str) -> dict[str, dict[str, str]]: + grouped_labels: dict[str, dict[str, str]] = { + "interpreter": {}, + "abi": {}, + "platform": {}, + "other": {}, + } + if filename.endswith(".egg"): + grouped_labels["other"]["egg"] = "Egg" + return grouped_labels + elif not filename.endswith(".whl"): + grouped_labels["other"]["source"] = "Source" + return grouped_labels -def filename_to_filters(filename: str) -> dict[str, list[str]]: tags = filename_to_tags(filename) - return tags_to_filters(tags) - - -def tags_to_filters(tags: set[packaging.tags.Tag]) -> dict[str, list[str]]: - interpreters = set() - abis = set() - platforms = set() - for tag in tags or []: - interpreters.add(tag.interpreter) - abis.add(tag.abi) - platforms.add(tag.platform) - - return { - "interpreters": sorted(interpreters), - "abis": sorted(abis), - "platforms": sorted(platforms), + for tag in tags: + _add_group_label( + grouped_labels, "interpreter", tag.interpreter, _interpreter_to_label(tag) + ) + _add_group_label(grouped_labels, "abi", tag.abi, _abi_to_label(tag)) + _add_group_label( + grouped_labels, "platform", tag.platform, _platform_to_label(tag) + ) + return grouped_labels + + +def filenames_to_grouped_labels(filenames: list[str]) -> dict[str, dict[str, str]]: + grouped_labels: dict[str, dict[str, str]] = { + "interpreter": {}, + "abi": {}, + "platform": {}, + "other": {}, } + for filename in filenames: + grouped = filename_to_grouped_labels(filename) + for kind, kind_items in grouped.items(): + for value, label in kind_items.items(): + if value not in grouped_labels[kind]: + grouped_labels[kind][value] = label + return grouped_labels