diff --git a/tests/frontend/filter_list_controller_test.js b/tests/frontend/filter_list_controller_test.js new file mode 100644 index 000000000000..78525f9fcc6e --- /dev/null +++ b/tests/frontend/filter_list_controller_test.js @@ -0,0 +1,241 @@ +/* Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* global expect, beforeEach, afterEach, describe, it, jest */ + + +import {Application} from "@hotwired/stimulus"; +import FilterListController from "../../warehouse/static/js/warehouse/controllers/filter_list_controller"; + + +const testFixtureHTMLVisibilityToggle = ` + +

Initially shown, should end up hidden.

+`; +const testFixtureHTMLShowing = ` +

+`; +const testFixtureHTMLFilters = ` + + +`; +const testFixtureHTMLItems = ` + +
Item 1
+
Item 2
+
Item 3
+`; + + +describe("Filter list controller", () => { + describe("is initialized as expected", () => { + describe("makes expected elements visible", () => { + let application; + beforeEach(() => { + document.body.innerHTML = ` +
+ ${testFixtureHTMLVisibilityToggle} +
+ `; + + application = Application.start(); + application.register("filter-list", FilterListController); + }); + afterEach(() => { + document.body.innerHTML = ""; + application.stop(); + }); + + it("toggles visibility", () => { + const elShown = document.getElementById("initial-toggle-visibility-shown"); + expect(elShown.classList).not.toContain("hidden"); + + const elHidden = document.getElementById("initial-toggle-visibility-hidden"); + expect(elHidden.classList).toContain("hidden"); + }); + }); + + describe("finds filters and items", () => { + let application; + beforeEach(() => { + document.body.innerHTML = ` +
+ ${testFixtureHTMLFilters} + ${testFixtureHTMLItems} +
+ `; + + application = Application.start(); + application.register("filter-list", FilterListController); + }); + afterEach(() => { + document.body.innerHTML = ""; + application.stop(); + }); + + + it("has expected items and filters", () => { + const elController = document.getElementById("controller"); + const controller = application.getControllerForElementAndIdentifier(elController, "filter-list"); + + expect(controller.itemTargets).toHaveLength(3); + expect(controller.itemTargets[0]).toHaveTextContent("Item 1"); + expect(controller.itemTargets[1]).toHaveTextContent("Item 2"); + expect(controller.itemTargets[2]).toHaveTextContent("Item 3"); + + expect(controller.filterTargets).toHaveLength(2); + expect(controller.filterTargets[0].id).toEqual("filter-input"); + expect(controller.filterTargets[1].id).toEqual("filter-select"); + + expect(Object.keys(controller.mappingItemFilterData)).toHaveLength(3); + expect(controller.mappingItemFilterData["0"]).toEqual({ + "contentType": ["contentType1", "contentType1a"], + "myattr": ["myattr1"], + }); + expect(controller.mappingItemFilterData["1"]).toEqual({ + "contentType": ["contentType2", "contentType2a"], + "myattr": ["myattr2"], + }); + expect(controller.mappingItemFilterData["2"]).toEqual({ + "contentType": ["contentType3", "contentType3a"], + "myattr": ["myattr3"], + }); + + const elP = document.getElementById("url-update"); + expect(elP.textContent).toEqual("https://example.com/#testing"); + }); + }); + }); + + describe("displays count of visible items", () => { + let application; + beforeEach(() => { + document.body.innerHTML = ` +
+ ${testFixtureHTMLShowing} + ${testFixtureHTMLFilters} + ${testFixtureHTMLItems} +
+ `; + + application = Application.start(); + application.register("filter-list", FilterListController); + }); + afterEach(() => { + document.body.innerHTML = ""; + application.stop(); + }); + + it("all items begin shown", () => { + const elP = document.getElementById("shown-and-total"); + expect(elP.textContent).toEqual("Showing 3 of 3 files."); + }); + }); + + + describe("allows filtering", () => { + describe("input text filters the items", () => { + let application; + beforeEach(() => { + document.body.innerHTML = ` +
+ ${testFixtureHTMLFilters} + ${testFixtureHTMLItems} +
+ `; + + application = Application.start(); + application.register("filter-list", FilterListController); + }); + afterEach(() => { + document.body.innerHTML = ""; + application.stop(); + }); + + it("the item classes are updated", () => { + + const elFilter = document.getElementById("filter-input"); + const dispatchEventSpy = jest.spyOn(elFilter, "dispatchEvent"); + + elFilter.value = "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 elItem1 = document.getElementById("item-1"); + expect(elItem1.classList).toContainEqual("hidden"); + + const elItem2 = document.getElementById("item-2"); + expect(elItem2.classList).not.toContainEqual("hidden"); + + const elItem3 = document.getElementById("item-3"); + expect(elItem3.classList).toContainEqual("hidden"); + + const elP = document.getElementById("url-update"); + expect(elP.textContent).toEqual("https://example.com/?contentType=2#testing"); + }); + }); + + describe("selecting an option filters the items", () => { + let application; + beforeEach(() => { + document.body.innerHTML = ` +
+ ${testFixtureHTMLFilters} + ${testFixtureHTMLItems} +
+ `; + + application = Application.start(); + application.register("filter-list", FilterListController); + }); + afterEach(() => { + document.body.innerHTML = ""; + application.stop(); + }); + + 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); + + 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).not.toContainEqual("hidden"); + + const elP = document.getElementById("url-update"); + expect(elP.textContent).toEqual("https://example.com/?myattr=myattr3#testing"); + }); + }); + }); +}); diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index f914c3e97b1b..40cd1680c72c 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -233,6 +233,13 @@ 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": "", + }, } def test_detail_renders_files_natural_sort(self, db_request): @@ -243,17 +250,27 @@ def test_detail_renders_files_natural_sort(self, db_request): files = [ FileFactory.create( release=release, - filename=f"{project.name}-{release.version}-{py_ver}.whl", + filename="-".join( + [project.name, release.version, py_ver, py_abi, py_platform] + ) + + ".whl", python_version="py2.py3", packagetype="bdist_wheel", ) for py_ver in ["cp27", "cp310", "cp39"] # intentionally out of order + for py_abi in ["none"] + for py_platform in ["any"] ] sorted_files = natsorted(files, reverse=True, key=lambda f: f.filename) result = views.release_detail(release, 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"]}, + ] def test_license_from_classifier(self, db_request): """A license label is added when a license classifier exists.""" diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 81584f2f2bbc..252297cccb63 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -837,7 +837,7 @@ msgstr "" msgid "Provide an Inspector link to specific lines of code." msgstr "" -#: warehouse/packaging/views.py:353 +#: warehouse/packaging/views.py:367 msgid "Your report has been recorded. Thank you for your help." msgstr "" @@ -845,6 +845,12 @@ msgstr "" msgid "Copied" msgstr "" +#: warehouse/static/js/warehouse/controllers/filter_list_controller.js:81 +msgid "Showing %1 of %2 file." +msgid_plural "Showing %1 of %2 files." +msgstr[0] "" +msgstr[1] "" + #: warehouse/static/js/warehouse/controllers/password_breach_controller.js:48 msgid "Error while validating hashed password, disregard on development" msgstr "" @@ -967,8 +973,9 @@ msgstr "" #: warehouse/templates/manage/project/release.html:194 #: warehouse/templates/manage/project/releases.html:140 #: warehouse/templates/manage/project/releases.html:179 -#: warehouse/templates/packaging/detail.html:417 -#: warehouse/templates/packaging/detail.html:437 +#: warehouse/templates/packaging/detail.html:443 +#: warehouse/templates/packaging/detail.html:463 +#: warehouse/templates/packaging/detail.html:482 #: warehouse/templates/pages/classifiers.html:25 #: warehouse/templates/pages/help.html:20 #: warehouse/templates/pages/help.html:230 @@ -2901,7 +2908,8 @@ msgstr "" #: warehouse/templates/manage/account/recovery_codes-provision.html:58 #: warehouse/templates/manage/account/totp-provision.html:57 #: warehouse/templates/manage/unverified-account.html:179 -#: warehouse/templates/packaging/detail.html:167 +#: warehouse/templates/packaging/detail.html:193 +#: warehouse/templates/packaging/detail.html:499 #: warehouse/templates/pages/classifiers.html:38 msgid "Copy to clipboard" msgstr "" @@ -2913,6 +2921,7 @@ msgstr "" #: warehouse/templates/manage/account/recovery_codes-provision.html:59 #: warehouse/templates/manage/account/totp-provision.html:58 #: warehouse/templates/manage/unverified-account.html:180 +#: warehouse/templates/packaging/detail.html:500 #: warehouse/templates/pages/classifiers.html:39 msgid "Copy" msgstr "" @@ -6180,12 +6189,12 @@ msgid "Back to projects" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:70 -#: warehouse/templates/packaging/detail.html:302 +#: warehouse/templates/packaging/detail.html:328 msgid "This project has been quarantined." msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:72 -#: warehouse/templates/packaging/detail.html:304 +#: warehouse/templates/packaging/detail.html:330 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 " @@ -6193,7 +6202,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:79 -#: warehouse/templates/packaging/detail.html:311 +#: warehouse/templates/packaging/detail.html:337 #, python-format msgid "" "Read more in the project in quarantine help " @@ -6201,7 +6210,7 @@ msgid "" msgstr "" #: warehouse/templates/manage/project/manage_project_base.html:86 -#: warehouse/templates/packaging/detail.html:318 +#: warehouse/templates/packaging/detail.html:344 msgid "This project has been archived." msgstr "" @@ -7115,138 +7124,144 @@ msgstr "" msgid "%(org)s has not uploaded any projects to PyPI, yet" msgstr "" -#: warehouse/templates/packaging/detail.html:109 +#: warehouse/templates/packaging/detail.html:117 msgid "view details" msgstr "" -#: warehouse/templates/packaging/detail.html:128 +#: warehouse/templates/packaging/detail.html:133 +#: warehouse/templates/packaging/detail.html:140 +#, python-format +msgid "%(title)s" +msgstr "" + +#: warehouse/templates/packaging/detail.html:154 #, python-format msgid "RSS: latest releases for %(project_name)s" msgstr "" -#: warehouse/templates/packaging/detail.html:169 +#: warehouse/templates/packaging/detail.html:195 msgid "Copy PIP instructions" msgstr "" -#: warehouse/templates/packaging/detail.html:180 +#: warehouse/templates/packaging/detail.html:206 msgid "This project has been quarantined" msgstr "" -#: warehouse/templates/packaging/detail.html:186 +#: warehouse/templates/packaging/detail.html:212 msgid "This release has been yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:192 +#: warehouse/templates/packaging/detail.html:218 #, python-format msgid "Stable version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:196 +#: warehouse/templates/packaging/detail.html:222 #, python-format msgid "Newer version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:200 +#: warehouse/templates/packaging/detail.html:226 msgid "Latest version" msgstr "" -#: warehouse/templates/packaging/detail.html:205 +#: warehouse/templates/packaging/detail.html:231 #, python-format msgid "Released: %(release_date)s" msgstr "" -#: warehouse/templates/packaging/detail.html:219 +#: warehouse/templates/packaging/detail.html:245 msgid "No project description provided" msgstr "" -#: warehouse/templates/packaging/detail.html:232 +#: warehouse/templates/packaging/detail.html:258 msgid "Navigation" msgstr "" -#: warehouse/templates/packaging/detail.html:233 -#: warehouse/templates/packaging/detail.html:267 +#: warehouse/templates/packaging/detail.html:259 +#: warehouse/templates/packaging/detail.html:293 #, python-format msgid "Navigation for %(project)s" msgstr "" -#: warehouse/templates/packaging/detail.html:236 -#: warehouse/templates/packaging/detail.html:270 +#: warehouse/templates/packaging/detail.html:262 +#: warehouse/templates/packaging/detail.html:296 msgid "Project description. Focus will be moved to the description." msgstr "" -#: warehouse/templates/packaging/detail.html:238 -#: warehouse/templates/packaging/detail.html:272 -#: warehouse/templates/packaging/detail.html:332 +#: warehouse/templates/packaging/detail.html:264 +#: warehouse/templates/packaging/detail.html:298 +#: warehouse/templates/packaging/detail.html:358 msgid "Project description" msgstr "" -#: warehouse/templates/packaging/detail.html:242 -#: warehouse/templates/packaging/detail.html:282 +#: warehouse/templates/packaging/detail.html:268 +#: warehouse/templates/packaging/detail.html:308 msgid "Release history. Focus will be moved to the history panel." msgstr "" -#: warehouse/templates/packaging/detail.html:244 -#: warehouse/templates/packaging/detail.html:284 -#: warehouse/templates/packaging/detail.html:354 +#: warehouse/templates/packaging/detail.html:270 +#: warehouse/templates/packaging/detail.html:310 +#: warehouse/templates/packaging/detail.html:380 msgid "Release history" msgstr "" -#: warehouse/templates/packaging/detail.html:249 -#: warehouse/templates/packaging/detail.html:289 +#: warehouse/templates/packaging/detail.html:275 +#: warehouse/templates/packaging/detail.html:315 msgid "Download files. Focus will be moved to the project files." msgstr "" -#: warehouse/templates/packaging/detail.html:251 -#: warehouse/templates/packaging/detail.html:291 -#: warehouse/templates/packaging/detail.html:416 +#: warehouse/templates/packaging/detail.html:277 +#: warehouse/templates/packaging/detail.html:317 +#: warehouse/templates/packaging/detail.html:442 msgid "Download files" msgstr "" -#: warehouse/templates/packaging/detail.html:276 +#: warehouse/templates/packaging/detail.html:302 msgid "Project details. Focus will be moved to the project details." msgstr "" -#: warehouse/templates/packaging/detail.html:278 -#: warehouse/templates/packaging/detail.html:346 +#: warehouse/templates/packaging/detail.html:304 +#: warehouse/templates/packaging/detail.html:372 msgid "Project details" msgstr "" -#: warehouse/templates/packaging/detail.html:320 +#: warehouse/templates/packaging/detail.html:346 msgid "" "The maintainers of this project have marked this project as archived. No " "new releases are expected." msgstr "" -#: warehouse/templates/packaging/detail.html:328 -#: warehouse/templates/packaging/detail.html:403 +#: warehouse/templates/packaging/detail.html:354 +#: warehouse/templates/packaging/detail.html:429 msgid "Reason this release was yanked:" msgstr "" -#: warehouse/templates/packaging/detail.html:339 +#: warehouse/templates/packaging/detail.html:365 msgid "The author of this package has not provided a project description" msgstr "" -#: warehouse/templates/packaging/detail.html:356 +#: warehouse/templates/packaging/detail.html:382 msgid "Release notifications" msgstr "" -#: warehouse/templates/packaging/detail.html:357 +#: warehouse/templates/packaging/detail.html:383 msgid "RSS feed" msgstr "" -#: warehouse/templates/packaging/detail.html:369 +#: warehouse/templates/packaging/detail.html:395 msgid "This version" msgstr "" -#: warehouse/templates/packaging/detail.html:389 +#: warehouse/templates/packaging/detail.html:415 msgid "pre-release" msgstr "" -#: warehouse/templates/packaging/detail.html:394 +#: warehouse/templates/packaging/detail.html:420 msgid "yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:417 +#: warehouse/templates/packaging/detail.html:443 #, python-format msgid "" "Download the file for your platform. If you're not sure which to choose, " @@ -7254,29 +7269,58 @@ msgid "" "target=\"_blank\" rel=\"noopener\">installing packages." msgstr "" -#: warehouse/templates/packaging/detail.html:420 +#: warehouse/templates/packaging/detail.html:446 msgid "Source Distribution" msgid_plural "Source Distributions" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/packaging/detail.html:436 +#: warehouse/templates/packaging/detail.html:462 msgid "No source distribution files available for this release." msgstr "" -#: warehouse/templates/packaging/detail.html:437 +#: warehouse/templates/packaging/detail.html:463 #, python-format msgid "" "See tutorial on generating distribution archives." msgstr "" -#: warehouse/templates/packaging/detail.html:444 +#: warehouse/templates/packaging/detail.html:470 msgid "Built Distribution" msgid_plural "Built Distributions" msgstr[0] "" msgstr[1] "" +#: warehouse/templates/packaging/detail.html:478 +msgid "Filter files by name, interpreter, ABI, and platform." +msgstr "" + +#: warehouse/templates/packaging/detail.html:482 +#, 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:487 +msgid "The dropdown lists show the available interpreters, ABIs, and platforms." +msgstr "" + +#: warehouse/templates/packaging/detail.html:491 +msgid "Enable javascript to be able to filter the list of wheel files." +msgstr "" + +#: warehouse/templates/packaging/detail.html:496 +msgid "Copy a direct link to the current filters" +msgstr "" + +#: warehouse/templates/packaging/detail.html:512 +#: warehouse/templates/packaging/detail.html:518 +msgid "File name" +msgstr "" + #: warehouse/templates/packaging/submit-malware-observation.html:23 msgid "Submit Malware Report for Project" msgstr "" diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 0ceb3cc635a9..cec004222038 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -966,6 +966,10 @@ def validates_requires_python(self, *args, **kwargs): def pretty_wheel_tags(self) -> list[str]: return wheel.filename_to_pretty_tags(self.filename) + @property + def wheel_filters(self): + return wheel.filename_to_filters(self.filename) + class Filename(db.ModelBase): __tablename__ = "file_registry" diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index e318d573c2b8..9c2aae9cb3a4 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -30,6 +30,7 @@ from warehouse.observations.models import ObservationKind from warehouse.packaging.forms import SubmitMalwareObservationForm from warehouse.packaging.models import Description, File, Project, Release, Role +from warehouse.utils import wheel class PEP740AttestationViewer: @@ -276,6 +277,17 @@ def release_detail(release, request): key=lambda f: f.filename, ) + # 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", ""), + } + return { "project": project, "release": release, @@ -289,6 +301,8 @@ def release_detail(release, request): "license": license, # 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 new file mode 100644 index 000000000000..0805d17cb1ac --- /dev/null +++ b/warehouse/static/js/warehouse/controllers/filter_list_controller.js @@ -0,0 +1,232 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* + * This controller enables filtering a list using either input or select elements. + * Each element can filter on a separate data attribute applied to items in the list to be filtered. + * + * Add these data attributes to each input / select: + * - data-action="filter-list#filter" + * - data-filter-list-target="filter" + * - data-filtered-source="[name of filter group in camelCase e.g. contentType]" + * + * Apply these data attributes to each item in the list to be filtered: + * - data-filter-list-target="item" + * - 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"; + +export default class extends Controller { + static targets = ["item", "filter", "summary", "url"]; + static values = { + group: String, + }; + + mappingItemFilterData = {}; + + connect() { + this._populateMappings(); + this._initVisibility(); + + this.filter(); + } + + /** + * Filter the values of the target items using the values of the target filters. + */ + filter() { + // Stop here if there are no items. + if (!this.hasItemTarget) { + return; + } + + const filterData = this._buildFilterData(); + + let total = 0; + let shown = 0; + + this.itemTargets.forEach((item, index) => { + total += 1; + const itemData = this.mappingItemFilterData[index]; + const compareResult = 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; + } else { + // no match: hide item + item.classList.add("hidden"); + } + }); + + // show the number of matches and the total number of items + if (this.hasSummaryTarget) { + this.summaryTarget.textContent = ngettext( + "Showing %1 of %2 file.", + "Showing %1 of %2 files.", + total, + shown, + total); + } + + // Update the direct url to this filter + 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 qs = searchParams.toString(); + const baseUrl = new URL(this.urlTarget.href); + if (qs) { + baseUrl.search = "?" + qs; + } + this.urlTarget.textContent = baseUrl.toString(); + } + } + + /** + * Set the visibility of elements. + * Use to show only relevant elements depending on whether js is enabled. + * @private + */ + _initVisibility() { + document.querySelectorAll(".initial-toggle-visibility").forEach(item => { + if (item.classList.contains("hidden")) { + item.classList.remove("hidden"); + } else { + item.classList.add("hidden"); + } + }); + } + + /** + * Pre-populate the mapping from item, to filter keys, to the item's data used to filter. + * Performance improvement by avoiding re-calculating the item data. + * This assumes that the elements will not change after the page has loaded. + * @private + */ + _populateMappings() { + const filterData = this._buildFilterData(); + + // reset the item filter mapping data + this.mappingItemFilterData = {}; + + if (!this.hasItemTarget) { + return; + } + + this.itemTargets.forEach((item, index) => { + const dataAttrs = item.dataset; + this.mappingItemFilterData[index] = {}; + for (const filterKey in filterData) { + const dataAttrsKey = `filteredTarget${filterKey.charAt(0).toUpperCase()}${filterKey.slice(1)}`; + const dataAttrValue = dataAttrs[dataAttrsKey]; + if (!dataAttrValue) { + console.warn(`Item target at index ${index} does not have a value for data attribute '${dataAttrsKey}'.`); + } + this.mappingItemFilterData[index][filterKey] = JSON.parse(dataAttrValue || "[]"); + } + }); + + } + + /** + * Build a mapping of filteredSource names to array of values. + * @returns {{}} + * @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); + }); + } + 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: *[]}} + * @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; + } + + if (filterItemValue && !hasKeyFilter) { + // Record whether there are any filter values in *this* filter key. + hasKeyFilter = true; + } + + // 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}); + } + } + } + isMatch.push(!hasKeyFilter || (isKeyMatch && hasKeyFilter)); + } + + return { + "isMatch": isMatch.every(value => value), + "hasFilter": hasFilter, + "matches": matches, + "misses": misses, + }; + } +} diff --git a/warehouse/static/sass/layout-helpers/_columns.scss b/warehouse/static/sass/layout-helpers/_columns.scss index fbcff3e29786..18ed997803ba 100644 --- a/warehouse/static/sass/layout-helpers/_columns.scss +++ b/warehouse/static/sass/layout-helpers/_columns.scss @@ -17,6 +17,11 @@
+ + Column that is 33% wide: +
+
+
*/ .col-grid { @@ -40,3 +45,21 @@ } } } + +.col-third { + grid-column: span 4; + + &:last-of-type { + margin: 0; + } + + @media only screen and (max-width: $tablet) { + float: none; + margin: 0; + width: 100%; + + &:first-of-type { + margin-bottom: $spacing-unit; + } + } +} diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index a789e0897261..775c222a9394 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -95,7 +95,15 @@

{%- macro file_table(files) -%} {% for file in files %} -
+
@@ -120,6 +128,24 @@

{%- endmacro -%} +{% macro filter_select(name, title, selected) %} + + + +{% endmacro %} + {% block title %}{{ release.project.name }}{% endblock %} {% block description %}{{ release.summary }}{% endblock %} @@ -412,7 +438,7 @@

{% if files %} {# Tab: Downloads #} -
+

{% 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 %}

@@ -448,6 +474,74 @@

{% 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) }} +
+ +
+ +
+ {{ file_table(bdists) }} {% endif %}
diff --git a/warehouse/utils/wheel.py b/warehouse/utils/wheel.py index ef699641e205..92ae3a234953 100644 --- a/warehouse/utils/wheel.py +++ b/warehouse/utils/wheel.py @@ -12,6 +12,7 @@ import re +import packaging.tags import packaging.utils # import sentry_sdk @@ -79,16 +80,22 @@ def _format_version(s: str) -> str: return f"{s[0]}.{s[1:]}" +def filename_to_tags(filename: str) -> set[packaging.tags.Tag]: + """Parse a wheel file name to extract the tags.""" + try: + _, _, _, tags = packaging.utils.parse_wheel_filename(filename) + return set(tags) + except packaging.utils.InvalidWheelFilename: + return set() + + def filename_to_pretty_tags(filename: str) -> list[str]: if filename.endswith(".egg"): return ["Egg"] elif not filename.endswith(".whl"): return ["Source"] - try: - _, _, _, tags = packaging.utils.parse_wheel_filename(filename) - except packaging.utils.InvalidWheelFilename: - return [] + tags = filename_to_tags(filename) pretty_tags = set() for tag in tags: @@ -128,3 +135,31 @@ def filename_to_pretty_tags(filename: str) -> list[str]: pretty_tags.add(tag.interpreter) 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_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), + }