From 67e0987d58e9cc8ec967d18c2d4a439bf46101d2 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Wed, 20 Dec 2023 00:11:26 +1000 Subject: [PATCH 1/7] initial investigation of filtering project release wheel files --- warehouse/packaging/models.py | 36 +++++++++++++++++++ warehouse/packaging/views.py | 6 +++- warehouse/templates/packaging/detail.html | 43 +++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 2bb9ad491ae7..a4728fcde5ac 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -715,6 +715,13 @@ def metadata_path(self): def validates_requires_python(self, *args, **kwargs): raise RuntimeError("Cannot set File.requires_python") + @property + def bdist_tags(self): + if self.packagetype == "sdist" or not self.filename: + # Don't parse source package file names or missing file names. + return None + return bdist_filename_tags(self.filename) + class Filename(db.ModelBase): __tablename__ = "file_registry" @@ -786,3 +793,32 @@ class ProhibitedProjectName(db.Model): ) prohibited_by: Mapped[User] = orm.relationship() comment: Mapped[str] = mapped_column(server_default="") + + +def bdist_filename_tags(filename: str): + """Parse a wheel file name to extract the tags.""" + + # see: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-format + # see: https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/ + _, __, ___, tags = packaging.utils.parse_wheel_filename(filename) + return tags + + +def bdist_filenames_tags(available) -> dict[str, set]: + result = { + 'interpreters': set(), + 'abis': set(), + 'platforms': set(), + } + for tags in available: + if not tags: + continue + for tag in tags: + result['interpreters'].add(tag.interpreter) + result['abis'].add(tag.abi) + result['platforms'].add(tag.platform) + + result['interpreters'] = sorted(result['interpreters']) + result['abis'] = sorted(result['abis']) + result['platforms'] = sorted(result['platforms']) + return result diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 9c6e54fc7548..9004a741817d 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -17,7 +17,7 @@ from warehouse.accounts.models import User from warehouse.cache.origin import origin_cache -from warehouse.packaging.models import File, Project, Release, Role +from warehouse.packaging.models import File, Project, Release, Role, bdist_filenames_tags from warehouse.utils import readme @@ -138,6 +138,9 @@ def release_detail(release, request): key=lambda f: f.filename, ) + # Collect all the available bdist details to enable building filters. + bdist_tags = bdist_filenames_tags([bdist.bdist_tags for bdist in bdists]) + return { "project": project, "release": release, @@ -149,6 +152,7 @@ def release_detail(release, request): "all_versions": project.all_versions, "maintainers": maintainers, "license": license, + 'bdist_tags': bdist_tags, } diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index 4d9826f2b091..d8a456e1a028 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -394,6 +394,49 @@

{% endtrans %}

