Repository: pypi/warehouse

Issue:
Monospaced font for text/plain long_description
Don't you think that would be nice to wrap project descriptions in text/plain with pre tag?
Close if duplicate. I'm really sorry, looking through over 400 issues of production system is beyond of my capabilities.


Discussion:
Hi, thanks for the issue. I think it makes sense, and I don't think we have an existing issue about this yet.

I've added the 'good first issue' label here.
Thanks! This would keep ASCII art intact.
Division of responsibility question:
Since the rendering task in warehouse will prefer the `html` column of a Release's Description, should `readme_renderer` return a `text/plain` object with `<pre>` tags wrapping the entire block [over here](https://github.com/pypa/readme_renderer/blob/34e27d3d658287d9a25e8117c3950e27aeda52a8/readme_renderer/txt.py#L22-L24), or should warehouse use Description's `content_type` and wrap during display, something like this?

```diff
diff --git a/warehouse/packaging/views.py b/warehouse/packaging/views.py
index c9ea1bc2..3ea3b731 100644
--- a/warehouse/packaging/views.py
+++ b/warehouse/packaging/views.py
@@ -90,6 +90,9 @@ def release_detail(release, request):
     #       already rendered content.
     if release.description.html:
         description = release.description.html
+        if release.description.content_type == "text/plain":
+            # Wrap as preformatted text to preserve whitespace.
+            description = f"<pre>{description}</pre>"
     else:
         description = readme.render(
             release.description.raw, release.description.content_type
```

My preference leans towards implementing in `readme_renderer`, but that does change the current output behavior for the `readme_renderer.txt.render()`
I would expect `readme_renderer` to continue 'rendering' plaintext as plaintext with no HTML -- I think it's up to the consumer (Warehouse) to determine what to do with that rendered output and if it needs to be wrapped.

So I think something like the diff you have here would be sufficient.
> continue 'rendering' plaintext as plaintext with no HTML

The only transformations we perform on plaintext today in `readme_renderer` [is replacing `\n` with HTML `<br>` tags](https://github.com/pypa/readme_renderer/blob/34e27d3d658287d9a25e8117c3950e27aeda52a8/readme_renderer/txt.py#L23), so we're not **not** transforming it already over there. :grin:
Hence my preference for adding the `<pre>` prefixing over in the library vs warehouse, since we're already doing a little to make the content prepared for warehouse.

>  I think it's up to the consumer (Warehouse) to determine what to do with that rendered output and if it needs to be wrapped.

That makes sense in the grand scheme of how things work today, since the rendered output gets inserted into the `html` column that we're reading from, vs `raw`.

If we want to encourage the notion of: "plaintext gets non-transformed by the library and then transformed on warehouse", and still accomplish something like this cleanly, I've thought of a few things to make the overall behavior consistent:

### alternative 1
- change the library to not transform plaintext at all (a slightly breaking change to the `.txt` renderer)
- continue to store the output in the `html` column like it does today (which is only slightly wrong)
- apply proposed diff to wrap the plaintext-from-html column with `<pre>` - should automatically handle the `\n` newlines in the source content (adds a small cost to request-time)

### alternative 2
- change the warehouse to look at the `raw` description column for plaintext content and transform to `<pre>` on display (slightly different from my proposed diff, adds small cost to request-time)
- only store `raw` for plaintext entries (would need to clear out older plaintext `html` column entries to make accurate)
- tell the warehouse to not run plaintext through the library (no change to the library, leaving "incorrect" txt render behavior alone, could mark deprecated, but might have impact on other users like `twine check`, would need to verify.)

Both alternatives have tradeoffs and add a small amount of runtime cost, since instead of fetching fully-rendered HTML from a column, there's a bit of extra transformation needed to the raw.

I'm leaning towards alternative 2 since it ends up the most "correct" overall - curious to know what you think!

### data
For fun, can we see how many descriptions are actually affected today? Something like:

```sql
select count(*) from release_descriptions where content_type = 'text/plain';
```
```
warehouse=> select count(*) from release_descriptions where content_type = 'text/plain';

 count
-------
  6328
(1 row)
```
Thanks @di! 
With that low amount of descriptions, I'm even more leaning towards alternative 2, since while it adds runtime cost, it's for so few items.
I'm not sure I totally follow the approach, in my mind we could just add a case statement to the Jinja template here that takes into account the content type.

Resolving patch:
diff --git a/warehouse/utils/readme.py b/warehouse/utils/readme.py
--- a/warehouse/utils/readme.py
+++ b/warehouse/utils/readme.py
@@ -41,6 +41,10 @@ def render(value, content_type=None, use_fallback=True):
     # also ensure that it's had any disallowed markup removed.
     rendered = renderer.render(value, **parameters)
 
+    # Wrap plaintext as preformatted to preserve whitespace.
+    if content_type == "text/plain":
+        rendered = f"<pre>{rendered}</pre>"
+
     # 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.


Test patch:
diff --git a/tests/unit/packaging/test_views.py b/tests/unit/packaging/test_views.py
--- a/tests/unit/packaging/test_views.py
+++ b/tests/unit/packaging/test_views.py
@@ -176,6 +176,53 @@ def test_normalizing_version_redirects(self, db_request):
             pretend.call(name=release.project.name, version=release.version)
         ]
 
