Skip to content

Commit 40a982e

Browse files
mblaymandstufft
authored andcommitted
Fail to upload package when description rendering is invalid. (#3980)
1 parent 27cd530 commit 40a982e

File tree

5 files changed

+114
-3
lines changed

5 files changed

+114
-3
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ warehouse/static/dist
2929
warehouse/admin/static/dist
3030

3131
tags
32+
*.sw*

tests/unit/forklift/test_legacy.py

+59
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,64 @@ def test_fails_with_invalid_names(self, pyramid_config, db_request, name):
919919
"for more information."
920920
).format(name)
921921

922+
@pytest.mark.parametrize(
923+
("description_content_type", "description", "message"),
924+
[
925+
(
926+
"text/x-rst",
927+
".. invalid-directive::",
928+
"400 The description failed to render for 'text/x-rst'. "
929+
"See /the/help/url/ for more information.",
930+
),
931+
(
932+
"",
933+
".. invalid-directive::",
934+
"400 The description failed to render in the default format "
935+
"of reStructuredText. "
936+
"See /the/help/url/ for more information.",
937+
),
938+
],
939+
)
940+
def test_fails_invalid_render(
941+
self, pyramid_config, db_request, description_content_type, description, message
942+
):
943+
pyramid_config.testing_securitypolicy(userid=1)
944+
user = UserFactory.create()
945+
EmailFactory.create(user=user)
946+
db_request.user = user
947+
db_request.remote_addr = "10.10.10.30"
948+
949+
db_request.POST = MultiDict(
950+
{
951+
"metadata_version": "1.2",
952+
"name": "example",
953+
"version": "1.0",
954+
"filetype": "sdist",
955+
"md5_digest": "a fake md5 digest",
956+
"content": pretend.stub(
957+
filename="example-1.0.tar.gz",
958+
file=io.BytesIO(b"A fake file."),
959+
type="application/tar",
960+
),
961+
"description_content_type": description_content_type,
962+
"description": description,
963+
}
964+
)
965+
966+
db_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/")
967+
968+
with pytest.raises(HTTPBadRequest) as excinfo:
969+
legacy.file_upload(db_request)
970+
971+
resp = excinfo.value
972+
973+
assert db_request.help_url.calls == [
974+
pretend.call(_anchor="description-content-type")
975+
]
976+
977+
assert resp.status_code == 400
978+
assert resp.status == message
979+
922980
@pytest.mark.parametrize(
923981
"name",
924982
[
@@ -1158,6 +1216,7 @@ def test_successful_upload(
11581216
"filetype": "sdist",
11591217
"pyversion": "source",
11601218
"content": content,
1219+
"description": "an example description",
11611220
}
11621221
)
11631222
db_request.POST.extend([("classifiers", "Environment :: Other Environment")])

warehouse/forklift/legacy.py

+28-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@
5252
JournalEntry,
5353
BlacklistedProject,
5454
)
55-
from warehouse.utils import http
55+
from warehouse.utils import http, readme
5656

5757

5858
MAX_FILESIZE = 60 * 1024 * 1024 # 60M
@@ -891,6 +891,33 @@ def file_upload(request):
891891
),
892892
)
893893

