135 lines
5.1 KiB
Diff
135 lines
5.1 KiB
Diff
From 4ea6e22b489ec388d6004cfbca52dd5b147127c5 Mon Sep 17 00:00:00 2001
|
|
From: Marcelo Trylesinski <marcelotryle@gmail.com>
|
|
Date: Tue, 28 Oct 2025 18:14:01 +0100
|
|
Subject: [PATCH] Merge commit from fork
|
|
|
|
---
|
|
starlette/responses.py | 46 ++++++++++++++++++++++++++++-------------
|
|
tests/test_responses.py | 28 +++++++++++++++++++++++++
|
|
2 files changed, 60 insertions(+), 14 deletions(-)
|
|
|
|
Index: starlette-0.41.3/starlette/responses.py
|
|
===================================================================
|
|
--- starlette-0.41.3.orig/starlette/responses.py
|
|
+++ starlette-0.41.3/starlette/responses.py
|
|
@@ -3,7 +3,6 @@ from __future__ import annotations
|
|
import http.cookies
|
|
import json
|
|
import os
|
|
-import re
|
|
import stat
|
|
import typing
|
|
import warnings
|
|
@@ -272,9 +271,6 @@ class RangeNotSatisfiable(Exception):
|
|
self.max_size = max_size
|
|
|
|
|
|
-_RANGE_PATTERN = re.compile(r"(\d*)-(\d*)")
|
|
-
|
|
-
|
|
class FileResponse(Response):
|
|
chunk_size = 64 * 1024
|
|
|
|
@@ -435,8 +431,8 @@ class FileResponse(Response):
|
|
etag = f'"{md5_hexdigest(etag_base.encode(), usedforsecurity=False)}"'
|
|
return http_if_range == formatdate(stat_result.st_mtime, usegmt=True) or http_if_range == etag
|
|
|
|
- @staticmethod
|
|
- def _parse_range_header(http_range: str, file_size: int) -> list[tuple[int, int]]:
|
|
+ @classmethod
|
|
+ def _parse_range_header(cls, http_range: str, file_size: int) -> list[tuple[int, int]]:
|
|
ranges: list[tuple[int, int]] = []
|
|
try:
|
|
units, range_ = http_range.split("=", 1)
|
|
@@ -448,14 +444,7 @@ class FileResponse(Response):
|
|
if units != "bytes":
|
|
raise MalformedRangeHeader("Only support bytes range")
|
|
|
|
- ranges = [
|
|
- (
|
|
- int(_[0]) if _[0] else file_size - int(_[1]),
|
|
- int(_[1]) + 1 if _[0] and _[1] and int(_[1]) < file_size else file_size,
|
|
- )
|
|
- for _ in _RANGE_PATTERN.findall(range_)
|
|
- if _ != ("", "")
|
|
- ]
|
|
+ ranges = cls._parse_ranges(range_, file_size)
|
|
|
|
if len(ranges) == 0:
|
|
raise MalformedRangeHeader("Range header: range must be requested")
|
|
@@ -487,6 +476,35 @@ class FileResponse(Response):
|
|
|
|
return result
|
|
|
|
+ @classmethod
|
|
+ def _parse_ranges(cls, range_: str, file_size: int) -> list[tuple[int, int]]:
|
|
+ ranges: list[tuple[int, int]] = []
|
|
+
|
|
+ for part in range_.split(","):
|
|
+ part = part.strip()
|
|
+
|
|
+ # If the range is empty or a single dash, we ignore it.
|
|
+ if not part or part == "-":
|
|
+ continue
|
|
+
|
|
+ # If the range is not in the format "start-end", we ignore it.
|
|
+ if "-" not in part:
|
|
+ continue
|
|
+
|
|
+ start_str, end_str = part.split("-", 1)
|
|
+ start_str = start_str.strip()
|
|
+ end_str = end_str.strip()
|
|
+
|
|
+ try:
|
|
+ start = int(start_str) if start_str else file_size - int(end_str)
|
|
+ end = int(end_str) + 1 if start_str and end_str and int(end_str) < file_size else file_size
|
|
+ ranges.append((start, end))
|
|
+ except ValueError:
|
|
+ # If the range is not numeric, we ignore it.
|
|
+ continue
|
|
+
|
|
+ return ranges
|
|
+
|
|
def generate_multipart(
|
|
self,
|
|
ranges: typing.Sequence[tuple[int, int]],
|
|
Index: starlette-0.41.3/tests/test_responses.py
|
|
===================================================================
|
|
--- starlette-0.41.3.orig/tests/test_responses.py
|
|
+++ starlette-0.41.3/tests/test_responses.py
|
|
@@ -684,6 +684,34 @@ def test_file_response_insert_ranges(fil
|
|
]
|
|
|
|
|
|
+def test_file_response_range_without_dash(file_response_client: TestClient) -> None:
|
|
+ response = file_response_client.get("/", headers={"Range": "bytes=100, 0-50"})
|
|
+ assert response.status_code == 206
|
|
+ assert response.headers["content-range"] == f"bytes 0-50/{len(README.encode('utf8'))}"
|
|
+
|
|
+
|
|
+def test_file_response_range_empty_start_and_end(file_response_client: TestClient) -> None:
|
|
+ response = file_response_client.get("/", headers={"Range": "bytes= - , 0-50"})
|
|
+ assert response.status_code == 206
|
|
+ assert response.headers["content-range"] == f"bytes 0-50/{len(README.encode('utf8'))}"
|
|
+
|
|
+
|
|
+def test_file_response_range_ignore_non_numeric(file_response_client: TestClient) -> None:
|
|
+ response = file_response_client.get("/", headers={"Range": "bytes=abc-def, 0-50"})
|
|
+ assert response.status_code == 206
|
|
+ assert response.headers["content-range"] == f"bytes 0-50/{len(README.encode('utf8'))}"
|
|
+
|
|
+
|
|
+def test_file_response_suffix_range(file_response_client: TestClient) -> None:
|
|
+ # Test suffix range (last N bytes) - line 523 with empty start_str
|
|
+ response = file_response_client.get("/", headers={"Range": "bytes=-100"})
|
|
+ assert response.status_code == 206
|
|
+ file_size = len(README.encode("utf8"))
|
|
+ assert response.headers["content-range"] == f"bytes {file_size - 100}-{file_size - 1}/{file_size}"
|
|
+ assert response.headers["content-length"] == "100"
|
|
+ assert response.content == README.encode("utf8")[-100:]
|
|
+
|
|
+
|
|
@pytest.mark.anyio
|
|
async def test_file_response_multi_small_chunk_size(readme_file: Path) -> None:
|
|
class SmallChunkSizeFileResponse(FileResponse):
|