diff --git a/.gitignore b/.gitignore index 45d95a43ff86..6f7dd4e351d2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ warehouse/static/dist warehouse/admin/static/dist tags +*.sw* diff --git a/tests/unit/forklift/test_legacy.py b/tests/unit/forklift/test_legacy.py index e2f378656e32..a567f0161f8b 100644 --- a/tests/unit/forklift/test_legacy.py +++ b/tests/unit/forklift/test_legacy.py @@ -919,6 +919,64 @@ def test_fails_with_invalid_names(self, pyramid_config, db_request, name): "for more information." ).format(name) + @pytest.mark.parametrize( + ("description_content_type", "description", "message"), + [ + ( + "text/x-rst", + ".. invalid-directive::", + "400 The description failed to render for 'text/x-rst'. " + "See /the/help/url/ for more information.", + ), + ( + "", + ".. invalid-directive::", + "400 The description failed to render in the default format " + "of reStructuredText. " + "See /the/help/url/ for more information.", + ), + ], + ) + def test_fails_invalid_render( + self, pyramid_config, db_request, description_content_type, description, message + ): + pyramid_config.testing_securitypolicy(userid=1) + user = UserFactory.create() + EmailFactory.create(user=user) + db_request.user = user + db_request.remote_addr = "10.10.10.30" + + db_request.POST = MultiDict( + { + "metadata_version": "1.2", + "name": "example", + "version": "1.0", + "filetype": "sdist", + "md5_digest": "a fake md5 digest", + "content": pretend.stub( + filename="example-1.0.tar.gz", + file=io.BytesIO(b"A fake file."), + type="application/tar", + ), + "description_content_type": description_content_type, + "description": description, + } + ) + + db_request.help_url = pretend.call_recorder(lambda **kw: "/the/help/url/") + + with pytest.raises(HTTPBadRequest) as excinfo: + legacy.file_upload(db_request) + + resp = excinfo.value + + assert db_request.help_url.calls == [ + pretend.call(_anchor="description-content-type") + ] + + assert resp.status_code == 400 + assert resp.status == message + @pytest.mark.parametrize( "name", [ @@ -1158,6 +1216,7 @@ def test_successful_upload( "filetype": "sdist", "pyversion": "source", "content": content, + "description": "an example description", } ) db_request.POST.extend([("classifiers", "Environment :: Other Environment")]) diff --git a/warehouse/forklift/legacy.py b/warehouse/forklift/legacy.py index 9b496e1a5ccc..89868cce565e 100644 --- a/warehouse/forklift/legacy.py +++ b/warehouse/forklift/legacy.py @@ -52,7 +52,7 @@ JournalEntry, BlacklistedProject, ) -from warehouse.utils import http +from warehouse.utils import http, readme MAX_FILESIZE = 60 * 1024 * 1024 # 60M @@ -891,6 +891,33 @@ def file_upload(request): ), ) + # Uploading should prevent broken rendered descriptions. + if form.description.data: + description_content_type = form.description_content_type.data + if not description_content_type: + description_content_type = "text/x-rst" + rendered = readme.render( + form.description.data, description_content_type, use_fallback=False + ) + if rendered is None: + if form.description_content_type.data: + message = ( + "The description failed to render " + "for '{description_content_type}'." + ).format(description_content_type=description_content_type) + else: + message = ( + "The description failed to render " + "in the default format of reStructuredText." + ) + raise _exc_with_message( + HTTPBadRequest, + "{message} See {projecthelp} for more information.".format( + message=message, + projecthelp=request.help_url(_anchor="description-content-type"), + ), + ) from None + try: canonical_version = packaging.utils.canonicalize_version(form.version.data) release = ( diff --git a/warehouse/templates/pages/help.html b/warehouse/templates/pages/help.html index 99f883df11e3..8f2af3f1c0fd 100644 --- a/warehouse/templates/pages/help.html +++ b/warehouse/templates/pages/help.html @@ -39,6 +39,7 @@ {% macro file_name_reuse() %}Why am I getting a "Filename or contents already exists" or "Filename has been previously used" error?{% endmacro %} {% macro project_name() %}Why isn't my desired project name available?{% endmacro %} {% macro project_name_claim() %}How do I claim an abandoned or previously registered project name?{% endmacro %} +{% macro description_content_type() %}How can I upload a description in a different format?{% endmacro %} {% macro new_classifier() %}How do I request a new trove classifier?{% endmacro %} {% macro feedback() %}Where can I report a bug or provide feedback?{% endmacro %} {% macro maintainers() %}Who maintains PyPI?{% endmacro %} @@ -84,6 +85,7 @@

Problems

  • {{ private_indices() }}
  • {{ project_name() }}
  • {{ project_name_claim() }}
  • +
  • {{ description_content_type() }}
  • {{ file_size_limit() }}
  • {{ admin_intervention() }}
  • {{ file_name_reuse() }}
  • @@ -247,6 +249,27 @@

    {{ project_name_claim() }}

    PEP 541 has been accepted, and PyPI is creating a workflow which will be documented here.

    +

    {{ description_content_type() }}

    +

    + By default, + an upload's description will render + with reStructuredText. + If the description is in an alternate format + like Markdown, + a package may set the long_description_content_type + in setup.py + to the alternate format. + Refer to the Python Packaging User Guide + for details on the available formats. +

    +

    + PyPI will reject uploads + if the description fails to render. + To check a description locally for validity, + you may use readme_renderer, + which is the same description renderer used by PyPI. +

    +

    {{ file_size_limit() }}

    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 under the limit (a developmental release version number is fine). Then, file an issue and tell us:

    diff --git a/warehouse/utils/readme.py b/warehouse/utils/readme.py index c0fa0f877109..5b2df3001f94 100644 --- a/warehouse/utils/readme.py +++ b/warehouse/utils/readme.py @@ -29,7 +29,7 @@ } -def render(value, content_type=None): +def render(value, content_type=None, use_fallback=True): if value is None: return value @@ -45,7 +45,8 @@ def render(value, content_type=None): # If the content was not rendered, we'll render as plaintext instead. The # reason it's necessary to do this instead of just accepting plaintext is # that readme_renderer will deal with sanitizing the content. - if rendered is None: + # Skip the fallback option when validating that rendered output is ok. + if use_fallback and rendered is None: rendered = readme_renderer.txt.render(value) return rendered