diff --git a/requirements/main.in b/requirements/main.in index f39a8dd9b5cb..5909c1630099 100644 --- a/requirements/main.in +++ b/requirements/main.in @@ -53,6 +53,7 @@ sqlalchemy-citext stdlib-list structlog transaction +trove-classifiers typeguard webauthn whitenoise diff --git a/requirements/main.txt b/requirements/main.txt index f33964e53f2d..66fbe01c5ea2 100644 --- a/requirements/main.txt +++ b/requirements/main.txt @@ -569,6 +569,9 @@ transaction==3.0.0 \ translationstring==1.3 \ --hash=sha256:4ee44cfa58c52ade8910ea0ebc3d2d84bdcad9fa0422405b1801ec9b9a65b72d \ --hash=sha256:e26c7bf383413234ed442e0980a2ebe192b95e3745288a8fd2805156d27515b4 +trove-classifiers==2020.4.1 \ + --hash=sha256:9e1dcd47920817eaeb4cc67004b3fee430f3fc692e926f6ab1e337035b7a590d \ + --hash=sha256:d8adb5d687ee15fe83c4c23404a8fbc0ff267ca997c6870419cc625fdea449e0 typeguard==2.7.1 \ --hash=sha256:1d3710251d3d3d6c64e0c49f45edec2e88ddc386a51e89c3ec0703efeb8b3b81 \ --hash=sha256:2d545c71e9439c21bcd7c28f5f55b3606e6106f7031ab58375656a1aed483ef2 diff --git a/tests/common/db/classifiers.py b/tests/common/db/classifiers.py index 2656d78860ae..01118c8bde67 100644 --- a/tests/common/db/classifiers.py +++ b/tests/common/db/classifiers.py @@ -10,9 +10,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import factory -import factory.fuzzy - from warehouse.classifiers.models import Classifier from .base import WarehouseFactory @@ -21,8 +18,3 @@ class ClassifierFactory(WarehouseFactory): class Meta: model = Classifier - - l2 = factory.fuzzy.FuzzyInteger(0) - l3 = factory.fuzzy.FuzzyInteger(0) - l4 = factory.fuzzy.FuzzyInteger(0) - l5 = factory.fuzzy.FuzzyInteger(0) diff --git a/tests/unit/admin/test_routes.py b/tests/unit/admin/test_routes.py index 18ea9a3738e2..f3739ef18b8c 100644 --- a/tests/unit/admin/test_routes.py +++ b/tests/unit/admin/test_routes.py @@ -101,15 +101,6 @@ def test_includeme(): domain=warehouse, ), pretend.call("admin.journals.list", "/admin/journals/", domain=warehouse), - pretend.call("admin.classifiers", "/admin/classifiers/", domain=warehouse), - pretend.call( - "admin.classifiers.add", "/admin/classifiers/add/", domain=warehouse - ), - pretend.call( - "admin.classifiers.deprecate", - "/admin/classifiers/deprecate/", - domain=warehouse, - ), pretend.call("admin.blacklist.list", "/admin/blacklist/", domain=warehouse), pretend.call("admin.blacklist.add", "/admin/blacklist/add/", domain=warehouse), pretend.call( diff --git a/tests/unit/admin/views/test_classifiers.py b/tests/unit/admin/views/test_classifiers.py deleted file mode 100644 index b3be0dad91ed..000000000000 --- a/tests/unit/admin/views/test_classifiers.py +++ /dev/null @@ -1,134 +0,0 @@ -# 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. - -import pretend -import pytest -import sqlalchemy - -from warehouse.admin.views import classifiers as views -from warehouse.classifiers.models import Classifier - -from ....common.db.classifiers import ClassifierFactory - - -class TestGetClassifiers: - def test_get_classifiers(self, db_request): - classifier_a = ClassifierFactory(classifier="I am first") - classifier_b = ClassifierFactory(classifier="I am last") - - assert views.get_classifiers(db_request) == { - "classifiers": [classifier_a, classifier_b] - } - - -class TestAddClassifier: - @pytest.mark.parametrize( - "parent_levels, expected_levels", - [ - ((2, 0, 0, 0), (2, None, 0, 0)), - ((2, 3, 0, 0), (2, 3, None, 0)), - ((2, 3, 4, 0), (2, 3, 4, None)), - # This won't actually happen but it's needed for coverage - ((2, 3, 4, 5), (2, 3, 4, 5)), - ], - ) - def test_add_child_classifier(self, db_request, parent_levels, expected_levels): - l2, l3, l4, l5 = parent_levels - parent = ClassifierFactory(l2=l2, l3=l3, l4=l4, l5=l5, classifier="Parent") - - db_request.params = {"parent_id": parent.id, "child": "Foobar"} - db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) - db_request.route_path = lambda *a: "/the/path" - - views.AddClassifier(db_request).add_child_classifier() - - new = ( - db_request.db.query(Classifier) - .filter(Classifier.classifier == "Parent :: Foobar") - .one() - ) - - new_l2, new_l3, new_l4, new_l5 = expected_levels - assert new.l2 == new_l2 if new_l2 is not None else new.id - assert new.l3 == new_l3 if new_l3 is not None else new.id - assert new.l4 == new_l4 if new_l4 is not None else new.id - assert new.l5 == new_l5 if new_l5 is not None else new.id - - def test_add_parent_classifier(self, db_request): - db_request.params = {"parent": "Foo :: Bar"} - db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) - db_request.route_path = lambda *a: "/the/path" - - views.AddClassifier(db_request).add_parent_classifier() - - new = ( - db_request.db.query(Classifier) - .filter(Classifier.classifier == "Foo :: Bar") - .one() - ) - - assert new.l2 == new.id - assert new.l3 == 0 - assert new.l4 == 0 - assert new.l5 == 0 - - @pytest.mark.parametrize( - "parent_classifier, parent_levels, expected_levels", - [ - ("private", (2, 0, 0, 0), (2, None, 0, 0)), - ("private", (2, 3, 0, 0), (2, 3, None, 0)), - ("private", (2, 3, 4, 0), (2, 3, 4, None)), - ("Private", (2, 0, 0, 0), (2, None, 0, 0)), - ("Private", (2, 3, 0, 0), (2, 3, None, 0)), - ("Private", (2, 3, 4, 0), (2, 3, 4, None)), - ("PrIvAtE", (2, 0, 0, 0), (2, None, 0, 0)), - ("PrIvAtE", (2, 3, 0, 0), (2, 3, None, 0)), - ("PrIvAtE", (2, 3, 4, 0), (2, 3, 4, None)), - ], - ) - def test_add_private_child_classifier( - self, db_request, parent_classifier, parent_levels, expected_levels - ): - l2, l3, l4, l5 = parent_levels - parent = ClassifierFactory( - l2=l2, l3=l3, l4=l4, l5=l5, classifier=parent_classifier - ) - - db_request.params = {"parent_id": parent.id, "child": "Foobar"} - db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) - db_request.route_path = lambda *a: "/the/path" - - with pytest.raises(sqlalchemy.exc.IntegrityError): - views.AddClassifier(db_request).add_child_classifier() - - @pytest.mark.parametrize("parent_classifier", ["private", "Private", "PrIvAtE"]) - def test_add_private_parent_classifier(self, db_request, parent_classifier): - db_request.params = {"parent": f"{parent_classifier} :: Do Not Upload"} - db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) - db_request.route_path = lambda *a: "/the/path" - - with pytest.raises(sqlalchemy.exc.IntegrityError): - views.AddClassifier(db_request).add_parent_classifier() - - -class TestDeprecateClassifier: - def test_deprecate_classifier(self, db_request): - classifier = ClassifierFactory(deprecated=False) - - db_request.params = {"classifier_id": classifier.id} - db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None) - db_request.route_path = lambda *a: "/the/path" - - views.deprecate_classifier(db_request) - db_request.db.flush() - - assert classifier.deprecated diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index d3e8543f23ef..abe3dfa0d370 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -26,7 +26,9 @@ import requests from pyramid.httpexceptions import HTTPBadRequest, HTTPForbidden +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import joinedload +from trove_classifiers import classifiers from webob.multidict import MultiDict from wtforms.form import Form from wtforms.validators import ValidationError @@ -328,25 +330,42 @@ def test_validate_description_content_type_invalid(self, data): legacy._validate_description_content_type(form, field) def test_validate_no_deprecated_classifiers_valid(self, db_request): - valid_classifier = ClassifierFactory(deprecated=False) - validator = legacy._no_deprecated_classifiers(db_request) + valid_classifier = ClassifierFactory(classifier="AA :: BB") form = pretend.stub() field = pretend.stub(data=[valid_classifier.classifier]) - validator(form, field) + legacy._validate_no_deprecated_classifiers(form, field) - def test_validate_no_deprecated_classifiers_invalid(self, db_request): - deprecated_classifier = ClassifierFactory(classifier="AA: BB", deprecated=True) - validator = legacy._no_deprecated_classifiers(db_request) - db_request.registry = pretend.stub(settings={"warehouse.domain": "host"}) - db_request.route_url = pretend.call_recorder(lambda *a, **kw: "/url") + @pytest.mark.parametrize( + "deprecated_classifiers", [({"AA :: BB": []}), ({"AA :: BB": ["CC :: DD"]})] + ) + def test_validate_no_deprecated_classifiers_invalid( + self, db_request, deprecated_classifiers, monkeypatch + ): + monkeypatch.setattr(legacy, "deprecated_classifiers", deprecated_classifiers) + + form = pretend.stub() + field = pretend.stub(data=["AA :: BB"]) + + with pytest.raises(ValidationError): + legacy._validate_no_deprecated_classifiers(form, field) + + def test_validate_classifiers_valid(self, db_request, monkeypatch): + monkeypatch.setattr(legacy, "classifiers", {"AA :: BB"}) + + form = pretend.stub() + field = pretend.stub(data=["AA :: BB"]) + legacy._validate_classifiers(form, field) + + @pytest.mark.parametrize("data", [(["AA :: BB"]), (["AA :: BB", "CC :: DD"])]) + def test_validate_classifiers_invalid(self, db_request, data): form = pretend.stub() - field = pretend.stub(data=[deprecated_classifier.classifier]) + field = pretend.stub(data=data) with pytest.raises(ValidationError): - validator(form, field) + legacy._validate_classifiers(form, field) def test_construct_dependencies(): @@ -1639,7 +1658,7 @@ def test_upload_fails_with_invalid_classifier(self, pyramid_config, db_request): ), } ) - db_request.POST.extend([("classifiers", "Environment :: Other Environment")]) + db_request.POST.extend([("classifiers", "Invalid :: Classifier")]) with pytest.raises(HTTPBadRequest) as excinfo: legacy.file_upload(db_request) @@ -1648,12 +1667,29 @@ def test_upload_fails_with_invalid_classifier(self, pyramid_config, db_request): assert resp.status_code == 400 assert resp.status == ( - "400 Invalid value for classifiers. " - "Error: 'Environment :: Other Environment' is not a valid choice " - "for this field" + "400 Invalid value for classifiers. Error: Classifier 'Invalid :: " + "Classifier' is not a valid classifier." ) - def test_upload_fails_with_deprecated_classifier(self, pyramid_config, db_request): + @pytest.mark.parametrize( + "deprecated_classifiers, expected", + [ + ( + {"AA :: BB": ["CC :: DD"]}, + "400 Invalid value for classifiers. Error: Classifier 'AA :: " + "BB' has been deprecated, use the following classifier(s) " + "instead: ['CC :: DD']", + ), + ( + {"AA :: BB": []}, + "400 Invalid value for classifiers. Error: Classifier 'AA :: " + "BB' has been deprecated.", + ), + ], + ) + def test_upload_fails_with_deprecated_classifier( + self, pyramid_config, db_request, monkeypatch, deprecated_classifiers, expected + ): pyramid_config.testing_securitypolicy(userid=1) user = UserFactory.create() @@ -1662,7 +1698,9 @@ def test_upload_fails_with_deprecated_classifier(self, pyramid_config, db_reques project = ProjectFactory.create() release = ReleaseFactory.create(project=project, version="1.0") RoleFactory.create(user=user, project=project) - classifier = ClassifierFactory(classifier="AA :: BB", deprecated=True) + classifier = ClassifierFactory(classifier="AA :: BB") + + monkeypatch.setattr(legacy, "deprecated_classifiers", deprecated_classifiers) filename = "{}-{}.tar.gz".format(project.name, release.version) @@ -1689,11 +1727,7 @@ def test_upload_fails_with_deprecated_classifier(self, pyramid_config, db_reques resp = excinfo.value assert resp.status_code == 400 - assert resp.status == ( - "400 Invalid value for classifiers. " - "Error: Classifier 'AA :: BB' has been deprecated, see /url " - "for a list of valid classifiers." - ) + assert resp.status == expected @pytest.mark.parametrize( "digests", @@ -2786,6 +2820,91 @@ def test_upload_succeeds_creates_release(self, pyramid_config, db_request, metri ), ] + def test_upload_succeeds_creates_classifier( + self, pyramid_config, db_request, metrics, monkeypatch + ): + pyramid_config.testing_securitypolicy(userid=1) + + user = UserFactory.create() + EmailFactory.create(user=user) + project = ProjectFactory.create() + RoleFactory.create(user=user, project=project) + + monkeypatch.setattr(legacy, "classifiers", {"AA :: BB", "CC :: DD"}) + + db_request.db.add(Classifier(classifier="AA :: BB")) + + filename = "{}-{}.tar.gz".format(project.name, "1.0") + + db_request.user = user + db_request.remote_addr = "10.10.10.20" + db_request.user_agent = "warehouse-tests/6.6.6" + db_request.POST = MultiDict( + { + "metadata_version": "1.2", + "name": project.name, + "version": "1.0", + "summary": "This is my summary!", + "filetype": "sdist", + "md5_digest": _TAR_GZ_PKG_MD5, + "content": pretend.stub( + filename=filename, + file=io.BytesIO(_TAR_GZ_PKG_TESTDATA), + type="application/tar", + ), + } + ) + db_request.POST.extend( + [ + ("classifiers", "AA :: BB"), + ("classifiers", "CC :: DD"), + ("requires_dist", "foo"), + ("requires_dist", "bar (>1.0)"), + ("project_urls", "Test, https://example.com/"), + ("requires_external", "Cheese (>1.0)"), + ("provides", "testing"), + ] + ) + + storage_service = pretend.stub(store=lambda path, filepath, meta: None) + db_request.find_service = lambda svc, name=None, context=None: { + IFileStorage: storage_service, + IMetricsService: metrics, + }.get(svc) + + resp = legacy.file_upload(db_request) + + assert resp.status_code == 200 + + # Ensure that a new Classifier has been created + classifier = ( + db_request.db.query(Classifier) + .filter(Classifier.classifier == "CC :: DD") + .one() + ) + assert classifier.classifier == "CC :: DD" + + # Ensure that the Release has the new classifier + release = ( + db_request.db.query(Release) + .filter((Release.project == project) & (Release.version == "1.0")) + .one() + ) + assert release.classifiers == ["AA :: BB", "CC :: DD"] + + def test_all_valid_classifiers_can_be_created(self, db_request): + for classifier in classifiers: + db_request.db.add(Classifier(classifier=classifier)) + db_request.db.commit() + + @pytest.mark.parametrize( + "parent_classifier", ["private", "Private", "PrIvAtE"], + ) + def test_private_classifiers_cannot_be_created(self, db_request, parent_classifier): + with pytest.raises(IntegrityError): + db_request.db.add(Classifier(classifier=f"{parent_classifier} :: Foo")) + db_request.db.commit() + def test_equivalent_version_one_release(self, pyramid_config, db_request, metrics): """ Test that if a release with a version like '1.0' exists, that a future diff --git a/tests/unit/legacy/api/test_pypi.py b/tests/unit/legacy/api/test_pypi.py index 9082b9a33cf5..429daa77227f 100644 --- a/tests/unit/legacy/api/test_pypi.py +++ b/tests/unit/legacy/api/test_pypi.py @@ -14,6 +14,7 @@ import pytest from pyramid.httpexceptions import HTTPBadRequest, HTTPMovedPermanently, HTTPNotFound +from trove_classifiers import classifiers from warehouse.legacy.api import pypi @@ -67,14 +68,10 @@ def test_forbidden_legacy(): def test_list_classifiers(db_request): - ClassifierFactory.create(classifier="foo :: bar") - ClassifierFactory.create(classifier="foo :: baz") - ClassifierFactory.create(classifier="fiz :: buz") - resp = pypi.list_classifiers(db_request) assert resp.status_code == 200 - assert resp.text == "fiz :: buz\nfoo :: bar\nfoo :: baz" + assert resp.text == "\n".join(sorted(classifiers)) def test_search(): diff --git a/tests/unit/test_views.py b/tests/unit/test_views.py index f03729e88eaf..ff0a9af04e1e 100644 --- a/tests/unit/test_views.py +++ b/tests/unit/test_views.py @@ -22,11 +22,11 @@ HTTPSeeOther, HTTPServiceUnavailable, ) +from trove_classifiers import classifiers from webob.multidict import MultiDict from warehouse import views from warehouse.views import ( - classifiers, current_user_indicator, flash_messages, forbidden, @@ -35,6 +35,7 @@ health, httpexception_view, index, + list_classifiers, locale, opensearchxml, robotstxt, @@ -424,12 +425,7 @@ def raiser(*args, **kwargs): def test_classifiers(db_request): - classifier_a = ClassifierFactory(classifier="I am first") - classifier_b = ClassifierFactory(classifier="I am last") - - assert classifiers(db_request) == { - "classifiers": [(classifier_a.classifier,), (classifier_b.classifier,)] - } + assert list_classifiers(db_request) == {"classifiers": sorted(classifiers)} def test_stats(db_request): diff --git a/warehouse/admin/routes.py b/warehouse/admin/routes.py index 67e6ee495325..f9ed9d7fb1e3 100644 --- a/warehouse/admin/routes.py +++ b/warehouse/admin/routes.py @@ -99,15 +99,6 @@ def includeme(config): # Journal related Admin pages config.add_route("admin.journals.list", "/admin/journals/", domain=warehouse) - # Classifier related Admin pages - config.add_route("admin.classifiers", "/admin/classifiers/", domain=warehouse) - config.add_route( - "admin.classifiers.add", "/admin/classifiers/add/", domain=warehouse - ) - config.add_route( - "admin.classifiers.deprecate", "/admin/classifiers/deprecate/", domain=warehouse - ) - # Blacklist related Admin pages config.add_route("admin.blacklist.list", "/admin/blacklist/", domain=warehouse) config.add_route("admin.blacklist.add", "/admin/blacklist/add/", domain=warehouse) diff --git a/warehouse/admin/static/js/controllers/child_classifier_controller.js b/warehouse/admin/static/js/controllers/child_classifier_controller.js deleted file mode 100644 index 7cc8a07f881b..000000000000 --- a/warehouse/admin/static/js/controllers/child_classifier_controller.js +++ /dev/null @@ -1,36 +0,0 @@ -/** - * 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. - */ - - -import { Controller } from "stimulus"; - -export default class extends Controller { - static targets = [ - "parent", "input", "preview", "submit", - ] - - update() { - // Set the preview - this.previewTarget.innerHTML = [ - this.parentTarget.options[this.parentTarget.selectedIndex].text, - this.inputTarget.value, - ].join(" :: "); - - // Enable the input target - this.inputTarget.disabled = !this.parentTarget.value; - - // Enable the submit button - this.submitTarget.disabled = !(this.parentTarget.value && this.inputTarget.value); - } -} diff --git a/warehouse/admin/static/js/controllers/parent_classifier_controller.js b/warehouse/admin/static/js/controllers/parent_classifier_controller.js deleted file mode 100644 index dfd3cad456bd..000000000000 --- a/warehouse/admin/static/js/controllers/parent_classifier_controller.js +++ /dev/null @@ -1,42 +0,0 @@ -/** - * 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. - */ - - -import { Controller } from "stimulus"; - -export default class extends Controller { - static targets = ["input", "preview", "submit"] - - update() { - // Set the preview - this.previewTarget.innerHTML = this.inputTarget.value; - - // Classifier is made up of words which can contain a-z, A-Z, 0-9, - // underscore, hyphen, period, octothorp, plus, or parentheses - var word_sub = "[\\w.\\(\\)\\+#-]"; - - // Words can be repeated one or more times, separated by a space - var words_sub = `${word_sub}+(\\s${word_sub}*)*`; - - // Classifer must have two parts, separated by a ' :: ' - var classifier_re = new RegExp(`^${words_sub} :: ${words_sub}$`); - - if (classifier_re.test(this.inputTarget.value)) { - // Enable the submit button - this.submitTarget.disabled = false; - } else { - this.submitTarget.disabled = true; - } - } -} diff --git a/warehouse/admin/static/js/warehouse.js b/warehouse/admin/static/js/warehouse.js index acd0c11943e3..1374a3e98450 100644 --- a/warehouse/admin/static/js/warehouse.js +++ b/warehouse/admin/static/js/warehouse.js @@ -11,10 +11,6 @@ * limitations under the License. */ -// Import stimulus -import { Application } from "stimulus"; -import { definitionsFromContext } from "stimulus/webpack-helpers"; - document.querySelectorAll("a[data-form-submit]").forEach(function (element) { element.addEventListener("click", function(event) { // We're turning this element into a form submission, so instead of the @@ -25,7 +21,3 @@ document.querySelectorAll("a[data-form-submit]").forEach(function (element) { document.querySelector("form#" + element.dataset.formSubmit).submit(); }); }); - -const application = Application.start(); -const context = require.context("./controllers", true, /\.js$/); -application.load(definitionsFromContext(context)); diff --git a/warehouse/admin/templates/admin/base.html b/warehouse/admin/templates/admin/base.html index 18be097d6576..3049334de164 100644 --- a/warehouse/admin/templates/admin/base.html +++ b/warehouse/admin/templates/admin/base.html @@ -100,11 +100,6 @@ Journals -