Repository: pallets/werkzeug
Pull request #2873 (2024-04-01 19:01:31)

Merge 3.0.x


Patch:
diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml
index d7aee0c8..7978190f 100644
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -33,7 +33,7 @@ jobs:
       id-token: write
       contents: write
     # Can't pin with hash due to how this workflow works.
-    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
+    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.10.0
     with:
       base64-subjects: ${{ needs.build.outputs.hash }}
   create-release:
diff --git a/CHANGES.rst b/CHANGES.rst
index ca857171..6bd956c7 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -7,6 +7,20 @@ Unreleased
 
 -  Support Cookie CHIPS (Partitioned Cookies). :issue:`2797`
 
+Version 3.0.2
+-------------
+
+Released 2024-04-01
+
+-   Ensure setting merge_slashes to False results in NotFound for
+    repeated-slash requests against single slash routes. :issue:`2834`
+-   Fix handling of TypeError in TypeConversionDict.get() to match
+    ValueErrors. :issue:`2843`
+-   Fix response_wrapper type check in test client. :issue:`2831`
+-   Make the return type of ``MultiPartParser.parse`` more
+    precise. :issue:`2840`
+-   Raise an error if converter arguments cannot be
+    parsed. :issue:`2822`
 
 Version 3.0.1
 -------------
diff --git a/pyproject.toml b/pyproject.toml
index 161b4bc6..6b3509a8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "Werkzeug"
-version = "3.0.1"
+version = "3.0.2"
 description = "The comprehensive WSGI web application library."
 readme = "README.rst"
 license = {file = "LICENSE.rst"}
diff --git a/src/werkzeug/datastructures/structures.py b/src/werkzeug/datastructures/structures.py
index e863cd8a..4279ceb9 100644
--- a/src/werkzeug/datastructures/structures.py
+++ b/src/werkzeug/datastructures/structures.py
@@ -70,8 +70,12 @@ class TypeConversionDict(dict):
                         be looked up.  If not further specified `None` is
                         returned.
         :param type: A callable that is used to cast the value in the
-                     :class:`MultiDict`.  If a :exc:`ValueError` is raised
-                     by this callable the default value is returned.
+                     :class:`MultiDict`.  If a :exc:`ValueError` or a
+                     :exc:`TypeError` is raised by this callable the default
+                     value is returned.
+
+        .. versionchanged:: 3.0.2
+           Returns the default value on :exc:`TypeError`, too.
         """
         try:
             rv = self[key]
@@ -80,7 +84,7 @@ class TypeConversionDict(dict):
         if type is not None:
             try:
                 rv = type(rv)
-            except ValueError:
+            except (ValueError, TypeError):
                 rv = default
         return rv
 
diff --git a/src/werkzeug/formparser.py b/src/werkzeug/formparser.py
index ee30666d..5117a267 100644
--- a/src/werkzeug/formparser.py
+++ b/src/werkzeug/formparser.py
@@ -352,7 +352,7 @@ class MultiPartParser:
 
     def parse(
         self, stream: t.IO[bytes], boundary: bytes, content_length: int | None
-    ) -> tuple[MultiDict, MultiDict]:
+    ) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]:
         current_part: Field | File
         container: t.IO[bytes] | list[bytes]
         _write: t.Callable[[bytes], t.Any]
diff --git a/src/werkzeug/routing/map.py b/src/werkzeug/routing/map.py
index 76bbe2f3..87b83a54 100644
--- a/src/werkzeug/routing/map.py
+++ b/src/werkzeug/routing/map.py
@@ -109,7 +109,6 @@ class Map:
 
         self.default_subdomain = default_subdomain
         self.strict_slashes = strict_slashes
-        self.merge_slashes = merge_slashes
         self.redirect_defaults = redirect_defaults
         self.host_matching = host_matching
 
@@ -123,6 +122,14 @@ class Map:
         for rulefactory in rules or ():
             self.add(rulefactory)
 
+    @property
+    def merge_slashes(self) -> bool:
+        return self._matcher.merge_slashes
+
+    @merge_slashes.setter
+    def merge_slashes(self, value: bool) -> None:
+        self._matcher.merge_slashes = value
+
     def is_endpoint_expecting(self, endpoint: str, *arguments: str) -> bool:
         """Iterate over all rules and check if the endpoint expects
         the arguments provided.  This is for example useful if you have
diff --git a/src/werkzeug/routing/matcher.py b/src/werkzeug/routing/matcher.py
index 0d1210a6..1fd00efc 100644
--- a/src/werkzeug/routing/matcher.py
+++ b/src/werkzeug/routing/matcher.py
@@ -177,7 +177,7 @@ class StateMachineMatcher:
                 rv = _match(self._root, [domain, *path.split("/")], [])
             except SlashRequired:
                 raise RequestPath(f"{path}/") from None
-            if rv is None:
+            if rv is None or rv[0].merge_slashes is False:
                 raise NoMatch(have_match_for, websocket_mismatch)
             else:
                 raise RequestPath(f"{path}")