+    def test_detail_render_plain(self, db_request):
+        users = [UserFactory.create(), UserFactory.create(), UserFactory.create()]
+        project = ProjectFactory.create()
+        releases = [
+            ReleaseFactory.create(
+                project=project,
+                version=v,
+                description=DescriptionFactory.create(
+                    raw="plaintext description",
+                    html="",
+                    content_type="text/plain",
+                ),
+            )
+            for v in ["1.0", "2.0", "3.0", "4.0.dev0"]
+        ]
+        files = [
+            FileFactory.create(
+                release=r,
+                filename="{}-{}.tar.gz".format(project.name, r.version),
+                python_version="source",
+                packagetype="sdist",
+            )
+            for r in releases
+        ]
+
+        # Create a role for each user
+        for user in users:
+            RoleFactory.create(user=user, project=project)
+
+        result = views.release_detail(releases[1], db_request)
+
+        assert result == {
+            "project": project,
+            "release": releases[1],
+            "files": [files[1]],
+            "sdists": [files[1]],
+            "bdists": [],
+            "description": "<pre>plaintext description</pre>",
+            "latest_version": project.latest_version,
+            "all_versions": [
+                (r.version, r.created, r.is_prerelease, r.yanked)
+                for r in reversed(releases)
+            ],
+            "maintainers": sorted(users, key=lambda u: u.username.lower()),
+            "license": None,
+        }
+
     def test_detail_rendered(self, db_request):
         users = [UserFactory.create(), UserFactory.create(), UserFactory.create()]
         project = ProjectFactory.create()
@@ -186,7 +233,7 @@ def test_detail_rendered(self, db_request):
                 description=DescriptionFactory.create(
                     raw="unrendered description",
                     html="rendered description",
-                    content_type="text/plain",
+                    content_type="text/html",
                 ),
             )
             for v in ["1.0", "2.0", "3.0", "4.0.dev0"]
@@ -231,7 +278,7 @@ def test_detail_renders(self, monkeypatch, db_request):
                 project=project,
                 version=v,
                 description=DescriptionFactory.create(
-                    raw="unrendered description", html="", content_type="text/plain"
+                    raw="unrendered description", html="", content_type="text/html"
                 ),
             )
             for v in ["1.0", "2.0", "3.0", "4.0.dev0"]
@@ -275,7 +322,7 @@ def test_detail_renders(self, monkeypatch, db_request):
         }
 
         assert render_description.calls == [
-            pretend.call("unrendered description", "text/plain")
+            pretend.call("unrendered description", "text/html")
         ]
 
     def test_detail_renders_files_natural_sort(self, db_request):
diff --git a/tests/unit/utils/test_readme.py b/tests/unit/utils/test_readme.py
--- a/tests/unit/utils/test_readme.py
+++ b/tests/unit/utils/test_readme.py
@@ -30,7 +30,7 @@ def test_cant_render_rst():
 
 def test_can_render_plaintext():
     result = readme.render("raw thing", "text/plain")
-    assert result == "raw thing"
+    assert result == "<pre>raw thing</pre>"
 
 
 def test_can_render_markdown():