servestatic 4.3.2__tar.gz → 4.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {servestatic-4.3.2 → servestatic-4.3.4}/PKG-INFO +23 -2
  2. {servestatic-4.3.2 → servestatic-4.3.4}/README.md +22 -1
  3. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/__init__.py +1 -1
  4. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/base.py +9 -2
  5. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/responders.py +26 -15
  6. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/utils.py +40 -0
  7. {servestatic-4.3.2 → servestatic-4.3.4}/.gitignore +0 -0
  8. {servestatic-4.3.2 → servestatic-4.3.4}/LICENSE.md +0 -0
  9. {servestatic-4.3.2 → servestatic-4.3.4}/pyproject.toml +0 -0
  10. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/apps.py +0 -0
  11. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/asgi.py +0 -0
  12. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/checks.py +0 -0
  13. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/cli.py +0 -0
  14. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/compress.py +0 -0
  15. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/management/__init__.py +0 -0
  16. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/management/commands/__init__.py +0 -0
  17. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/management/commands/runserver.py +0 -0
  18. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/manifest_hash.py +0 -0
  19. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/media_types.py +0 -0
  20. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/middleware.py +0 -0
  21. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/runserver_nostatic/__init__.py +0 -0
  22. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/runserver_nostatic/apps.py +0 -0
  23. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/runserver_nostatic/management/__init__.py +0 -0
  24. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/runserver_nostatic/management/commands/__init__.py +0 -0
  25. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/runserver_nostatic/management/commands/runserver.py +0 -0
  26. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/storage.py +0 -0
  27. {servestatic-4.3.2 → servestatic-4.3.4}/src/servestatic/wsgi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: servestatic
3
- Version: 4.3.2
3
+ Version: 4.3.4
4
4
  Summary: Production-grade static file server for Python WSGI & ASGI.
5
5
  Project-URL: Changelog, https://archmonger.github.io/ServeStatic/latest/changelog/
6
6
  Project-URL: Documentation, https://archmonger.github.io/ServeStatic/
@@ -63,7 +63,28 @@ When using `ServeStatic`, best practices are automatically handled such as:
63
63
  - Proper handling of `Accept-Encoding` and `Vary` headers
64
64
  - Setting far-future cache headers for immutable static files.
65
65
 