+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {{ file_table(bdists) }} {% endif %} From 75ced1dd1e6078ac76b53950b0c3b6602905dbb5 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Thu, 21 Dec 2023 21:17:51 +1000 Subject: [PATCH 2/7] created controller for generic filterable list Can be filtered using input or select elements. --- warehouse/packaging/models.py | 32 ++-- warehouse/packaging/views.py | 10 +- .../controllers/filter_list_controller.js | 173 ++++++++++++++++++ warehouse/templates/packaging/detail.html | 38 +++- 4 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 warehouse/static/js/warehouse/controllers/filter_list_controller.js diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index a4728fcde5ac..2cb1968a407d 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -722,6 +722,17 @@ def bdist_tags(self): return None return bdist_filename_tags(self.filename) + @property + def bdist_tags_collected(self): + interpreters = set() + abis = set() + platforms = set() + for tag in self.bdist_tags or []: + interpreters.add(tag.interpreter) + abis.add(tag.abi) + platforms.add(tag.platform) + return sorted(interpreters), sorted(abis), sorted(platforms) + class Filename(db.ModelBase): __tablename__ = "file_registry" @@ -797,28 +808,25 @@ class ProhibitedProjectName(db.Model): def bdist_filename_tags(filename: str): """Parse a wheel file name to extract the tags.""" - - # see: https://packaging.python.org/en/latest/specifications/binary-distribution-format/#file-format - # see: https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/ _, __, ___, tags = packaging.utils.parse_wheel_filename(filename) return tags def bdist_filenames_tags(available) -> dict[str, set]: result = { - 'interpreters': set(), - 'abis': set(), - 'platforms': set(), + "interpreters": set(), + "abis": set(), + "platforms": set(), } for tags in available: if not tags: continue for tag in tags: - result['interpreters'].add(tag.interpreter) - result['abis'].add(tag.abi) - result['platforms'].add(tag.platform) + result["interpreters"].add(tag.interpreter) + result["abis"].add(tag.abi) + result["platforms"].add(tag.platform) - result['interpreters'] = sorted(result['interpreters']) - result['abis'] = sorted(result['abis']) - result['platforms'] = sorted(result['platforms']) + result["interpreters"] = sorted(result["interpreters"]) + result["abis"] = sorted(result["abis"]) + result["platforms"] = sorted(result["platforms"]) return result diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 9004a741817d..28744ef7d0e5 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -17,7 +17,13 @@ from warehouse.accounts.models import User from warehouse.cache.origin import origin_cache -from warehouse.packaging.models import File, Project, Release, Role, bdist_filenames_tags +from warehouse.packaging.models import ( + File, + Project, + Release, + Role, + bdist_filenames_tags, +) from warehouse.utils import readme @@ -152,7 +158,7 @@ def release_detail(release, request): "all_versions": project.all_versions, "maintainers": maintainers, "license": license, - 'bdist_tags': bdist_tags, + "bdist_tags": bdist_tags, } 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..d2fec89bbb34 --- /dev/null +++ b/warehouse/static/js/warehouse/controllers/filter_list_controller.js @@ -0,0 +1,173 @@ +/** + * 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]="[a list joined by '--;--']" (zero or more) + */ +import {Controller} from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = ["item", "filter", "total", "shown"]; + static values = { + group: String, + }; + + static _listSep = "--;--"; + + connect() { + + this.filterTimeout = null; + + 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) { + console.debug("There are no built distribution wheel files to filter."); + return; + } + + const filterData = this._buildFilterData(); + + let total = 0; + let shown = 0; + + this.itemTargets.forEach(item => { + total += 1; + const itemData = this._buildItemData(item, filterData); + 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.style.display = "unset"; + shown += 1; + } else { + // no match: hide item + item.style.display = "none"; + } + }); + + this.shownTarget.textContent = shown.toString(); + this.totalTarget.textContent = total.toString(); + + console.debug(`Filtered built distribution wheel files. Now showing ${shown} of ${total}.`); + } + + /** + * 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; + } + + /** + * Build a mapping of filteredTarget names to array of values. + * @param item The item element. + * @param filterData The filter mapping. + * @returns {{}} + * @private + */ + _buildItemData(item, filterData) { + const dataAttrs = item.dataset; + const itemData = {}; + for (const filterKey in filterData) { + itemData[filterKey] = dataAttrs[`filteredTarget${filterKey.charAt(0).toUpperCase()}${filterKey.slice(1)}`].split(this._listSep); + } + return itemData; + } + + /** + * 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 = []; + let matches = []; + 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}); + } + } + } + isMatch.push(!hasKeyFilter || (isKeyMatch && hasKeyFilter)); + } + + return { + "isMatch": isMatch.every(value => value), + "hasFilter": hasFilter, + "matches": matches, + }; + } +} diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index a5bb213f4888..8d3daecc679a 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -95,7 +95,16 @@

{%- macro file_table(files) -%} {% for file in files %} -
+
@@ -365,7 +374,7 @@

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

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

{% trans href='https://packaging.python.org/installing/', title=gettext('External link') %}Download the file for your platform. If you're not sure which to choose, learn more about installing packages.{% endtrans %}

@@ -405,7 +414,8 @@

+ placeholder="e.g. 'py3', 'cp311', 'abi3', 'linux'" + data-action="filter-list#filter" data-filter-list-target="filter" data-filtered-source="filename">
@@ -413,8 +423,9 @@

- + {% for interpreter in bdist_tags.interpreters %} {% endfor %} @@ -424,8 +435,9 @@

- + {% for abi in bdist_tags.abis %} {% endfor %} @@ -433,8 +445,9 @@

- + {% for platform in bdist_tags.platforms %} {% endfor %} @@ -443,6 +456,13 @@

