flet-web 1.1.0.dev1__tar.gz → 1.1.0.dev3__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 (29) hide show
  1. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/PKG-INFO +2 -2
  2. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/pyproject.toml +2 -2
  3. flet_web-1.1.0.dev3/src/flet_web/compression.py +101 -0
  4. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_static_files.py +185 -1
  5. flet_web-1.1.0.dev3/src/flet_web/version.py +1 -0
  6. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/PKG-INFO +2 -2
  7. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/SOURCES.txt +3 -1
  8. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/requires.txt +1 -1
  9. flet_web-1.1.0.dev3/tests/test_static_compression.py +346 -0
  10. flet_web-1.1.0.dev1/src/flet_web/version.py +0 -1
  11. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/README.md +0 -0
  12. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/setup.cfg +0 -0
  13. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/__init__.py +0 -0
  14. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/__init__.py +0 -0
  15. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/app.py +0 -0
  16. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_app.py +0 -0
  17. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_app_manager.py +0 -0
  18. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_fastapi.py +0 -0
  19. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_oauth.py +0 -0
  20. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_upload.py +0 -0
  21. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/oauth_state.py +0 -0
  22. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/serve_fastapi_web_app.py +0 -0
  23. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/patch_index.py +0 -0
  24. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/uploads.py +0 -0
  25. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/dependency_links.txt +0 -0
  26. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/top_level.txt +0 -0
  27. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/tests/test_fastapi.py +0 -0
  28. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/tests/test_loading_logo.py +0 -0
  29. {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/tests/test_patch_index.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-web
3
- Version: 1.1.0.dev1
3
+ Version: 1.1.0.dev3
4
4
  Summary: Flet web client in Flutter.
5
5
  Author-email: "Appveyor Systems Inc." <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -9,7 +9,7 @@ Project-URL: Repository, https://github.com/flet-dev/flet
9
9
  Project-URL: Documentation, https://flet.dev/docs
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
- Requires-Dist: flet==1.1.0.dev1
12
+ Requires-Dist: flet==1.1.0.dev3
13
13
  Requires-Dist: fastapi>=0.115.12
14
14
  Requires-Dist: websockets>=13.0
15
15
  Requires-Dist: uvicorn>=0.35.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "flet-web"
3
- version = "1.1.0.dev1"
3
+ version = "1.1.0.dev3"
4
4
  description = "Flet web client in Flutter."
5
5
  authors = [{ name = "Appveyor Systems Inc.", email = "hello@flet.dev" }]
6
6
  license = "Apache-2.0"
7
7
  readme = "README.md"
8
8
  requires-python = ">=3.10"
9
9
  dependencies = [
10
- "flet==1.1.0.dev1",
10
+ "flet==1.1.0.dev3",
11
11
  "fastapi >=0.115.12",
12
12
  "websockets >=13.0",
13
13
  "uvicorn >=0.35.0",
@@ -0,0 +1,101 @@
1
+ import gzip
2
+ import os
3
+ import shutil
4
+
5
+ COMPRESSIBLE_EXTENSIONS = {
6
+ ".css",
7
+ ".html",
8
+ ".js",
9
+ ".json",
10
+ ".map",
11
+ ".mjs",
12
+ ".otf",
13
+ ".svg",
14
+ ".ttf",
15
+ ".txt",
16
+ ".wasm",
17
+ ".xml",
18
+ }
19
+ """
20
+ Extensions of files sent gzip-compressed to browsers that accept it.
21
+
22
+ Text and WebAssembly files shrink about 3x with gzip; images, archives and
23
+ `.woff2` fonts are already compressed.
24
+ """
25
+
26
+ MIN_COMPRESSED_FILE_SIZE = 1024
27
+ """
28
+ Size in bytes below which files are sent uncompressed, as compression saves less
29
+ than it costs.
30
+ """
31
+
32
+
33
+ def is_compressible(path: str, stat_result: os.stat_result) -> bool:
34
+ """
35
+ Whether a static file is served gzip-compressed to browsers that accept it.
36
+
37
+ Args:
38
+ path: Path of the file.
39
+ stat_result: The file's `os.stat()` result.
40
+ """
41
+
42
+ return (
43
+ os.path.splitext(path)[1].lower() in COMPRESSIBLE_EXTENSIONS
44
+ and stat_result.st_size >= MIN_COMPRESSED_FILE_SIZE
45
+ )
46
+
47
+
48
+ def accepts_gzip(accept_encoding: str) -> bool:
49
+ """
50
+ Whether an `Accept-Encoding` request header allows a gzip response.
51
+
52
+ Args:
53
+ accept_encoding: Value of the header, e.g. `"gzip, deflate, br"`.
54
+
55
+ Returns:
56
+ `True` if `gzip`, or else `*`, is listed without `q=0`.
57
+ """
58
+
59
+ qualities = {}
60
+ for coding in accept_encoding.split(","):
61
+ name, *params = coding.split(";")
62
+ quality = 1.0
63
+ for param in params:
64
+ key, _, value = param.partition("=")
65
+ if key.strip().lower() == "q":
66
+ try:
67
+ quality = float(value)
68
+ except ValueError:
69
+ quality = 0.0
70
+ qualities[name.strip().lower()] = quality
71
+ return qualities.get("gzip", qualities.get("*", 0.0)) > 0
72
+
73
+
74
+ def write_gzip(source: str, target: str, stat_result: os.stat_result):
75
+ """
76
+ Write a gzip-compressed copy of a file.
77
+
78
+ The copy gets the source's modification time, so that it is served with the
79
+ same `Last-Modified` header. A copy left unfinished by an error is removed.
80
+
81
+ Args:
82
+ source: Path of the file to compress.
83
+ target: Path of the compressed copy.
84
+ stat_result: The source's `os.stat()` result.
85
+ """
86
+
87
+ temp = f"{target}.tmp"
88
+ try:
89
+ with (
90
+ open(source, "rb") as src,
91
+ open(temp, "wb") as raw,
92
+ gzip.GzipFile(
93
+ filename="", mode="wb", compresslevel=6, fileobj=raw, mtime=0
94
+ ) as dst,
95
+ ):
96
+ shutil.copyfileobj(src, dst)
97
+ os.utime(temp, ns=(stat_result.st_atime_ns, stat_result.st_mtime_ns))
98
+ os.replace(temp, target)
99
+ finally:
100
+ if os.path.exists(temp):
101
+ os.remove(temp)
@@ -1,12 +1,19 @@
1
+ import hashlib
1
2
  import logging
2
3
  import mimetypes
3
4
  import os
4
5
  import shutil
5
6
  import tempfile
7
+ from email.utils import parsedate
6
8
  from pathlib import Path
7
- from typing import Optional
9
+ from typing import Optional, Union
8
10
 
11
+ import anyio
12
+ import anyio.to_thread
9
13
  from fastapi.staticfiles import StaticFiles
14
+ from starlette.datastructures import Headers
15
+ from starlette.responses import FileResponse, Response
16
+ from starlette.staticfiles import NotModifiedResponse
10
17
  from starlette.types import Receive, Scope, Send
11
18
 
12
19
  import flet_web.fastapi as flet_fastapi
@@ -18,6 +25,7 @@ from flet_web import (
18
25
  patch_index_html,
19
26
  patch_manifest_json,
20
27
  )
28
+ from flet_web.compression import accepts_gzip, is_compressible, write_gzip
21
29
  from flet_web.fastapi.flet_app_manager import app_manager
22
30
 
23
31
  logger = logging.getLogger(flet_fastapi.__name__)
@@ -63,6 +71,13 @@ class FletStaticFiles(StaticFiles):
63
71
  """
64
72
  Serve Flet app static files.
65
73
 
74
+ Text and WebAssembly files, like the web client's `main.dart.wasm` and
75
+ `main.dart.js`, are sent gzip-compressed to browsers that accept it, which
76
+ makes them about 3x smaller to download. Each file is compressed once, in a
77
+ worker thread, on its first request, and the compressed copy is kept in a
78
+ temporary directory until the server exits. Byte range requests get the
79
+ uncompressed file.
80
+
66
81
  Args:
67
82
  assets_dir: Path to app's assets directory. A relative path is resolved
68
83
  against the current working directory.
@@ -104,6 +119,9 @@ class FletStaticFiles(StaticFiles):
104
119
  self.__no_cdn = no_cdn
105
120
  self.__websocket_endpoint_path = websocket_endpoint_path
106
121
  self.__once = Once()
122
+ self.__gzip_dir: Optional[str] = None
123
+ self.__gzip_locks: dict[str, anyio.Lock] = {}
124
+ self.__gzip_failures: set[str] = set()
107
125
 
108
126
  env_web_renderer = os.getenv("FLET_WEB_RENDERER")
109
127
  if env_web_renderer:
@@ -133,6 +151,167 @@ class FletStaticFiles(StaticFiles):
133
151
  await self.__once.do(self.__config, scope["root_path"])
134
152
  await super().__call__(scope, receive, send)
135
153
 
154
+ def file_response(
155
+ self,
156
+ full_path: Union[str, os.PathLike[str]],
157
+ stat_result: os.stat_result,
158
+ scope: Scope,
159
+ status_code: int = 200,
160
+ ) -> Response:
161
+ """
162
+ Respond with a static file, adding `Vary: Accept-Encoding` to
163
+ compressible ones.
164
+
165
+ Unlike `StaticFiles`, this never responds with `304 Not Modified`:
166
+ `get_response()` checks the browser's cached copy once it has picked
167
+ the plain or compressed file.
168
+ """
169
+ response = FileResponse(
170
+ full_path, status_code=status_code, stat_result=stat_result
171
+ )
172
+ if is_compressible(str(full_path), stat_result):
173
+ # Tells caches that the response depends on the browser's
174
+ # `Accept-Encoding`, so they keep the plain and gzip versions apart.
175
+ response.headers["Vary"] = "Accept-Encoding"
176
+ return response
177
+
178
+ async def get_response(self, path: str, scope: Scope) -> Response:
179
+ """
180
+ Respond with a static file, gzip-compressed if the browser accepts it.
181
+
182
+ Args:
183
+ path: Requested path, relative to the mounted static root.
184
+ scope: ASGI request scope.
185
+
186
+ Returns:
187
+ The compressed file for a `200` response of a compressible file to a
188
+ request that accepts gzip and has no `Range` header; otherwise
189
+ the response of `StaticFiles`. Either is `304 Not Modified` if
190
+ the browser has that same file cached.
191
+ """
192
+
193
+ response = await super().get_response(path, scope)
194
+ if response.status_code != 200 or not isinstance(response, FileResponse):
195
+ return response
196
+
197
+ request_headers = Headers(scope=scope)
198
+ if (
199
+ response.stat_result is not None
200
+ and is_compressible(str(response.path), response.stat_result)
201
+ and "range" not in request_headers
202
+ and accepts_gzip(request_headers.get("accept-encoding", ""))
203
+ ):
204
+ # Derived from the plain file's `ETag`, so it changes whenever the
205
+ # file does, and never equals it.
206
+ etag = response.headers["etag"].removesuffix('"') + '-gzip"'
207
+ gzip_headers = Headers(
208
+ {
209
+ "etag": etag,
210
+ "last-modified": response.headers["last-modified"],
211
+ "vary": "Accept-Encoding",
212
+ }
213
+ )
214
+ # Checked before compressing, so a browser revalidating its copy
215
+ # after a server restart doesn't wait for the file to be compressed.
216
+ if self.is_not_modified(gzip_headers, request_headers):
217
+ return NotModifiedResponse(gzip_headers)
218
+
219
+ compressed = await self.__compress(str(response.path), response.stat_result)
220
+ if compressed:
221
+ gzip_path, gzip_stat = compressed
222
+ response = FileResponse(
223
+ gzip_path,
224
+ stat_result=gzip_stat,
225
+ media_type=response.media_type,
226
+ headers={
227
+ "Content-Encoding": "gzip",
228
+ "ETag": etag,
229
+ "Vary": "Accept-Encoding",
230
+ },
231
+ )
232
+
233
+ if self.is_not_modified(response.headers, request_headers):
234
+ return NotModifiedResponse(response.headers)
235
+ return response
236
+
237
+ def is_not_modified(
238
+ self, response_headers: Headers, request_headers: Headers
239
+ ) -> bool:
240
+ """
241
+ Whether the browser's cached copy of a file is still current.
242
+
243
+ `If-None-Match`, when sent, decides alone, so a cached copy in the
244
+ other encoding, which has the same `Last-Modified`, is never taken as
245
+ current.
246
+
247
+ Args:
248
+ response_headers: Headers of the response that would be sent.
249
+ request_headers: Headers of the request.
250
+ """
251
+
252
+ if if_none_match := request_headers.get("if-none-match"):
253
+ tags = [tag.strip().removeprefix("W/") for tag in if_none_match.split(",")]
254
+ return response_headers["etag"] in tags or "*" in tags
255
+
256
+ if_modified_since = parsedate(request_headers.get("if-modified-since", ""))
257
+ last_modified = parsedate(response_headers.get("last-modified", ""))
258
+ return (
259
+ if_modified_since is not None
260
+ and last_modified is not None
261
+ and if_modified_since >= last_modified
262
+ )
263
+
264
+ async def __compress(
265
+ self, path: str, stat_result: os.stat_result
266
+ ) -> Optional[tuple[str, os.stat_result]]:
267
+ """
268
+ Get the gzip-compressed copy of a file, compressing it first if needed.
269
+
270
+ Copies are keyed by the file's identity on disk, size and modification
271
+ time: a file changed while the server runs is compressed again, and
272
+ paths that name the same file, such as differently cased ones on a
273
+ case-insensitive file system, share one copy. Concurrent requests for
274
+ the same file wait for one compression. A file that fails to compress
275
+ is logged and not tried again until it changes.
276
+
277
+ Args:
278
+ path: Path of the file.
279
+ stat_result: The file's `os.stat()` result.
280
+
281
+ Returns:
282
+ The path and `os.stat()` result of the compressed copy, or `None` if
283
+ the file could not be compressed.
284
+ """
285
+
286
+ assert self.__gzip_dir
287
+ identity = (
288
+ f"{stat_result.st_dev}:{stat_result.st_ino}"
289
+ if stat_result.st_ino
290
+ else os.path.normcase(os.path.realpath(path))
291
+ )
292
+ key = hashlib.sha256(
293
+ f"{identity}\0{stat_result.st_size}\0{stat_result.st_mtime_ns}".encode()
294
+ ).hexdigest()
295
+ if key in self.__gzip_failures:
296
+ return None
297
+ gzip_path = os.path.join(self.__gzip_dir, f"{key}.gz")
298
+
299
+ def compress() -> os.stat_result:
300
+ if not os.path.exists(gzip_path):
301
+ write_gzip(path, gzip_path, stat_result)
302
+ return os.stat(gzip_path)
303
+
304
+ lock = self.__gzip_locks.setdefault(key, anyio.Lock())
305
+ async with lock:
306
+ if key in self.__gzip_failures:
307
+ return None
308
+ try:
309
+ return gzip_path, await anyio.to_thread.run_sync(compress)
310
+ except OSError as e:
311
+ logger.warning(f"Could not compress {path}: {e}")
312
+ self.__gzip_failures.add(key)
313
+ return None
314
+
136
315
  def lookup_path(self, path: str) -> tuple[str, Optional[os.stat_result]]:
137
316
  """Resolve a static file path, with SPA fallback for client-side routes.
138
317
 
@@ -195,6 +374,11 @@ class FletStaticFiles(StaticFiles):
195
374
  app_manager.add_temp_dir(temp_dir)
196
375
  logger.info(f"Temp dir created for patched index and manifest: {temp_dir}")
197
376
 
377
+ # where gzip-compressed copies of served files are stored; outside
378
+ # temp_dir, which is served
379
+ self.__gzip_dir = tempfile.mkdtemp()
380
+ app_manager.add_temp_dir(self.__gzip_dir)
381
+
198
382
  # "standard" web files
199
383
  web_dir = get_package_web_dir()
200
384
  if os.getenv("FLET_WEB_PATH"):
@@ -0,0 +1 @@
1
+ version = "1.1.0.dev3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flet-web
3
- Version: 1.1.0.dev1
3
+ Version: 1.1.0.dev3
4
4
  Summary: Flet web client in Flutter.
5
5
  Author-email: "Appveyor Systems Inc." <hello@flet.dev>
6
6
  License-Expression: Apache-2.0
@@ -9,7 +9,7 @@ Project-URL: Repository, https://github.com/flet-dev/flet
9
9
  Project-URL: Documentation, https://flet.dev/docs
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
- Requires-Dist: flet==1.1.0.dev1
12
+ Requires-Dist: flet==1.1.0.dev3
13
13
  Requires-Dist: fastapi>=0.115.12
14
14
  Requires-Dist: websockets>=13.0
15
15
  Requires-Dist: uvicorn>=0.35.0
@@ -1,6 +1,7 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/flet_web/__init__.py
4
+ src/flet_web/compression.py
4
5
  src/flet_web/patch_index.py
5
6
  src/flet_web/uploads.py
6
7
  src/flet_web/version.py
@@ -21,4 +22,5 @@ src/flet_web/fastapi/oauth_state.py
21
22
  src/flet_web/fastapi/serve_fastapi_web_app.py
22
23
  tests/test_fastapi.py
23
24
  tests/test_loading_logo.py
24
- tests/test_patch_index.py
25
+ tests/test_patch_index.py
26
+ tests/test_static_compression.py
@@ -1,4 +1,4 @@
1
- flet==1.1.0.dev1
1
+ flet==1.1.0.dev3
2
2
  fastapi>=0.115.12
3
3
  websockets>=13.0
4
4
  uvicorn>=0.35.0
@@ -0,0 +1,346 @@
1
+ """Gzip compression of the static files served for a Flet web app."""
2
+
3
+ import gzip
4
+ import importlib
5
+ import os
6
+
7
+ import anyio
8
+ import httpx
9
+ import pytest
10
+ from fastapi import FastAPI
11
+ from fastapi.testclient import TestClient
12
+
13
+ compression = importlib.import_module("flet_web.compression")
14
+ static_files = importlib.import_module("flet_web.fastapi.flet_static_files")
15
+
16
+ GZIP = {"Accept-Encoding": "gzip"}
17
+ WASM = bytes(range(256)) * 4096
18
+
19
+
20
+ @pytest.fixture
21
+ def web(tmp_path, monkeypatch):
22
+ """A packaged web client directory with a compressible and a binary file."""
23
+ web = tmp_path / "web"
24
+ web.mkdir()
25
+ (web / "index.html").write_text("<html><head></head><body></body></html>")
26
+ (web / "manifest.json").write_text("{}")
27
+ (web / "main.dart.wasm").write_bytes(WASM)
28
+ (web / "small.js").write_text("main();")
29
+ (web / "logo.png").write_bytes(b"\x89PNG" + bytes(4096))
30
+ monkeypatch.setattr(static_files, "get_package_web_dir", lambda: str(web))
31
+ return web
32
+
33
+
34
+ @pytest.fixture
35
+ def compressions(monkeypatch):
36
+ """Paths of the files compressed, in order."""
37
+ calls = []
38
+ write_gzip = static_files.write_gzip
39
+ monkeypatch.setattr(
40
+ static_files,
41
+ "write_gzip",
42
+ lambda *args: calls.append(args[0]) or write_gzip(*args),
43
+ )
44
+ return calls
45
+
46
+
47
+ @pytest.fixture
48
+ def client(web):
49
+ """A test client of an app serving `web`."""
50
+ app = FastAPI()
51
+ app.mount("/", static_files.FletStaticFiles())
52
+ with TestClient(app) as client:
53
+ yield client
54
+
55
+
56
+ class TestAcceptsGzip:
57
+ @pytest.mark.parametrize(
58
+ ("accept_encoding", "expected"),
59
+ [
60
+ ("gzip, deflate, br, zstd", True),
61
+ ("GZIP", True),
62
+ ("br;q=1.0, gzip;q=0.5", True),
63
+ ("gzip;q=0", False),
64
+ ("gzip;q=0.0, br", False),
65
+ ("gzip;q=invalid", False),
66
+ ("br, deflate", False),
67
+ ("", False),
68
+ ("*;q=1, identity;q=0", True),
69
+ ("*, gzip;q=0", False),
70
+ ("*;q=0", False),
71
+ ],
72
+ )
73
+ def test_reads_accept_encoding(self, accept_encoding, expected):
74
+ """`gzip`, or else `*`, is accepted when listed without a zero quality."""
75
+ assert compression.accepts_gzip(accept_encoding) is expected
76
+
77
+
78
+ class TestCompressedResponses:
79
+ def test_sends_gzip_to_browsers_that_accept_it(self, client):
80
+ """A compressible file is sent gzipped with its own media type."""
81
+ response = client.get("/main.dart.wasm", headers=GZIP)
82
+
83
+ assert response.status_code == 200
84
+ assert response.headers["content-encoding"] == "gzip"
85
+ assert response.headers["content-type"] == "application/wasm"
86
+ assert response.headers["vary"] == "Accept-Encoding"
87
+ assert int(response.headers["content-length"]) < len(WASM) / 10
88
+ assert response.content == WASM
89
+
90
+ def test_sends_plain_file_to_other_browsers(self, client):
91
+ """Without gzip in `Accept-Encoding`, the file is sent as is."""
92
+ response = client.get(
93
+ "/main.dart.wasm", headers={"Accept-Encoding": "identity"}
94
+ )
95
+
96
+ assert response.status_code == 200
97
+ assert "content-encoding" not in response.headers
98
+ assert response.headers["vary"] == "Accept-Encoding"
99
+ assert int(response.headers["content-length"]) == len(WASM)
100
+ assert response.content == WASM
101
+
102
+ def test_sends_plain_bytes_for_range_requests(self, client):
103
+ """A byte range is taken from the uncompressed file."""
104
+ response = client.get(
105
+ "/main.dart.wasm", headers={**GZIP, "Range": "bytes=256-511"}
106
+ )
107
+
108
+ assert response.status_code == 206
109
+ assert "content-encoding" not in response.headers
110
+ assert response.content == WASM[256:512]
111
+
112
+ def test_head_request_gets_compressed_headers(self, client):
113
+ """A `HEAD` request gets the compressed file's headers and no body."""
114
+ get = client.get("/main.dart.wasm", headers=GZIP)
115
+ head = client.head("/main.dart.wasm", headers=GZIP)
116
+
117
+ assert head.status_code == 200
118
+ assert head.headers["content-encoding"] == "gzip"
119
+ assert head.headers["content-length"] == get.headers["content-length"]
120
+ assert head.headers["etag"] == get.headers["etag"]
121
+ assert head.content == b""
122
+
123
+ @pytest.mark.parametrize("path", ["/small.js", "/logo.png"])
124
+ def test_sends_small_and_binary_files_as_is(self, client, path):
125
+ """Files too small or already compressed are not compressed."""
126
+ response = client.get(path, headers=GZIP)
127
+
128
+ assert response.status_code == 200
129
+ assert "content-encoding" not in response.headers
130
+ assert "vary" not in response.headers
131
+
132
+ def test_compresses_the_patched_index(self, web):
133
+ """The index served for app routes is compressed when large enough."""
134
+ (web / "index.html").write_text(f"<html><body>{'x' * 4096}</body></html>")
135
+ app = FastAPI()
136
+ app.mount("/", static_files.FletStaticFiles())
137
+ with TestClient(app) as client:
138
+ response = client.get("/some/route", headers=GZIP)
139
+
140
+ assert response.status_code == 200
141
+ assert response.headers["content-encoding"] == "gzip"
142
+ assert "x" * 4096 in response.text
143
+
144
+
145
+ class TestCompressedCopies:
146
+ def test_compresses_each_file_once(self, client, compressions):
147
+ """Later requests reuse the compressed copy."""
148
+ for _ in range(3):
149
+ assert client.get("/main.dart.wasm", headers=GZIP).content == WASM
150
+
151
+ assert len(compressions) == 1
152
+
153
+ def test_concurrent_first_requests_compress_once(self, web, compressions):
154
+ """Requests arriving together wait for one compression."""
155
+ app = FastAPI()
156
+ app.mount("/", static_files.FletStaticFiles())
157
+ contents = []
158
+
159
+ async def fetch(client):
160
+ response = await client.get("/main.dart.wasm", headers=GZIP)
161
+ contents.append(response.content)
162
+
163
+ async def main():
164
+ transport = httpx.ASGITransport(app=app)
165
+ async with (
166
+ httpx.AsyncClient(
167
+ transport=transport, base_url="http://test"
168
+ ) as client,
169
+ anyio.create_task_group() as tg,
170
+ ):
171
+ for _ in range(5):
172
+ tg.start_soon(fetch, client)
173
+
174
+ anyio.run(main)
175
+
176
+ assert contents == [WASM] * 5
177
+ assert len(compressions) == 1
178
+
179
+ def test_compresses_a_changed_file_again(self, client, web):
180
+ """A file changed while the server runs is served with its new content."""
181
+ assert client.get("/main.dart.wasm", headers=GZIP).content == WASM
182
+
183
+ changed = WASM[::-1]
184
+ path = web / "main.dart.wasm"
185
+ path.write_bytes(changed)
186
+ stat = path.stat()
187
+ os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000_000))
188
+
189
+ assert client.get("/main.dart.wasm", headers=GZIP).content == changed
190
+
191
+ def test_paths_naming_the_same_file_share_one_copy(self, client, web, compressions):
192
+ """Differently cased paths to one file are compressed once."""
193
+ if not (web / "MAIN.DART.WASM").exists():
194
+ pytest.skip("The file system is case-sensitive")
195
+
196
+ for path in ["/main.dart.wasm", "/MAIN.dart.wasm", "/Main.Dart.Wasm"]:
197
+ assert client.get(path, headers=GZIP).content == WASM
198
+
199
+ assert len(compressions) == 1
200
+
201
+ def test_compressed_copy_is_reproducible(self, web, tmp_path):
202
+ """Compressing a file twice gives the same bytes and modification time."""
203
+ source = web / "main.dart.wasm"
204
+ stat = source.stat()
205
+ first, second = tmp_path / "first.gz", tmp_path / "second.gz"
206
+ compression.write_gzip(str(source), str(first), stat)
207
+ compression.write_gzip(str(source), str(second), stat)
208
+
209
+ assert first.read_bytes() == second.read_bytes()
210
+ assert gzip.decompress(first.read_bytes()) == WASM
211
+ assert first.stat().st_mtime_ns == stat.st_mtime_ns
212
+
213
+ def test_unfinished_copy_is_removed(self, web, tmp_path, monkeypatch):
214
+ """A copy that fails partway through writing leaves no file behind."""
215
+
216
+ def fail(src, dst):
217
+ dst.write(src.read(1024))
218
+ raise OSError("No space left on device")
219
+
220
+ monkeypatch.setattr(compression.shutil, "copyfileobj", fail)
221
+ source = web / "main.dart.wasm"
222
+ target = tmp_path / "copies" / "main.gz"
223
+ target.parent.mkdir()
224
+
225
+ with pytest.raises(OSError):
226
+ compression.write_gzip(str(source), str(target), source.stat())
227
+
228
+ assert list(target.parent.iterdir()) == []
229
+
230
+ def test_sends_plain_file_when_compression_fails(self, client, monkeypatch):
231
+ """A file that fails to compress is sent plain and not compressed again."""
232
+ calls = []
233
+
234
+ def fail(*args):
235
+ calls.append(args[0])
236
+ raise OSError("No space left on device")
237
+
238
+ monkeypatch.setattr(static_files, "write_gzip", fail)
239
+ for _ in range(3):
240
+ response = client.get("/main.dart.wasm", headers=GZIP)
241
+ assert response.status_code == 200
242
+ assert "content-encoding" not in response.headers
243
+ assert response.content == WASM
244
+
245
+ assert len(calls) == 1
246
+
247
+
248
+ class TestRevalidation:
249
+ def test_compressed_and_plain_files_have_different_etags(self, client):
250
+ """Caches can tell the two encodings of a file apart."""
251
+ compressed = client.get("/main.dart.wasm", headers=GZIP)
252
+ plain = client.get("/main.dart.wasm", headers={"Accept-Encoding": "identity"})
253
+
254
+ assert compressed.headers["etag"] != plain.headers["etag"]
255
+ assert compressed.headers["last-modified"] == plain.headers["last-modified"]
256
+
257
+ def test_cached_compressed_file_is_not_modified(self, client):
258
+ """The `ETag` of a cached compressed file gets `304 Not Modified`."""
259
+ etag = client.get("/main.dart.wasm", headers=GZIP).headers["etag"]
260
+
261
+ response = client.get(
262
+ "/main.dart.wasm", headers={**GZIP, "If-None-Match": etag}
263
+ )
264
+
265
+ assert response.status_code == 304
266
+ assert response.headers["vary"] == "Accept-Encoding"
267
+
268
+ def test_compressed_etag_does_not_match_plain_file(self, client):
269
+ """A plain request with the compressed file's `ETag` gets the file."""
270
+ etag = client.get("/main.dart.wasm", headers=GZIP).headers["etag"]
271
+
272
+ response = client.get(
273
+ "/main.dart.wasm",
274
+ headers={"Accept-Encoding": "identity", "If-None-Match": etag},
275
+ )
276
+
277
+ assert response.status_code == 200
278
+ assert response.content == WASM
279
+
280
+ def test_cached_compressed_file_is_revalidated_by_date(self, client):
281
+ """`If-Modified-Since` alone gets a `304` for the compressed file."""
282
+ compressed = client.get("/main.dart.wasm", headers=GZIP)
283
+
284
+ response = client.get(
285
+ "/main.dart.wasm",
286
+ headers={
287
+ **GZIP,
288
+ "If-Modified-Since": compressed.headers["last-modified"],
289
+ },
290
+ )
291
+
292
+ assert response.status_code == 304
293
+ assert response.headers["etag"] == compressed.headers["etag"]
294
+
295
+ def test_plain_etag_does_not_match_compressed_file(self, client):
296
+ """A gzip request with the plain file's `ETag` gets the compressed file."""
297
+ plain = client.get("/main.dart.wasm", headers={"Accept-Encoding": "identity"})
298
+
299
+ response = client.get(
300
+ "/main.dart.wasm",
301
+ headers={
302
+ **GZIP,
303
+ "If-None-Match": plain.headers["etag"],
304
+ "If-Modified-Since": plain.headers["last-modified"],
305
+ },
306
+ )
307
+
308
+ assert response.status_code == 200
309
+ assert response.headers["content-encoding"] == "gzip"
310
+ assert response.content == WASM
311
+
312
+ def test_etag_changes_with_the_file(self, client, web):
313
+ """Changed content of the same modification time gets a new `ETag`."""
314
+ path = web / "main.dart.wasm"
315
+ path.write_bytes(b"A" * 1024)
316
+ stat = path.stat()
317
+ old = client.get("/main.dart.wasm", headers=GZIP)
318
+
319
+ path.write_bytes(b"A" * 1025)
320
+ os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns))
321
+ response = client.get(
322
+ "/main.dart.wasm", headers={**GZIP, "If-None-Match": old.headers["etag"]}
323
+ )
324
+
325
+ assert response.status_code == 200
326
+ assert response.headers["etag"] != old.headers["etag"]
327
+ assert response.content == b"A" * 1025
328
+
329
+ def test_revalidation_after_restart_does_not_compress(self, web, compressions):
330
+ """A cached compressed file is confirmed without compressing it again."""
331
+ app = FastAPI()
332
+ app.mount("/", static_files.FletStaticFiles())
333
+ with TestClient(app) as client:
334
+ etag = client.get("/main.dart.wasm", headers=GZIP).headers["etag"]
335
+
336
+ restarted = FastAPI()
337
+ restarted.mount("/", static_files.FletStaticFiles())
338
+ with TestClient(restarted) as client:
339
+ response = client.get(
340
+ "/main.dart.wasm", headers={**GZIP, "If-None-Match": etag}
341
+ )
342
+
343
+ assert response.status_code == 304
344
+ assert response.headers["etag"] == etag
345
+ assert response.headers["vary"] == "Accept-Encoding"
346
+ assert len(compressions) == 1
@@ -1 +0,0 @@
1
- version = "1.1.0.dev1"
File without changes
File without changes