66
- Visit the [documentation](https://archmonger.github.io/ServeStatic/) to get started or learn more about `ServeStatic` .
66
+
67
+ ## Quick Start
68
+
69
+ Here is a short example of ASGI standalone mode:
70
+
71
+ ```python
72
+ # filename: my_project.py
73
+ from servestatic import ServeStaticASGI
74
+
75
+ asgi_app = ServeStaticASGI(
76
+ application=None, # `application=None` will run ServeStatic in standalone mode.
77
+ root="/path/to/static/files",
78
+ )
79
+ ```
80
+
81
+ Now, use any webserver CLI to serve static files. Since this example uses ASGI, here is a sample command for `uvicorn` (a popular ASGI webserver):
82
+
83
+ ```
84
+ uvicorn my_project:asgi_app
85
+ ```
86
+
87
+ Visit the [documentation](https://archmonger.github.io/ServeStatic/) to get started, or learn more about `ServeStatic` .
67
88
 
68
89
  ## Frequently Asked Questions
69
90
 
@@ -29,7 +29,28 @@ When using `ServeStatic`, best practices are automatically handled such as:
29
29
  - Proper handling of `Accept-Encoding` and `Vary` headers
30
30
  - Setting far-future cache headers for immutable static files.
31
31
 
32
- Visit the [documentation](https://archmonger.github.io/ServeStatic/) to get started or learn more about `ServeStatic` .
32
+
33
+ ## Quick Start
34
+
35
+ Here is a short example of ASGI standalone mode:
36
+
37
+ ```python
38
+ # filename: my_project.py
39
+ from servestatic import ServeStaticASGI
40
+
41
+ asgi_app = ServeStaticASGI(
42
+ application=None, # `application=None` will run ServeStatic in standalone mode.
43
+ root="/path/to/static/files",
44
+ )
45
+ ```
46
+
47
+ Now, use any webserver CLI to serve static files. Since this example uses ASGI, here is a sample command for `uvicorn` (a popular ASGI webserver):
48
+
49
+ ```
50
+ uvicorn my_project:asgi_app
51
+ ```
52
+
53
+ Visit the [documentation](https://archmonger.github.io/ServeStatic/) to get started, or learn more about `ServeStatic` .
33
54
 
34
55
  ## Frequently Asked Questions
35
56
 
@@ -3,6 +3,6 @@ from __future__ import annotations
3
3
  from servestatic.asgi import ServeStaticASGI
4
4
  from servestatic.wsgi import ServeStatic
5
5
 
6
- __version__ = "4.3.2"
6
+ __version__ = "4.3.4"
7
7
 
8
8
  __all__ = ["ServeStatic", "ServeStaticASGI"]
@@ -162,8 +162,15 @@ class ServeStaticBase:
162
162
  if getattr(self, "allow_unsafe_symlinks", False):
163
163
  return True
164
164
 
165
- resolved_root = os.path.realpath(normalized_root)
166
- resolved_path = os.path.realpath(normalized_path)
165
+ try:
166
+ resolved_root = os.path.realpath(normalized_root)
167
+ resolved_path = os.path.realpath(normalized_path)
168
+ except ValueError:
169
+ # A raw NUL byte (e.g. from a request path containing %00) survives
170
+ # normpath() but makes realpath() raise. Treat such a path as being
171
+ # outside the root (i.e. not found/forbidden) rather than letting an
172
+ # unhandled ValueError propagate and crash the request.
173
+ return False
167
174
  return self._path_is_within(resolved_root, resolved_path)
168
175
 
169
176
  @staticmethod
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import contextlib
4
4
  import errno
5
5
  import os
6
- import re
7
6
  import stat
8
7
  from email.utils import formatdate, parsedate
9
8
  from http import HTTPStatus
@@ -13,7 +12,7 @@ from typing import TYPE_CHECKING
13
12
  from urllib.parse import quote
14
13
  from wsgiref.headers import Headers
15
14
 
16
- from servestatic.utils import AsyncFile
15
+ from servestatic.utils import AsyncFile, parse_accept_encoding
17
16
 
18
17
  if TYPE_CHECKING:
19
18
  from collections.abc import Callable, Mapping
@@ -42,6 +41,7 @@ NOT_ALLOWED_RESPONSE = Response(
42
41
  # Headers which should be returned with a 304 Not Modified response as
43
42
  # specified here: https://tools.ietf.org/html/rfc7232#section-4.1
44
43
  NOT_MODIFIED_HEADERS = (
44
+ "Access-Control-Allow-Origin",
45
45
  "Cache-Control",
46
46
  "Content-Location",
47
47
  "Date",
@@ -184,7 +184,7 @@ class StaticFile:
184
184
  raise ValueError(msg)
185
185
  start, end = self.get_byte_range(range_header, size)
186
186
  if start > end:
187
- return self.get_range_not_satisfiable_response(file_handle, size)
187
+ return self.get_range_not_satisfiable_response(file_handle, size, headers)
188
188
  if file_handle is not None:
189
189
  file_handle = SlicedFile(file_handle, start, end)
190
190
  headers.extend((
@@ -213,7 +213,7 @@ class StaticFile:
213
213
  raise ValueError(msg)
214
214
  start, end = self.get_byte_range(range_header, size)
215
215
  if start > end:
216
- return await self.aget_range_not_satisfiable_response(file_handle, size)
216
+ return await self.aget_range_not_satisfiable_response(file_handle, size, headers)
217
217
  sliced_file: AsyncSlicedFile | None = None
218
218
  if file_handle is not None:
219
219
  sliced_file = AsyncSlicedFile(file_handle, start, end)
@@ -249,23 +249,35 @@ class StaticFile:
249
249
  return start, end
250
250
 
251
251
  @staticmethod
252
- def get_range_not_satisfiable_response(file_handle: BufferedIOBase | None, size: int) -> Response:
252
+ def get_range_not_satisfiable_response(
253
+ file_handle: BufferedIOBase | None,
254
+ size: int,
255
+ headers: list[tuple[str, str | None]] | None = None,
256
+ ) -> Response:
253
257
  if file_handle is not None:
254
258
  file_handle.close()
259
+ response_headers = list(headers) if headers else []
260
+ response_headers.append(("Content-Range", f"bytes */{size}"))
255
261
  return Response(
256
262
  HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
257
- [("Content-Range", f"bytes */{size}")],
263
+ response_headers,
258
264
  None,
259
265
  )
260
266
 
261
267
  @staticmethod
262
- async def aget_range_not_satisfiable_response(file_handle: AsyncFile | None, size: int) -> Response:
268
+ async def aget_range_not_satisfiable_response(
269
+ file_handle: AsyncFile | None,
270
+ size: int,
271
+ headers: list[tuple[str, str | None]] | None = None,
272
+ ) -> Response:
263
273
  """Variant of `get_range_not_satisfiable_response` that works with
264
274
  async file objects. Async file handles do not need to be closed, since they
265
275
  are only opened via context managers while being dispatched."""
276
+ response_headers = list(headers) if headers else []
277
+ response_headers.append(("Content-Range", f"bytes */{size}"))
266
278
  return Response(
267
279
  HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
268
- [("Content-Range", f"bytes */{size}")],
280
+ response_headers,
269
281
  None,
270
282
  )
271
283
 
@@ -313,7 +325,7 @@ class StaticFile:
313
325
  def get_alternatives(
314
326
  base_headers: Headers,
315
327
  files: Mapping[str | None, FileEntry],
316
- ) -> list[tuple[re.Pattern[str], str, list[tuple[str, str | None]]]]:
328
+ ) -> list[tuple[str | None, str, list[tuple[str, str | None]]]]:
317
329
  # Sort by size so that the smallest compressed alternative matches first
318
330
  alternatives = []
319
331
  files_by_size = sorted(files.items(), key=lambda i: i[1].size)
@@ -322,10 +334,7 @@ class StaticFile:
322
334
  headers["Content-Length"] = str(file_entry.size)
323
335
  if encoding:
324
336
  headers["Content-Encoding"] = encoding
325
- encoding_re = re.compile(rf"\b{encoding}\b")
326
- else:
327
- encoding_re = re.compile(r"")
328
- alternatives.append((encoding_re, file_entry.path, headers.items()))
337
+ alternatives.append((encoding, file_entry.path, headers.items()))
329
338
  return alternatives
330
339
 
331
340
  def is_not_modified(self, request_headers: Mapping[str, str]) -> bool:
@@ -347,12 +356,14 @@ class StaticFile:
347
356
  accept_encoding = request_headers.get("HTTP_ACCEPT_ENCODING", "")
348
357
  if accept_encoding == "*":
349
358
  accept_encoding = ""
359
+ accepted = parse_accept_encoding(accept_encoding)
350
360
  # These are sorted by size so first match is the best
351
361
  result = next(
352
362
  (
353
363
  (path, headers)
354
- for encoding_re, path, headers in self.alternatives
355
- if encoding_re.search(accept_encoding)
364
+ for encoding, path, headers in self.alternatives
365
+ # An uncompressed file is always an acceptable fallback.
366
+ if not encoding or encoding in accepted
356
367
  ),
357
368
  None,
358
369
  )
@@ -4,6 +4,7 @@ import asyncio
4
4
  import concurrent.futures
5
5
  import contextlib
6
6
  import functools
7
+ import math
7
8
  import os
8
9
  from concurrent.futures import ThreadPoolExecutor
9
10
  from typing import TYPE_CHECKING, Concatenate, ParamSpec, TypeVar, cast
@@ -38,6 +39,45 @@ def ensure_leading_trailing_slash(path: str | None) -> str:
38
39
  return f"/{path}/" if path else "/"
39
40
 
40
41
 
42
+ def parse_accept_encoding(header: str) -> set[str]:
43
+ """
44
+ Return the set of content codings the client is willing to accept.
45
+
46
+ The header is parsed as described in RFC 9110 section 12.5.3 rather than
47
+ matched as a substring, so that a coding the client has refused with
48
+ ``q=0`` is not offered to it, and a coding name is only recognised when it
49
+ appears as a whole token.
50
+ """
51
+ accepted: set[str] = set()
52
+ for entry in header.split(","):
53
+ coding, _, params = entry.partition(";")
54
+ coding = coding.strip().lower()
55
+ if not coding:
56
+ continue
57
+ quality = 1.0
58
+ for param in params.split(";"):
59
+ name, sep, value = param.partition("=")
60
+ if name.strip().lower() != "q":
61
+ continue
62
+ if not sep:
63
+ break
64
+ try:
65
+ quality = float(value.strip())
66
+ except ValueError:
67
+ # An unparseable qvalue makes the whole entry invalid; treat
68
+ # the coding as unacceptable rather than guess.
69
+ quality = 0.0
70
+ else:
71
+ # Per RFC 9110 a qvalue must be a number in the range 0.000 to
72
+ # 1.000; any other finite value (e.g. ``inf``) is invalid.
73
+ if not math.isfinite(quality) or not 0.0 <= quality <= 1.0:
74
+ quality = 0.0
75
+ break
76
+ if quality > 0:
77
+ accepted.add(coding)
78
+ return accepted
79
+
80
+
41
81
  def scantree(root: str | PathLike[str]) -> Iterator[tuple[str, os.stat_result]]:
42
82
  """
43
83
  Recurse the given directory yielding (pathname, os.stat(pathname)) pairs
File without changes
File without changes
File without changes