+

+ Showing + + of + . +

+ {{ file_table(bdists) }} {% endif %}

From 908a6ff8be8027960910ffd5a49117fddba03fc3 Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Thu, 21 Dec 2023 22:57:36 +1000 Subject: [PATCH 3/7] style UI for filtering release built distributions --- warehouse/locale/messages.pot | 90 +++++++------- .../controllers/filter_list_controller.js | 4 +- .../static/sass/layout-helpers/_columns.scss | 21 ++++ warehouse/templates/packaging/detail.html | 116 +++++++++++------- 4 files changed, 137 insertions(+), 94 deletions(-) diff --git a/warehouse/locale/messages.pot b/warehouse/locale/messages.pot index 379745903d9d..8ca9739ab5fc 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -694,8 +694,8 @@ msgstr "" #: warehouse/templates/manage/project/release.html:200 #: warehouse/templates/manage/project/releases.html:140 #: warehouse/templates/manage/project/releases.html:179 -#: warehouse/templates/packaging/detail.html:370 -#: warehouse/templates/packaging/detail.html:389 +#: warehouse/templates/packaging/detail.html:379 +#: warehouse/templates/packaging/detail.html:398 #: warehouse/templates/pages/classifiers.html:25 #: warehouse/templates/pages/help.html:20 #: warehouse/templates/pages/help.html:218 @@ -2779,7 +2779,7 @@ msgstr "" #: warehouse/templates/manage/account.html:206 #: warehouse/templates/manage/account/recovery_codes-provision.html:58 #: warehouse/templates/manage/account/totp-provision.html:57 -#: warehouse/templates/packaging/detail.html:160 +#: warehouse/templates/packaging/detail.html:169 #: warehouse/templates/pages/classifiers.html:38 msgid "Copy to clipboard" msgstr "" @@ -6454,127 +6454,127 @@ msgstr "" msgid "%(org)s has not uploaded any projects to PyPI, yet" msgstr "" -#: warehouse/templates/packaging/detail.html:108 +#: warehouse/templates/packaging/detail.html:117 msgid "view hashes" msgstr "" -#: warehouse/templates/packaging/detail.html:128 +#: warehouse/templates/packaging/detail.html:137 #, python-format msgid "RSS: latest releases for %(project_name)s" msgstr "" -#: warehouse/templates/packaging/detail.html:162 +#: warehouse/templates/packaging/detail.html:171 msgid "Copy PIP instructions" msgstr "" -#: warehouse/templates/packaging/detail.html:173 +#: warehouse/templates/packaging/detail.html:182 msgid "This release has been yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:179 +#: warehouse/templates/packaging/detail.html:188 #, python-format msgid "Stable version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:183 +#: warehouse/templates/packaging/detail.html:192 #, python-format msgid "Newer version available (%(version)s)" msgstr "" -#: warehouse/templates/packaging/detail.html:187 +#: warehouse/templates/packaging/detail.html:196 msgid "Latest version" msgstr "" -#: warehouse/templates/packaging/detail.html:192 +#: warehouse/templates/packaging/detail.html:201 #, python-format msgid "Released: %(release_date)s" msgstr "" -#: warehouse/templates/packaging/detail.html:206 +#: warehouse/templates/packaging/detail.html:215 msgid "No project description provided" msgstr "" -#: warehouse/templates/packaging/detail.html:219 +#: warehouse/templates/packaging/detail.html:228 msgid "Navigation" msgstr "" -#: warehouse/templates/packaging/detail.html:220 -#: warehouse/templates/packaging/detail.html:252 +#: warehouse/templates/packaging/detail.html:229 +#: warehouse/templates/packaging/detail.html:261 #, python-format msgid "Navigation for %(project)s" msgstr "" -#: warehouse/templates/packaging/detail.html:223 -#: warehouse/templates/packaging/detail.html:255 +#: warehouse/templates/packaging/detail.html:232 +#: warehouse/templates/packaging/detail.html:264 msgid "Project description. Focus will be moved to the description." msgstr "" -#: warehouse/templates/packaging/detail.html:225 -#: warehouse/templates/packaging/detail.html:257 -#: warehouse/templates/packaging/detail.html:291 +#: warehouse/templates/packaging/detail.html:234 +#: warehouse/templates/packaging/detail.html:266 +#: warehouse/templates/packaging/detail.html:300 msgid "Project description" msgstr "" -#: warehouse/templates/packaging/detail.html:229 -#: warehouse/templates/packaging/detail.html:267 +#: warehouse/templates/packaging/detail.html:238 +#: warehouse/templates/packaging/detail.html:276 msgid "Release history. Focus will be moved to the history panel." msgstr "" -#: warehouse/templates/packaging/detail.html:231 -#: warehouse/templates/packaging/detail.html:269 -#: warehouse/templates/packaging/detail.html:313 +#: warehouse/templates/packaging/detail.html:240 +#: warehouse/templates/packaging/detail.html:278 +#: warehouse/templates/packaging/detail.html:322 msgid "Release history" msgstr "" -#: warehouse/templates/packaging/detail.html:236 -#: warehouse/templates/packaging/detail.html:274 +#: warehouse/templates/packaging/detail.html:245 +#: warehouse/templates/packaging/detail.html:283 msgid "Download files. Focus will be moved to the project files." msgstr "" -#: warehouse/templates/packaging/detail.html:238 -#: warehouse/templates/packaging/detail.html:276 -#: warehouse/templates/packaging/detail.html:369 +#: warehouse/templates/packaging/detail.html:247 +#: warehouse/templates/packaging/detail.html:285 +#: warehouse/templates/packaging/detail.html:378 msgid "Download files" msgstr "" -#: warehouse/templates/packaging/detail.html:261 +#: warehouse/templates/packaging/detail.html:270 msgid "Project details. Focus will be moved to the project details." msgstr "" -#: warehouse/templates/packaging/detail.html:263 -#: warehouse/templates/packaging/detail.html:305 +#: warehouse/templates/packaging/detail.html:272 +#: warehouse/templates/packaging/detail.html:314 msgid "Project details" msgstr "" -#: warehouse/templates/packaging/detail.html:287 +#: warehouse/templates/packaging/detail.html:296 msgid "Reason this release was yanked:" msgstr "" -#: warehouse/templates/packaging/detail.html:298 +#: warehouse/templates/packaging/detail.html:307 msgid "The author of this package has not provided a project description" msgstr "" -#: warehouse/templates/packaging/detail.html:315 +#: warehouse/templates/packaging/detail.html:324 msgid "Release notifications" msgstr "" -#: warehouse/templates/packaging/detail.html:316 +#: warehouse/templates/packaging/detail.html:325 msgid "RSS feed" msgstr "" -#: warehouse/templates/packaging/detail.html:328 +#: warehouse/templates/packaging/detail.html:337 msgid "This version" msgstr "" -#: warehouse/templates/packaging/detail.html:348 +#: warehouse/templates/packaging/detail.html:357 msgid "pre-release" msgstr "" -#: warehouse/templates/packaging/detail.html:353 +#: warehouse/templates/packaging/detail.html:362 msgid "yanked" msgstr "" -#: warehouse/templates/packaging/detail.html:370 +#: warehouse/templates/packaging/detail.html:379 #, python-format msgid "" "Download the file for your platform. If you're not sure which to choose, " @@ -6582,24 +6582,24 @@ msgid "" "target=\"_blank\" rel=\"noopener\">installing packages." msgstr "" -#: warehouse/templates/packaging/detail.html:372 +#: warehouse/templates/packaging/detail.html:381 msgid "Source Distribution" msgid_plural "Source Distributions" msgstr[0] "" msgstr[1] "" -#: warehouse/templates/packaging/detail.html:388 +#: warehouse/templates/packaging/detail.html:397 msgid "No source distribution files available for this release." msgstr "" -#: warehouse/templates/packaging/detail.html:389 +#: warehouse/templates/packaging/detail.html:398 #, python-format msgid "" "See tutorial on generating distribution archives." msgstr "" -#: warehouse/templates/packaging/detail.html:396 +#: warehouse/templates/packaging/detail.html:405 msgid "Built Distribution" msgid_plural "Built Distributions" msgstr[0] "" diff --git a/warehouse/static/js/warehouse/controllers/filter_list_controller.js b/warehouse/static/js/warehouse/controllers/filter_list_controller.js index d2fec89bbb34..b6cd35a49b1b 100644 --- a/warehouse/static/js/warehouse/controllers/filter_list_controller.js +++ b/warehouse/static/js/warehouse/controllers/filter_list_controller.js @@ -68,11 +68,11 @@ export default class extends Controller { const isShow = !compareResult.hasFilter || (compareResult.hasFilter && compareResult.isMatch); if (isShow) { // match: show item - item.style.display = "unset"; + item.classList.remove("hidden"); shown += 1; } else { // no match: hide item - item.style.display = "none"; + item.classList.add("hidden"); } }); diff --git a/warehouse/static/sass/layout-helpers/_columns.scss b/warehouse/static/sass/layout-helpers/_columns.scss index 80b021006053..9fdecf680d31 100644 --- a/warehouse/static/sass/layout-helpers/_columns.scss +++ b/warehouse/static/sass/layout-helpers/_columns.scss @@ -15,6 +15,9 @@ /* Column that is 50% wide:
+ + Column that is 33% wide: +
*/ .col-half { @@ -34,3 +37,21 @@ } } } + +.col-third { + @include span-columns(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 8d3daecc679a..afa548472cc7 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -409,60 +409,82 @@

{% endtrans %}

-
- -
- - -
- -
-
- - -
-
- - -
-
- - -
-
-
+

+ Filter files by name, + interpreter, + ABI, + and platform. +

Showing of - . + + files.

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ {{ file_table(bdists) }} {% endif %}
From 65c85d3fc40734abad960013e7be1102f7e4242d Mon Sep 17 00:00:00 2001 From: Mark Cottman-Fields Date: Mon, 1 Jan 2024 17:53:04 +1000 Subject: [PATCH 4/7] add noscript display and js test Fixed tests. Added some missing translation blocks. Slightly improved js. --- tests/frontend/filter_list_controller_test.js | 237 ++++++++++++++++++ tests/unit/packaging/test_views.py | 15 +- warehouse/locale/messages.pot | 46 ++++ warehouse/packaging/models.py | 47 ++-- warehouse/packaging/views.py | 10 +- .../controllers/filter_list_controller.js | 92 +++++-- warehouse/templates/packaging/detail.html | 43 ++-- 7 files changed, 409 insertions(+), 81 deletions(-) create mode 100644 tests/frontend/filter_list_controller_test.js diff --git a/tests/frontend/filter_list_controller_test.js b/tests/frontend/filter_list_controller_test.js new file mode 100644 index 000000000000..30d679d771b4 --- /dev/null +++ b/tests/frontend/filter_list_controller_test.js @@ -0,0 +1,237 @@ +/* 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 = ` +

Showing of files.

+`; +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"], + }); + + }); + }); + + 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."); + + const elTotal = document.getElementById("total"); + expect(elTotal).toHaveTextContent("3"); + + const elShown = document.getElementById("shown"); + expect(elShown).toHaveTextContent("3"); + }); + }); + }); + + 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"); + }); + + 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"); + }); + }); + }); + }); +}); diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py index a4ef9083e247..a8f07ba5743e 100644 --- a/tests/unit/packaging/test_views.py +++ b/tests/unit/packaging/test_views.py @@ -221,6 +221,7 @@ def test_detail_render_plain(self, db_request): ], "maintainers": sorted(users, key=lambda u: u.username.lower()), "license": None, + "bdist_tags": {"interpreters": [], "abis": [], "platforms": []}, } def test_detail_rendered(self, db_request): @@ -268,6 +269,7 @@ def test_detail_rendered(self, db_request): ], "maintainers": sorted(users, key=lambda u: u.username.lower()), "license": None, + "bdist_tags": {"interpreters": [], "abis": [], "platforms": []}, } def test_detail_renders(self, monkeypatch, db_request): @@ -319,6 +321,7 @@ def test_detail_renders(self, monkeypatch, db_request): ], "maintainers": sorted(users, key=lambda u: u.username.lower()), "license": None, + "bdist_tags": {"interpreters": [], "abis": [], "platforms": []}, } assert render_description.calls == [ @@ -333,17 +336,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.bdist_tags_collected for file in result["files"]] == [ + (["cp310"], ["none"], ["any"]), + (["cp39"], ["none"], ["any"]), + (["cp27"], ["none"], ["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 8ca9739ab5fc..bf0cba68f43d 100644 --- a/warehouse/locale/messages.pot +++ b/warehouse/locale/messages.pot @@ -696,6 +696,7 @@ msgstr "" #: warehouse/templates/manage/project/releases.html:179 #: warehouse/templates/packaging/detail.html:379 #: warehouse/templates/packaging/detail.html:398 +#: warehouse/templates/packaging/detail.html:417 #: warehouse/templates/pages/classifiers.html:25 #: warehouse/templates/pages/help.html:20 #: warehouse/templates/pages/help.html:218 @@ -6605,6 +6606,51 @@ msgid_plural "Built Distributions" msgstr[0] "" msgstr[1] "" +#: warehouse/templates/packaging/detail.html:413 +msgid "Filter files by name, interpreter, ABI, and platform." +msgstr "" + +#: warehouse/templates/packaging/detail.html:417 +#, 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:422 +msgid "The dropdown lists show the available interpreters, ABIs, and platforms." +msgstr "" + +#: warehouse/templates/packaging/detail.html:426 +msgid "Enable javascript to be able to filter the list of wheel files." +msgstr "" + +#: warehouse/templates/packaging/detail.html:431 +msgid "" +"Showing of files." +msgstr "" + +#: warehouse/templates/packaging/detail.html:438 +#: warehouse/templates/packaging/detail.html:442 +msgid "File name" +msgstr "" + +#: warehouse/templates/packaging/detail.html:450 +#: warehouse/templates/packaging/detail.html:456 +msgid "Interpreter" +msgstr "" + +#: warehouse/templates/packaging/detail.html:465 +#: warehouse/templates/packaging/detail.html:471 +msgid "ABI" +msgstr "" + +#: warehouse/templates/packaging/detail.html:480 +msgid "Platform" +msgstr "" + #: warehouse/templates/pages/classifiers.html:22 msgid "" "Each project's maintainers provide PyPI with a list of \"Trove " diff --git a/warehouse/packaging/models.py b/warehouse/packaging/models.py index 2cb1968a407d..15b461654282 100644 --- a/warehouse/packaging/models.py +++ b/warehouse/packaging/models.py @@ -69,6 +69,8 @@ from warehouse.utils.db.types import bool_false, datetime_now if typing.TYPE_CHECKING: + from packaging.tags import Tag + from warehouse.oidc.models import OIDCPublisher @@ -717,21 +719,12 @@ def validates_requires_python(self, *args, **kwargs): @property def bdist_tags(self): - if self.packagetype == "sdist" or not self.filename: - # Don't parse source package file names or missing file names. - return None return bdist_filename_tags(self.filename) @property def bdist_tags_collected(self): - interpreters = set() - abis = set() - platforms = set() - for tag in self.bdist_tags or []: - interpreters.add(tag.interpreter) - abis.add(tag.abi) - platforms.add(tag.platform) - return sorted(interpreters), sorted(abis), sorted(platforms) + result = bdist_collect_tags([self.bdist_tags or []]) + return result.get("interpreters"), result.get("abis"), result.get("platforms") class Filename(db.ModelBase): @@ -812,21 +805,19 @@ def bdist_filename_tags(filename: str): return tags -def bdist_filenames_tags(available) -> dict[str, set]: - result = { - "interpreters": set(), - "abis": set(), - "platforms": set(), +def bdist_collect_tags(available: typing.Iterable[frozenset[Tag]]) -> dict[str, list]: + interpreters = set() + abis = set() + platforms = set() + + for tags in available or []: + 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 tags in available: - if not tags: - continue - for tag in tags: - result["interpreters"].add(tag.interpreter) - result["abis"].add(tag.abi) - result["platforms"].add(tag.platform) - - result["interpreters"] = sorted(result["interpreters"]) - result["abis"] = sorted(result["abis"]) - result["platforms"] = sorted(result["platforms"]) - return result diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py index 28744ef7d0e5..ab77f87e4a26 100644 --- a/warehouse/packaging/views.py +++ b/warehouse/packaging/views.py @@ -17,13 +17,7 @@ from warehouse.accounts.models import User from warehouse.cache.origin import origin_cache -from warehouse.packaging.models import ( - File, - Project, - Release, - Role, - bdist_filenames_tags, -) +from warehouse.packaging.models import File, Project, Release, Role, bdist_collect_tags from warehouse.utils import readme @@ -145,7 +139,7 @@ def release_detail(release, request): ) # Collect all the available bdist details to enable building filters. - bdist_tags = bdist_filenames_tags([bdist.bdist_tags for bdist in bdists]) + bdist_tags = bdist_collect_tags([bdist.bdist_tags for bdist in bdists]) return { "project": project, diff --git a/warehouse/static/js/warehouse/controllers/filter_list_controller.js b/warehouse/static/js/warehouse/controllers/filter_list_controller.js index b6cd35a49b1b..e5be473f3bc8 100644 --- a/warehouse/static/js/warehouse/controllers/filter_list_controller.js +++ b/warehouse/static/js/warehouse/controllers/filter_list_controller.js @@ -34,11 +34,13 @@ export default class extends Controller { group: String, }; - static _listSep = "--;--"; + _listSep = "--;--"; - connect() { + mappingItemFilterData = {}; - this.filterTimeout = null; + connect() { + this._populateMappings(); + this._initVisibility(); this.filter(); } @@ -49,7 +51,6 @@ export default class extends Controller { filter() { // Stop here if there are no items. if (!this.hasItemTarget) { - console.debug("There are no built distribution wheel files to filter."); return; } @@ -58,9 +59,9 @@ export default class extends Controller { let total = 0; let shown = 0; - this.itemTargets.forEach(item => { + this.itemTargets.forEach((item, index) => { total += 1; - const itemData = this._buildItemData(item, filterData); + const itemData = this.mappingItemFilterData[index]; const compareResult = this._compare(itemData, filterData); // Should the item be displayed or not? @@ -76,10 +77,60 @@ export default class extends Controller { } }); - this.shownTarget.textContent = shown.toString(); - this.totalTarget.textContent = total.toString(); + // show the number of matches and the total number of items + if (this.hasShownTarget) { + this.shownTarget.textContent = shown.toString(); + } + if (this.hasTotalTarget) { + this.totalTarget.textContent = total.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}'.`); + } + const dataAttrValueSplit = dataAttrValue ? dataAttrValue.split(this._listSep) : []; + this.mappingItemFilterData[index][filterKey] = dataAttrValueSplit; + } + }); - console.debug(`Filtered built distribution wheel files. Now showing ${shown} of ${total}.`); } /** @@ -102,22 +153,6 @@ export default class extends Controller { return filterData; } - /** - * Build a mapping of filteredTarget names to array of values. - * @param item The item element. - * @param filterData The filter mapping. - * @returns {{}} - * @private - */ - _buildItemData(item, filterData) { - const dataAttrs = item.dataset; - const itemData = {}; - for (const filterKey in filterData) { - itemData[filterKey] = dataAttrs[`filteredTarget${filterKey.charAt(0).toUpperCase()}${filterKey.slice(1)}`].split(this._listSep); - } - return itemData; - } - /** * Compare an item's tags to all filter values and find matches. * @param itemData The item mapping. @@ -131,7 +166,8 @@ export default class extends Controller { // at least one item value for the same key includes any filter value. const isMatch = []; - let matches = []; + const matches = []; + const misses = []; let hasFilter = false; for (const itemKey in itemData) { const itemValues = itemData[itemKey]; @@ -159,6 +195,9 @@ export default class extends Controller { 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)); @@ -168,6 +207,7 @@ export default class extends Controller { "isMatch": isMatch.every(value => value), "hasFilter": hasFilter, "matches": matches, + "misses": misses, }; } } diff --git a/warehouse/templates/packaging/detail.html b/warehouse/templates/packaging/detail.html index afa548472cc7..11deca892ab0 100644 --- a/warehouse/templates/packaging/detail.html +++ b/warehouse/templates/packaging/detail.html @@ -409,30 +409,37 @@

{% endtrans %}

-

- Filter files by name, - interpreter, - ABI, - and platform. +

- Showing - - of - - files. + {% 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 %} +

+ + + +
-
+