Skip to content

Commit cbf27e7

Browse files
committed
Allow None header values to opt out of default response headers
ResponseHeaders now accepts None values, enabling views to opt out of default headers (like X-Frame-Options for iframe embedding). None values are filtered out at serialization boundaries (WSGI handler) rather than being deleted by middleware. Also removes unused Response methods: serialize_headers, serialize, file-like interface stubs (write, flush, tell, readable, seekable, writable, writelines), text property, pickling support, and getvalue.
1 parent ee7acaa commit cbf27e7

File tree

4 files changed

+10
-102
lines changed

4 files changed

+10
-102
lines changed

plain-support/plain/support/views.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def get_response(self) -> ResponseBase:
5454
# X-Frame-Options are typically in DEFAULT_RESPONSE_HEADERS.
5555
# Set to None to signal the middleware to skip applying this default header.
5656
# We can't del/pop it because middleware runs after and would add it back.
57-
response.headers["X-Frame-Options"] = None # type: ignore[assignment]
57+
response.headers["X-Frame-Options"] = None
5858

5959
return response
6060

plain/plain/http/response.py

Lines changed: 6 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import time
1111
from collections.abc import Iterator
1212
from email.header import Header
13-
from functools import cached_property
1413
from http.client import responses
1514
from http.cookies import SimpleCookie
1615
from typing import IO, Any
@@ -86,10 +85,13 @@ def _convert_to_charset(
8685
def __delitem__(self, key: str) -> None:
8786
self.pop(key)
8887

89-
def __setitem__(self, key: str, value: str | bytes) -> None:
88+
def __setitem__(self, key: str, value: str | bytes | None) -> None:
9089
key = self._convert_to_charset(key, "ascii")
91-
value = self._convert_to_charset(value, "latin-1", mime_encode=True)
92-
self._store[key.lower()] = (key, value)
90+
if value is None:
91+
self._store[key.lower()] = (key, None)
92+
else:
93+
value = self._convert_to_charset(value, "latin-1", mime_encode=True)
94+
self._store[key.lower()] = (key, value)
9395

9496
def pop(self, key: str, default: Any = None) -> Any:
9597
return self._store.pop(key.lower(), default)
@@ -182,17 +184,6 @@ def charset(self) -> str:
182184
def charset(self, value: str) -> None:
183185
self._charset = value
184186

185-
def serialize_headers(self) -> bytes:
186-
"""HTTP headers as a bytestring."""
187-
return b"\r\n".join(
188-
[
189-
key.encode("ascii") + b": " + value.encode("latin-1")
190-
for key, value in self.headers.items()
191-
]
192-
)
193-
194-
__bytes__ = serialize_headers
195-
196187
@property
197188
def _content_type_for_repr(self) -> str:
198189
return (
@@ -315,9 +306,6 @@ def make_bytes(self, value: str | bytes) -> bytes:
315306
# Handle non-string types.
316307
return str(value).encode(self.charset)
317308

318-
# These methods partially implement the file-like object interface.
319-
# See https://docs.python.org/library/io.html#io.IOBase
320-
321309
# The WSGI server must call this method upon completion of the request.
322310
# See http://blog.dscpl.com.au/2012/10/obligations-for-calling-close-on.html
323311
def close(self) -> None:
@@ -331,32 +319,6 @@ def close(self) -> None:
331319
self.closed = True
332320
signals.request_finished.send(sender=self._handler_class)
333321

334-
def write(self, content: bytes) -> None:
335-
raise OSError(f"This {self.__class__.__name__} instance is not writable")
336-
337-
def flush(self) -> None:
338-
pass
339-
340-
def tell(self) -> int:
341-
raise OSError(
342-
f"This {self.__class__.__name__} instance cannot tell its position"
343-
)
344-
345-
# These methods partially implement a stream-like object interface.
346-
# See https://docs.python.org/library/io.html#io.IOBase
347-
348-
def readable(self) -> bool:
349-
return False
350-
351-
def seekable(self) -> bool:
352-
return False
353-
354-
def writable(self) -> bool:
355-
return False
356-
357-
def writelines(self, lines: list[bytes | str]) -> None:
358-
raise OSError(f"This {self.__class__.__name__} instance is not writable")
359-
360322

361323
class Response(ResponseBase):
362324
"""
@@ -366,42 +328,19 @@ class Response(ResponseBase):
366328
"""
367329

368330
streaming = False
369-
non_picklable_attrs = frozenset(
370-
[
371-
"resolver_match",
372-
# Non-picklable attributes added by test clients.
373-
"client",
374-
"context",
375-
"json",
376-
"templates",
377-
]
378-
)
379331

380332
def __init__(self, content: bytes | str | Iterator[bytes] = b"", **kwargs: Any):
381333
super().__init__(**kwargs)
382334
# Content is a bytestring. See the `content` property methods.
383335
self.content = content
384336

385-
def __getstate__(self) -> dict[str, Any]:
386-
obj_dict = self.__dict__.copy()
387-
for attr in self.non_picklable_attrs:
388-
if attr in obj_dict:
389-
del obj_dict[attr]
390-
return obj_dict
391-
392337
def __repr__(self) -> str:
393338
return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { # noqa: UP031
394339
"cls": self.__class__.__name__,
395340
"status_code": self.status_code,
396341
"content_type": self._content_type_for_repr,
397342
}
398343

399-
def serialize(self) -> bytes:
400-
"""Full HTTP message, including headers, as a bytestring."""
401-
return self.serialize_headers() + b"\r\n\r\n" + self.content
402-
403-
__bytes__ = serialize
404-
405344
@property
406345
def content(self) -> bytes:
407346
return b"".join(self._container)
@@ -420,32 +359,11 @@ def content(self, value: bytes | str | Iterator[bytes]) -> None:
420359
pass
421360
else:
422361
content = self.make_bytes(value)
423-
# Create a list of properly encoded bytestrings to support write().
424362
self._container = [content]
425363

426-
@cached_property
427-
def text(self) -> str:
428-
return self.content.decode(self.charset or "utf-8")
429-
430364
def __iter__(self) -> Iterator[bytes]:
431365
return iter(self._container)
432366

433-
def write(self, content: bytes | str) -> None:
434-
self._container.append(self.make_bytes(content))
435-
436-
def tell(self) -> int:
437-
return len(self.content)
438-
439-
def getvalue(self) -> bytes:
440-
return self.content
441-
442-
def writable(self) -> bool:
443-
return True
444-
445-
def writelines(self, lines: list[bytes | str]) -> None:
446-
for line in lines:
447-
self.write(line)
448-
449367

450368
class StreamingResponse(ResponseBase):
451369
"""
@@ -495,9 +413,6 @@ def _set_streaming_content(self, value: Iterator[bytes | str]) -> None:
495413
def __iter__(self) -> Iterator[bytes]:
496414
return iter(self.streaming_content)
497415

498-
def getvalue(self) -> bytes:
499-
return b"".join(self.streaming_content)
500-
501416

502417
class FileResponse(StreamingResponse):
503418
"""

plain/plain/internal/handlers/wsgi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ def __call__(
146146

147147
status = "%d %s" % (response.status_code, response.reason_phrase) # noqa: UP031
148148
response_headers = [
149-
*response.headers.items(),
149+
# Filter out None values (used to opt-out of default headers)
150+
*((k, v) for k, v in response.headers.items() if v is not None),
150151
*(("Set-Cookie", c.output(header="")) for c in response.cookies.values()),
151152
]
152153
start_response(status, response_headers)

plain/plain/internal/middleware/headers.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,13 @@ class DefaultHeadersMiddleware(HttpMiddleware):
2121
View Customization Patterns:
2222
- Use default: Don't set the header (middleware applies it)
2323
- Override: Set the header to a different value
24-
- Remove: Set the header to None (middleware will delete it)
24+
- Remove: Set the header to None (not serialized in the response)
2525
- Extend: Read from settings.DEFAULT_RESPONSE_HEADERS, modify, then set
2626
2727
Format Strings:
2828
Header values can include {request.attribute} placeholders for dynamic
2929
content. Example: 'nonce-{request.csp_nonce}' will be formatted with
3030
the request's csp_nonce value. Headers without placeholders are used as-is.
31-
32-
None Removal:
33-
Views can set a header to None to opt-out of that default header entirely.
34-
The middleware will delete any header set to None, preventing the default
35-
from being applied.
3631
"""
3732

3833
def process_request(self, request: Request) -> Response:
@@ -47,9 +42,6 @@ def process_request(self, request: Request) -> Response:
4742
response.headers[header] = value.format(request=request)
4843
else:
4944
response.headers[header] = value
50-
elif response.headers[header] is None:
51-
# Header explicitly set to None by view - remove it
52-
del response.headers[header]
5345

5446
# Add the Content-Length header to non-streaming responses if not
5547
# already set.

0 commit comments

Comments
 (0)