Skip to content

Commit d826924

Browse files
authored
Deprecated classifiers (#3771)
* Add 'deprecated' column to Classifier * Get all classifiers, filter in template * Add UI to deprecate classifiers * Indicate deprecated classifiers in admin UI * Don't show deprecated classifiers in list_classifiers * Don't show deprecated classifiers in search tree * Add a page with a list of classifiers * Add copy to clipboard link * Validate deprecated classifiers on upload
1 parent e26ec6c commit d826924

File tree

16 files changed

+303
-31
lines changed

16 files changed

+303
-31
lines changed

tests/unit/admin/test_routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ def test_includeme():
101101
'/admin/classifiers/add/',
102102
domain=warehouse,
103103
),
104+
pretend.call(
105+
'admin.classifiers.deprecate',
106+
'/admin/classifiers/deprecate/',
107+
domain=warehouse,
108+
),
104109
pretend.call(
105110
"admin.blacklist.list",
106111
"/admin/blacklist/",

tests/unit/admin/views/test_classifiers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,8 @@
2222
class TestGetClassifiers:
2323

2424
def test_get_classifiers(self, db_request):
25-
classifier_a = ClassifierFactory(l5=0, classifier='I am first')
26-
classifier_b = ClassifierFactory(l5=0, classifier='I am last')
27-
ClassifierFactory(l5=1) # Ignored because it has a nonzero L5
25+
classifier_a = ClassifierFactory(classifier='I am first')
26+
classifier_b = ClassifierFactory(classifier='I am last')
2827

2928
assert views.get_classifiers(db_request) == {
3029
'classifiers': [classifier_a, classifier_b],
@@ -89,3 +88,18 @@ def test_add_parent_classifier(self, db_request):
8988
assert new.l3 == 0
9089
assert new.l4 == 0
9190
assert new.l5 == 0
91+
92+
93+
class TestDeprecateClassifier:
94+
95+
def test_deprecate_classifier(self, db_request):
96+
classifier = ClassifierFactory(deprecated=False)
97+
98+
db_request.params = {'classifier_id': classifier.id}
99+
db_request.session.flash = pretend.call_recorder(lambda *a, **kw: None)
100+
db_request.route_path = lambda *a: '/the/path'
101+
102+
views.deprecate_classifier(db_request)
103+
db_request.db.flush()
104+
105+
assert classifier.deprecated

tests/unit/forklift/test_legacy.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from warehouse.admin.flags import AdminFlag
4040

4141
from ...common.db.accounts import UserFactory, EmailFactory
42+
from ...common.db.classifiers import ClassifierFactory
4243
from ...common.db.packaging import (
4344
ProjectFactory, ReleaseFactory, FileFactory, RoleFactory,
4445
)
@@ -326,6 +327,31 @@ def test_validate_description_content_type_invalid(self, data):
326327
with pytest.raises(ValidationError):
327328
legacy._validate_description_content_type(form, field)
328329

330+
def test_validate_no_deprecated_classifiers_valid(self, db_request):
331+
valid_classifier = ClassifierFactory(deprecated=False)
332+
validator = legacy._no_deprecated_classifiers(db_request)
333+
334+
form = pretend.stub()
335+
field = pretend.stub(data=[valid_classifier.classifier])
336+
337+
validator(form, field)
338+
339+
def test_validate_no_deprecated_classifiers_invalid(self, db_request):
340+
deprecated_classifier = ClassifierFactory(
341+
classifier='AA: BB', deprecated=True,
342+
)
343+
validator = legacy._no_deprecated_classifiers(db_request)
344+
db_request.registry = pretend.stub(
345+
settings={'warehouse.domain': 'host'}
346+
)
347+
db_request.route_url = pretend.call_recorder(lambda *a, **kw: '/url')
348+
349+
form = pretend.stub()
350+
field = pretend.stub(data=[deprecated_classifier.classifier])
351+
352+
with pytest.raises(ValidationError):
353+
validator(form, field)
354+
329355

330356
def test_construct_dependencies():
331357
types = {
@@ -1522,6 +1548,48 @@ def test_upload_fails_with_invalid_classifier(self, pyramid_config,
15221548
"for this field"
15231549
)
15241550

1551+
def test_upload_fails_with_deprecated_classifier(
1552+
self, pyramid_config, db_request):
1553+
pyramid_config.testing_securitypolicy(userid=1)
1554+
1555+
user = UserFactory.create()
1556+
EmailFactory.create(user=user)
1557+
project = ProjectFactory.create()
1558+
release = ReleaseFactory.create(project=project, version="1.0")
1559+
RoleFactory.create(user=user, project=project)
1560+
classifier = ClassifierFactory(classifier='AA :: BB', deprecated=True)
1561+
1562+
filename = "{}-{}.tar.gz".format(project.name, release.version)
1563+
1564+
db_request.POST = MultiDict({
1565+
"metadata_version": "1.2",
1566+
"name": project.name,
1567+
"version": release.version,
1568+
"filetype": "sdist",
1569+
"md5_digest": "335c476dc930b959dda9ec82bd65ef19",
1570+
"content": pretend.stub(
1571+
filename=filename,
1572+
file=io.BytesIO(b"A fake file."),
1573+
type="application/tar",
1574+
),
1575+
})
1576+
db_request.POST.extend([
1577+
("classifiers", classifier.classifier),
1578+
])
1579+
db_request.route_url = pretend.call_recorder(lambda *a, **kw: '/url')
1580+
1581+
with pytest.raises(HTTPBadRequest) as excinfo:
1582+
legacy.file_upload(db_request)
1583+
1584+
resp = excinfo.value
1585+
1586+
assert resp.status_code == 400
1587+
assert resp.status == (
1588+
"400 Invalid value for classifiers. "
1589+
"Error: Classifier 'AA :: BB' has been deprecated, see /url "
1590+
"for a list of valid classifiers."
1591+
)
1592+
15251593
@pytest.mark.parametrize(
15261594
"digests",
15271595
[

tests/unit/test_routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def add_policy(name, filename):
114114
traverse="/{username}",
115115
domain=warehouse,
116116
),
117+
pretend.call("classifiers", "/classifiers/", domain=warehouse),
117118
pretend.call("search", "/search/", domain=warehouse),
118119
pretend.call(
119120
"accounts.profile",

tests/unit/test_views.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323

2424
from warehouse import views
2525
from warehouse.views import (
26-
SEARCH_BOOSTS, SEARCH_FIELDS, current_user_indicator, forbidden, health,
27-
httpexception_view, index, robotstxt, opensearchxml, search, force_status,
28-
flash_messages, forbidden_include
26+
SEARCH_BOOSTS, SEARCH_FIELDS, classifiers, current_user_indicator,
27+
forbidden, health, httpexception_view, index, robotstxt, opensearchxml,
28+
search, force_status, flash_messages, forbidden_include
2929
)
3030

3131
from ..common.db.accounts import UserFactory
@@ -545,6 +545,15 @@ def test_raises_400_with_pagenum_type_str(self, monkeypatch, db_request):
545545
assert page_cls.calls == []
546546

547547

548+
def test_classifiers(db_request):
549+
classifier_a = ClassifierFactory(classifier='I am first')
550+
classifier_b = ClassifierFactory(classifier='I am last')
551+
552+
assert classifiers(db_request) == {
553+
'classifiers': [(classifier_a.classifier,), (classifier_b.classifier,)]
554+
}
555+
556+
548557
def test_health():
549558
request = pretend.stub(
550559
db=pretend.stub(

warehouse/admin/routes.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def includeme(config):
102102
'/admin/classifiers/add/',
103103
domain=warehouse,
104104
)
105+
config.add_route(
106+
'admin.classifiers.deprecate',
107+
'/admin/classifiers/deprecate/',
108+
domain=warehouse,
109+
)
105110

106111
# Blacklist related Admin pages
107112
config.add_route(

warehouse/admin/templates/admin/classifiers/index.html

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ <h3 class="box-title">Add Sub-Classifier</h3>
5656
<select name="parent_id" data-target="child-classifier.parent" data-action="child-classifier#update">
5757
<option disabled selected>Select one</option>
5858
{% for classifier in classifiers %}
59-
<option value="{{ classifier.id }}">{{ classifier.classifier }}</option>
59+
{% if classifier.l5 == 0 %}
60+
{# Only show classifiers that can be parent classifiers #}
61+
<option value="{{ classifier.id }}" {{'disabled' if classifier.deprecated else ''}}>
62+
{{ classifier.classifier }}{{ ' (Deprecated)' if classifier.deprecated else ''}}
63+
</option>
64+
{% endif %}
6065
{% endfor %}
6166
</select>
6267
<label for="child">Child:</label>
@@ -73,27 +78,30 @@ <h3 class="box-title">Add Sub-Classifier</h3>
7378
</form>
7479
</div>
7580

76-
{#
7781
<div class="box box-primary">
7882
<div class="box-header with-border">
7983
<h3 class="box-title">Deprecate Classifier</h3>
8084
</div>
81-
<div class="box-body">
82-
<select>
83-
<option disabled selected>Select one</option>
84-
{% for classifier in classifiers %}
85-
<option value="{{ classifier.id }}">{{ classifier.classifier }}</option>
86-
{% endfor %}
87-
</select>
88-
</div>
89-
<div class="box-footer">
90-
Deprecate classifier
91-
<code></code>?
92-
<div class="pull-right">
93-
<button type="submit" class="btn btn-primary">Deprecate</button>
85+
<form method="POST" action="{{ request.route_path('admin.classifiers.deprecate') }}">
86+
<input name="csrf_token" type="hidden" value="{{ request.session.get_csrf_token() }}">
87+
<div class="box-body">
88+
<div class="form-group col-sm-4">
89+
<label for="parent_id">Classifier:</label>
90+
<select name="classifier_id">
91+
<option disabled selected>Select one</option>
92+
{% for classifier in classifiers %}
93+
<option value="{{ classifier.id }}" {{'disabled' if classifier.deprecated else ''}}>
94+
{{ classifier.classifier }}{{ ' (Deprecated)' if classifier.deprecated else ''}}
95+
</option>
96+
{% endfor %}
97+
</select>
98+
</div>
9499
</div>
95-
</div>
100+
<div class="box-footer">
101+
<div class="pull-right">
102+
<button type="submit" class="btn btn-primary">Deprecate</button>
103+
</div>
104+
</div>
105+
</form>
96106
</div>
97-
#}
98-
99107
{% endblock content %}

warehouse/admin/views/classifiers.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
def get_classifiers(request):
2626
classifiers = (
2727
request.db.query(Classifier)
28-
.filter(Classifier.l5 == 0) # Can be a parent
2928
.order_by(Classifier.classifier)
3029
.all()
3130
)
@@ -97,3 +96,28 @@ def add_child_classifier(self):
9796
)
9897

9998
return HTTPSeeOther(self.request.route_path('admin.classifiers'))
99+
100+
101+
@view_config(
102+
route_name='admin.classifiers.deprecate',
103+
permission='admin',
104+
request_method='POST',
105+
uses_session=True,
106+
require_methods=False,
107+
require_csrf=True,
108+
)
109+
def deprecate_classifier(request):
110+
classifier = (
111+
request.db
112+
.query(Classifier)
113+
.get(request.params.get('classifier_id'))
114+
)
115+
116+
classifier.deprecated = True
117+
118+
request.session.flash(
119+
f'Successfully deprecated classifier {classifier.classifier!r}',
120+
queue='success',
121+
)
122+
123+
return HTTPSeeOther(request.route_path('admin.classifiers'))

warehouse/classifiers/models.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
# See the License for the specific language governing permissions and
1111
# limitations under the License.
1212

13-
from sqlalchemy import Column, Index, Integer, Text
13+
from sqlalchemy import Boolean, Column, Index, Integer, Text, sql
1414

1515
from warehouse import db
1616
from warehouse.utils.attrs import make_repr
@@ -28,6 +28,7 @@ class Classifier(db.ModelBase):
2828

2929
id = Column(Integer, primary_key=True, nullable=False)
3030
classifier = Column(Text, unique=True)
31+
deprecated = Column(Boolean, nullable=False, server_default=sql.false())
3132
l2 = Column(Integer)
3233
l3 = Column(Integer)
3334
l4 = Column(Integer)

warehouse/forklift/legacy.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -696,6 +696,32 @@ def _is_duplicate_file(db_session, filename, hashes):
696696
return None
697697

698698

699+
def _no_deprecated_classifiers(request):
700+
deprecated_classifiers = {
701+
classifier.classifier
702+
for classifier in (
703+
request.db.query(Classifier.classifier)
704+
.filter(Classifier.deprecated.is_(True))
705+
.all()
706+
)
707+
}
708+
709+
def validate_no_deprecated_classifiers(form, field):
710+
invalid_classifiers = set(field.data or []) & deprecated_classifiers
711+
if invalid_classifiers:
712+
first_invalid_classifier = sorted(invalid_classifiers)[0]
713+
host = request.registry.settings.get("warehouse.domain")
714+
classifiers_url = request.route_url('classifiers', _host=host)
715+
716+
raise wtforms.validators.ValidationError(
717+
f'Classifier {first_invalid_classifier!r} has been '
718+
f'deprecated, see {classifiers_url} for a list of valid '
719+
'classifiers.'
720+
)
721+
722+
return validate_no_deprecated_classifiers
723+
724+
699725
@view_config(
700726
route_name="forklift.legacy.file_upload",
701727
uses_session=True,
@@ -757,6 +783,9 @@ def file_upload(request):
757783
# Validate and process the incoming metadata.
758784
form = MetadataForm(request.POST)
759785

786+
# Add a validator for deprecated classifiers
787+
form.classifiers.validators.append(_no_deprecated_classifiers(request))
788+
760789
form.classifiers.choices = [
761790
(c.classifier, c.classifier) for c in all_classifiers
762791
]

warehouse/legacy/api/pypi.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ def forbidden_legacy(exc, request):
8181
def list_classifiers(request):
8282
classifiers = (
8383
request.db.query(Classifier.classifier)
84-
.order_by(Classifier.classifier)
85-
.all()
84+
.filter(Classifier.deprecated.is_(False))
85+
.order_by(Classifier.classifier)
86+
.all()
8687
)
8788

8889
return Response(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# Licensed under the Apache License, Version 2.0 (the "License");
2+
# you may not use this file except in compliance with the License.
3+
# You may obtain a copy of the License at
4+
#
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
#
7+
# Unless required by applicable law or agreed to in writing, software
8+
# distributed under the License is distributed on an "AS IS" BASIS,
9+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10+
# See the License for the specific language governing permissions and
11+
# limitations under the License.
12+
"""
13+
Add 'deprecated' column to classifiers
14+
15+
Revision ID: e0ca60b6a30b
16+
Revises: 6714f3f04f0f
17+
Create Date: 2018-04-18 23:24:13.009357
18+
"""
19+
20+
from alembic import op
21+
import sqlalchemy as sa
22+
23+
24+
revision = "e0ca60b6a30b"
25+
down_revision = "6714f3f04f0f"
26+
27+
28+
def upgrade():
29+
op.add_column(
30+
"trove_classifiers",
31+
sa.Column(
32+
"deprecated",
33+
sa.Boolean(),
34+
server_default=sa.text("false"),
35+
nullable=False,
36+
),
37+
)
38+
39+
40+
def downgrade():
41+
op.drop_column("trove_classifiers", "deprecated")

0 commit comments

Comments
 (0)