Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

3.0.x #2872

Closed
wants to merge 9 commits into from
Closed

3.0.x #2872

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
15 changes: 15 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
.. currentmodule:: werkzeug

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
-------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
10 changes: 7 additions & 3 deletions src/werkzeug/datastructures/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ def get(self, key, default=None, type=None):
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]
Expand All @@ -80,7 +84,7 @@ def get(self, key, default=None, type=None):
if type is not None:
try:
rv = type(rv)
except ValueError:
except (ValueError, TypeError):
rv = default
return rv

Expand Down
2 changes: 1 addition & 1 deletion src/werkzeug/formparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def start_file_streaming(

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]
Expand Down
9 changes: 8 additions & 1 deletion src/werkzeug/routing/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ def __init__(

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

Expand All @@ -123,6 +122,14 @@ def __init__(
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
Expand Down
2 changes: 1 addition & 1 deletion src/werkzeug/routing/matcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ def _match(
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}")
Expand Down
8 changes: 8 additions & 0 deletions src/werkzeug/routing/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class RulePart:
_simple_rule_re = re.compile(r"<([^>]+)>")
_converter_args_re = re.compile(
r"""
\s*
((?P<name>\w+)\s*=\s*)?
(?P<value>
True|False|
Expand Down Expand Up @@ -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")
Expand All @@ -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

Expand Down
6 changes: 4 additions & 2 deletions src/werkzeug/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -809,10 +809,12 @@ def __init__(

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),
{},
)

Expand Down
3 changes: 2 additions & 1 deletion tests/test_datastructures.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,9 @@ def test_value_conversion(self):
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")
Expand Down
6 changes: 3 additions & 3 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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


Expand Down Expand Up @@ -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
),
)
Expand All @@ -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
),
)
Expand Down
7 changes: 7 additions & 0 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "/")
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 22 additions & 0 deletions tests/test_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
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
Expand Down Expand Up @@ -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
Loading