diff --git a/docs/applications.md b/docs/applications.md index 6fb74f19f..b90c20f32 100644 --- a/docs/applications.md +++ b/docs/applications.md @@ -3,6 +3,8 @@ Starlette includes an application class `Starlette` that nicely ties together al its other functionality. ```python +from contextlib import asynccontextmanager + from starlette.applications import Starlette from starlette.responses import PlainTextResponse from starlette.routing import Route, Mount, WebSocketRoute @@ -25,8 +27,11 @@ async def websocket_endpoint(websocket): await websocket.send_text('Hello, websocket!') await websocket.close() -def startup(): - print('Ready to go') +@asyncontextmanager +async def lifespan(app): + print('Startup') + yield + print('Shutdown') routes = [ @@ -37,7 +42,7 @@ routes = [ Mount('/static', StaticFiles(directory="static")), ] -app = Starlette(debug=True, routes=routes, on_startup=[startup]) +app = Starlette(debug=True, routes=routes, lifespan=lifespan) ``` ### Instantiating the application diff --git a/docs/index.md b/docs/index.md index cc243b43c..faf1c7c55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,3 @@ ---- -hide: navigation ---- -

starlette starlette @@ -52,18 +48,18 @@ It is production-ready, and gives you the following: ## Installation ```shell -$ pip install starlette +pip install starlette ``` You'll also want to install an ASGI server, such as [uvicorn](https://www.uvicorn.org/), [daphne](https://github.com/django/daphne/), or [hypercorn](https://hypercorn.readthedocs.io/en/latest/). ```shell -$ pip install uvicorn +pip install uvicorn ``` ## Example -```python title="example.py" +```python title="main.py" from starlette.applications import Starlette from starlette.responses import JSONResponse from starlette.routing import Route @@ -81,11 +77,9 @@ app = Starlette(debug=True, routes=[ Then run the application... ```shell -$ uvicorn example:app +uvicorn main:app ``` -For a more complete example, [see here](https://github.com/encode/starlette-example). - ## Dependencies Starlette only requires `anyio`, and the following dependencies are optional: @@ -103,7 +97,7 @@ You can install all of these with `pip install starlette[full]`. Starlette is designed to be used either as a complete framework, or as an ASGI toolkit. You can use any of its components independently. -```python +```python title="main.py" from starlette.responses import PlainTextResponse @@ -113,10 +107,10 @@ async def app(scope, receive, send): await response(scope, receive, send) ``` -Run the `app` application in `example.py`: +Run the `app` application in `main.py`: ```shell -$ uvicorn example:app +$ uvicorn main:app INFO: Started server process [11509] INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) ``` diff --git a/docs/release-notes.md b/docs/release-notes.md index 1b56b342a..c484234f3 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,8 +1,15 @@ --- -hide: navigation toc_depth: 2 --- +## 0.41.3 (November 18, 2024) + +#### Fixed + +* Exclude the query parameters from the `scope[raw_path]` on the `TestClient` [#2716](https://github.com/encode/starlette/pull/2716). +* Replace `dict` by `Mapping` on `HTTPException.headers` [#2749](https://github.com/encode/starlette/pull/2749). +* Correct middleware argument passing and improve factory pattern [#2752](https://github.com/encode/starlette/2752). + ## 0.41.2 (October 27, 2024) #### Fixed diff --git a/mkdocs.yml b/mkdocs.yml index ad503317d..83d245e04 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,7 +21,6 @@ theme: repo: fontawesome/brands/github features: - content.code.copy - - navigation.tabs - toc.follow repo_name: encode/starlette @@ -29,7 +28,7 @@ repo_url: https://github.com/encode/starlette edit_uri: edit/master/docs/ nav: - - Home: "index.md" + - Introduction: "index.md" - Features: - Applications: "applications.md" - Requests: "requests.md" diff --git a/starlette/__init__.py b/starlette/__init__.py index b0416f28d..ef838c985 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1 +1 @@ -__version__ = "0.41.2" +__version__ = "0.41.3" diff --git a/starlette/applications.py b/starlette/applications.py index 0feae72e4..aae38f588 100644 --- a/starlette/applications.py +++ b/starlette/applications.py @@ -10,7 +10,7 @@ from typing_extensions import ParamSpec from starlette.datastructures import State, URLPath -from starlette.middleware import Middleware, _MiddlewareClass +from starlette.middleware import Middleware, _MiddlewareFactory from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.errors import ServerErrorMiddleware from starlette.middleware.exceptions import ExceptionMiddleware @@ -96,7 +96,7 @@ def build_middleware_stack(self) -> ASGIApp: app = self.router for cls, args, kwargs in reversed(middleware): - app = cls(app=app, *args, **kwargs) + app = cls(app, *args, **kwargs) return app @property @@ -123,7 +123,7 @@ def host(self, host: str, app: ASGIApp, name: str | None = None) -> None: def add_middleware( self, - middleware_class: type[_MiddlewareClass[P]], + middleware_class: _MiddlewareFactory[P], *args: P.args, **kwargs: P.kwargs, ) -> None: diff --git a/starlette/exceptions.py b/starlette/exceptions.py index bd3352eb0..c48f0838a 100644 --- a/starlette/exceptions.py +++ b/starlette/exceptions.py @@ -12,7 +12,7 @@ def __init__( self, status_code: int, detail: str | None = None, - headers: dict[str, str] | None = None, + headers: typing.Mapping[str, str] | None = None, ) -> None: if detail is None: detail = http.HTTPStatus(status_code).phrase diff --git a/starlette/middleware/__init__.py b/starlette/middleware/__init__.py index 8566aac08..8e0a54edb 100644 --- a/starlette/middleware/__init__.py +++ b/starlette/middleware/__init__.py @@ -8,21 +8,19 @@ else: # pragma: no cover from typing_extensions import ParamSpec -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import ASGIApp P = ParamSpec("P") -class _MiddlewareClass(Protocol[P]): - def __init__(self, app: ASGIApp, *args: P.args, **kwargs: P.kwargs) -> None: ... # pragma: no cover - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ... # pragma: no cover +class _MiddlewareFactory(Protocol[P]): + def __call__(self, app: ASGIApp, *args: P.args, **kwargs: P.kwargs) -> ASGIApp: ... # pragma: no cover class Middleware: def __init__( self, - cls: type[_MiddlewareClass[P]], + cls: _MiddlewareFactory[P], *args: P.args, **kwargs: P.kwargs, ) -> None: @@ -38,5 +36,6 @@ def __repr__(self) -> str: class_name = self.__class__.__name__ args_strings = [f"{value!r}" for value in self.args] option_strings = [f"{key}={value!r}" for key, value in self.kwargs.items()] - args_repr = ", ".join([self.cls.__name__] + args_strings + option_strings) + name = getattr(self.cls, "__name__", "") + args_repr = ", ".join([name] + args_strings + option_strings) return f"{class_name}({args_repr})" diff --git a/starlette/routing.py b/starlette/routing.py index 1504ef50a..3b3c52968 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -236,7 +236,7 @@ def __init__( if middleware is not None: for cls, args, kwargs in reversed(middleware): - self.app = cls(app=self.app, *args, **kwargs) + self.app = cls(self.app, *args, **kwargs) if methods is None: self.methods = None @@ -328,7 +328,7 @@ def __init__( if middleware is not None: for cls, args, kwargs in reversed(middleware): - self.app = cls(app=self.app, *args, **kwargs) + self.app = cls(self.app, *args, **kwargs) self.path_regex, self.path_format, self.param_convertors = compile_path(path) @@ -388,7 +388,7 @@ def __init__( self.app = self._base_app if middleware is not None: for cls, args, kwargs in reversed(middleware): - self.app = cls(app=self.app, *args, **kwargs) + self.app = cls(self.app, *args, **kwargs) self.name = name self.path_regex, self.path_format, self.param_convertors = compile_path(self.path + "/{path:path}") diff --git a/starlette/testclient.py b/starlette/testclient.py index 5143c4c57..645ca1090 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -281,7 +281,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: scope = { "type": "websocket", "path": unquote(path), - "raw_path": raw_path, + "raw_path": raw_path.split(b"?", 1)[0], "root_path": self.root_path, "scheme": scheme, "query_string": query.encode(), @@ -300,7 +300,7 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: "http_version": "1.1", "method": request.method, "path": unquote(path), - "raw_path": raw_path, + "raw_path": raw_path.split(b"?", 1)[0], "root_path": self.root_path, "scheme": scheme, "query_string": query.encode(), diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py index 041cc7ce2..fa0cba479 100644 --- a/tests/middleware/test_base.py +++ b/tests/middleware/test_base.py @@ -10,7 +10,7 @@ from starlette.applications import Starlette from starlette.background import BackgroundTask -from starlette.middleware import Middleware, _MiddlewareClass +from starlette.middleware import Middleware, _MiddlewareFactory from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from starlette.requests import ClientDisconnect, Request from starlette.responses import PlainTextResponse, Response, StreamingResponse @@ -232,7 +232,7 @@ async def dispatch( ) def test_contextvars( test_client_factory: TestClientFactory, - middleware_cls: type[_MiddlewareClass[Any]], + middleware_cls: _MiddlewareFactory[Any], ) -> None: # this has to be an async endpoint because Starlette calls run_in_threadpool # on sync endpoints which has it's own set of peculiarities w.r.t propagating diff --git a/tests/test_applications.py b/tests/test_applications.py index 056044438..29c011a29 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import os from contextlib import asynccontextmanager from pathlib import Path @@ -533,6 +535,48 @@ def get_app() -> ASGIApp: assert SimpleInitializableMiddleware.counter == 2 +def test_middleware_args(test_client_factory: TestClientFactory) -> None: + calls: list[str] = [] + + class MiddlewareWithArgs: + def __init__(self, app: ASGIApp, arg: str) -> None: + self.app = app + self.arg = arg + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + calls.append(self.arg) + await self.app(scope, receive, send) + + app = Starlette() + app.add_middleware(MiddlewareWithArgs, "foo") + app.add_middleware(MiddlewareWithArgs, "bar") + + with test_client_factory(app): + pass + + assert calls == ["bar", "foo"] + + +def test_middleware_factory(test_client_factory: TestClientFactory) -> None: + calls: list[str] = [] + + def _middleware_factory(app: ASGIApp, arg: str) -> ASGIApp: + async def _app(scope: Scope, receive: Receive, send: Send) -> None: + calls.append(arg) + await app(scope, receive, send) + + return _app + + app = Starlette() + app.add_middleware(_middleware_factory, arg="foo") + app.add_middleware(_middleware_factory, arg="bar") + + with test_client_factory(app): + pass + + assert calls == ["bar", "foo"] + + def test_lifespan_app_subclass() -> None: # This test exists to make sure that subclasses of Starlette # (like FastAPI) are compatible with the types hints for Lifespan diff --git a/tests/test_testclient.py b/tests/test_testclient.py index 92f16d336..68593a9ac 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -378,3 +378,27 @@ def homepage(request: Request) -> Response: client = test_client_factory(app, base_url="http://testserver/api/v1/") response = client.get("/bar") assert response.text == "/api/v1/bar" + + +def test_raw_path_with_querystring(test_client_factory: TestClientFactory) -> None: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + response = Response(scope.get("raw_path")) + await response(scope, receive, send) + + client = test_client_factory(app) + response = client.get("/hello-world", params={"foo": "bar"}) + assert response.content == b"/hello-world" + + +def test_websocket_raw_path_without_params(test_client_factory: TestClientFactory) -> None: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + websocket = WebSocket(scope, receive=receive, send=send) + await websocket.accept() + raw_path = scope.get("raw_path") + assert raw_path is not None + await websocket.send_bytes(raw_path) + + client = test_client_factory(app) + with client.websocket_connect("/hello-world", params={"foo": "bar"}) as websocket: + data = websocket.receive_bytes() + assert data == b"/hello-world"