Skip to content

Commit 4302e59

Browse files
authored
Staticfiles handle 404 (#54)
1 parent 746f251 commit 4302e59

6 files changed

Lines changed: 98 additions & 63 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ jobs:
4747
run: pdm run check
4848

4949
- name: Tests
50-
run: pdm run test --cov=./baize -o log_cli=true -o log_cli_level=DEBUG
50+
run: pdm run test --cov=./baize --cov-report=xml -o log_cli=true -o log_cli_level=DEBUG
5151

5252
- name: Upload coverage to Codecov
5353
uses: codecov/codecov-action@v3

baize/asgi/staticfiles.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,38 @@
1+
import os
12
import stat
23

34
from baize import staticfiles
45
from baize.datastructures import URL
56
from baize.exceptions import HTTPException
6-
from baize.typing import Receive, Scope, Send
7+
from baize.typing import ASGIApp, Receive, Scope, Send
78

89
from .responses import FileResponse, RedirectResponse, Response
910

1011

11-
class Files(staticfiles.BaseFiles):
12+
class Files(staticfiles.BaseFiles[ASGIApp]):
1213
"""
1314
Provide the ASGI application to download files in the specified path or
1415
the specified directory under the specified package.
1516
1617
Support request range and cache (304 status code).
17-
18-
NOTE: Need users handle HTTPException(404).
1918
"""
2019

20+
def file_response(
21+
self,
22+
filepath: str,
23+
stat_result: os.stat_result,
24+
if_none_match: str,
25+
if_modified_since: str,
26+
) -> Response:
27+
if self.if_none_match(
28+
FileResponse.generate_etag(stat_result), if_none_match
29+
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
30+
response = Response(304)
31+
else:
32+
response = FileResponse(filepath, stat_result=stat_result)
33+
self.set_response_headers(response)
34+
return response
35+
2136
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
2237
if_none_match: str = ""
2338
if_modified_since: str = ""
@@ -30,28 +45,24 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
3045
stat_result, is_file = self.check_path_is_file(filepath)
3146
if is_file and stat_result:
3247
assert filepath is not None # Just for type check
33-
if self.if_none_match(
34-
FileResponse.generate_etag(stat_result), if_none_match
35-
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
36-
response = Response(304)
37-
else:
38-
response = FileResponse(filepath, stat_result=stat_result)
39-
self.set_response_headers(response)
40-
return await response(scope, receive, send)
48+
return await self.file_response(
49+
filepath, stat_result, if_none_match, if_modified_since
50+
)(scope, receive, send)
4151

42-
raise HTTPException(404)
52+
if self.handle_404 is None:
53+
raise HTTPException(404)
54+
else:
55+
return await self.handle_404(scope, receive, send)
4356

4457

45-
class Pages(staticfiles.BasePages):
58+
class Pages(staticfiles.BasePages[ASGIApp], Files):
4659
"""
4760
Provide the ASGI application to download files in the specified path or
4861
the specified directory under the specified package.
4962
Unlike `Files`, when you visit a directory, it will try to return the content
5063
of the file named `index.html` in that directory.
5164
5265
Support request range and cache (304 status code).
53-
54-
NOTE: Need users handle HTTPException(404).
5566
"""
5667

5768
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
@@ -75,17 +86,15 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
7586
if stat_result is not None:
7687
assert filepath is not None # Just for type check
7788
if is_file:
78-
if self.if_none_match(
79-
FileResponse.generate_etag(stat_result), if_none_match
80-
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
81-
response = Response(304)
82-
else:
83-
response = FileResponse(filepath, stat_result=stat_result)
84-
self.set_response_headers(response)
85-
return await response(scope, receive, send)
89+
return await self.file_response(
90+
filepath, stat_result, if_none_match, if_modified_since
91+
)(scope, receive, send)
8692
if stat.S_ISDIR(stat_result.st_mode):
8793
url = URL(scope=scope)
8894
url = url.replace(scheme="", path=url.path + "/")
8995
return await RedirectResponse(url)(scope, receive, send)
9096

91-
raise HTTPException(404)
97+
if self.handle_404 is None:
98+
raise HTTPException(404)
99+
else:
100+
return await self.handle_404(scope, receive, send)

baize/staticfiles.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import os
33
import stat
44
from email.utils import parsedate_to_datetime
5-
from typing import Optional, Tuple, Union
5+
from typing import Generic, Optional, Tuple, TypeVar, Union
66

77
from .responses import BaseResponse
8-
from .typing import Literal
8+
from .typing import ASGIApp, Literal, WSGIApp
99

1010
try:
1111
from mypy_extensions import mypyc_attr
@@ -15,20 +15,25 @@ def mypyc_attr(*attrs, **kwattrs): # type: ignore
1515
return lambda x: x
1616

1717

18+
Interface = TypeVar("Interface", ASGIApp, WSGIApp)
19+
20+
1821
@mypyc_attr(allow_interpreted_subclasses=True)
19-
class BaseFiles:
22+
class BaseFiles(Generic[Interface]):
2023
def __init__(
2124
self,
2225
directory: Union[str, "os.PathLike[str]"],
2326
package: Optional[str] = None,
2427
*,
28+
handle_404: Optional[Interface] = None,
2529
cacheability: Literal["public", "private", "no-cache", "no-store"] = "public",
2630
max_age: int = 60 * 10, # 10 minutes
2731
) -> None:
2832
assert not (
2933
os.path.isabs(directory) and package is not None
3034
), "directory must be a relative path, with package is not None"
3135
self.directory = self.normalize_dir_path(str(directory), package)
36+
self.handle_404: Optional[Interface] = handle_404
3237
self.cacheability = cacheability
3338
self.max_age = max_age
3439

@@ -103,7 +108,7 @@ def set_response_headers(self, response: BaseResponse) -> None:
103108

104109

105110
@mypyc_attr(allow_interpreted_subclasses=True)
106-
class BasePages(BaseFiles):
111+
class BasePages(BaseFiles[Interface], Generic[Interface]):
107112
def ensure_absolute_path(self, path: str) -> Optional[str]:
108113
abspath = super().ensure_absolute_path(path)
109114
if abspath is not None:

baize/wsgi/staticfiles.py

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,39 @@
1+
import os
12
import stat
23
from typing import Iterable
34

45
from baize import staticfiles
56
from baize.datastructures import URL
67
from baize.exceptions import HTTPException
7-
from baize.typing import Environ, StartResponse
8+
from baize.typing import Environ, StartResponse, WSGIApp
89

910
from .responses import FileResponse, RedirectResponse, Response
1011

1112

12-
class Files(staticfiles.BaseFiles):
13+
class Files(staticfiles.BaseFiles[WSGIApp]):
1314
"""
1415
Provide the WSGI application to download files in the specified path or
1516
the specified directory under the specified package.
1617
1718
Support request range and cache (304 status code).
18-
19-
NOTE: Need users handle HTTPException(404).
2019
"""
2120

21+
def file_response(
22+
self,
23+
filepath: str,
24+
stat_result: os.stat_result,
25+
if_none_match: str,
26+
if_modified_since: str,
27+
) -> Response:
28+
if self.if_none_match(
29+
FileResponse.generate_etag(stat_result), if_none_match
30+
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
31+
response = Response(304)
32+
else:
33+
response = FileResponse(filepath, stat_result=stat_result)
34+
self.set_response_headers(response)
35+
return response
36+
2237
def __call__(
2338
self, environ: Environ, start_response: StartResponse
2439
) -> Iterable[bytes]:
@@ -28,19 +43,17 @@ def __call__(
2843
stat_result, is_file = self.check_path_is_file(filepath)
2944
if is_file and stat_result:
3045
assert filepath is not None # Just for type check
31-
if self.if_none_match(
32-
FileResponse.generate_etag(stat_result), if_none_match
33-
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
34-
response = Response(304)
35-
else:
36-
response = FileResponse(filepath, stat_result=stat_result)
37-
self.set_response_headers(response)
38-
return response(environ, start_response)
46+
return self.file_response(
47+
filepath, stat_result, if_none_match, if_modified_since
48+
)(environ, start_response)
3949

40-
raise HTTPException(404)
50+
if self.handle_404 is None:
51+
raise HTTPException(404)
52+
else:
53+
return self.handle_404(environ, start_response)
4154

4255

43-
class Pages(staticfiles.BasePages):
56+
class Pages(staticfiles.BasePages[WSGIApp], Files):
4457
"""
4558
Provide the WSGI application to download files in the specified path or
4659
the specified directory under the specified package.
@@ -49,8 +62,6 @@ class Pages(staticfiles.BasePages):
4962
exist, it will return the content of that file.
5063
5164
Support request range and cache (304 status code).
52-
53-
NOTE: Need users handle HTTPException(404).
5465
"""
5566

5667
def __call__(
@@ -71,17 +82,15 @@ def __call__(
7182
if stat_result is not None:
7283
assert filepath is not None # Just for type check
7384
if is_file:
74-
if self.if_none_match(
75-
FileResponse.generate_etag(stat_result), if_none_match
76-
) or self.if_modified_since(stat_result.st_ctime, if_modified_since):
77-
response = Response(304)
78-
else:
79-
response = FileResponse(filepath, stat_result=stat_result)
80-
self.set_response_headers(response)
81-
return response(environ, start_response)
85+
return self.file_response(
86+
filepath, stat_result, if_none_match, if_modified_since
87+
)(environ, start_response)
8288
if stat.S_ISDIR(stat_result.st_mode):
8389
url = URL(environ=environ)
8490
url = url.replace(scheme="", path=url.path + "/")
8591
return RedirectResponse(url)(environ, start_response)
8692

87-
raise HTTPException(404)
93+
if self.handle_404 is None:
94+
raise HTTPException(404)
95+
else:
96+
return self.handle_404(environ, start_response)

tests/test_asgi.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,7 @@ async def test_hosts():
10671067
[
10681068
Files(Path(__file__).absolute().parent.parent / "baize"),
10691069
Files(".", "baize"),
1070+
Files(".", "baize", handle_404=PlainTextResponse("", 404)),
10701071
],
10711072
)
10721073
async def test_files(app):
@@ -1107,11 +1108,16 @@ async def test_files(app):
11071108
)
11081109
).status_code == 304
11091110

1110-
with pytest.raises(HTTPException):
1111-
await client.get("/")
1111+
if app.handle_404 is None:
11121112

1113-
with pytest.raises(HTTPException):
1114-
await client.get("/%2E%2E/baize/%2E%2E/%2E%2E/README.md")
1113+
with pytest.raises(HTTPException):
1114+
await client.get("/")
1115+
1116+
with pytest.raises(HTTPException):
1117+
await client.get("/%2E%2E/baize/%2E%2E/%2E%2E/README.md")
1118+
1119+
else:
1120+
assert (await client.get("/")).status_code == 404
11151121

11161122

11171123
@pytest.mark.asyncio

tests/test_wsgi.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,7 @@ def test_hosts():
565565
[
566566
Files(Path(__file__).absolute().parent.parent / "baize"),
567567
Files(".", "baize"),
568+
Files(".", "baize", handle_404=PlainTextResponse("", 404)),
568569
],
569570
)
570571
def test_files(app):
@@ -603,11 +604,16 @@ def test_files(app):
603604
)
604605
).status_code == 304
605606

606-
with pytest.raises(HTTPException):
607-
client.get("/")
607+
if app.handle_404 is None:
608608

609-
with pytest.raises(HTTPException):
610-
client.get("/%2E%2E/baize/%2E%2E/%2E%2E/README.md")
609+
with pytest.raises(HTTPException):
610+
client.get("/")
611+
612+
with pytest.raises(HTTPException):
613+
client.get("/%2E%2E/baize/%2E%2E/%2E%2E/README.md")
614+
615+
else:
616+
assert client.get("/").status_code == 404
611617

612618

613619
def test_pages(tmpdir):

0 commit comments

Comments
 (0)