894+
# Uploading should prevent broken rendered descriptions.
895+
if form.description.data:
896+
description_content_type = form.description_content_type.data
897+
if not description_content_type:
898+
description_content_type = "text/x-rst"
899+
rendered = readme.render(
900+
form.description.data, description_content_type, use_fallback=False
901+
)
902+
if rendered is None:
903+
if form.description_content_type.data:
904+
message = (
905+
"The description failed to render "
906+
"for '{description_content_type}'."
907+
).format(description_content_type=description_content_type)
908+
else:
909+
message = (
910+
"The description failed to render "
911+
"in the default format of reStructuredText."
912+
)
913+
raise _exc_with_message(
914+
HTTPBadRequest,
915+
"{message} See {projecthelp} for more information.".format(
916+
message=message,
917+
projecthelp=request.help_url(_anchor="description-content-type"),
918+
),
919+
) from None
920+
894921
try:
895922
canonical_version = packaging.utils.canonicalize_version(form.version.data)
896923
release = (

warehouse/templates/pages/help.html

+23
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
{% macro file_name_reuse() %}Why am I getting a "Filename or contents already exists" or "Filename has been previously used" error?{% endmacro %}
4040
{% macro project_name() %}Why isn't my desired project name available?{% endmacro %}
4141
{% macro project_name_claim() %}How do I claim an abandoned or previously registered project name?{% endmacro %}
42+
{% macro description_content_type() %}How can I upload a description in a different format?{% endmacro %}
4243
{% macro new_classifier() %}How do I request a new trove classifier?{% endmacro %}
4344
{% macro feedback() %}Where can I report a bug or provide feedback?{% endmacro %}
4445
{% macro maintainers() %}Who maintains PyPI?{% endmacro %}
@@ -84,6 +85,7 @@ <h2><a href="#problems">Problems</a></h2>
8485
<li><a href="#private-indices">{{ private_indices() }}</a></li>
8586
<li><a href="#project-name">{{ project_name() }}</a></li>
8687
<li><a href="#project-name-claim">{{ project_name_claim() }}</a></li>
88+
<li><a href="#description-content-type">{{ description_content_type() }}</a></li>
8789
<li><a href="#file-size-limit">{{ file_size_limit() }}</a></li>
8890
<li><a href="#admin-intervention">{{ admin_intervention() }}</a></li>
8991
<li><a href="#file-name-reuse">{{ file_name_reuse() }}</a></li>
@@ -247,6 +249,27 @@ <h3 id="project-name-claim">{{ project_name_claim() }}</h3>
247249
PEP 541 has been accepted, and PyPI is <a href="https://github.com/pypa/warehouse/issues/1506">creating a workflow</a> which will be documented here.
248250
</p>
249251

252+
<h3 id="description-content-type">{{ description_content_type() }}</h3>
253+
<p>
254+
By default,
255+
an upload's description will render
256+
with <a href="http://docutils.sourceforge.net/rst.html">reStructuredText</a>.
257+
If the description is in an alternate format
258+
like Markdown,
259+
a package may set the <tt>long_description_content_type</tt>
260+
in <tt>setup.py</tt>
261+
to the alternate format.
262+
Refer to the <a href="https://packaging.python.org/tutorials/distributing-packages/#description">Python Packaging User Guide</a>
263+
for details on the available formats.
264+
</p>
265+
<p>
266+
PyPI will reject uploads
267+
if the description fails to render.
268+
To check a description locally for validity,
269+
you may use <a href="https://github.com/pypa/readme_renderer">readme_renderer</a>,
270+
which is the same description renderer used by PyPI.
271+
</p>
272+
250273
<h3 id="file-size-limit">{{ file_size_limit() }}</h3>
251274
<p>
252275
If you can't upload your project's release to PyPI because you're hitting the upload file size limit, we can sometimes increase your limit. Make sure you've uploaded at least one release for the project that's <i>under</i> the limit (a <a href="https://www.python.org/dev/peps/pep-0440/#developmental-releases">developmental release version number</a> is fine). Then, <a href="https://github.com/pypa/warehouse/issues/new" target="_blank" rel="noopener">file an issue</a> and tell us:</p>

warehouse/utils/readme.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
}
3030

3131

32-
def render(value, content_type=None):
32+
def render(value, content_type=None, use_fallback=True):
3333
if value is None:
3434
return value
3535

@@ -45,7 +45,8 @@ def render(value, content_type=None):
4545
# If the content was not rendered, we'll render as plaintext instead. The
4646
# reason it's necessary to do this instead of just accepting plaintext is
4747
# that readme_renderer will deal with sanitizing the content.
48-
if rendered is None:
48+
# Skip the fallback option when validating that rendered output is ok.
49+
if use_fallback and rendered is None:
4950
rendered = readme_renderer.txt.render(value)
5051

5152
return rendered

0 commit comments

Comments
 (0)