diff --git a/src/werkzeug/routing/rules.py b/src/werkzeug/routing/rules.py
index ea32d1dc..7029d8bc 100644
--- a/src/werkzeug/routing/rules.py
+++ b/src/werkzeug/routing/rules.py
@@ -67,6 +67,7 @@ _part_re = re.compile(
 _simple_rule_re = re.compile(r"<([^>]+)>")
 _converter_args_re = re.compile(
     r"""
+    \s*
     ((?P<name>\w+)\s*=\s*)?
     (?P<value>
         True|False|
@@ -112,8 +113,14 @@ def parse_converter_args(argstr: str) -> tuple[tuple[t.Any, ...], dict[str, t.An
     argstr += ","
     args = []
     kwargs = {}
+    position = 0
 
     for item in _converter_args_re.finditer(argstr):
+        if item.start() != position:
+            raise ValueError(
+                f"Cannot parse converter argument '{argstr[position:item.start()]}'"
+            )
+
         value = item.group("stringval")
         if value is None:
             value = item.group("value")
@@ -123,6 +130,7 @@ def parse_converter_args(argstr: str) -> tuple[tuple[t.Any, ...], dict[str, t.An
         else:
             name = item.group("name")
             kwargs[name] = value
+        position = item.end()
 
     return tuple(args), kwargs
 


Test patch:
diff --git a/src/werkzeug/test.py b/src/werkzeug/test.py
index 7b5899a9..694e5d8e 100644
--- a/src/werkzeug/test.py
+++ b/src/werkzeug/test.py
@@ -809,10 +809,12 @@ class Client:
 
         if response_wrapper in {None, Response}:
             response_wrapper = TestResponse
-        elif not isinstance(response_wrapper, TestResponse):
+        elif response_wrapper is not None and not issubclass(
+            response_wrapper, TestResponse
+        ):
             response_wrapper = type(
                 "WrapperTestResponse",
-                (TestResponse, response_wrapper),  # type: ignore
+                (TestResponse, response_wrapper),
                 {},
             )
 
diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py
index 5206aa6a..64330e1e 100644
--- a/tests/test_datastructures.py
+++ b/tests/test_datastructures.py
@@ -550,8 +550,9 @@ class TestTypeConversionDict:
         assert d.get("foo", type=int) == 1
 
     def test_return_default_when_conversion_is_not_possible(self):
-        d = self.storage_class(foo="bar")
+        d = self.storage_class(foo="bar", baz=None)
         assert d.get("foo", default=-1, type=int) == -1
+        assert d.get("baz", default=-1, type=int) == -1
 
     def test_propagate_exceptions_in_conversion(self):
         d = self.storage_class(foo="bar")
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index e4ee5863..91ad1a7c 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -7,7 +7,7 @@ from markupsafe import Markup
 from werkzeug import exceptions
 from werkzeug.datastructures import Headers
 from werkzeug.datastructures import WWWAuthenticate
-from werkzeug.exceptions import HTTPException
+from werkzeug.exceptions import default_exceptions, HTTPException
 from werkzeug.wrappers import Response
 
 
@@ -138,7 +138,7 @@ def test_retry_after_mixin(cls, value, expect):
 @pytest.mark.parametrize(
     "cls",
     sorted(
-        (e for e in HTTPException.__subclasses__() if e.code and e.code >= 400),
+        (e for e in default_exceptions.values() if e.code and e.code >= 400),
         key=lambda e: e.code,  # type: ignore
     ),
 )
@@ -158,7 +158,7 @@ def test_description_none():
 @pytest.mark.parametrize(
     "cls",
     sorted(
-        (e for e in HTTPException.__subclasses__() if e.code),
+        (e for e in default_exceptions.values() if e.code),
         key=lambda e: e.code,  # type: ignore
     ),
 )
diff --git a/tests/test_routing.py b/tests/test_routing.py
index 416fb4fc..02db898d 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -95,6 +95,7 @@ def test_merge_slashes_match():
             r.Rule("/yes/tail/", endpoint="yes_tail"),
             r.Rule("/with/<path:path>", endpoint="with_path"),
             r.Rule("/no//merge", endpoint="no_merge", merge_slashes=False),
+            r.Rule("/no/merging", endpoint="no_merging", merge_slashes=False),
         ]
     )
     adapter = url_map.bind("localhost", "/")
@@ -124,6 +125,9 @@ def test_merge_slashes_match():
 
     assert adapter.match("/no//merge")[0] == "no_merge"
 
+    assert adapter.match("/no/merging")[0] == "no_merging"
+    pytest.raises(NotFound, lambda: adapter.match("/no//merging"))
+
 
 @pytest.mark.parametrize(
     ("path", "expected"),
@@ -1072,6 +1076,9 @@ def test_converter_parser():
     args, kwargs = r.parse_converter_args('"foo", "bar"')
     assert args == ("foo", "bar")
 
+    with pytest.raises(ValueError):
+        r.parse_converter_args("min=0;max=500")
+
 
 def test_alias_redirects():
     m = r.Map(
diff --git a/tests/test_test.py b/tests/test_test.py
index c7f21fa1..d317d69c 100644
--- a/tests/test_test.py
+++ b/tests/test_test.py
@@ -16,6 +16,7 @@ from werkzeug.test import create_environ
 from werkzeug.test import EnvironBuilder
 from werkzeug.test import run_wsgi_app
 from werkzeug.test import stream_encode_multipart
+from werkzeug.test import TestResponse
 from werkzeug.utils import redirect
 from werkzeug.wrappers import Request
 from werkzeug.wrappers import Response
@@ -903,3 +904,24 @@ def test_no_content_type_header_addition():
     c = Client(no_response_headers_app)
     response = c.open()
     assert response.headers == Headers([("Content-Length", "8")])
+
+
+def test_client_response_wrapper():
+    class CustomResponse(Response):
+        pass
+
+    class CustomTestResponse(TestResponse, Response):
+        pass
+
+    c1 = Client(Response(), CustomResponse)
+    r1 = c1.open()
+
+    assert isinstance(r1, CustomResponse)
+    assert type(r1) is not CustomResponse  # Got subclassed
+    assert issubclass(type(r1), CustomResponse)
+
+    c2 = Client(Response(), CustomTestResponse)
+    r2 = c2.open()
+
+    assert isinstance(r2, CustomTestResponse)
+    assert type(r2) is CustomTestResponse  # Did not get subclassed