tigrcorn-static 0.3.16.dev12__tar.gz → 0.3.17.dev1__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 (22) hide show
  1. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/PKG-INFO +4 -4
  2. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/pyproject.toml +4 -4
  3. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/__init__.py +34 -0
  4. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/app.py +92 -0
  5. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/models.py +45 -0
  6. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/mounting.py +99 -0
  7. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/representations.py +167 -0
  8. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/responses.py +189 -0
  9. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static/static/security.py +228 -0
  10. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/PKG-INFO +4 -4
  11. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static.egg-info/SOURCES.txt +17 -0
  12. tigrcorn_static-0.3.17.dev1/src/tigrcorn_static.egg-info/requires.txt +3 -0
  13. tigrcorn_static-0.3.16.dev12/src/tigrcorn_static/static.py +0 -557
  14. tigrcorn_static-0.3.16.dev12/src/tigrcorn_static.egg-info/SOURCES.txt +0 -11
  15. tigrcorn_static-0.3.16.dev12/src/tigrcorn_static.egg-info/requires.txt +0 -3
  16. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/LICENSE +0 -0
  17. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/README.md +0 -0
  18. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/setup.cfg +0 -0
  19. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static/__init__.py +0 -0
  20. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static/py.typed +0 -0
  21. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/dependency_links.txt +0 -0
  22. {tigrcorn_static-0.3.16.dev12 → tigrcorn_static-0.3.17.dev1}/src/tigrcorn_static.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tigrcorn-static
3
- Version: 0.3.16.dev12
3
+ Version: 0.3.17.dev1
4
4
  Summary: Static file origin, file-send, validators, ranges, and cache-aware HTTP responses for the Tigrcorn ASGI server.
5
5
  Author-email: Jacob Stewart <jacob@swarmauri.com>
6
6
  License: Apache License
@@ -186,9 +186,9 @@ Classifier: Typing :: Typed
186
186
  Requires-Python: <3.15,>=3.10
187
187
  Description-Content-Type: text/markdown
188
188
  License-File: LICENSE
189
- Requires-Dist: tigrcorn-core==0.3.16.dev12
190
- Requires-Dist: tigrcorn-asgi==0.3.16.dev12
191
- Requires-Dist: tigrcorn-http==0.3.16.dev12
189
+ Requires-Dist: tigrcorn-core==0.3.17.dev1
190
+ Requires-Dist: tigrcorn-asgi==0.3.17.dev1
191
+ Requires-Dist: tigrcorn-http==0.3.17.dev1
192
192
  Dynamic: license-file
193
193
 
194
194
  <div align="center">
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tigrcorn-static"
7
- version = "0.3.16.dev12"
7
+ version = "0.3.17.dev1"
8
8
  description = "Static file origin, file-send, validators, ranges, and cache-aware HTTP responses for the Tigrcorn ASGI server."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10,<3.15"
@@ -29,9 +29,9 @@ classifiers = [
29
29
  "Typing :: Typed",
30
30
  ]
31
31
  dependencies = [
32
- "tigrcorn-core==0.3.16.dev12",
33
- "tigrcorn-asgi==0.3.16.dev12",
34
- "tigrcorn-http==0.3.16.dev12",
32
+ "tigrcorn-core==0.3.17.dev1",
33
+ "tigrcorn-asgi==0.3.17.dev1",
34
+ "tigrcorn-http==0.3.17.dev1",
35
35
  ]
36
36
 
37
37
  [tool.setuptools]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from .app import StaticFilesApp
