diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 0bb570ced..fac946039 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: "actions/checkout@v4" diff --git a/README.md b/README.md index 1357f9c0e..7427c3bdc 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Python 3.8+ $ pip3 install starlette ``` -You'll also want to install an ASGI server, such as [uvicorn](http://www.uvicorn.org/), [daphne](https://github.com/django/daphne/), or [hypercorn](https://pgjones.gitlab.io/hypercorn/). +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 $ pip3 install uvicorn diff --git a/docs/index.md b/docs/index.md index 9500c0fc1..44f0c3708 100644 --- a/docs/index.md +++ b/docs/index.md @@ -46,7 +46,7 @@ Python 3.8+ $ pip3 install starlette ``` -You'll also want to install an ASGI server, such as [uvicorn](http://www.uvicorn.org/), [daphne](https://github.com/django/daphne/), or [hypercorn](https://pgjones.gitlab.io/hypercorn/). +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 $ pip3 install uvicorn diff --git a/docs/release-notes.md b/docs/release-notes.md index b5e2124c5..1fd9e79da 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,3 +1,38 @@ +## 0.38.2 + +July 27, 2024 + +#### Fixed + +* Not assume all routines have `__name__` on `routing.get_name()` [#2648](https://github.com/encode/starlette/pull/2648). + +## 0.38.1 + +July 23, 2024 + +#### Removed + +* Revert "Add support for ASGI pathsend extension" [#2649](https://github.com/encode/starlette/pull/2649). + +## 0.38.0 + +July 20, 2024 + +#### Added + +* Allow use of `memoryview` in `StreamingResponse` and `Response` [#2576](https://github.com/encode/starlette/pull/2576) + and [#2577](https://github.com/encode/starlette/pull/2577). +* Send 404 instead of 500 when filename requested is too long on `StaticFiles` [#2583](https://github.com/encode/starlette/pull/2583). + +#### Changed + +* Fail fast on invalid `Jinja2Template` instantiation parameters [#2568](https://github.com/encode/starlette/pull/2568). +* Check endpoint handler is async only once [#2536](https://github.com/encode/starlette/pull/2536). + +#### Fixed + +* Add proper synchronization to `WebSocketTestSession` [#2597](https://github.com/encode/starlette/pull/2597). + ## 0.37.2 March 5, 2024 diff --git a/docs/third-party-packages.md b/docs/third-party-packages.md index c65a77348..9632a8bfe 100644 --- a/docs/third-party-packages.md +++ b/docs/third-party-packages.md @@ -66,6 +66,13 @@ Simple APISpec integration for Starlette. Document your REST API built with Starlette by declaring OpenAPI (Swagger) schemas in YAML format in your endpoint's docstrings. +### Starlette Compress + +GitHub + +Starlette-Compress is a fast and simple middleware for compressing responses in Starlette. +It adds ZStd, Brotli, and GZip compression support with sensible default configuration. + ### Starlette Context GitHub @@ -148,6 +155,12 @@ Built with [Tabler](https://tabler.io/) and [Datatables](https://datatables.net/ to quickly generate fully customizable admin interface for your models. You can export your data to many formats (*CSV*, *PDF*, *Excel*, etc), filter your data with complex query including `AND` and `OR` conditions, upload files, ... +### Vellox + +GitHub + +Serverless ASGI adapter for GCP Cloud Functions. + ## Starlette Bridge GitHub | @@ -247,3 +260,10 @@ Allows mounting [package resources](https://docs.python.org/3/library/importlib. Documentation Sentry is a software error detection tool. It offers actionable insights for resolving performance issues and errors, allowing users to diagnose, fix, and optimize Python debugging. Additionally, it integrates seamlessly with Starlette for Python application development. Sentry's capabilities include error tracking, performance insights, contextual information, and alerts/notifications. + +### Shiny + +GitHub | +Documentation + +Leveraging Starlette and asyncio, Shiny allows developers to create effortless Python web applications using the power of reactive programming. Shiny eliminates the hassle of manual state management, automatically determining the best execution path for your app at runtime while simultaneously minimizing re-rendering. This means that Shiny can support everything from the simplest dashboard to full-featured web apps. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 679deaade..f2721c870 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Internet :: WWW/HTTP", ] dependencies = [ @@ -51,6 +52,7 @@ path = "starlette/__init__.py" [tool.ruff.lint] select = ["E", "F", "I", "FA", "UP"] +ignore = ["UP031"] [tool.ruff.lint.isort] combine-as-imports = true @@ -65,7 +67,7 @@ module = "starlette.testclient.*" implicit_optional = true [tool.pytest.ini_options] -addopts = "-rxXs --strict-config --strict-markers" +addopts = "-rXs --strict-config --strict-markers" xfail_strict = true filterwarnings = [ # Turn warnings that aren't filtered into exceptions @@ -78,6 +80,8 @@ filterwarnings = [ "ignore: The `allow_redirects` argument is deprecated. Use `follow_redirects` instead.:DeprecationWarning", "ignore: 'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", "ignore: You seem to already have a custom sys.excepthook handler installed. I'll skip installing Trio's custom handler, but this means MultiErrors will not show full tracebacks.:RuntimeWarning", + # TODO: This warning appeared when we bumped anyio to 4.4.0. + "ignore: Unclosed .MemoryObject(Send|Receive)Stream.:ResourceWarning", ] [tool.coverage.run] diff --git a/requirements.txt b/requirements.txt index 5652a865e..669700d0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,22 +2,24 @@ -e .[full] # Testing -coverage==7.4.3 -importlib-metadata==7.0.1 -mypy==1.8.0 -ruff==0.1.15 -typing_extensions==4.10.0 +coverage==7.6.0 +importlib-metadata==8.2.0 +mypy==1.11.1 +ruff==0.5.5 +typing_extensions==4.12.2 types-contextvars==2.4.7.3 -types-PyYAML==6.0.12.12 +types-PyYAML==6.0.12.20240724 types-dataclasses==0.6.6 -pytest==8.0.2 -trio==0.24.0 +pytest==8.3.2 +trio==0.26.1 +# TODO: Remove when trio is updated. +attrs==23.2.0 # Documentation -mkdocs==1.5.3 -mkdocs-material==9.5.12 +mkdocs==1.6.0 +mkdocs-material==9.5.30 mkautodoc==0.2.0 # Packaging -build==1.1.1 -twine==5.0.0 +build==1.2.1 +twine==5.1.1 diff --git a/scripts/lint b/scripts/lint index 586aedfc3..19dbc7db4 100755 --- a/scripts/lint +++ b/scripts/lint @@ -9,4 +9,4 @@ export SOURCE_FILES="starlette tests" set -x ${PREFIX}ruff format $SOURCE_FILES -${PREFIX}ruff --fix $SOURCE_FILES +${PREFIX}ruff check --fix $SOURCE_FILES diff --git a/starlette/__init__.py b/starlette/__init__.py index 4a2dd331e..52d9aaa46 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1 +1 @@ -__version__ = "0.37.2" +__version__ = "0.38.2" diff --git a/starlette/_utils.py b/starlette/_utils.py index 634e54882..5d1d2311f 100644 --- a/starlette/_utils.py +++ b/starlette/_utils.py @@ -26,13 +26,11 @@ @typing.overload -def is_async_callable(obj: AwaitableCallable[T]) -> TypeGuard[AwaitableCallable[T]]: - ... +def is_async_callable(obj: AwaitableCallable[T]) -> TypeGuard[AwaitableCallable[T]]: ... @typing.overload -def is_async_callable(obj: typing.Any) -> TypeGuard[AwaitableCallable[typing.Any]]: - ... +def is_async_callable(obj: typing.Any) -> TypeGuard[AwaitableCallable[typing.Any]]: ... def is_async_callable(obj: typing.Any) -> typing.Any: @@ -49,13 +47,11 @@ def is_async_callable(obj: typing.Any) -> typing.Any: class AwaitableOrContextManager( typing.Awaitable[T_co], typing.AsyncContextManager[T_co], typing.Protocol[T_co] -): - ... +): ... class SupportsAsyncClose(typing.Protocol): - async def close(self) -> None: - ... # pragma: no cover + async def close(self) -> None: ... # pragma: no cover SupportsAsyncCloseType = typing.TypeVar( diff --git a/starlette/config.py b/starlette/config.py index 5b9813bea..4c3dfe5b0 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -68,16 +68,13 @@ def __init__( self.file_values = self._read_file(env_file) @typing.overload - def __call__(self, key: str, *, default: None) -> str | None: - ... + def __call__(self, key: str, *, default: None) -> str | None: ... @typing.overload - def __call__(self, key: str, cast: type[T], default: T = ...) -> T: - ... + def __call__(self, key: str, cast: type[T], default: T = ...) -> T: ... @typing.overload - def __call__(self, key: str, cast: type[str] = ..., default: str = ...) -> str: - ... + def __call__(self, key: str, cast: type[str] = ..., default: str = ...) -> str: ... @typing.overload def __call__( @@ -85,12 +82,12 @@ def __call__( key: str, cast: typing.Callable[[typing.Any], T] = ..., default: typing.Any = ..., - ) -> T: - ... + ) -> T: ... @typing.overload - def __call__(self, key: str, cast: type[str] = ..., default: T = ...) -> T | str: - ... + def __call__( + self, key: str, cast: type[str] = ..., default: T = ... + ) -> T | str: ... def __call__( self, diff --git a/starlette/middleware/__init__.py b/starlette/middleware/__init__.py index 3d0342dc3..d9e64f574 100644 --- a/starlette/middleware/__init__.py +++ b/starlette/middleware/__init__.py @@ -14,11 +14,13 @@ class _MiddlewareClass(Protocol[P]): - def __init__(self, app: ASGIApp, *args: P.args, **kwargs: P.kwargs) -> None: - ... # pragma: no cover + 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 + async def __call__( + self, scope: Scope, receive: Receive, send: Send + ) -> None: ... # pragma: no cover class Middleware: diff --git a/starlette/middleware/errors.py b/starlette/middleware/errors.py index e9eba62b0..3fc4a4402 100644 --- a/starlette/middleware/errors.py +++ b/starlette/middleware/errors.py @@ -2,6 +2,7 @@ import html import inspect +import sys import traceback import typing @@ -237,11 +238,13 @@ def generate_html(self, exc: Exception, limit: int = 7) -> str: exc_html += self.generate_frame_html(frame, is_collapsed) is_collapsed = True + if sys.version_info >= (3, 13): # pragma: no cover + exc_type_str = traceback_obj.exc_type_str + else: # pragma: no cover + exc_type_str = traceback_obj.exc_type.__name__ + # escape error class and text - error = ( - f"{html.escape(traceback_obj.exc_type.__name__)}: " - f"{html.escape(str(traceback_obj))}" - ) + error = f"{html.escape(exc_type_str)}: {html.escape(str(traceback_obj))}" return TEMPLATE.format(styles=STYLES, js=JS, error=error, exc_html=exc_html) diff --git a/starlette/middleware/gzip.py b/starlette/middleware/gzip.py index cbb0f4a5b..0579e0410 100644 --- a/starlette/middleware/gzip.py +++ b/starlette/middleware/gzip.py @@ -41,7 +41,8 @@ def __init__(self, app: ASGIApp, minimum_size: int, compresslevel: int = 9) -> N async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: self.send = send - await self.app(scope, receive, self.send_with_gzip) + with self.gzip_buffer, self.gzip_file: + await self.app(scope, receive, self.send_with_gzip) async def send_with_gzip(self, message: Message) -> None: message_type = message["type"] diff --git a/starlette/responses.py b/starlette/responses.py index 047ff9561..4f15404ca 100644 --- a/starlette/responses.py +++ b/starlette/responses.py @@ -41,10 +41,10 @@ def __init__( self.body = self.render(content) self.init_headers(headers) - def render(self, content: typing.Any) -> bytes: + def render(self, content: typing.Any) -> bytes | memoryview: if content is None: return b"" - if isinstance(content, bytes): + if isinstance(content, (bytes, memoryview)): return content return content.encode(self.charset) # type: ignore @@ -94,7 +94,7 @@ def set_cookie( value: str = "", max_age: int | None = None, expires: datetime | str | int | None = None, - path: str = "/", + path: str | None = "/", domain: str | None = None, secure: bool = False, httponly: bool = False, @@ -299,12 +299,10 @@ def __init__( if self.filename is not None: content_disposition_filename = quote(self.filename) if content_disposition_filename != self.filename: - content_disposition = "{}; filename*=utf-8''{}".format( - content_disposition_type, content_disposition_filename - ) + content_disposition = f"{content_disposition_type}; filename*=utf-8''{content_disposition_filename}" # noqa: E501 else: - content_disposition = '{}; filename="{}"'.format( - content_disposition_type, self.filename + content_disposition = ( + f'{content_disposition_type}; filename="{self.filename}"' ) self.headers.setdefault("content-disposition", content_disposition) self.stat_result = stat_result @@ -341,8 +339,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ) if scope["method"].upper() == "HEAD": await send({"type": "http.response.body", "body": b"", "more_body": False}) - elif "extensions" in scope and "http.response.pathsend" in scope["extensions"]: - await send({"type": "http.response.pathsend", "path": str(self.path)}) else: async with await anyio.open_file(self.path, mode="rb") as file: more_body = True diff --git a/starlette/routing.py b/starlette/routing.py index 75a5ec3f3..481b13f5d 100644 --- a/starlette/routing.py +++ b/starlette/routing.py @@ -99,9 +99,7 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: def get_name(endpoint: typing.Callable[..., typing.Any]) -> str: - if inspect.isroutine(endpoint) or inspect.isclass(endpoint): - return endpoint.__name__ - return endpoint.__class__.__name__ + return getattr(endpoint, "__name__", endpoint.__class__.__name__) def replace_params( diff --git a/starlette/staticfiles.py b/starlette/staticfiles.py index 5d0856ccc..afb09b56b 100644 --- a/starlette/staticfiles.py +++ b/starlette/staticfiles.py @@ -1,5 +1,6 @@ from __future__ import annotations +import errno import importlib.util import os import stat @@ -124,8 +125,12 @@ async def get_response(self, path: str, scope: Scope) -> Response: ) except PermissionError: raise HTTPException(status_code=401) - except OSError: - raise + except OSError as exc: + # Filename is too long, so it can't be a valid static file. + if exc.errno == errno.ENAMETOOLONG: + raise HTTPException(status_code=404) + + raise exc if stat_result and stat.S_ISREG(stat_result.st_mode): # We have a static file to serve. diff --git a/starlette/status.py b/starlette/status.py index 2cd5db575..93ae7a17c 100644 --- a/starlette/status.py +++ b/starlette/status.py @@ -5,6 +5,7 @@ And RFC 2324 - https://tools.ietf.org/html/rfc2324 """ + from __future__ import annotations import warnings diff --git a/starlette/templating.py b/starlette/templating.py index 01af5e9bb..aae2cbe24 100644 --- a/starlette/templating.py +++ b/starlette/templating.py @@ -66,15 +66,12 @@ class Jinja2Templates: @typing.overload def __init__( self, - directory: str - | PathLike[typing.AnyStr] - | typing.Sequence[str | PathLike[typing.AnyStr]], + directory: str | PathLike[str] | typing.Sequence[str | PathLike[str]], *, context_processors: list[typing.Callable[[Request], dict[str, typing.Any]]] | None = None, **env_options: typing.Any, - ) -> None: - ... + ) -> None: ... @typing.overload def __init__( @@ -83,14 +80,13 @@ def __init__( env: jinja2.Environment, context_processors: list[typing.Callable[[Request], dict[str, typing.Any]]] | None = None, - ) -> None: - ... + ) -> None: ... def __init__( self, directory: str - | PathLike[typing.AnyStr] - | typing.Sequence[str | PathLike[typing.AnyStr]] + | PathLike[str] + | typing.Sequence[str | PathLike[str]] | None = None, *, context_processors: list[typing.Callable[[Request], dict[str, typing.Any]]] @@ -117,9 +113,7 @@ def __init__( def _create_env( self, - directory: str - | PathLike[typing.AnyStr] - | typing.Sequence[str | PathLike[typing.AnyStr]], + directory: str | PathLike[str] | typing.Sequence[str | PathLike[str]], **env_options: typing.Any, ) -> jinja2.Environment: loader = jinja2.FileSystemLoader(directory) @@ -154,8 +148,7 @@ def TemplateResponse( headers: typing.Mapping[str, str] | None = None, media_type: str | None = None, background: BackgroundTask | None = None, - ) -> _TemplateResponse: - ... + ) -> _TemplateResponse: ... @typing.overload def TemplateResponse( diff --git a/starlette/testclient.py b/starlette/testclient.py index f17d4e892..bf928d23f 100644 --- a/starlette/testclient.py +++ b/starlette/testclient.py @@ -160,7 +160,8 @@ async def run_app(tg: anyio.abc.TaskGroup) -> None: async def _asgi_receive(self) -> Message: while self._receive_queue.empty(): - await anyio.sleep(0) + self._queue_event = anyio.Event() + await self._queue_event.wait() return self._receive_queue.get() async def _asgi_send(self, message: Message) -> None: @@ -189,6 +190,8 @@ def _raise_on_close(self, message: Message) -> None: def send(self, message: Message) -> None: self._receive_queue.put(message) + if hasattr(self, "_queue_event"): + self.portal.start_task_soon(self._queue_event.set) def send_text(self, data: str) -> None: self.send({"type": "websocket.receive", "text": data}) diff --git a/tests/conftest.py b/tests/conftest.py index 724ca65d3..4db3ae018 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,12 @@ from __future__ import annotations import functools -from typing import Any, Callable, Literal +from typing import Any, Literal import pytest from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory @pytest.fixture diff --git a/tests/middleware/test_base.py b/tests/middleware/test_base.py index 2176404d8..3ad1751a2 100644 --- a/tests/middleware/test_base.py +++ b/tests/middleware/test_base.py @@ -5,7 +5,6 @@ from typing import ( Any, AsyncGenerator, - Callable, Generator, ) @@ -23,8 +22,7 @@ from starlette.testclient import TestClient from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.websockets import WebSocket - -TestClientFactory = Callable[[ASGIApp], TestClient] +from tests.types import TestClientFactory class CustomMiddleware(BaseHTTPMiddleware): diff --git a/tests/middleware/test_cors.py b/tests/middleware/test_cors.py index 09ec9513f..630361243 100644 --- a/tests/middleware/test_cors.py +++ b/tests/middleware/test_cors.py @@ -1,15 +1,10 @@ -from typing import Callable - from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.routing import Route -from starlette.testclient import TestClient -from starlette.types import ASGIApp - -TestClientFactory = Callable[[ASGIApp], TestClient] +from tests.types import TestClientFactory def test_cors_allow_all( diff --git a/tests/middleware/test_errors.py b/tests/middleware/test_errors.py index a2dbabd8a..e32f406ae 100644 --- a/tests/middleware/test_errors.py +++ b/tests/middleware/test_errors.py @@ -1,4 +1,4 @@ -from typing import Any, Callable +from typing import Any import pytest @@ -8,10 +8,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route -from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_handler( diff --git a/tests/middleware/test_gzip.py b/tests/middleware/test_gzip.py index 5bfecadb7..b6f68296d 100644 --- a/tests/middleware/test_gzip.py +++ b/tests/middleware/test_gzip.py @@ -1,15 +1,10 @@ -from typing import Callable - from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.gzip import GZipMiddleware from starlette.requests import Request from starlette.responses import ContentStream, PlainTextResponse, StreamingResponse from starlette.routing import Route -from starlette.testclient import TestClient -from starlette.types import ASGIApp - -TestClientFactory = Callable[[ASGIApp], TestClient] +from tests.types import TestClientFactory def test_gzip_responses(test_client_factory: TestClientFactory) -> None: diff --git a/tests/middleware/test_https_redirect.py b/tests/middleware/test_https_redirect.py index 9195694a3..22dfc14b6 100644 --- a/tests/middleware/test_https_redirect.py +++ b/tests/middleware/test_https_redirect.py @@ -1,14 +1,10 @@ -from typing import Callable - from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.routing import Route -from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_https_redirect_middleware(test_client_factory: TestClientFactory) -> None: diff --git a/tests/middleware/test_session.py b/tests/middleware/test_session.py index 4fbeec88c..9a0d70a0d 100644 --- a/tests/middleware/test_session.py +++ b/tests/middleware/test_session.py @@ -1,5 +1,4 @@ import re -from typing import Callable from starlette.applications import Starlette from starlette.middleware import Middleware @@ -8,8 +7,7 @@ from starlette.responses import JSONResponse from starlette.routing import Mount, Route from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def view_session(request: Request) -> JSONResponse: diff --git a/tests/middleware/test_trusted_host.py b/tests/middleware/test_trusted_host.py index 466302210..ddff46c48 100644 --- a/tests/middleware/test_trusted_host.py +++ b/tests/middleware/test_trusted_host.py @@ -1,14 +1,10 @@ -from typing import Callable - from starlette.applications import Starlette from starlette.middleware import Middleware from starlette.middleware.trustedhost import TrustedHostMiddleware from starlette.requests import Request from starlette.responses import PlainTextResponse from starlette.routing import Route -from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_trusted_host_middleware(test_client_factory: TestClientFactory) -> None: diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py index 69842d3ad..58696bb65 100644 --- a/tests/middleware/test_wsgi.py +++ b/tests/middleware/test_wsgi.py @@ -5,10 +5,9 @@ from starlette._utils import collapse_excgroups from starlette.middleware.wsgi import WSGIMiddleware, build_environ -from starlette.testclient import TestClient +from tests.types import TestClientFactory WSGIResponse = Iterable[bytes] -TestClientFactory = Callable[..., TestClient] StartResponse = Callable[..., Any] Environment = Dict[str, Any] diff --git a/tests/test__utils.py b/tests/test__utils.py index 264d2c5a1..f46775b4b 100644 --- a/tests/test__utils.py +++ b/tests/test__utils.py @@ -8,22 +8,18 @@ def test_async_func() -> None: - async def async_func() -> None: - ... # pragma: no cover + async def async_func() -> None: ... # pragma: no cover - def func() -> None: - ... # pragma: no cover + def func() -> None: ... # pragma: no cover assert is_async_callable(async_func) assert not is_async_callable(func) def test_async_partial() -> None: - async def async_func(a: Any, b: Any) -> None: - ... # pragma: no cover + async def async_func(a: Any, b: Any) -> None: ... # pragma: no cover - def func(a: Any, b: Any) -> None: - ... # pragma: no cover + def func(a: Any, b: Any) -> None: ... # pragma: no cover partial = functools.partial(async_func, 1) assert is_async_callable(partial) @@ -34,12 +30,10 @@ def func(a: Any, b: Any) -> None: def test_async_method() -> None: class Async: - async def method(self) -> None: - ... # pragma: no cover + async def method(self) -> None: ... # pragma: no cover class Sync: - def method(self) -> None: - ... # pragma: no cover + def method(self) -> None: ... # pragma: no cover assert is_async_callable(Async().method) assert not is_async_callable(Sync().method) @@ -47,12 +41,10 @@ def method(self) -> None: def test_async_object_call() -> None: class Async: - async def __call__(self) -> None: - ... # pragma: no cover + async def __call__(self) -> None: ... # pragma: no cover class Sync: - def __call__(self) -> None: - ... # pragma: no cover + def __call__(self) -> None: ... # pragma: no cover assert is_async_callable(Async()) assert not is_async_callable(Sync()) @@ -64,16 +56,14 @@ async def __call__( self, a: Any, b: Any, - ) -> None: - ... # pragma: no cover + ) -> None: ... # pragma: no cover class Sync: def __call__( self, a: Any, b: Any, - ) -> None: - ... # pragma: no cover + ) -> None: ... # pragma: no cover partial = functools.partial(Async(), 1) assert is_async_callable(partial) @@ -86,8 +76,7 @@ def test_async_nested_partial() -> None: async def async_func( a: Any, b: Any, - ) -> None: - ... # pragma: no cover + ) -> None: ... # pragma: no cover partial = functools.partial(async_func, b=2) nested_partial = functools.partial(partial, a=1) diff --git a/tests/test_applications.py b/tests/test_applications.py index 5b6c9d545..20da7ea81 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -1,7 +1,7 @@ import os from contextlib import asynccontextmanager from pathlib import Path -from typing import AsyncGenerator, AsyncIterator, Callable, Generator +from typing import AsyncGenerator, AsyncIterator, Generator import anyio import pytest @@ -20,8 +20,7 @@ from starlette.testclient import TestClient from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocket - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory async def error_500(request: Request, exc: HTTPException) -> JSONResponse: @@ -463,8 +462,7 @@ def test_decorator_deprecations() -> None: async def middleware( request: Request, call_next: RequestResponseEndpoint - ) -> None: - ... # pragma: no cover + ) -> None: ... # pragma: no cover app.middleware("http")(middleware) assert len(record) == 1 @@ -494,8 +492,7 @@ async def middleware( ) ) as record: - async def startup() -> None: - ... # pragma: no cover + async def startup() -> None: ... # pragma: no cover app.on_event("startup")(startup) assert len(record) == 1 diff --git a/tests/test_authentication.py b/tests/test_authentication.py index ecddda75e..35c1110d1 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -21,10 +21,9 @@ from starlette.requests import HTTPConnection, Request from starlette.responses import JSONResponse, Response from starlette.routing import Route, WebSocketRoute -from starlette.testclient import TestClient from starlette.websockets import WebSocket, WebSocketDisconnect +from tests.types import TestClientFactory -TestClientFactory = Callable[..., TestClient] AsyncEndpoint = Callable[..., Awaitable[Response]] SyncEndpoint = Callable[..., Response] diff --git a/tests/test_background.py b/tests/test_background.py index 846deecfd..cbffcc06a 100644 --- a/tests/test_background.py +++ b/tests/test_background.py @@ -1,13 +1,9 @@ -from typing import Callable - import pytest from starlette.background import BackgroundTask, BackgroundTasks from starlette.responses import Response -from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_async_task(test_client_factory: TestClientFactory) -> None: diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index aba3ceb1a..bac6814e4 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -1,5 +1,5 @@ from contextvars import ContextVar -from typing import Callable, Iterator +from typing import Iterator import anyio import pytest @@ -9,9 +9,7 @@ from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route -from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory @pytest.mark.anyio diff --git a/tests/test_convertors.py b/tests/test_convertors.py index 72ee17a82..520c98767 100644 --- a/tests/test_convertors.py +++ b/tests/test_convertors.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Callable, Iterator +from typing import Iterator import pytest @@ -8,9 +8,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route, Router -from starlette.testclient import TestClient - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory @pytest.fixture(scope="module", autouse=True) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index eeb0f2322..8f201e25b 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,4 +1,4 @@ -from typing import Callable, Iterator +from typing import Iterator import pytest @@ -8,8 +8,7 @@ from starlette.routing import Route, Router from starlette.testclient import TestClient from starlette.websockets import WebSocket - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory class Homepage(HTTPEndpoint): diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 401ad8212..f4e91ad87 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,5 +1,5 @@ import warnings -from typing import Callable, Generator +from typing import Generator import pytest @@ -10,8 +10,7 @@ from starlette.routing import Route, Router, WebSocketRoute from starlette.testclient import TestClient from starlette.types import Receive, Scope, Send - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def raise_runtime_error(request: Request) -> None: diff --git a/tests/test_formparsers.py b/tests/test_formparsers.py index ed2226878..8d97a0ba7 100644 --- a/tests/test_formparsers.py +++ b/tests/test_formparsers.py @@ -13,10 +13,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Mount -from starlette.testclient import TestClient from starlette.types import ASGIApp, Receive, Scope, Send - -TestClientFactory = typing.Callable[..., TestClient] +from tests.types import TestClientFactory class ForceMultipartDict(typing.Dict[typing.Any, typing.Any]): diff --git a/tests/test_requests.py b/tests/test_requests.py index d8e2e9477..02f29ee35 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -1,7 +1,7 @@ from __future__ import annotations import sys -from typing import Any, Callable, Iterator +from typing import Any, Iterator import anyio import pytest @@ -9,10 +9,8 @@ from starlette.datastructures import Address, State from starlette.requests import ClientDisconnect, Request from starlette.responses import JSONResponse, PlainTextResponse, Response -from starlette.testclient import TestClient from starlette.types import Message, Receive, Scope, Send - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_request_url(test_client_factory: TestClientFactory) -> None: @@ -269,8 +267,8 @@ async def receiver() -> Message: def test_request_is_disconnected(test_client_factory: TestClientFactory) -> None: """ - If a client disconnect occurs while reading request body - then ClientDisconnect should be raised. + If a client disconnect occurs after reading request body + then request will be set disconnected properly. """ disconnected_after_response = None @@ -278,15 +276,15 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: nonlocal disconnected_after_response request = Request(scope, receive) - await request.body() + body = await request.body() disconnected = await request.is_disconnected() - response = JSONResponse({"disconnected": disconnected}) + response = JSONResponse({"body": body.decode(), "disconnected": disconnected}) await response(scope, receive, send) disconnected_after_response = await request.is_disconnected() client = test_client_factory(app) - response = client.get("/") - assert response.json() == {"disconnected": False} + response = client.post("/", content="foo") + assert response.json() == {"body": "foo", "disconnected": False} assert disconnected_after_response diff --git a/tests/test_responses.py b/tests/test_responses.py index fa3c1009f..c63c92de5 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -1,11 +1,10 @@ from __future__ import annotations import datetime as dt -import os import time from http.cookies import SimpleCookie from pathlib import Path -from typing import AsyncIterator, Callable, Iterator +from typing import AsyncIterator, Iterator import anyio import pytest @@ -23,8 +22,7 @@ ) from starlette.testclient import TestClient from starlette.types import Message, Receive, Scope, Send - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_text_response(test_client_factory: TestClientFactory) -> None: @@ -216,11 +214,10 @@ def test_response_phrase(test_client_factory: TestClientFactory) -> None: assert response.reason_phrase == "" -def test_file_response(tmpdir: Path, test_client_factory: TestClientFactory) -> None: - path = os.path.join(tmpdir, "xyz") +def test_file_response(tmp_path: Path, test_client_factory: TestClientFactory) -> None: + path = tmp_path / "xyz" content = b"" * 1000 - with open(path, "wb") as file: - file.write(content) + path.write_bytes(content) filled_by_bg_task = "" @@ -259,11 +256,10 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: @pytest.mark.anyio -async def test_file_response_on_head_method(tmpdir: Path) -> None: - path = os.path.join(tmpdir, "xyz") +async def test_file_response_on_head_method(tmp_path: Path) -> None: + path = tmp_path / "xyz" content = b"" * 1000 - with open(path, "wb") as file: - file.write(content) + path.write_bytes(content) app = FileResponse(path=path, filename="example.png") @@ -288,10 +284,24 @@ async def send(message: Message) -> None: await app({"type": "http", "method": "head"}, receive, send) +def test_file_response_set_media_type( + tmp_path: Path, test_client_factory: TestClientFactory +) -> None: + path = tmp_path / "xyz" + path.write_bytes(b"") + + # By default, FileResponse will determine the `content-type` based on + # the filename or path, unless a specific `media_type` is provided. + app = FileResponse(path=path, filename="example.png", media_type="image/jpeg") + client: TestClient = test_client_factory(app) + response = client.get("/") + assert response.headers["content-type"] == "image/jpeg" + + def test_file_response_with_directory_raises_error( - tmpdir: Path, test_client_factory: TestClientFactory + tmp_path: Path, test_client_factory: TestClientFactory ) -> None: - app = FileResponse(path=tmpdir, filename="example.png") + app = FileResponse(path=tmp_path, filename="example.png") client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: client.get("/") @@ -299,9 +309,9 @@ def test_file_response_with_directory_raises_error( def test_file_response_with_missing_file_raises_error( - tmpdir: Path, test_client_factory: TestClientFactory + tmp_path: Path, test_client_factory: TestClientFactory ) -> None: - path = os.path.join(tmpdir, "404.txt") + path = tmp_path / "404.txt" app = FileResponse(path=path, filename="404.txt") client = test_client_factory(app) with pytest.raises(RuntimeError) as exc_info: @@ -310,13 +320,12 @@ def test_file_response_with_missing_file_raises_error( def test_file_response_with_chinese_filename( - tmpdir: Path, test_client_factory: TestClientFactory + tmp_path: Path, test_client_factory: TestClientFactory ) -> None: content = b"file content" filename = "你好.txt" # probably "Hello.txt" in Chinese - path = os.path.join(tmpdir, filename) - with open(path, "wb") as f: - f.write(content) + path = tmp_path / filename + path.write_bytes(content) app = FileResponse(path=path, filename=filename) client = test_client_factory(app) response = client.get("/") @@ -327,13 +336,12 @@ def test_file_response_with_chinese_filename( def test_file_response_with_inline_disposition( - tmpdir: Path, test_client_factory: TestClientFactory + tmp_path: Path, test_client_factory: TestClientFactory ) -> None: content = b"file content" filename = "hello.txt" - path = os.path.join(tmpdir, filename) - with open(path, "wb") as f: - f.write(content) + path = tmp_path / filename + path.write_bytes(content) app = FileResponse(path=path, filename=filename, content_disposition_type="inline") client = test_client_factory(app) response = client.get("/") @@ -343,43 +351,9 @@ def test_file_response_with_inline_disposition( assert response.headers["content-disposition"] == expected_disposition -def test_file_response_with_method_warns( - tmpdir: Path, test_client_factory: TestClientFactory -) -> None: +def test_file_response_with_method_warns(tmp_path: Path) -> None: with pytest.warns(DeprecationWarning): - FileResponse(path=tmpdir, filename="example.png", method="GET") - - -@pytest.mark.anyio -async def test_file_response_with_pathsend(tmpdir: Path) -> None: - path = os.path.join(tmpdir, "xyz") - content = b"" * 1000 - with open(path, "wb") as file: - file.write(content) - - app = FileResponse(path=path, filename="example.png") - - async def receive() -> Message: # type: ignore[empty-body] - ... # pragma: no cover - - async def send(message: Message) -> None: - if message["type"] == "http.response.start": - assert message["status"] == status.HTTP_200_OK - headers = Headers(raw=message["headers"]) - assert headers["content-type"] == "image/png" - assert "content-length" in headers - assert "content-disposition" in headers - assert "last-modified" in headers - assert "etag" in headers - elif message["type"] == "http.response.pathsend": - assert message["path"] == str(path) - - # Since the TestClient doesn't support `pathsend`, we need to test this directly. - await app( - {"type": "http", "method": "get", "extensions": {"http.response.pathsend": {}}}, - receive, - send, - ) + FileResponse(path=tmp_path, filename="example.png", method="GET") def test_set_cookie( @@ -414,6 +388,18 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: ) +def test_set_cookie_path_none(test_client_factory: TestClientFactory) -> None: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + response = Response("Hello, world!", media_type="text/plain") + response.set_cookie("mycookie", "myvalue", path=None) + await response(scope, receive, send) + + client = test_client_factory(app) + response = client.get("/") + assert response.text == "Hello, world!" + assert response.headers["set-cookie"] == "mycookie=myvalue; SameSite=lax" + + @pytest.mark.parametrize( "expires", [ @@ -510,12 +496,11 @@ def test_response_do_not_add_redundant_charset( def test_file_response_known_size( - tmpdir: Path, test_client_factory: TestClientFactory + tmp_path: Path, test_client_factory: TestClientFactory ) -> None: - path = os.path.join(tmpdir, "xyz") + path = tmp_path / "xyz" content = b"" * 1000 - with open(path, "wb") as file: - file.write(content) + path.write_bytes(content) app = FileResponse(path=path, filename="example.png") client: TestClient = test_client_factory(app) @@ -541,11 +526,18 @@ def test_streaming_response_known_size(test_client_factory: TestClientFactory) - assert response.headers["content-length"] == "10" +def test_response_memoryview(test_client_factory: TestClientFactory) -> None: + app = Response(content=memoryview(b"\xc0")) + client: TestClient = test_client_factory(app) + response = client.get("/") + assert response.content == b"\xc0" + + def test_streaming_response_memoryview(test_client_factory: TestClientFactory) -> None: - app = StreamingResponse(content=iter([memoryview(b"hello"), memoryview(b"world")])) + app = StreamingResponse(content=iter([memoryview(b"\xc0"), memoryview(b"\xf5")])) client: TestClient = test_client_factory(app) response = client.get("/") - assert response.text == "helloworld" + assert response.content == b"\xc0\xf5" @pytest.mark.anyio diff --git a/tests/test_routing.py b/tests/test_routing.py index d9466fbeb..e5e10ebd9 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -17,8 +17,7 @@ from starlette.testclient import TestClient from starlette.types import ASGIApp, Message, Receive, Scope, Send from starlette.websockets import WebSocket, WebSocketDisconnect - -TestClientFactory = typing.Callable[..., TestClient] +from tests.types import TestClientFactory def homepage(request: Request) -> Response: @@ -909,19 +908,15 @@ def test_duplicated_param_names() -> None: class Endpoint: - async def my_method(self, request: Request) -> None: - ... # pragma: no cover + async def my_method(self, request: Request) -> None: ... # pragma: no cover @classmethod - async def my_classmethod(cls, request: Request) -> None: - ... # pragma: no cover + async def my_classmethod(cls, request: Request) -> None: ... # pragma: no cover @staticmethod - async def my_staticmethod(request: Request) -> None: - ... # pragma: no cover + async def my_staticmethod(request: Request) -> None: ... # pragma: no cover - def __call__(self, request: Request) -> None: - ... # pragma: no cover + def __call__(self, request: Request) -> None: ... # pragma: no cover @pytest.mark.parametrize( @@ -1254,8 +1249,7 @@ def test_decorator_deprecations() -> None: with pytest.deprecated_call(): - async def startup() -> None: - ... # pragma: nocover + async def startup() -> None: ... # pragma: nocover router.on_event("startup")(startup) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index e00b2b8de..f4a5b4ad9 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -1,20 +1,16 @@ -from typing import Callable - from starlette.applications import Starlette from starlette.endpoints import HTTPEndpoint from starlette.requests import Request from starlette.responses import Response from starlette.routing import Host, Mount, Route, Router, WebSocketRoute from starlette.schemas import SchemaGenerator -from starlette.testclient import TestClient from starlette.websockets import WebSocket +from tests.types import TestClientFactory schemas = SchemaGenerator( {"openapi": "3.0.0", "info": {"title": "Example API", "version": "1.0"}} ) -TestClientFactory = Callable[..., TestClient] - def ws(session: WebSocket) -> None: """ws""" diff --git a/tests/test_staticfiles.py b/tests/test_staticfiles.py index d20bb7ef7..65d71b97b 100644 --- a/tests/test_staticfiles.py +++ b/tests/test_staticfiles.py @@ -16,9 +16,7 @@ from starlette.responses import Response from starlette.routing import Mount from starlette.staticfiles import StaticFiles -from starlette.testclient import TestClient - -TestClientFactory = typing.Callable[..., TestClient] +from tests.types import TestClientFactory def test_staticfiles(tmpdir: Path, test_client_factory: TestClientFactory) -> None: @@ -469,6 +467,19 @@ def test_staticfiles_access_file_as_dir_returns_404( assert response.text == "Not Found" +def test_staticfiles_filename_too_long( + tmpdir: Path, test_client_factory: TestClientFactory +) -> None: + routes = [Mount("/", app=StaticFiles(directory=tmpdir), name="static")] + app = Starlette(routes=routes) + client = test_client_factory(app) + + path_max_size = os.pathconf("/", "PC_PATH_MAX") + response = client.get(f"/{'a' * path_max_size}.txt") + assert response.status_code == 404 + assert response.text == "Not Found" + + def test_staticfiles_unhandled_os_error_returns_500( tmpdir: Path, test_client_factory: TestClientFactory, diff --git a/tests/test_templates.py b/tests/test_templates.py index 10a1366bc..8e344f331 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import typing from pathlib import Path from unittest import mock @@ -16,9 +15,7 @@ from starlette.responses import Response from starlette.routing import Route from starlette.templating import Jinja2Templates -from starlette.testclient import TestClient - -TestClientFactory = typing.Callable[..., TestClient] +from tests.types import TestClientFactory def test_templates(tmpdir: Path, test_client_factory: TestClientFactory) -> None: diff --git a/tests/test_testclient.py b/tests/test_testclient.py index 4ed1ced9a..77de3d976 100644 --- a/tests/test_testclient.py +++ b/tests/test_testclient.py @@ -4,7 +4,7 @@ import sys from asyncio import Task, current_task as asyncio_current_task from contextlib import asynccontextmanager -from typing import Any, AsyncGenerator, Callable +from typing import Any, AsyncGenerator import anyio import anyio.lowlevel @@ -20,8 +20,7 @@ from starlette.testclient import ASGIInstance, TestClient from starlette.types import ASGIApp, Receive, Scope, Send from starlette.websockets import WebSocket, WebSocketDisconnect - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def mock_service_endpoint(request: Request) -> JSONResponse: @@ -212,7 +211,7 @@ async def inner(receive: Receive, send: Send) -> None: return inner - client = test_client_factory(app) + client = test_client_factory(app) # type: ignore response = client.get("/") assert response.text == "Hello, world!" @@ -252,7 +251,7 @@ async def asgi(receive: Receive, send: Send) -> None: return asgi - client = test_client_factory(app) + client = test_client_factory(app) # type: ignore with client.websocket_connect("/") as websocket: data = websocket.receive_json() assert data == {"message": "test"} @@ -268,7 +267,7 @@ async def asgi(receive: Receive, send: Send) -> None: return asgi - client = test_client_factory(app) + client = test_client_factory(app) # type: ignore with client.websocket_connect("/") as websocket: ... assert websocket.should_close.is_set() diff --git a/tests/test_websockets.py b/tests/test_websockets.py index 854c26914..16d2d0f1f 100644 --- a/tests/test_websockets.py +++ b/tests/test_websockets.py @@ -1,5 +1,5 @@ import sys -from typing import Any, Callable, MutableMapping +from typing import Any, MutableMapping import anyio import pytest @@ -7,11 +7,10 @@ from starlette import status from starlette.responses import Response -from starlette.testclient import TestClient, WebSocketDenialResponse +from starlette.testclient import WebSocketDenialResponse from starlette.types import Message, Receive, Scope, Send from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState - -TestClientFactory = Callable[..., TestClient] +from tests.types import TestClientFactory def test_websocket_url(test_client_factory: TestClientFactory) -> None: @@ -492,8 +491,7 @@ def test_websocket_scope_interface() -> None: async def mock_receive() -> Message: # type: ignore ... # pragma: no cover - async def mock_send(message: Message) -> None: - ... # pragma: no cover + async def mock_send(message: Message) -> None: ... # pragma: no cover websocket = WebSocket( {"type": "websocket", "path": "/abc/", "headers": []}, diff --git a/tests/types.py b/tests/types.py new file mode 100644 index 000000000..1cbacf107 --- /dev/null +++ b/tests/types.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +import httpx + +from starlette.testclient import TestClient +from starlette.types import ASGIApp + +if TYPE_CHECKING: + + class TestClientFactory(Protocol): # pragma: no cover + def __call__( + self, + app: ASGIApp, + base_url: str = "http://testserver", + raise_server_exceptions: bool = True, + root_path: str = "", + cookies: httpx._types.CookieTypes | None = None, + headers: dict[str, str] | None = None, + follow_redirects: bool = True, + ) -> TestClient: ... +else: # pragma: no cover + + class TestClientFactory: + __test__ = False