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.
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/PKG-INFO +2 -2
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/pyproject.toml +2 -2
- flet_web-1.1.0.dev3/src/flet_web/compression.py +101 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_static_files.py +185 -1
- flet_web-1.1.0.dev3/src/flet_web/version.py +1 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/PKG-INFO +2 -2
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/SOURCES.txt +3 -1
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/requires.txt +1 -1
- flet_web-1.1.0.dev3/tests/test_static_compression.py +346 -0
- flet_web-1.1.0.dev1/src/flet_web/version.py +0 -1
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/README.md +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/setup.cfg +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/__init__.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/__init__.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/app.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_app.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_app_manager.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_fastapi.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_oauth.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/flet_upload.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/oauth_state.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/fastapi/serve_fastapi_web_app.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/patch_index.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web/uploads.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/dependency_links.txt +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/src/flet_web.egg-info/top_level.txt +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/tests/test_fastapi.py +0 -0
- {flet_web-1.1.0.dev1 → flet_web-1.1.0.dev3}/tests/test_loading_logo.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|