4
+ from .mounting import mount_static_app, normalize_static_route
5
+ from .security import (
6
+ StaticSecurityCertificationError,
7
+ StaticSecurityPolicy,
8
+ certify_static_delivery_security,
9
+ static_alt_svc_headers,
10
+ static_cache_headers,
11
+ static_delivery_certification_artifact,
12
+ static_security_policy,
13
+ validate_static_early_hints,
14
+ validate_static_path,
15
+ validate_static_range_request,
16
+ validate_static_resolved_path,
17
+ )
18
+
19
+ __all__ = [
20
+ "StaticFilesApp",
21
+ "StaticSecurityCertificationError",
22
+ "StaticSecurityPolicy",
23
+ "certify_static_delivery_security",
24
+ "mount_static_app",
25
+ "normalize_static_route",
26
+ "static_alt_svc_headers",
27
+ "static_cache_headers",
28
+ "static_delivery_certification_artifact",
29
+ "static_security_policy",
30
+ "validate_static_early_hints",
31
+ "validate_static_path",
32
+ "validate_static_range_request",
33
+ "validate_static_resolved_path",
34
+ ]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Iterable
6
+
7
+ from .representations import StaticRepresentationMixin
8
+ from .responses import StaticResponseMixin
9
+
10
+
11
+ class StaticFilesApp(StaticResponseMixin, StaticRepresentationMixin):
12
+ def __init__(
13
+ self,
14
+ directory: str | Path,
15
+ *,
16
+ index_file: str | None = "index.html",
17
+ dir_to_file: bool = True,
18
+ expires: int | None = None,
19
+ default_headers: Iterable[tuple[bytes, bytes] | tuple[str, str]] = (),
20
+ apply_content_coding: bool = True,
21
+ content_coding_policy: str = "allowlist",
22
+ content_codings: Iterable[str] = ("br", "gzip", "deflate"),
23
+ use_precompressed_sidecars: bool = True,
24
+ precompressed_codings: Iterable[str] = ("br", "gzip"),
25
+ ) -> None:
26
+ self.directory = Path(directory).resolve()
27
+ self.index_file = index_file
28
+ self.dir_to_file = bool(dir_to_file)
29
+ self.expires = None if expires is None else int(expires)
30
+ self.default_headers = [
31
+ (
32
+ name if isinstance(name, bytes) else str(name).encode("latin1"),
33
+ value if isinstance(value, bytes) else str(value).encode("latin1"),
34
+ )
35
+ for name, value in default_headers
36
+ ]
37
+ self.apply_content_coding = apply_content_coding
38
+ self.content_coding_policy = str(content_coding_policy)
39
+ self.content_codings = tuple(str(coding) for coding in content_codings)
40
+ self.use_precompressed_sidecars = bool(use_precompressed_sidecars)
41
+ self.precompressed_codings = tuple(str(coding).lower() for coding in precompressed_codings)
42
+ self._etag_cache: dict[tuple[str, int, int], bytes] = {}
43
+
44
+ @staticmethod
45
+ def _supports_file_response(scope: dict) -> bool:
46
+ extensions = scope.get("extensions") or {}
47
+ return bool(extensions.get("tigrcorn.http.response.file"))
48
+
49
+ @staticmethod
50
+ def _supports_pathsend(scope: dict) -> bool:
51
+ extensions = scope.get("extensions") or {}
52
+ return "http.response.pathsend" in extensions
53
+
54
+ async def __call__(self, scope, receive, send) -> None:
55
+ if scope["type"] != "http":
56
+ raise RuntimeError("StaticFilesApp only supports HTTP scopes")
57
+ method = str(scope.get("method", "GET")).upper()
58
+ if method not in {"GET", "HEAD"}:
59
+ await send(
60
+ {
61
+ "type": "http.response.start",
62
+ "status": 405,
63
+ "headers": [(b"allow", b"GET, HEAD"), (b"content-type", b"text/plain; charset=utf-8")],
64
+ }
65
+ )
66
+ await send({"type": "http.response.body", "body": b"method not allowed"})
67
+ return
68
+ request_headers = [(bytes(name).lower(), bytes(value)) for name, value in scope.get("headers", [])]
69
+ supports_file_response = self._supports_file_response(scope)
70
+ supports_pathsend = self._supports_pathsend(scope)
71
+ response = await self._response_for_path(
72
+ method,
73
+ scope.get("path", "/"),
74
+ request_headers,
75
+ supports_streaming_response=supports_file_response or supports_pathsend,
76
+ )
77
+ await send({"type": "http.response.start", "status": response.status, "headers": response.headers})
78
+ if response.preprocessed:
79
+ pathsend_segment = self._pathsend_segment(response) if supports_pathsend else None
80
+ if pathsend_segment is not None:
81
+ await send({"type": "http.response.pathsend", "path": os.fspath(pathsend_segment.path)})
82
+ return
83
+ if supports_file_response and response.segments:
84
+ await send(
85
+ {
86
+ "type": "tigrcorn.http.response.file",
87
+ "segments": self._serialize_segments(response.segments),
88
+ "more_body": False,
89
+ }
90
+ )
91
+ return
92
+ await send({"type": "http.response.body", "body": response.body})
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from tigrcorn_asgi.send import FileBodySegment, MemoryBodySegment
8
+
9
+
10
+ HeaderList = list[tuple[bytes, bytes]]
11
+ PRECOMPRESSED_SIDECAR_SUFFIXES: dict[str, str] = {"br": ".br", "gzip": ".gz"}
12
+ BUFFERED_DYNAMIC_CODING_MAX_BYTES = 256 * 1024
13
+ MAX_ETAG_CACHE_ENTRIES = 1024
14
+
15
+
16
+ @dataclass(slots=True)
17
+ class StaticFileResponse:
18
+ status: int
19
+ headers: HeaderList
20
+ body: bytes = b""
21
+ segments: tuple[MemoryBodySegment | FileBodySegment, ...] = ()
22
+ preprocessed: bool = False
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class StaticPathDecision:
27
+ accepted: bool
28
+ normalized_path: str | None
29
+ reason: str
30
+
31
+ def as_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "accepted": self.accepted,
34
+ "normalized_path": self.normalized_path,
35
+ "reason": self.reason,
36
+ }
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class SelectedRepresentation:
41
+ path: Path
42
+ content_encoding: str | None
43
+ mtime: float
44
+ size: int
45
+ mtime_ns: int
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+
6
+ from tigrcorn_core.types import ASGIApp
7
+ from tigrcorn_core.utils.proxy import strip_root_path
8
+
9
+ from .app import StaticFilesApp
10
+
11
+
12
+ async def _not_found_app(scope, receive, send) -> None:
13
+ if scope["type"] == "lifespan":
14
+ while True:
15
+ message = await receive()
16
+ if message["type"] == "lifespan.startup":
17
+ await send({"type": "lifespan.startup.complete"})
18
+ elif message["type"] == "lifespan.shutdown":
19
+ await send({"type": "lifespan.shutdown.complete"})
20
+ return
21
+ if scope["type"] == "websocket":
22
+ await send({"type": "websocket.close", "code": 1000})
23
+ return
24
+ if scope["type"] != "http":
25
+ raise RuntimeError(f"unsupported scope type for static fallback: {scope['type']!r}")
26
+ if scope.get("method", "GET").upper() not in {"GET", "HEAD"}:
27
+ await send(
28
+ {
29
+ "type": "http.response.start",
30
+ "status": 405,
31
+ "headers": [(b"allow", b"GET, HEAD"), (b"content-type", b"text/plain; charset=utf-8")],
32
+ }
33
+ )
34
+ await send({"type": "http.response.body", "body": b"method not allowed"})
35
+ return
36
+ await send({"type": "http.response.start", "status": 404, "headers": [(b"content-type", b"text/plain; charset=utf-8")]})
37
+ await send({"type": "http.response.body", "body": b"not found"})
38
+
39
+
40
+ def normalize_static_route(route: str | None) -> str:
41
+ if not route:
42
+ return "/"
43
+ return ("/" + str(route).lstrip("/")).rstrip("/") or "/"
44
+
45
+
46
+ def _route_matches(route: str, path: str) -> bool:
47
+ if route == "/":
48
+ return True
49
+ return path == route or path.startswith(route + "/")
50
+
51
+
52
+ def mount_static_app(
53
+ app: ASGIApp | None,
54
+ *,
55
+ route: str,
56
+ directory: str | Path,
57
+ dir_to_file: bool = True,
58
+ index_file: str | None = "index.html",
59
+ expires: int | None = None,
60
+ apply_content_coding: bool = True,
61
+ content_coding_policy: str = "allowlist",
62
+ content_codings: Iterable[str] = ("br", "gzip", "deflate"),
63
+ use_precompressed_sidecars: bool = True,
64
+ precompressed_codings: Iterable[str] = ("br", "gzip"),
65
+ ) -> ASGIApp:
66
+ static_app = StaticFilesApp(
67
+ directory,
68
+ index_file=index_file,
69
+ dir_to_file=dir_to_file,
70
+ expires=expires,
71
+ apply_content_coding=apply_content_coding,
72
+ content_coding_policy=content_coding_policy,
73
+ content_codings=content_codings,
74
+ use_precompressed_sidecars=use_precompressed_sidecars,
75
+ precompressed_codings=precompressed_codings,
76
+ )
77
+ fallback = app or _not_found_app
78
+ normalized_route = normalize_static_route(route)
79
+
80
+ async def wrapped(scope, receive, send) -> None:
81
+ if scope["type"] != "http":
82
+ await fallback(scope, receive, send)
83
+ return
84
+ path = str(scope.get("path") or "/")
85
+ if not _route_matches(normalized_route, path):
86
+ await fallback(scope, receive, send)
87
+ return
88
+ raw_path = bytes(scope.get("raw_path") or path.encode("latin1"))
89
+ mounted_path, mounted_raw_path = strip_root_path(path, raw_path, normalized_route)
90
+ mounted_scope = dict(scope)
91
+ mounted_scope["path"] = mounted_path
92
+ mounted_scope["raw_path"] = mounted_raw_path
93
+ if normalized_route != "/":
94
+ existing_root = str(scope.get("root_path") or "")
95
+ combined_root = (existing_root.rstrip("/") + normalized_route).rstrip("/") or "/"
96
+ mounted_scope["root_path"] = combined_root
97
+ await static_app(mounted_scope, receive, send)
98
+
99
+ return wrapped
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import mimetypes
5
+ import time
6
+ from email.utils import formatdate
7
+ from pathlib import Path, PurePosixPath
8
+ from urllib.parse import unquote
9
+
10
+ from tigrcorn_asgi.send import FileBodySegment, iter_response_body_segments
11
+ from tigrcorn_core.utils.headers import append_if_missing, get_header
12
+
13
+ from .models import (
14
+ HeaderList,
15
+ MAX_ETAG_CACHE_ENTRIES,
16
+ PRECOMPRESSED_SIDECAR_SUFFIXES,
17
+ SelectedRepresentation,
18
+ )
19
+
20
+
21
+ class StaticRepresentationMixin:
22
+ def _resolve_candidate(self, path: str) -> Path | None:
23
+ decoded = unquote(path or "/")
24
+ if "\x00" in decoded:
25
+ return None
26
+ parts: list[str] = []
27
+ for part in PurePosixPath(decoded).parts:
28
+ if part in {"", "/", "."}:
29
+ continue
30
+ if part == "..":
31
+ return None
32
+ if "\\" in part:
33
+ return None
34
+ parts.append(part)
35
+ candidate = self.directory.joinpath(*parts).resolve()
36
+ try:
37
+ candidate.relative_to(self.directory)
38
+ except ValueError:
39
+ return None
40
+ if candidate.is_dir():
41
+ if not self.dir_to_file or not self.index_file:
42
+ return None
43
+ candidate = candidate / self.index_file
44
+ try:
45
+ candidate.relative_to(self.directory)
46
+ except ValueError:
47
+ return None
48
+ return candidate
49
+
50
+ @staticmethod
51
+ def _parse_qvalue(raw: str) -> float:
52
+ try:
53
+ value = float(raw)
54
+ except ValueError:
55
+ return 0.0
56
+ if value < 0.0:
57
+ return 0.0
58
+ if value > 1.0:
59
+ return 1.0
60
+ return value
61
+
62
+ def _preferred_precompressed_codings(
63
+ self,
64
+ request_headers: list[tuple[bytes, bytes]],
65
+ ) -> list[str]:
66
+ header_value = get_header(request_headers, b"accept-encoding")
67
+ if header_value is None:
68
+ return []
69
+ wildcard_q: float | None = None
70
+ coding_q: dict[str, float] = {}
71
+ order: dict[str, int] = {}
72
+ for index, part in enumerate(header_value.decode("ascii", "ignore").split(",")):
73
+ token = part.strip()
74
+ if not token:
75
+ continue
76
+ name, *params = [piece.strip() for piece in token.split(";")]
77
+ lower = name.lower()
78
+ q = 1.0
79
+ for param in params:
80
+ if "=" not in param:
81
+ continue
82
+ key, value = param.split("=", 1)
83
+ if key.strip().lower() == "q":
84
+ q = self._parse_qvalue(value.strip())
85
+ if lower == "*":
86
+ wildcard_q = q
87
+ else:
88
+ coding_q[lower] = q
89
+ order.setdefault(lower, index)
90
+
91
+ ranked: list[tuple[float, int, str]] = []
92
+ for index, coding in enumerate(self.precompressed_codings):
93
+ q = coding_q.get(coding)
94
+ if q is None:
95
+ q = wildcard_q if wildcard_q is not None else 0.0
96
+ if q <= 0.0:
97
+ continue
98
+ ranked.append((-q, order.get(coding, 1000 + index), coding))
99
+ ranked.sort()
100
+ return [coding for _neg_q, _order, coding in ranked]
101
+
102
+ def _select_representation(
103
+ self,
104
+ candidate: Path,
105
+ request_headers: list[tuple[bytes, bytes]],
106
+ ) -> SelectedRepresentation:
107
+ origin_stat = candidate.stat()
108
+ if not self.apply_content_coding or not self.use_precompressed_sidecars:
109
+ return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
110
+ if get_header(request_headers, b"range") is not None:
111
+ return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
112
+ for coding in self._preferred_precompressed_codings(request_headers):
113
+ suffix = PRECOMPRESSED_SIDECAR_SUFFIXES.get(coding)
114
+ if suffix is None:
115
+ continue
116
+ sidecar = candidate.with_name(candidate.name + suffix)
117
+ if not sidecar.exists() or not sidecar.is_file():
118
+ continue
119
+ sidecar_stat = sidecar.stat()
120
+ return SelectedRepresentation(
121
+ sidecar,
122
+ coding,
123
+ max(origin_stat.st_mtime, sidecar_stat.st_mtime),
124
+ sidecar_stat.st_size,
125
+ max(origin_stat.st_mtime_ns, sidecar_stat.st_mtime_ns),
126
+ )
127
+ return SelectedRepresentation(candidate, None, origin_stat.st_mtime, origin_stat.st_size, origin_stat.st_mtime_ns)
128
+
129
+ async def _representation_etag(self, representation: SelectedRepresentation) -> bytes:
130
+ cache_key = (str(representation.path), representation.size, representation.mtime_ns)
131
+ cached = self._etag_cache.get(cache_key)
132
+ if cached is not None:
133
+ return cached
134
+ digest = hashlib.blake2s(digest_size=16)
135
+ segment = FileBodySegment(str(representation.path), 0, representation.size)
136
+ async for chunk in iter_response_body_segments((segment,)):
137
+ digest.update(chunk)
138
+ value = b'"' + digest.hexdigest().encode("ascii") + b'"'
139
+ self._etag_cache[cache_key] = value
140
+ if len(self._etag_cache) > MAX_ETAG_CACHE_ENTRIES:
141
+ self._etag_cache.pop(next(iter(self._etag_cache)))
142
+ return value
143
+
144
+ def _base_headers(
145
+ self,
146
+ candidate: Path,
147
+ representation: SelectedRepresentation,
148
+ *,
149
+ etag: bytes,
150
+ ) -> HeaderList:
151
+ content_type = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
152
+ headers: HeaderList = [
153
+ (b"content-type", content_type.encode("latin1")),
154
+ (b"last-modified", formatdate(representation.mtime, usegmt=True).encode("ascii")),
155
+ (b"etag", etag),
156
+ *self.default_headers,
157
+ ]
158
+ if representation.content_encoding is not None:
159
+ headers.append((b"content-encoding", representation.content_encoding.encode("ascii")))
160
+ append_if_missing(headers, b"vary", b"accept-encoding")
161
+ if self.expires is not None:
162
+ if self.expires <= 0:
163
+ headers.append((b"cache-control", b"no-store"))
164
+ else:
165
+ headers.append((b"cache-control", f"public, max-age={self.expires}".encode("ascii")))
166
+ headers.append((b"expires", formatdate(time.time() + self.expires, usegmt=True).encode("ascii")))
167
+ return headers
@@ -0,0 +1,189 @@
1
+ from __future__ import annotations
2
+
3
+ import mimetypes
4
+ from pathlib import Path
5
+
6
+ from tigrcorn_asgi.send import FileBodySegment, MemoryBodySegment, materialize_response_body_segments
7
+ from tigrcorn_core.utils.headers import get_header
8
+ from tigrcorn_http.conditional import apply_conditional_request
9
+ from tigrcorn_http.entity import apply_response_entity_semantics, finalize_response_content_length
10
+ from tigrcorn_http.range import FileRangePlan, plan_file_byte_ranges
11
+ from tigrcorn_protocols.http1.serializer import response_allows_body
12
+
13
+ from .models import BUFFERED_DYNAMIC_CODING_MAX_BYTES, StaticFileResponse
14
+
15
+
16
+ class StaticResponseMixin:
17
+ async def _buffered_dynamic_coding_response(
18
+ self,
19
+ *,
20
+ method: str,
21
+ request_headers: list[tuple[bytes, bytes]],
22
+ candidate: Path,
23
+ representation,
24
+ ) -> StaticFileResponse:
25
+ segment = FileBodySegment(str(representation.path), 0, representation.size)
26
+ body = await materialize_response_body_segments((segment,))
27
+ headers = self._base_headers(candidate, representation, etag=await self._representation_etag(representation))
28
+ processed = apply_response_entity_semantics(
29
+ method=method,
30
+ request_headers=request_headers,
31
+ response_headers=headers,
32
+ body=body,
33
+ status=200,
34
+ apply_content_coding=True,
35
+ content_coding_policy=self.content_coding_policy,
36
+ supported_codings=self.content_codings,
37
+ generate_etag=False,
38
+ )
39
+ segments = (MemoryBodySegment(processed.body),) if processed.body else ()
40
+ return StaticFileResponse(
41
+ status=processed.status,
42
+ headers=processed.headers,
43
+ body=processed.body,
44
+ segments=segments,
45
+ preprocessed=True,
46
+ )
47
+
48
+ @staticmethod
49
+ def _multipart_segments(
50
+ *,
51
+ path: Path,
52
+ plan: FileRangePlan,
53
+ total_length: int,
54
+ source_content_type: bytes | None,
55
+ ) -> tuple[MemoryBodySegment | FileBodySegment, ...]:
56
+ assert plan.boundary is not None
57
+ segments: list[MemoryBodySegment | FileBodySegment] = []
58
+ for item in plan.parts:
59
+ lines = [b"--" + plan.boundary]
60
+ if source_content_type is not None:
61
+ lines.append(b"Content-Type: " + source_content_type)
62
+ lines.append(b"Content-Range: bytes " + f"{item.start}-{item.end}/{total_length}".encode("ascii"))
63
+ segments.append(MemoryBodySegment(b"\r\n".join(lines) + b"\r\n\r\n"))
64
+ segments.append(FileBodySegment(str(path), item.start, item.end - item.start + 1))
65
+ segments.append(MemoryBodySegment(b"\r\n"))
66
+ segments.append(MemoryBodySegment(b"--" + plan.boundary + b"--\r\n"))
67
+ return tuple(segments)
68
+
69
+ async def _static_file_plan(
70
+ self,
71
+ *,
72
+ method: str,
73
+ request_headers: list[tuple[bytes, bytes]],
74
+ candidate: Path,
75
+ representation,
76
+ supports_file_response: bool,
77
+ ) -> StaticFileResponse:
78
+ etag = await self._representation_etag(representation)
79
+ headers = self._base_headers(candidate, representation, etag=etag)
80
+ conditional = apply_conditional_request(
81
+ method=method.upper(),
82
+ request_headers=request_headers,
83
+ response_headers=headers,
84
+ body=b"",
85
+ status=200,
86
+ )
87
+ if conditional.not_modified or conditional.precondition_failed:
88
+ processed = apply_response_entity_semantics(
89
+ method=method,
90
+ request_headers=request_headers,
91
+ response_headers=conditional.headers,
92
+ body=conditional.body,
93
+ status=conditional.status,
94
+ apply_content_coding=False,
95
+ generate_etag=False,
96
+ )
97
+ segments = (MemoryBodySegment(processed.body),) if processed.body else ()
98
+ return StaticFileResponse(processed.status, processed.headers, processed.body, segments, True)
99
+
100
+ plan = plan_file_byte_ranges(
101
+ method=method,
102
+ request_headers=request_headers,
103
+ response_headers=conditional.headers,
104
+ resource_length=representation.size,
105
+ status=conditional.status,
106
+ )
107
+ headers = finalize_response_content_length(
108
+ method=method.upper(),
109
+ status=plan.status,
110
+ headers=plan.headers,
111
+ body_length=plan.body_length,
112
+ )
113
+ segments: tuple[MemoryBodySegment | FileBodySegment, ...]
114
+ if method.upper() == "HEAD" or not response_allows_body(plan.status) or plan.unsatisfied:
115
+ segments = ()
116
+ elif plan.applied and len(plan.parts) > 1:
117
+ source_content_type = mimetypes.guess_type(str(candidate))[0]
118
+ segments = self._multipart_segments(
119
+ path=representation.path,
120
+ plan=plan,
121
+ total_length=representation.size,
122
+ source_content_type=None if source_content_type is None else source_content_type.encode("latin1"),
123
+ )
124
+ elif plan.applied and len(plan.parts) == 1:
125
+ item = plan.parts[0]
126
+ segments = (FileBodySegment(str(representation.path), item.start, item.end - item.start + 1),)
127
+ else:
128
+ segments = (FileBodySegment(str(representation.path), 0, representation.size),)
129
+
130
+ body = await materialize_response_body_segments(segments) if (segments and not supports_file_response) else b""
131
+ return StaticFileResponse(plan.status, headers, body, segments, True)
132
+
133
+ async def _response_for_path(
134
+ self,
135
+ method: str,
136
+ path: str,
137
+ request_headers: list[tuple[bytes, bytes]],
138
+ *,
139
+ supports_streaming_response: bool,
140
+ ) -> StaticFileResponse:
141
+ candidate = self._resolve_candidate(path)
142
+ if candidate is None or not candidate.exists() or not candidate.is_file():
143
+ return StaticFileResponse(404, [(b"content-type", b"text/plain; charset=utf-8")], b"not found")
144
+ representation = self._select_representation(candidate, request_headers)
145
+ if (
146
+ representation.content_encoding is None
147
+ and self.apply_content_coding
148
+ and get_header(request_headers, b"accept-encoding") is not None
149
+ and get_header(request_headers, b"range") is None
150
+ and representation.size <= BUFFERED_DYNAMIC_CODING_MAX_BYTES
151
+ ):
152
+ return await self._buffered_dynamic_coding_response(
153
+ method=method,
154
+ request_headers=request_headers,
155
+ candidate=candidate,
156
+ representation=representation,
157
+ )
158
+ return await self._static_file_plan(
159
+ method=method,
160
+ request_headers=request_headers,
161
+ candidate=candidate,
162
+ representation=representation,
163
+ supports_file_response=supports_streaming_response,
164
+ )
165
+
166
+ @staticmethod
167
+ def _pathsend_segment(response: StaticFileResponse) -> FileBodySegment | None:
168
+ if not response.segments or len(response.segments) != 1:
169
+ return None
170
+ segment = response.segments[0]
171
+ if not isinstance(segment, FileBodySegment) or segment.offset != 0:
172
+ return None
173
+ if segment.count is None:
174
+ return segment
175
+ try:
176
+ size = Path(segment.path).stat().st_size
177
+ except FileNotFoundError:
178
+ return None
179
+ return segment if segment.count == size else None
180
+
181
+ @staticmethod
182
+ def _serialize_segments(segments: tuple[MemoryBodySegment | FileBodySegment, ...]) -> list[dict]:
183
+ serialized: list[dict] = []
184
+ for segment in segments:
185
+ if isinstance(segment, MemoryBodySegment):
186
+ serialized.append({"type": "memory", "body": segment.data})
187
+ else:
188
+ serialized.append({"type": "file", "path": segment.path, "offset": segment.offset, "count": segment.count})
189
+ return serialized