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

Add ContentType generic to Response #2547

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Expand Up @@ -27,7 +27,7 @@ classifiers = [
]
dependencies = [
"anyio>=3.4.0,<5",
"typing_extensions>=3.10.0; python_version < '3.10'",
"typing_extensions>=4.4.0; python_version < '3.13'",
Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

]

[project.optional-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Expand Up @@ -4,7 +4,7 @@
# Testing
coverage==7.4.3
importlib-metadata==7.0.1
mypy==1.8.0
mypy==1.9.0
Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last release supports default=.

ruff==0.1.15
typing_extensions==4.10.0
types-contextvars==2.4.7.3
Expand Down
26 changes: 17 additions & 9 deletions starlette/responses.py
Expand Up @@ -4,6 +4,7 @@
import json
import os
import stat
import sys
import typing
import warnings
from datetime import datetime
Expand All @@ -21,14 +22,21 @@
from starlette.datastructures import URL, MutableHeaders
from starlette.types import Receive, Scope, Send

if sys.version_info >= (3, 13): # pragma: no cover
from typing import TypeVar
else:
from typing_extensions import TypeVar

class Response:
Content = TypeVar("Content", default=typing.Any)


class Response(typing.Generic[Content]):
Comment on lines +30 to +33
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to me like the reality is that the default should be str | bytes given what Response accepts. Then subclasses like StreamingResponse should override this generic with whatever they accept. If we make the default typing.Any then Response is not correctly typed. Since we're setting the default in this PR we might as well choose an appropriate value.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not changing this in this PR. There was a previous attempt to change the type to str | bytes | None, and it was reverted - I don't remember the reason.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok. It would be good to find that reason.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I see. So yes the right thing to do would be to make a BaseResponse and make Response just one of the many concrete classes:

_ResponseDataT = TypeVar('_ResponseDataT')

class BaseResponse(Generic[_ResponseDataT]):
    def render(self, data: _ResponseDataT | None) -> bytes:
        raise NotImplementedError

class Response(BaseResponse[str | bytes]):
    ...

_JsonData = str | bytes | float | int | None | ...

class JsonResponse(BaseResponse[_JsonData]):
    ...

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the Response class does have the .render() method... So I don't see how I'd that? I can't make the classes stop inheriting Response, because that would be a breaking change.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is also what I think is unreasonable about Starlette. For example, StreamResponse and FileReponse do not use render, but they inherit this method.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you suggesting adding a breaking change then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. 😊

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do some naming to make it a non-breaking change or minimize the breakage? We could leave the render() method on the base class but mark it as deprecated vi a warning. Adding a BaseResponse class is not a breaking change. I think removing the render() method from eg StreamingResponse (because it inherits from BaseResponse and not Response) would also be okay.

media_type = None
charset = "utf-8"

def __init__(
self,
content: typing.Any = None,
content: Content | None = None,
status_code: int = 200,
headers: typing.Mapping[str, str] | None = None,
media_type: str | None = None,
Expand All @@ -41,7 +49,7 @@ def __init__(
self.body = self.render(content)
self.init_headers(headers)

def render(self, content: typing.Any) -> bytes:
def render(self, content: Content | None) -> bytes:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

| was introduced in 3.10.

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use __future__.annotations, and the pipeline passes. 👀

if content is None:
return b""
if isinstance(content, bytes):
Expand Down Expand Up @@ -170,20 +178,20 @@ class PlainTextResponse(Response):
media_type = "text/plain"


class JSONResponse(Response):
class JSONResponse(Response[Content]):
media_type = "application/json"

def __init__(
self,
content: typing.Any,
content: Content,
status_code: int = 200,
headers: typing.Mapping[str, str] | None = None,
media_type: str | None = None,
background: BackgroundTask | None = None,
) -> None:
super().__init__(content, status_code, headers, media_type, background)

def render(self, content: typing.Any) -> bytes:
def render(self, content: Content | None) -> bytes:
return json.dumps(
content,
ensure_ascii=False,
Expand All @@ -207,9 +215,9 @@ def __init__(
self.headers["location"] = quote(str(url), safe=":/%#?=@[]!$&'()*+,;")


Content = typing.Union[str, bytes]
SyncContentStream = typing.Iterable[Content]
AsyncContentStream = typing.AsyncIterable[Content]
_Content = typing.Union[str, bytes]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm, this is a breaking change right?

Copy link
Sponsor Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, it is...

SyncContentStream = typing.Iterable[_Content]
AsyncContentStream = typing.AsyncIterable[_Content]
ContentStream = typing.Union[AsyncContentStream, SyncContentStream]


Expand Down