Repository: pallets/werkzeug

Issue:
send_file: allow explicit etag passing
`send_file` should have a kwarg for explicitly specifying an etag so it can be used even without a physical file where it would be autogenerated.

Right now one has to copy&paste code (the stuff from `if conditional:`) into their if they want to include an etag for a file that does not represent a physical file on disk, because `make_conditional` should ideally be used on a response that already has an etag set...

This depends on #1850 since it moves `send_file` from Flask to werkzeug.


Discussion:
Would allowing `add_etags` to be a string or list of strings in addition to `True` be a good idea?
@ThiefMaster let me know if that's what you had in mind, or something else.
Not sure if mixing bool and string is that clean but it certainly sounds like a solution that would work.
Will be working on this.
I couldn't find any reference for allowing multiple `ETag` header values in the response, so I'm only implementing str, not list. Taking advantage of the fact that we're renaming other parameters during the move, I renamed the parameter to `etag`, since the previous name seemed incorrect on a few levels. I added a corresponding rename and warning to Flask (to go with the other two so far), although since this defaults to `True` it's unlikely to come up much.

Resolving patch:
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -563,7 +563,7 @@ def send_file(
     as_attachment: bool = False,
     download_name: t.Optional[str] = None,
     conditional: bool = True,
-    add_etags: bool = True,
+    etag: t.Union[bool, str] = True,
     last_modified: t.Optional[t.Union[datetime, int, float, struct_time]] = None,
     max_age: t.Optional[
         t.Union[int, t.Callable[[t.Optional[t.Union[os.PathLike, str]]], int]]
@@ -603,8 +603,8 @@ def send_file(
         the file. Defaults to the passed file name.
     :param conditional: Enable conditional and range responses based on
         request headers. Requires passing a file path and ``environ``.
-    :param add_etags: Calculate an ETag for the file. Requires passing a
-        file path.
+    :param etag: Calculate an ETag for the file, which requires passing
+        a file path. Can also be a string to use instead.
     :param last_modified: The last modified time to send for the file,
         in seconds. If not provided, it will try to detect it from the
         file path.
@@ -631,6 +631,10 @@ def send_file(
         ``max_age`` replaces Flask's ``cache_timeout`` parameter.
         ``conditional`` is enabled and ``max_age`` is not set by
         default.
+
+    .. versionchanged:: 2.0.0
+        ``etag`` replaces Flask's ``add_etags`` parameter. It can be a
+        string to use instead of generating one.
     """
     if response_class is None:
         from .wrappers import Response
@@ -734,7 +738,9 @@ def send_file(
         rv.cache_control.max_age = max_age
         rv.expires = int(time() + max_age)  # type: ignore
 
-    if add_etags and path is not None:
+    if isinstance(etag, str):
+        rv.set_etag(etag)
+    elif etag and path is not None:
         check = adler32(str(path).encode("utf-8")) & 0xFFFFFFFF
         rv.set_etag(f"{mtime}-{size}-{check}")
 


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
@@ -148,6 +148,18 @@ def test_max_age(value, public):
     assert rv.status_code == 200
 
 
+def test_etag():
+    rv = send_file(txt_path, environ)
+    rv.close()
+    assert rv.headers["ETag"].count("-") == 2
+    rv = send_file(txt_path, environ, etag=False)
+    rv.close()
+    assert "ETag" not in rv.headers
+    rv = send_file(txt_path, environ, etag="unique")
+    rv.close()
+    assert rv.headers["ETag"] == '"unique"'
+
+
 @pytest.mark.parametrize(
     ("directory", "path"),
     [(str(res_path), "test.txt"), (res_path, pathlib.Path("test.txt"))],