Repository: pallets/werkzeug

Issue:
send_file: allow filename for inline content-disposition 
`send_file` should support `attachment_filename` even for content-disposition inline.

Including a filename with inline files makes perfect sense e.g. when serving images users may want to "rightclick -> save as".
To avoid breaking API changes I would recommend keeping `attachment_filename` for it, but sending a content-disposition:inline header in case `as_attachment` is false. Alternatively we could deprecate `attachment_filename` in favor of a new `client_filename`, where the old kwarg would not add an inline disposition while the new one would.

This depends on #1850 since it moves `send_file` from Flask to werkzeug.


Discussion:
Since this is essentially a fresh start, we can just rename it `client_filename` with the behavior described. Flask's `send_file` will still exist as it has to get various Flask-specific data, so it can do the deprecation there.

If `client_filename` is set, set disposition to inline, unless `as_attachment` is set too, then set disposition to attachment.
`download_name` seems like a clearer description of what the parameter does.

Resolving patch:
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -557,7 +557,7 @@ def send_file(
     environ=None,
     mimetype=None,
     as_attachment=False,
-    attachment_filename=None,
+    download_name=None,
     add_etags=True,
     cache_timeout=43200,
     conditional=False,
@@ -592,8 +592,8 @@ def send_file(
         provided, it will try to detect it from the file name.
     :param as_attachment: Indicate to a browser that it should offer to
         save the file instead of displaying it.
-    :param attachment_filename: The file name browsers will use when
-        saving the file. Defaults to the passed file name.
+    :param download_name: The default name browsers will use when saving
+        the file. Defaults to the passed file name.
     :param add_etags: Calculate an ETag for the file. Requires passing a
         file path.
     :param conditional: Enable conditional and range responses based on
@@ -611,6 +611,11 @@ def send_file(
 
     .. versionadded:: 2.0.0
         Adapted from Flask's implementation.
+
+    .. versionchanged:: 2.0.0
+        ``download_name`` replaces Flask's ``attachment_filename``
+         parameter. If ``as_attachment=False``, it is passed with
+         ``Content-Disposition: inline`` instead.
     """
     if response_class is None:
         from .wrappers import Response as response_class
@@ -627,44 +632,39 @@ def send_file(
         path = size = mtime = None
         file = path_or_file
 
-    if attachment_filename is None and path is not None:
-        attachment_filename = path.name
+    if download_name is None and path is not None:
+        download_name = path.name
 
     if mimetype is None:
-        if attachment_filename is not None:
-            mimetype = (
-                mimetypes.guess_type(attachment_filename)[0]
-                or "application/octet-stream"
-            )
-
-        if mimetype is None:
+        if download_name is None:
             raise ValueError(
                 "Unable to detect the MIME type because a file name is"
-                " not available. Either set 'attachment_filename', pass"
-                " a path instead of a file, or set 'mimetype'."
+                " not available. Either set 'download_name', pass a"
+                " path instead of a file, or set 'mimetype'."
             )
 
-    headers = Headers()
+        mimetype = mimetypes.guess_type(download_name)[0] or "application/octet-stream"
 
-    if as_attachment:
-        if attachment_filename is None:
-            raise TypeError(
-                "No name provided for attachment. Either set"
-                " 'attachment_filename' or pass a path instead of a"
-                " file."
-            )
+    headers = Headers()
 
+    if download_name is not None:
         try:
-            attachment_filename = attachment_filename.encode("ascii")
+            download_name = download_name.encode("ascii")
         except UnicodeEncodeError:
-            simple = unicodedata.normalize("NFKD", attachment_filename)
+            simple = unicodedata.normalize("NFKD", download_name)
             simple = simple.encode("ascii", "ignore")
-            quoted = url_quote(attachment_filename, safe="")
-            filenames = {"filename": simple, "filename*": f"UTF-8''{quoted}"}
+            quoted = url_quote(download_name, safe="")
+            names = {"filename": simple, "filename*": f"UTF-8''{quoted}"}
         else:
-            filenames = {"filename": attachment_filename}
+            names = {"filename": download_name}
 
-        headers.add("Content-Disposition", "attachment", **filenames)
+        value = "attachment" if as_attachment else "inline"
+        headers.set("Content-Disposition", value, **names)
+    elif as_attachment:
+        raise TypeError(
+            "No name provided for attachment. Either set"
+            " 'download_name' or pass a path instead of a file."
+        )
 
     if use_x_sendfile and path:
         headers["X-Sendfile"] = str(path)


Test patch:
diff --git a/tests/test_send_file.py b/tests/test_send_file.py
--- a/tests/test_send_file.py
+++ b/tests/test_send_file.py
@@ -55,8 +55,8 @@ def test_object_without_mimetype():
         send_file(io.BytesIO(b"test"))
 
 
-def test_object_mimetype_from_attachment():
-    rv = send_file(io.BytesIO(b"test"), attachment_filename="test.txt")
+def test_object_mimetype_from_name():
+    rv = send_file(io.BytesIO(b"test"), download_name="test.txt")
     assert rv.mimetype == "text/plain"
     rv.close()
 
@@ -69,6 +69,24 @@ def test_text_mode_fails(file_factory):
         send_file(f, os.path.realpath(__file__), mimetype="text/plain")
 
 
+@pytest.mark.parametrize(
+    ("as_attachment", "value"), [(False, "inline"), (True, "attachment")]
+)
+def test_disposition_name(as_attachment, value):
+    rv = send_file(txt_path, as_attachment=as_attachment)
+    assert rv.headers["Content-Disposition"] == f"{value}; filename=test.txt"
+    rv.close()
+
+
+def test_object_attachment_requires_name():
+    with pytest.raises(TypeError, match="attachment"):
+        send_file(io.BytesIO(b"test"), mimetype="text/plain", as_attachment=True)
+
+    rv = send_file(io.BytesIO(b"test"), as_attachment=True, download_name="test.txt")
+    assert rv.headers["Content-Disposition"] == f"attachment; filename=test.txt"
+    rv.close()
+
+
 @pytest.mark.parametrize(
     ("name", "ascii", "utf8"),
     (
@@ -84,8 +102,8 @@ def test_text_mode_fails(file_factory):
         ("те:/ст", '":/"', "%D1%82%D0%B5%3A%2F%D1%81%D1%82"),
     ),
 )
-def test_non_ascii_filename(name, ascii, utf8):
-    rv = send_file(html_path, as_attachment=True, attachment_filename=name)
+def test_non_ascii_name(name, ascii, utf8):
+    rv = send_file(html_path, as_attachment=True, download_name=name)
     rv.close()
     content_disposition = rv.headers["Content-Disposition"]
     assert f"filename={ascii}" in content_disposition