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

[pull] main from pallets:main #303

Merged
merged 29 commits into from
Oct 28, 2024
Merged
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8d6a12e
start version 3.0.6
davidism Oct 25, 2024
8760275
apply max_form_memory_size another level up in the parser
davidism Oct 25, 2024
50cfeeb
Merge commit from fork
davidism Oct 25, 2024
87cc78a
catch special absolute path on Windows Python < 3.11
davidism Oct 25, 2024
2767bcb
Merge commit from fork
davidism Oct 25, 2024
5eaefc3
release version 3.0.6
davidism Oct 25, 2024
f028c21
release version 3.0.6 (#2962)
davidism Oct 25, 2024
f95ddda
Merge branch 'stable'
davidism Oct 25, 2024
437cf6f
add 421 Misdirected Request exception
charan-kumar-137 May 16, 2024
276f150
Adding 421 Misdirected Request HTTP Exception (#2902)
davidism Oct 25, 2024
7ff62c9
update CHANGES.rst
davidism Oct 25, 2024
5c49369
Merge branch 'stable'
davidism Oct 25, 2024
06e5094
fix ghsa links
davidism Oct 25, 2024
87bf546
Merge branch 'stable'
davidism Oct 25, 2024
14c5d99
Add support for RFC5861 Cache-Control headers
calpaterson Sep 9, 2024
849a708
Add support for RFC5861 Cache-Control headers (#2949)
davidism Oct 25, 2024
3a4126d
manage dev server subprocess directly
davidism Oct 27, 2024
e829265
remove resource warning ignores
davidism Oct 27, 2024
73b2b05
annotate dev server client
davidism Oct 27, 2024
f83df43
document DevServerClient
davidism Oct 27, 2024
2ed6d7e
replace pytest-xprocess (#2963)
davidism Oct 27, 2024
d27debc
default max_form_memory_size to 500kB
davidism Oct 27, 2024
0839766
default max_form_memory_size to 500kB (#2965)
davidism Oct 27, 2024
8e62ad6
drop support for Python 3.8
davidism Oct 27, 2024
bf9873b
drop support for Python 3.8 (#2966)
davidism Oct 27, 2024
c147760
remove previously deprecated code
davidism Oct 27, 2024
2139fa0
remove previously deprecated code (#2967)
davidism Oct 27, 2024
763609a
increase pbkdf2 iterations
davidism Oct 27, 2024
7e22812
increase pbkdf2 iterations (#2971)
davidism Oct 27, 2024
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
1 change: 0 additions & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -20,7 +20,6 @@ jobs:
- {python: '3.11'}
- {python: '3.10'}
- {python: '3.9'}
- {python: '3.8'}
- {name: PyPy, python: 'pypy-3.10', tox: pypy310}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
21 changes: 21 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -5,10 +5,31 @@ Version 3.1.0

Unreleased

- Drop support for Python 3.8. :pr:`2966`
- Remove previously deprecated code. :pr:`2967`
- ``Request.max_form_memory_size`` defaults to 500kB instead of unlimited.
Non-file form fields over this size will cause a ``RequestEntityTooLarge``
error. :issue:`2964`
- Support Cookie CHIPS (Partitioned Cookies). :issue:`2797`
- ``CacheControl.no_transform`` is a boolean when present. ``min_fresh`` is
``None`` when not present. Added the ``must_understand`` attribute. Fixed
some typing issues on cache control. :issue:`2881`
- Add ``stale_while_revalidate`` and ``stale_if_error`` properties to
``ResponseCacheControl``. :issue:`2948`
- Add 421 ``MisdirectedRequest`` HTTP exception. :issue:`2850`
- Increase default work factor for PBKDF2 to 1,000,000 iterations. :issue:`2969`



Version 3.0.6
-------------

Released 2024-10-25

- Fix how ``max_form_memory_size`` is applied when parsing large non-file
fields. :ghsa:`q34m-jh98-gwm2`
- ``safe_join`` catches certain paths on Windows that were not caught by
``ntpath.isabs`` on Python < 3.11. :ghsa:`f9vj-2wh5-fj8j`


Version 3.0.5
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@
extlinks = {
"issue": ("https://github.com/pallets/werkzeug/issues/%s", "#%s"),
"pr": ("https://github.com/pallets/werkzeug/pull/%s", "#%s"),
"ghsa": ("https://github.com/advisories/%s", "GHSA-%s"),
"ghsa": ("https://github.com/advisories/GHSA-%s", "GHSA-%s"),
}
intersphinx_mapping = {
"python": ("https://docs.python.org/3/", None),
22 changes: 4 additions & 18 deletions docs/datastructures.rst
Original file line number Diff line number Diff line change
@@ -69,26 +69,12 @@ HTTP Related
.. autoclass:: LanguageAccept

.. autoclass:: RequestCacheControl
:members:

.. autoattribute:: no_cache

.. autoattribute:: no_store

.. autoattribute:: max_age

.. autoattribute:: no_transform
:members:
:inherited-members:

.. autoclass:: ResponseCacheControl
:members:

.. autoattribute:: no_cache

.. autoattribute:: no_store

.. autoattribute:: max_age

.. autoattribute:: no_transform
:members:
:inherited-members:

.. autoclass:: ETags
:members:
2 changes: 2 additions & 0 deletions docs/exceptions.rst
Original file line number Diff line number Diff line change
@@ -44,6 +44,8 @@ The following error classes exist in Werkzeug:

.. autoexception:: ImATeapot

.. autoexception:: MisdirectedRequest

.. autoexception:: UnprocessableEntity

.. autoexception:: Locked
2 changes: 1 addition & 1 deletion docs/installation.rst
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ Python Version
--------------

We recommend using the latest version of Python. Werkzeug supports
Python 3.8 and newer.
Python 3.9 and newer.


Optional dependencies
15 changes: 11 additions & 4 deletions docs/request_data.rst
Original file line number Diff line number Diff line change
@@ -79,16 +79,23 @@ request in such a way that the server uses too many resources to handle it. Each
these limits will raise a :exc:`~werkzeug.exceptions.RequestEntityTooLarge` if they are
exceeded.

- :attr:`~Request.max_content_length` Stop reading request data after this number
- :attr:`~Request.max_content_length` - Stop reading request data after this number
of bytes. It's better to configure this in the WSGI server or HTTP server, rather
than the WSGI application.
- :attr:`~Request.max_form_memory_size` Stop reading request data if any form part is
larger than this number of bytes. While file parts can be moved to disk, regular
form field data is stored in memory only.
- :attr:`~Request.max_form_memory_size` - Stop reading request data if any
non-file form field is larger than this number of bytes. While file parts
can be moved to disk, regular form field data is stored in memory only and
could fill up memory. The default is 500kB.
- :attr:`~Request.max_form_parts` Stop reading request data if more than this number
of parts are sent in multipart form data. This is useful to stop a very large number
of very small parts, especially file parts. The default is 1000.

Each of these values can be set on the ``Request`` class to affect the default
for all requests, or on a ``request`` instance to change the behavior for a
specific request. For example, a small limit can be set by default, and a large
limit can be set on an endpoint that accepts video uploads. These values should
be tuned to the specific needs of your application and endpoints.

Using Werkzeug to set these limits is only one layer of protection. WSGI servers
and HTTPS servers should set their own limits on size and timeouts. The operating system
or container manager should set limits on memory and processing time for server
6 changes: 4 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Typing :: Typed",
]
requires-python = ">=3.8"
requires-python = ">=3.9"
dependencies = [
"MarkupSafe>=2.1.1",
]
@@ -70,6 +70,7 @@ source = ["werkzeug", "tests"]
source = ["src", "*/site-packages"]

[tool.mypy]
python_version = "3.9"
files = ["src/werkzeug"]
show_error_codes = true
pretty = true
@@ -79,13 +80,14 @@ strict = true
module = [
"colorama.*",
"cryptography.*",
"ephemeral_port_reserve",
"watchdog.*",
"xprocess.*",
]
ignore_missing_imports = true

[tool.pyright]
pythonVersion = "3.8"
pythonVersion = "3.9"
include = ["src/werkzeug"]

[tool.ruff]
7 changes: 0 additions & 7 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
@@ -104,10 +104,6 @@ pluggy==1.5.0
# tox
pre-commit==4.0.1
# via -r dev.in
psutil==6.1.0
# via
# -r tests.txt
# pytest-xprocess
pycparser==2.22
# via
# -r tests.txt
@@ -125,11 +121,8 @@ pytest==8.3.3
# -r tests.txt
# -r typing.txt
# pytest-timeout
# pytest-xprocess
pytest-timeout==2.3.1
# via -r tests.txt
pytest-xprocess==0.23.0
# via -r tests.txt
pyyaml==6.0.2
# via pre-commit
requests==2.32.3
1 change: 0 additions & 1 deletion requirements/tests.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
pytest
pytest-timeout
pytest-xprocess<1
cryptography
watchdog
ephemeral-port-reserve
5 changes: 0 additions & 5 deletions requirements/tests.txt
Original file line number Diff line number Diff line change
@@ -18,18 +18,13 @@ packaging==24.1
# via pytest
pluggy==1.5.0
# via pytest
psutil==6.1.0
# via pytest-xprocess
pycparser==2.22
# via cffi
pytest==8.3.3
# via
# -r tests.in
# pytest-timeout
# pytest-xprocess
pytest-timeout==2.3.1
# via -r tests.in
pytest-xprocess==0.23.0
# via -r tests.in
watchdog==5.0.3
# via -r tests.in
39 changes: 0 additions & 39 deletions requirements/tests38.txt

This file was deleted.

21 changes: 0 additions & 21 deletions src/werkzeug/__init__.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,4 @@
from __future__ import annotations

import typing as t

from .serving import run_simple as run_simple
from .test import Client as Client
from .wrappers import Request as Request
from .wrappers import Response as Response


def __getattr__(name: str) -> t.Any:
if name == "__version__":
import importlib.metadata
import warnings

warnings.warn(
"The '__version__' attribute is deprecated and will be removed in"
" Werkzeug 3.1. Use feature detection or"
" 'importlib.metadata.version(\"werkzeug\")' instead.",
DeprecationWarning,
stacklevel=2,
)
return importlib.metadata.version("werkzeug")

raise AttributeError(name)
5 changes: 4 additions & 1 deletion src/werkzeug/datastructures/cache_control.py
Original file line number Diff line number Diff line change
@@ -165,7 +165,8 @@ class ResponseCacheControl(_CacheControl):
``no_transform`` is a boolean when present.

.. versionchanged:: 3.1
Added the ``must_understand`` attribute.
Added the ``must_understand``, ``stale_while_revalidate``, and
``stale_if_error`` attributes.

.. versionchanged:: 2.1.1
``s_maxage`` converts the value to an int.
@@ -186,6 +187,8 @@ class ResponseCacheControl(_CacheControl):
s_maxage = cache_control_property("s-maxage", None, int)
immutable = cache_control_property("immutable", None, bool)
must_understand = cache_control_property("must-understand", None, bool)
stale_while_revalidate = cache_control_property("stale-while-revalidate", None, int)
stale_if_error = cache_control_property("stale-if-error", None, int)


# circular dependencies
12 changes: 12 additions & 0 deletions src/werkzeug/datastructures/cache_control.pyi
Original file line number Diff line number Diff line change
@@ -116,3 +116,15 @@ class ResponseCacheControl(_CacheControl):
def must_understand(self, value: bool | None) -> None: ...
@must_understand.deleter
def must_understand(self) -> None: ...
@property
def stale_while_revalidate(self) -> int | None: ...
@stale_while_revalidate.setter
def stale_while_revalidate(self, value: int | None) -> None: ...
@stale_while_revalidate.deleter
def stale_while_revalidate(self) -> None: ...
@property
def stale_if_error(self) -> int | None: ...
@stale_if_error.setter
def stale_if_error(self, value: int | None) -> None: ...
@stale_if_error.deleter
def stale_if_error(self) -> None: ...
2 changes: 1 addition & 1 deletion src/werkzeug/debug/tbtools.py
Original file line number Diff line number Diff line change
@@ -185,7 +185,7 @@ def _process_traceback(
"globals": f.f_globals,
}

if hasattr(fs, "colno"):
if sys.version_info >= (3, 11):
frame_args["colno"] = fs.colno
frame_args["end_colno"] = fs.end_colno

11 changes: 11 additions & 0 deletions src/werkzeug/exceptions.py
Original file line number Diff line number Diff line change
@@ -571,6 +571,17 @@ class ImATeapot(HTTPException):
description = "This server is a teapot, not a coffee machine"


class MisdirectedRequest(HTTPException):
"""421 Misdirected Request

Indicates that the request was directed to a server that is not able to
produce a response.
"""

code = 421
description = "The server is not able to produce a response."


class UnprocessableEntity(HTTPException):
"""*422* `Unprocessable Entity`

13 changes: 12 additions & 1 deletion src/werkzeug/formparser.py
Original file line number Diff line number Diff line change
@@ -33,7 +33,7 @@

from _typeshed.wsgi import WSGIEnvironment

t_parse_result = t.Tuple[
t_parse_result = tuple[
t.IO[bytes], MultiDict[str, str], MultiDict[str, FileStorage]
]

@@ -352,6 +352,7 @@ def parse(
self, stream: t.IO[bytes], boundary: bytes, content_length: int | None
) -> tuple[MultiDict[str, str], MultiDict[str, FileStorage]]:
current_part: Field | File
field_size: int | None = None
container: t.IO[bytes] | list[bytes]
_write: t.Callable[[bytes], t.Any]

@@ -370,13 +371,23 @@ def parse(
while not isinstance(event, (Epilogue, NeedData)):
if isinstance(event, Field):
current_part = event
field_size = 0
container = []
_write = container.append
elif isinstance(event, File):
current_part = event
field_size = None
container = self.start_file_streaming(event, content_length)
_write = container.write
elif isinstance(event, Data):
if self.max_form_memory_size is not None and field_size is not None:
# Ensure that accumulated data events do not exceed limit.
# Also checked within single event in MultipartDecoder.
field_size += len(event.data)

if field_size > self.max_form_memory_size:
raise RequestEntityTooLarge()

_write(event.data)
if not event.more_data:
if isinstance(current_part, Field):
2 changes: 1 addition & 1 deletion src/werkzeug/http.py
Original file line number Diff line number Diff line change
@@ -599,7 +599,7 @@ def parse_accept_header(
Parse according to RFC 9110. Items with invalid ``q`` values are skipped.
"""
if cls is None:
cls = t.cast(t.Type[_TAnyAccept], ds.Accept)
cls = t.cast(type[_TAnyAccept], ds.Accept)

if not value:
return cls(None)
2 changes: 1 addition & 1 deletion src/werkzeug/middleware/lint.py
Original file line number Diff line number Diff line change
@@ -435,5 +435,5 @@ def checking_start_response(
app_iter = self.app(environ, t.cast("StartResponse", checking_start_response))
self.check_iterator(app_iter)
return GuardedIterator(
app_iter, t.cast(t.Tuple[int, Headers], headers_set), chunks
app_iter, t.cast(tuple[int, Headers], headers_set), chunks
)
4 changes: 2 additions & 2 deletions src/werkzeug/middleware/shared_data.py
Original file line number Diff line number Diff line change
@@ -30,8 +30,8 @@
from ..wsgi import get_path_info
from ..wsgi import wrap_file

_TOpener = t.Callable[[], t.Tuple[t.IO[bytes], datetime, int]]
_TLoader = t.Callable[[t.Optional[str]], t.Tuple[t.Optional[str], t.Optional[_TOpener]]]
_TOpener = t.Callable[[], tuple[t.IO[bytes], datetime, int]]
_TLoader = t.Callable[[t.Optional[str]], tuple[t.Optional[str], t.Optional[_TOpener]]]

if t.TYPE_CHECKING:
from _typeshed.wsgi import StartResponse
2 changes: 2 additions & 0 deletions src/werkzeug/sansio/multipart.py
Original file line number Diff line number Diff line change
@@ -140,6 +140,8 @@ def receive_data(self, data: bytes | None) -> None:
self.max_form_memory_size is not None
and len(self.buffer) + len(data) > self.max_form_memory_size
):
# Ensure that data within single event does not exceed limit.
# Also checked across accumulated events in MultiPartParser.
raise RequestEntityTooLarge()
else:
self.buffer.extend(data)
4 changes: 3 additions & 1 deletion src/werkzeug/security.py
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
import secrets

SALT_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
DEFAULT_PBKDF2_ITERATIONS = 600000
DEFAULT_PBKDF2_ITERATIONS = 1_000_000

_os_alt_seps: list[str] = list(
sep for sep in [os.sep, os.path.altsep] if sep is not None and sep != "/"
@@ -151,6 +151,8 @@ def safe_join(directory: str, *pathnames: str) -> str | None:
if (
any(sep in filename for sep in _os_alt_seps)
or os.path.isabs(filename)
# ntpath.isabs doesn't catch this on Python < 3.11
or filename.startswith("/")
or filename == ".."
or filename.startswith("../")
):
2 changes: 1 addition & 1 deletion src/werkzeug/serving.py
Original file line number Diff line number Diff line change
@@ -81,7 +81,7 @@ class ForkingMixIn: # type: ignore
LISTEN_QUEUE = 128

_TSSLContextArg = t.Optional[
t.Union["ssl.SSLContext", t.Tuple[str, t.Optional[str]], t.Literal["adhoc"]]
t.Union["ssl.SSLContext", tuple[str, t.Optional[str]], t.Literal["adhoc"]]
]

if t.TYPE_CHECKING:
2 changes: 1 addition & 1 deletion src/werkzeug/test.py
Original file line number Diff line number Diff line change
@@ -818,7 +818,7 @@ def __init__(
{},
)

self.response_wrapper = t.cast(t.Type["TestResponse"], response_wrapper)
self.response_wrapper = t.cast(type["TestResponse"], response_wrapper)

if use_cookies:
self._cookies: dict[tuple[str, str, str], Cookie] | None = {}
5 changes: 4 additions & 1 deletion src/werkzeug/wrappers/request.py
Original file line number Diff line number Diff line change
@@ -84,8 +84,11 @@ class Request(_SansIORequest):
#: data in memory for post data is longer than the specified value a
#: :exc:`~werkzeug.exceptions.RequestEntityTooLarge` exception is raised.
#:
#: .. versionchanged:: 3.1
#: Defaults to 500kB instead of unlimited.
#:
#: .. versionadded:: 0.5
max_form_memory_size: int | None = None
max_form_memory_size: int | None = 500_000

#: The maximum number of multipart parts to parse, passed to
#: :attr:`form_data_parser_class`. Parsing form data with more than this
247 changes: 182 additions & 65 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,149 @@
from __future__ import annotations

import collections.abc as cabc
import http.client
import json
import os
import socket
import ssl
import subprocess
import sys
import time
import typing as t
from contextlib import closing
from contextlib import ExitStack
from pathlib import Path
from types import TracebackType

import ephemeral_port_reserve
import pytest
from xprocess import ProcessStarter

from werkzeug.utils import cached_property

run_path = str(Path(__file__).parent / "live_apps" / "run.py")
if t.TYPE_CHECKING:
import typing_extensions as te


class UnixSocketHTTPConnection(http.client.HTTPConnection):
def connect(self):
def connect(self) -> None:
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
# Raises FileNotFoundError if the server hasn't started yet.
self.sock.connect(self.host)


# Used to annotate the ``DevServerClient.request`` return value.
class DataHTTPResponse(http.client.HTTPResponse):
data: bytes
json: t.Any


class DevServerClient:
def __init__(self, kwargs):
host = kwargs.get("hostname", "127.0.0.1")
"""Manage a live dev server process and make requests to it. Must be used
as a context manager.
If ``hostname`` starts with ``unix://``, the server listens to a unix socket
file instead of a TCP socket.
If ``port`` is not given, a random port is reserved for use by the server,
to allow multiple servers to run simultaneously.
If ``ssl_context`` is given, the server listens with TLS enabled. It can be
the special value ``custom`` to generate and pass a context to
``run_simple``, as opposed to ``adhoc`` which tells ``run_simple`` to
generate the context.
:param app_name: The name of the app from the ``live_apps`` folder to load.
:param tmp_path: The current test's temporary directory. The server process
sets the working dir here, it is added to the Python path, the log file
is written here, and for unix connections the socket is opened here.
:param server_kwargs: Arguments to pass to ``live_apps/run.py`` to control
how ``run_simple`` is called in the subprocess.
"""

if not host.startswith("unix"):
port = kwargs.get("port")
scheme: str
"""One of ``http``, ``https``, or ``unix``. Set based on ``ssl_context`` or
``hostname``.
"""
addr: str
"""The host and port."""
url: str
"""The scheme, host, and port."""

def __init__(
self, app_name: str = "standard", *, tmp_path: Path, **server_kwargs: t.Any
) -> None:
host = server_kwargs.get("hostname", "127.0.0.1")

if not host.startswith("unix://"):
port = server_kwargs.get("port")

if port is None:
kwargs["port"] = port = ephemeral_port_reserve.reserve(host)
server_kwargs["port"] = port = ephemeral_port_reserve.reserve(host)

scheme = "https" if "ssl_context" in kwargs else "http"
self.scheme = "https" if "ssl_context" in server_kwargs else "http"
self.addr = f"{host}:{port}"
self.url = f"{scheme}://{self.addr}"
self.url = f"{self.scheme}://{self.addr}"
else:
self.scheme = "unix"
self.addr = host[7:] # strip "unix://"
self.url = host

self.log = None

def tail_log(self, path):
# surrogateescape allows for handling of file streams
# containing junk binary values as normal text streams
self.log = open(path, errors="surrogateescape")
self.log.read()

def connect(self, **kwargs):
protocol = self.url.partition(":")[0]

if protocol == "https":
self._app_name = app_name
self._server_kwargs = server_kwargs
self._tmp_path = tmp_path
self._log_write: t.IO[bytes] | None = None
self._log_read: t.IO[str] | None = None
self._proc: subprocess.Popen[bytes] | None = None

def __enter__(self) -> te.Self:
"""Start the server process and wait for it to be ready."""
log_path = self._tmp_path / "log.txt"
self._log_write = open(log_path, "wb")
self._log_read = open(log_path, encoding="utf8", errors="surrogateescape")
tmp_dir = os.fspath(self._tmp_path)
self._proc = subprocess.Popen(
[
sys.executable,
os.fspath(Path(__file__).parent / "live_apps/run.py"),
self._app_name,
json.dumps(self._server_kwargs),
],
env={**os.environ, "PYTHONUNBUFFERED": "1", "PYTHONPATH": tmp_dir},
cwd=tmp_dir,
close_fds=True,
stdout=self._log_write,
stderr=subprocess.STDOUT,
)
self.wait_ready()
return self

def __exit__(
self,
exc_type: type[BaseException],
exc_val: BaseException,
exc_tb: TracebackType,
) -> None:
"""Clean up the server process."""
assert self._proc is not None
self._proc.terminate()
self._proc.wait()
self._proc = None
assert self._log_read is not None
self._log_read.close()
self._log_read = None
assert self._log_write is not None
self._log_write.close()
self._log_write = None

def connect(self, **kwargs: t.Any) -> http.client.HTTPConnection:
"""Create a connection to the server, without sending a request.
Useful if a test requires lower level methods to try something that
``HTTPClient.request`` will not do.
If the server's scheme is HTTPS and the TLS ``context`` argument is not
given, a default permissive context is used.
:param kwargs: Arguments to :class:`http.client.HTTPConnection`.
"""
if self.scheme == "https":
if "context" not in kwargs:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
@@ -58,21 +152,31 @@ def connect(self, **kwargs):

return http.client.HTTPSConnection(self.addr, **kwargs)

if protocol == "unix":
if self.scheme == "unix":
return UnixSocketHTTPConnection(self.addr, **kwargs)

return http.client.HTTPConnection(self.addr, **kwargs)

def request(self, path="", **kwargs):
def request(self, url: str = "", **kwargs: t.Any) -> DataHTTPResponse:
"""Open a connection and make a request to the server, returning the
response.
The response object ``data`` parameter has the result of
``response.read()``. If the response has a ``application/json`` content
type, the ``json`` parameter is populated with ``json.loads(data)``.
:param url: URL to put in the request line.
:param kwargs: Arguments to :meth:`http.client.HTTPConnection.request`.
"""
kwargs.setdefault("method", "GET")
kwargs.setdefault("url", path)
conn = self.connect()
conn.request(**kwargs)
kwargs["url"] = url
response: DataHTTPResponse

with conn.getresponse() as response:
response.data = response.read()
with closing(self.connect()) as conn:
conn.request(**kwargs)

conn.close()
with conn.getresponse() as response: # type: ignore[assignment]
response.data = response.read()

if response.headers.get("Content-Type", "").startswith("application/json"):
response.json = json.loads(response.data)
@@ -81,53 +185,66 @@ def request(self, path="", **kwargs):

return response

def wait_for_log(self, start):
def wait_ready(self) -> None:
"""Wait until a request to ``/ensure`` is successful, indicating the
server has started and is listening.
"""
while True:
for line in self.log:
if line.startswith(start):
return
try:
self.request("/ensure")
return
# ConnectionRefusedError for http, FileNotFoundError for unix
except (ConnectionRefusedError, FileNotFoundError):
time.sleep(0.1)

def wait_for_reload(self):
self.wait_for_log(" * Restarting with ")
def read_log(self) -> str:
"""Read from the current position to the current end of the log."""
assert self._log_read is not None
return self._log_read.read()

def wait_for_log(self, value: str) -> None:
"""Wait until a line in the log contains the given string.
@pytest.fixture()
def dev_server(xprocess, request, tmp_path):
"""A function that will start a dev server in an external process
and return a client for interacting with the server.
"""
:param value: The string to search for.
"""
assert self._log_read is not None

def start_dev_server(name="standard", **kwargs):
client = DevServerClient(kwargs)
while True:
for line in self._log_read:
if value in line:
return

time.sleep(0.1)

def wait_for_reload(self) -> None:
"""Wait until the server logs that it is restarting, then wait for it to
be ready.
"""
self.wait_for_log("Restarting with")
self.wait_ready()

class Starter(ProcessStarter):
args = [sys.executable, run_path, name, json.dumps(kwargs)]
# Extend the existing env, otherwise Windows and CI fails.
# Modules will be imported from tmp_path for the reloader.
# Unbuffered output so the logs update immediately.
env = {**os.environ, "PYTHONPATH": str(tmp_path), "PYTHONUNBUFFERED": "1"}

@cached_property
def pattern(self):
client.request("/ensure")
return "GET /ensure"
class StartDevServer(t.Protocol):
def __call__(self, name: str = "standard", **kwargs: t.Any) -> DevServerClient: ...

# Each test that uses the fixture will have a different log.
xp_name = f"dev_server-{request.node.name}"
_, log_path = xprocess.ensure(xp_name, Starter, restart=True)
client.tail_log(log_path)

@request.addfinalizer
def close():
xprocess.getinfo(xp_name).terminate()
client.log.close()
@pytest.fixture()
def dev_server(tmp_path: Path) -> cabc.Iterator[StartDevServer]:
"""A function that will start a dev server in a subprocess and return a
client for interacting with the server.
"""
exit_stack = ExitStack()

def start_dev_server(name: str = "standard", **kwargs: t.Any) -> DevServerClient:
client = DevServerClient(name, tmp_path=tmp_path, **kwargs)
exit_stack.enter_context(client) # type: ignore[arg-type]
return client

return start_dev_server
with exit_stack:
yield start_dev_server


@pytest.fixture()
def standard_app(dev_server):
def standard_app(dev_server: t.Callable[..., DevServerClient]) -> DevServerClient:
"""Equivalent to ``dev_server("standard")``."""
return dev_server()
1 change: 0 additions & 1 deletion tests/middleware/test_http_proxy.py
Original file line number Diff line number Diff line change
@@ -5,7 +5,6 @@
from werkzeug.wrappers import Response


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_http_proxy(standard_app):
app = ProxyMiddleware(
17 changes: 14 additions & 3 deletions tests/test_datastructures.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import io
import pickle
import tempfile
import typing as t
from contextlib import contextmanager
from copy import copy
from copy import deepcopy
@@ -42,7 +41,7 @@ def items(self, multi=1):


class _MutableMultiDictTests:
storage_class: t.Type["ds.MultiDict"]
storage_class: type["ds.MultiDict"]

def test_pickle(self):
cls = self.storage_class
@@ -259,7 +258,7 @@ def test_basic_interface(self):


class _ImmutableDictTests:
storage_class: t.Type[dict]
storage_class: type[dict]

def test_follows_dict_interface(self):
cls = self.storage_class
@@ -973,6 +972,18 @@ def test_must_understand(self):
cc = ds.ResponseCacheControl()
assert cc.must_understand is False

def test_stale_while_revalidate(self):
cc = ds.ResponseCacheControl([("stale-while-revalidate", "1")])
assert cc.stale_while_revalidate == 1
cc = ds.ResponseCacheControl()
assert cc.stale_while_revalidate is None

def test_stale_if_error(self):
cc = ds.ResponseCacheControl([("stale-if-error", "1")])
assert cc.stale_if_error == 1
cc = ds.ResponseCacheControl()
assert cc.stale_while_revalidate is None


class TestContentSecurityPolicy:
def test_construct(self):
1 change: 0 additions & 1 deletion tests/test_debug.py
Original file line number Diff line number Diff line change
@@ -245,7 +245,6 @@ def test_get_machine_id():
assert isinstance(rv, bytes)


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize("crash", (True, False))
@pytest.mark.dev_server
def test_basic(dev_server, crash):
1 change: 1 addition & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@ def test_proxy_exception():
(exceptions.RequestEntityTooLarge, 413),
(exceptions.RequestURITooLarge, 414),
(exceptions.UnsupportedMediaType, 415),
(exceptions.MisdirectedRequest, 421),
(exceptions.UnprocessableEntity, 422),
(exceptions.Locked, 423),
(exceptions.InternalServerError, 500),
12 changes: 12 additions & 0 deletions tests/test_formparser.py
Original file line number Diff line number Diff line change
@@ -456,3 +456,15 @@ def test_file_rfc2231_filename_continuations(self):
) as request:
assert request.files["rfc2231"].filename == "a b c d e f.txt"
assert request.files["rfc2231"].read() == b"file contents"


def test_multipart_max_form_memory_size() -> None:
"""max_form_memory_size is tracked across multiple data events."""
data = b"--bound\r\nContent-Disposition: form-field; name=a\r\n\r\n"
data += b"a" * 15 + b"\r\n--bound--"
# The buffer size is less than the max size, so multiple data events will be
# returned. The field size is greater than the max.
parser = formparser.MultiPartParser(max_form_memory_size=10, buffer_size=5)

with pytest.raises(RequestEntityTooLarge):
parser.parse(io.BytesIO(data), b"bound", None)
19 changes: 12 additions & 7 deletions tests/test_security.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import os
import posixpath
import sys

import pytest
@@ -26,7 +25,7 @@ def test_scrypt():
def test_pbkdf2():
value = generate_password_hash("secret", method="pbkdf2")
assert check_password_hash(value, "secret")
assert value.startswith("pbkdf2:sha256:600000$")
assert value.startswith("pbkdf2:sha256:1000000$")


def test_salted_hashes():
@@ -47,11 +46,17 @@ def test_invalid_method():
generate_password_hash("secret", "sha256")


def test_safe_join():
assert safe_join("foo", "bar/baz") == posixpath.join("foo", "bar/baz")
assert safe_join("foo", "../bar/baz") is None
if os.name == "nt":
assert safe_join("foo", "foo\\bar") is None
@pytest.mark.parametrize(
("path", "expect"),
[
("b/c", "a/b/c"),
("../b/c", None),
("b\\c", None if os.name == "nt" else "a/b\\c"),
("//b/c", None),
],
)
def test_safe_join(path, expect):
assert safe_join("a", path) == expect


def test_safe_join_os_sep():
100 changes: 50 additions & 50 deletions tests/test_serving.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
from __future__ import annotations

import collections.abc as cabc
import http.client
import json
import os
import shutil
import socket
import ssl
import sys
import typing as t
from io import BytesIO
from pathlib import Path
from unittest.mock import Mock
from unittest.mock import patch

import pytest
@@ -24,8 +29,11 @@
from werkzeug.serving import make_ssl_devcert
from werkzeug.test import stream_encode_multipart

if t.TYPE_CHECKING:
from conftest import DevServerClient
from conftest import StartDevServer


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize(
"kwargs",
[
@@ -42,7 +50,9 @@
],
)
@pytest.mark.dev_server
def test_server(tmp_path, dev_server, kwargs: dict):
def test_server(
tmp_path: Path, dev_server: StartDevServer, kwargs: dict[str, t.Any]
) -> None:
if kwargs.get("hostname") == "unix":
kwargs["hostname"] = f"unix://{tmp_path / 'test.sock'}"

@@ -52,9 +62,8 @@ def test_server(tmp_path, dev_server, kwargs: dict):
assert r.json["PATH_INFO"] == "/"


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_untrusted_host(standard_app):
def test_untrusted_host(standard_app: DevServerClient) -> None:
r = standard_app.request(
"http://missing.test:1337/index.html#ignore",
headers={"x-base-url": standard_app.url},
@@ -66,45 +75,42 @@ def test_untrusted_host(standard_app):
assert r.json["SERVER_PORT"] == port


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_double_slash_path(standard_app):
def test_double_slash_path(standard_app: DevServerClient) -> None:
r = standard_app.request("//double-slash")
assert "double-slash" not in r.json["HTTP_HOST"]
assert r.json["PATH_INFO"] == "/double-slash"


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_500_error(standard_app):
def test_500_error(standard_app: DevServerClient) -> None:
r = standard_app.request("/crash")
assert r.status == 500
assert b"Internal Server Error" in r.data


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_ssl_dev_cert(tmp_path, dev_server):
client = dev_server(ssl_context=make_ssl_devcert(tmp_path))
def test_ssl_dev_cert(tmp_path: Path, dev_server: StartDevServer) -> None:
client = dev_server(ssl_context=make_ssl_devcert(os.fspath(tmp_path)))
r = client.request()
assert r.json["wsgi.url_scheme"] == "https"


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_ssl_object(dev_server):
def test_ssl_object(dev_server: StartDevServer) -> None:
client = dev_server(ssl_context="custom")
r = client.request()
assert r.json["wsgi.url_scheme"] == "https"


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize("reloader_type", ["stat", "watchdog"])
@pytest.mark.skipif(
os.name == "nt" and "CI" in os.environ, reason="unreliable on Windows during CI"
)
@pytest.mark.dev_server
def test_reloader_sys_path(tmp_path, dev_server, reloader_type):
def test_reloader_sys_path(
tmp_path: Path, dev_server: StartDevServer, reloader_type: str
) -> None:
"""This tests the general behavior of the reloader. It also tests
that fixing an import error triggers a reload, not just Python
retrying the failed import.
@@ -116,51 +122,51 @@ def test_reloader_sys_path(tmp_path, dev_server, reloader_type):
assert client.request().status == 500

shutil.copyfile(Path(__file__).parent / "live_apps" / "standard_app.py", real_path)
client.wait_for_log(f" * Detected change in {str(real_path)!r}, reloading")
client.wait_for_log(f"Detected change in {str(real_path)!r}")
client.wait_for_reload()
assert client.request().status == 200


@patch.object(WatchdogReloaderLoop, "trigger_reload")
def test_watchdog_reloader_ignores_opened(mock_trigger_reload):
def test_watchdog_reloader_ignores_opened(mock_trigger_reload: Mock) -> None:
reloader = WatchdogReloaderLoop()
modified_event = FileModifiedEvent("")
modified_event.event_type = EVENT_TYPE_MODIFIED
reloader.event_handler.on_any_event(modified_event)
mock_trigger_reload.assert_called_once()

reloader.trigger_reload.reset_mock()

mock_trigger_reload.reset_mock()
opened_event = FileModifiedEvent("")
opened_event.event_type = EVENT_TYPE_OPENED
reloader.event_handler.on_any_event(opened_event)
reloader.trigger_reload.assert_not_called()
mock_trigger_reload.assert_not_called()


@pytest.mark.skipif(
watchdog_version.VERSION_MAJOR < 5,
reason="'closed no write' event introduced in watchdog 5.0",
)
@patch.object(WatchdogReloaderLoop, "trigger_reload")
def test_watchdog_reloader_ignores_closed_no_write(mock_trigger_reload):
from watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE
def test_watchdog_reloader_ignores_closed_no_write(mock_trigger_reload: Mock) -> None:
from watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE # type: ignore[attr-defined]

reloader = WatchdogReloaderLoop()
modified_event = FileModifiedEvent("")
modified_event.event_type = EVENT_TYPE_MODIFIED
reloader.event_handler.on_any_event(modified_event)
mock_trigger_reload.assert_called_once()

reloader.trigger_reload.reset_mock()

mock_trigger_reload.reset_mock()
opened_event = FileModifiedEvent("")
opened_event.event_type = EVENT_TYPE_CLOSED_NO_WRITE
reloader.event_handler.on_any_event(opened_event)
reloader.trigger_reload.assert_not_called()
mock_trigger_reload.assert_not_called()


@pytest.mark.skipif(sys.version_info >= (3, 10), reason="not needed on >= 3.10")
def test_windows_get_args_for_reloading(monkeypatch, tmp_path):
def test_windows_get_args_for_reloading(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
argv = [str(tmp_path / "test.exe"), "run"]
monkeypatch.setattr("sys.executable", str(tmp_path / "python.exe"))
monkeypatch.setattr("sys.argv", argv)
@@ -170,9 +176,10 @@ def test_windows_get_args_for_reloading(monkeypatch, tmp_path):
assert rv == argv


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize("find", [_find_stat_paths, _find_watchdog_paths])
def test_exclude_patterns(find):
def test_exclude_patterns(
find: t.Callable[[set[str], set[str]], cabc.Iterable[str]],
) -> None:
# Select a path to exclude from the unfiltered list, assert that it is present and
# then gets excluded.
paths = find(set(), set())
@@ -184,9 +191,8 @@ def test_exclude_patterns(find):
assert not any(p.startswith(path_to_exclude) for p in paths)


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_wrong_protocol(standard_app):
def test_wrong_protocol(standard_app: DevServerClient) -> None:
"""An HTTPS request to an HTTP server doesn't show a traceback.
https://github.com/pallets/werkzeug/pull/838
"""
@@ -195,12 +201,11 @@ def test_wrong_protocol(standard_app):
with pytest.raises(ssl.SSLError):
conn.request("GET", f"https://{standard_app.addr}")

assert "Traceback" not in standard_app.log.read()
assert "Traceback" not in standard_app.read_log()


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_content_type_and_length(standard_app):
def test_content_type_and_length(standard_app: DevServerClient) -> None:
r = standard_app.request()
assert "CONTENT_TYPE" not in r.json
assert "CONTENT_LENGTH" not in r.json
@@ -210,15 +215,16 @@ def test_content_type_and_length(standard_app):
assert r.json["CONTENT_LENGTH"] == "2"


def test_port_is_int():
def test_port_is_int() -> None:
with pytest.raises(TypeError, match="port must be an integer"):
run_simple("127.0.0.1", "5000", None)
run_simple("127.0.0.1", "5000", None) # type: ignore[arg-type]


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.parametrize("send_length", [False, True])
@pytest.mark.dev_server
def test_chunked_request(monkeypatch, dev_server, send_length):
def test_chunked_request(
monkeypatch: pytest.MonkeyPatch, dev_server: StartDevServer, send_length: bool
) -> None:
stream, length, boundary = stream_encode_multipart(
{
"value": "this is text",
@@ -258,9 +264,8 @@ def test_chunked_request(monkeypatch, dev_server, send_length):
assert environ["wsgi.input_terminated"]


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_multiple_headers_concatenated(standard_app):
def test_multiple_headers_concatenated(standard_app: DevServerClient) -> None:
"""A header key can be sent multiple times. The server will join all
the values with commas.
@@ -283,9 +288,8 @@ def test_multiple_headers_concatenated(standard_app):
assert data["HTTP_XYZ"] == "a ,b,c ,d"


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_multiline_header_folding(standard_app):
def test_multiline_header_folding(standard_app: DevServerClient) -> None:
"""A header value can be split over multiple lines with a leading
tab. The server will remove the newlines and preserve the tabs.
@@ -303,9 +307,8 @@ def test_multiline_header_folding(standard_app):


@pytest.mark.parametrize("endpoint", ["", "crash"])
@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_streaming_close_response(dev_server, endpoint):
def test_streaming_close_response(dev_server: StartDevServer, endpoint: str) -> None:
"""When using HTTP/1.0, chunked encoding is not supported. Fall
back to Connection: close, but this allows no reliable way to
distinguish between complete and truncated responses.
@@ -315,9 +318,8 @@ def test_streaming_close_response(dev_server, endpoint):
assert r.data == "".join(str(x) + "\n" for x in range(5)).encode()


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_streaming_chunked_response(dev_server):
def test_streaming_chunked_response(dev_server: StartDevServer) -> None:
"""When using HTTP/1.1, use Transfer-Encoding: chunked for streamed
responses, since it can distinguish the end of the response without
closing the connection.
@@ -329,22 +331,20 @@ def test_streaming_chunked_response(dev_server):
assert r.data == "".join(str(x) + "\n" for x in range(5)).encode()


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_streaming_chunked_truncation(dev_server):
def test_streaming_chunked_truncation(dev_server: StartDevServer) -> None:
"""When using HTTP/1.1, chunked encoding allows the client to detect
content truncated by a prematurely closed connection.
"""
with pytest.raises(http.client.IncompleteRead):
dev_server("streaming", threaded=True).request("/crash")


@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning")
@pytest.mark.dev_server
def test_host_with_ipv6_scope(dev_server):
def test_host_with_ipv6_scope(dev_server: StartDevServer) -> None:
client = dev_server(override_client_addr="fe80::1ff:fe23:4567:890a%eth2")
r = client.request("/crash")

assert r.status == 500
assert b"Internal Server Error" in r.data
assert "Logging error" not in client.log.read()
assert "Logging error" not in client.read_log()
13 changes: 1 addition & 12 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tox]
envlist =
py3{13,12,11,10,9,8}
py3{13,12,11,10,9}
pypy310
style
typing
@@ -15,9 +15,6 @@ use_frozen_constraints = true
deps = -r requirements/tests.txt
commands = pytest -v --tb=short --basetemp={envtmpdir} {posargs}

[testenv:py38,py3.8]
deps = -r requirements/tests38.txt

[testenv:style]
deps = pre-commit
skip_install = true
@@ -53,11 +50,3 @@ commands =
pip-compile tests.in -q {posargs:-U}
pip-compile typing.in -q {posargs:-U}
pip-compile dev.in -q {posargs:-U}

[testenv:update-requirements38]
base_python = 3.8
labels = update
deps = pip-tools
skip_install = true
change_dir = requirements
commands = pip-compile tests.in -q -o tests38.txt {posargs:-U}