Repository: pallets/werkzeug (python)

Issue:
Elapsed and timestamp values are not available to filename_format function
When the `ProfilerMiddleware` `filename_format` argument is a callable, the callable will only receive the WSGI environ variable. However, the default format string can use the `elapsed` and `time` values. This means that one using a format function cannot recreate a similar output pattern. In my case, I'd like to use both, but format the timestamp differently.


Example: I'd like a filename of the form `2023-08-21:14:05:05.POST.myurl.13ms.prof`. I can get the timestamp by doing my own `datetime.now()` call, but the elapsed time is not available.

This problem is solvable by subclassing `ProfilerMiddleware` or writing ones own profiling middleware, but this seems like a useful thing to have in the core library so library users don't need to duplicate the `__call__()` code in their own projects.

I can submit a PR if this change is welcome.


Pull request description:
Provide timing info to ProfilerMiddleware filename_format
This provides the `elapsed` and `time` values to the
`ProfileMiddleware(filename_format=...)` function. Prior to this change,
one could not replicate the format string format, much less modify how
it would render the timestamp or elapsed time values.

These new values can be found under the `werkzeug.profiler` key in the
WSGI environ dict passed into the `filename_format()` function.

- fixes #2775

Checklist:

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ec2ad5d5..f9c997dd 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,9 @@ Unreleased
 -   Deprecate the ``__version__`` attribute. Use feature detection, or
     ``importlib.metadata.version("werkzeug")``, instead. :issue:`2770`
 -   ``generate_password_hash`` uses scrypt by default. :issue:`2769`
+-   Add the ``"werkzeug.profiler"`` item to the  WSGI ``environ`` dictionary
+    passed to `ProfilerMiddleware`'s `filename_format` function. It contains
+    the ``elapsed`` and ``time`` values for the profiled request. :issue:`2775`
 
 
 Version 2.3.8
diff --git a/src/werkzeug/middleware/profiler.py b/src/werkzeug/middleware/profiler.py
index 2d806154..1120c83e 100644
--- a/src/werkzeug/middleware/profiler.py
+++ b/src/werkzeug/middleware/profiler.py
@@ -44,11 +44,16 @@ class ProfilerMiddleware:
 
     -   ``{method}`` - The request method; GET, POST, etc.
     -   ``{path}`` - The request path or 'root' should one not exist.
-    -   ``{elapsed}`` - The elapsed time of the request.
+    -   ``{elapsed}`` - The elapsed time of the request in milliseconds.
     -   ``{time}`` - The time of the request.
 
-    If it is a callable, it will be called with the WSGI ``environ``
-    dict and should return a filename.
+    If it is a callable, it will be called with the WSGI ``environ`` and
+    be expected to return a filename string. The ``environ`` dictionary
+    will also have the ``"werkzeug.profiler"`` key populated with a
+    dictionary containing the following fields (more may be added in the
+    future):
+    -   ``{elapsed}`` - The elapsed time of the request in milliseconds.
+    -   ``{time}`` - The time of the request.
 
     :param app: The WSGI application to wrap.
     :param stream: Write stats to this stream. Disable with ``None``.
@@ -65,6 +70,10 @@ class ProfilerMiddleware:
         from werkzeug.middleware.profiler import ProfilerMiddleware
         app = ProfilerMiddleware(app)
 
+    .. versionchanged:: 3.0
+        Added the ``"werkzeug.profiler"`` key to the ``filename_format(environ)``
+        parameter with the  ``elapsed`` and ``time`` fields.
+
     .. versionchanged:: 0.15
         Stats are written even if ``profile_dir`` is given, and can be
         disable by passing ``stream=None``.
@@ -118,6 +127,10 @@ class ProfilerMiddleware:
 
         if self._profile_dir is not None:
             if callable(self._filename_format):
+                environ["werkzeug.profiler"] = {
+                    "elapsed": elapsed * 1000.0,
+                    "time": time.time(),
+                }
                 filename = self._filename_format(environ)
             else:
                 filename = self._filename_format.format(


Test patch:
diff --git a/tests/middleware/test_profiler.py b/tests/middleware/test_profiler.py
new file mode 100644
index 00000000..585aeb54
--- /dev/null
+++ b/tests/middleware/test_profiler.py
@@ -0,0 +1,50 @@
+import datetime
+import os
+from unittest.mock import ANY
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from werkzeug.middleware.profiler import Profile
+from werkzeug.middleware.profiler import ProfilerMiddleware
+from werkzeug.test import Client
+
+
+def dummy_application(environ, start_response):
+    start_response("200 OK", [("Content-Type", "text/plain")])
+    return [b"Foo"]
+
+
+def test_filename_format_function():
+    # This should be called once with the generated file name
+    mock_capture_name = MagicMock()
+
+    def filename_format(env):
+        now = datetime.datetime.fromtimestamp(env["werkzeug.profiler"]["time"])
+        timestamp = now.strftime("%Y-%m-%d:%H:%M:%S")
+        path = (
+            "_".join(token for token in env["PATH_INFO"].split("/") if token) or "ROOT"
+        )
+        elapsed = env["werkzeug.profiler"]["elapsed"]
+        name = f"{timestamp}.{env['REQUEST_METHOD']}.{path}.{elapsed:.0f}ms.prof"
+        mock_capture_name(name=name)
+        return name
+
+    client = Client(
+        ProfilerMiddleware(
+            dummy_application,
+            stream=None,
+            profile_dir="profiles",
+            filename_format=filename_format,
+        )
+    )
+
+    # Replace the Profile class with a function that simulates an __init__()
+    # call and returns our mock instance.
+    mock_profile = MagicMock(wraps=Profile())
+    mock_profile.dump_stats = MagicMock()
+    with patch("werkzeug.middleware.profiler.Profile", lambda: mock_profile):
+        client.get("/foo/bar")
+
+        mock_capture_name.assert_called_once_with(name=ANY)
+        name = mock_capture_name.mock_calls[0].kwargs["name"]
+        mock_profile.dump_stats.assert_called_once_with(os.path.join("profiles", name))