Skip to content

Commit 3944084

Browse files
authored
Flask-Vite integration refactor before upstreaming (#5434)
* chore: refactor Vite-Flask integration * fix: tests * feature: handle exception when extension is not initialized * chore: tests cleanup * chore: more refactoring * chore: rename outdir env var for consistency * fix: rework extension init logic * fix: relative module path * fix: linting * fix: incorrect usage of __all__ * fix: comment * fix: config types * chore: refactor ProdViteIntegration - move public methods at the top of the class for clarity - add helper for checking if asset chunks exist in manifest - add helpers for checking if asset files exist in filesystem * fix: align extension name to - convention * feature: support for more source file extensions * chore: better typing * feature: auto-inject Vite dev tools in dev mode * fix: typing * fix: linting * feature: auto-injected dev tools test
1 parent f7b92db commit 3944084

18 files changed

Lines changed: 316 additions & 200 deletions

.env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
PORT=8004
22
VITE_PORT=5004
3-
VITE_OUTPUT_DIR=static/js/dist/vite
3+
VITE_OUTDIR=static/js/dist/vite
44
ENVIRONMENT=devel
55
FLASK_DEBUG=true
66
DEVEL=True

templates/_base-layout.html

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,6 @@
2323

2424
{{ vite_import('static/sass/styles.scss') }}
2525

26-
{% if IS_DEVELOPMENT %}
27-
{{ vite_dev_tools() }}
28-
{% endif %}
29-
3026
{{ vite_import('static/js/base/base.ts') }}
3127
{% block scripts_includes %}{% endblock %}
3228
<script src="https://assets.ubuntu.com/v1/703e23c9-lazysizes+noscript+native-loading.5.1.2.min.js" defer></script>

tests/tests_vite_integration.py

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@
44
from typing import cast
55
from urllib.parse import urlparse
66
from pathlib import Path
7+
from unittest.mock import patch
8+
9+
from webapp.app import create_app
710
from webapp.vite_integration.impl import (
811
ProdViteIntegration,
912
DevViteIntegration,
1013
)
1114
import webapp.vite_integration.exceptions as vite_exceptions
1215

13-
MOCK_OUTPUT_PATH = "/tmp/python_vite_test"
16+
17+
MOCK_CONFIG = {
18+
"mode": "development",
19+
"port": 9999,
20+
"outdir": "/tmp/python_vite_test",
21+
}
1422
MOCK_ASSET_PATH = "test/path/for/asset.ts"
1523
MOCK_SCSS_PATH = "test/path/for/styles.scss"
1624
MOCK_MANIFEST = {
@@ -41,12 +49,7 @@
4149

4250
class TestsDevViteIntegration(TestCase):
4351
def setUp(self):
44-
self.vite = DevViteIntegration()
45-
46-
def tests_dev_tools(self):
47-
dev_tools = self.vite.get_dev_tools()
48-
assert "@vite/client" in dev_tools
49-
assert "@react-refresh" in dev_tools
52+
self.vite = DevViteIntegration(MOCK_CONFIG)
5053

5154
def tests_get_asset_url(self):
5255
url = self.vite.get_asset_url(MOCK_ASSET_PATH)
@@ -69,27 +72,24 @@ def tests_get_imported_css(self):
6972
class TestsProdViteIntegration(TestCase):
7073
def setUp(self):
7174
# create a fake Vite output directory
72-
manifest_path = Path(f"{MOCK_OUTPUT_PATH}/.vite/manifest.json")
75+
manifest_path = Path(f'{MOCK_CONFIG["outdir"]}/.vite/manifest.json')
7376
manifest_path.parent.mkdir(exist_ok=True, parents=True)
7477
with manifest_path.open("w+") as file:
7578
file.write(json.dumps(MOCK_MANIFEST))
7679

7780
for entry in MOCK_MANIFEST.values():
7881
file = cast(dict, entry).get("file", "")
79-
file_path = Path(f"{MOCK_OUTPUT_PATH}/{file}")
82+
file_path = Path(f'{MOCK_CONFIG["outdir"]}/{file}')
8083
file_path.parent.mkdir(exist_ok=True, parents=True)
8184
with file_path.open("w+") as file:
8285
file.write("")
8386

84-
# inject the mocks in the static class scope before initalizing
85-
ProdViteIntegration.OUT_DIR = MOCK_OUTPUT_PATH
86-
8787
def tearDown(self):
88-
rmtree(MOCK_OUTPUT_PATH)
88+
rmtree(MOCK_CONFIG["outdir"])
8989

9090
def tests_good_manifest_file(self):
9191
# attempt to init
92-
ProdViteIntegration()
92+
ProdViteIntegration(MOCK_CONFIG)
9393

9494
def tests_bad_manifest_file(self):
9595
# try to init a ProdViteIntegration instance with a bad manifest file
@@ -100,50 +100,44 @@ def tests_bad_manifest_file(self):
100100
ProdViteIntegration.BUILD_MANIFEST = "file/that/does/not/exist"
101101

102102
with self.assertRaises(vite_exceptions.ManifestPathException):
103-
self.vite = ProdViteIntegration()
103+
self.vite = ProdViteIntegration(MOCK_CONFIG)
104104

105+
# reset build manifest path to previous value
105106
ProdViteIntegration.BUILD_MANIFEST = old_manifest_name
106107

107-
def tests_dev_tools(self):
108-
vite = ProdViteIntegration()
109-
dev_tools = vite.get_dev_tools()
110-
assert dev_tools == ""
111-
112108
def tests_get_asset_url__bad_asset(self):
113-
vite = ProdViteIntegration()
109+
vite = ProdViteIntegration(MOCK_CONFIG)
114110
with self.assertRaises(vite_exceptions.ManifestContentException):
115111
vite.get_asset_url("this_asset_does_not_exist.ts")
116112

117113
def tests_get_asset_url__bad_path(self):
118114
# try to load an asset declared in the manifest but without a real
119115
# file backing it
116+
120117
# load a proper manifest...
121118
ProdViteIntegration.manifest = MOCK_MANIFEST
122119
# but also load a broken OUT_DIR path
123-
ProdViteIntegration.OUT_DIR = "/tmp/path/does/not/exist"
124-
125-
vite = ProdViteIntegration()
120+
vite = ProdViteIntegration({"outdir": "/tmp/path/does/not/exist"})
126121
with self.assertRaises(vite_exceptions.AssetPathException):
127122
vite.get_asset_url(MOCK_ASSET_PATH)
128123

129124
# cleanup
130-
ProdViteIntegration.OUT_DIR = MOCK_OUTPUT_PATH
131125
ProdViteIntegration.manifest = None
132126

133127
def tests_get_asset_url__is_not_ts(self):
134-
vite = ProdViteIntegration()
128+
vite = ProdViteIntegration(MOCK_CONFIG)
135129
url = vite.get_asset_url(MOCK_ASSET_PATH)
136130
assert MOCK_ASSET_PATH not in url # source asset is a .ts file
137131
assert url.endswith(".js") # dist asset is a .js file
138132

139133
def tests_get_asset_url__is_not_scss(self):
140-
vite = ProdViteIntegration()
134+
vite = ProdViteIntegration(MOCK_CONFIG)
141135
url = vite.get_asset_url(MOCK_SCSS_PATH)
142136
assert MOCK_SCSS_PATH not in url # source asset is a .scss file
143137
assert url.endswith(".css") # dist asset is a .css file
144138

145139
def tests_get_imported_chunks__bad_asset(self):
146-
vite = ProdViteIntegration()
140+
vite = ProdViteIntegration(MOCK_CONFIG)
147141
with self.assertRaises(vite_exceptions.ManifestContentException):
148142
vite.get_imported_chunks("this_asset_does_not_exist.ts")
149143

@@ -153,18 +147,15 @@ def tests_get_imported_chunks__bad_path(self):
153147
# load a proper manifest...
154148
ProdViteIntegration.manifest = MOCK_MANIFEST
155149
# but also load a broken OUT_DIR path
156-
ProdViteIntegration.OUT_DIR = "/tmp/path/does/not/exist"
157-
158-
vite = ProdViteIntegration()
150+
vite = ProdViteIntegration({"outdir": "/tmp/path/does/not/exist"})
159151
with self.assertRaises(vite_exceptions.AssetPathException):
160152
vite.get_imported_chunks(MOCK_ASSET_PATH)
161153

162154
# cleanup
163-
ProdViteIntegration.OUT_DIR = MOCK_OUTPUT_PATH
164155
ProdViteIntegration.manifest = None
165156

166157
def tests_get_imported_chunks(self):
167-
vite = ProdViteIntegration()
158+
vite = ProdViteIntegration(MOCK_CONFIG)
168159
js_entries = filter(
169160
lambda x: x["file"].endswith(".js"), MOCK_MANIFEST.values()
170161
)
@@ -173,5 +164,30 @@ def tests_get_imported_chunks(self):
173164
)
174165

175166
def tests_get_imported_css(self):
176-
vite = ProdViteIntegration()
167+
vite = ProdViteIntegration(MOCK_CONFIG)
177168
assert len(vite.get_imported_css(MOCK_ASSET_PATH)) == 1
169+
170+
171+
class TestsFlaskViteExtension(TestCase):
172+
@patch("webapp.config.VITE_MODE", "development")
173+
@patch("webapp.config.VITE_PORT", MOCK_CONFIG["port"])
174+
@patch("webapp.config.VITE_OUTDIR", MOCK_CONFIG["outdir"])
175+
def setUp(self):
176+
self.app = create_app(testing=True)
177+
self.client = self.app.test_client()
178+
179+
def test_extension_init(self):
180+
self.assertEqual(self.app.config.get("VITE_MODE"), "development")
181+
self.assertEqual(self.app.config.get("VITE_PORT"), MOCK_CONFIG["port"])
182+
self.assertEqual(
183+
self.app.config.get("VITE_OUTDIR"), MOCK_CONFIG["outdir"]
184+
)
185+
186+
def test_dev_tools(self):
187+
port = self.app.config.get("VITE_PORT")
188+
response = self.client.get("/")
189+
self.assertEqual(response.status_code, 200)
190+
191+
body = response.get_data(as_text=True)
192+
self.assertGreater(len(body), 0)
193+
self.assertIn(f"http://localhost:{port}/@vite/client", body)

vite.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default defineConfig({
107107
modulePreload: false,
108108
emptyOutDir: true,
109109
sourcemap: "hidden",
110-
outDir: env?.VITE_OUTPUT_DIR || "static/js/dist/vite",
110+
outDir: env?.VITE_OUTDIR || "static/js/dist/vite",
111111
rollupOptions: {
112112
output: {
113113
entryFileNames: `[name]--[hash].js`,

webapp/app.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from canonicalwebteam.flask_base.app import FlaskBase
1414
from webapp.blog.views import init_blog
1515
from webapp.docs.views import init_docs
16-
from webapp.extensions import csrf
16+
from webapp.extensions import csrf, vite
1717
from webapp.handlers import set_handlers
1818
from webapp.login.views import login
1919
from webapp.login.oauth_views import oauth
@@ -53,15 +53,7 @@ def create_app(testing=False):
5353
app.name = "snapcraft"
5454
app.testing = testing
5555

56-
if not testing:
57-
init_extensions(app)
58-
59-
if testing:
60-
61-
@app.context_processor
62-
def inject_csrf_token():
63-
return dict(csrf_token=lambda: "mocked_csrf_token")
64-
56+
init_extensions(app)
6557
set_handlers(app)
6658

6759
app.register_blueprint(snapcraft_blueprint())
@@ -90,5 +82,13 @@ def inject_csrf_token():
9082
return app
9183

9284

93-
def init_extensions(app):
94-
csrf.init_app(app)
85+
def init_extensions(app: FlaskBase):
86+
vite.init_app(app)
87+
88+
if not app.testing:
89+
csrf.init_app(app)
90+
else:
91+
# add a helper for injecting a mock CSRF token
92+
@app.context_processor
93+
def inject_csrf_token():
94+
return dict(csrf_token=lambda: "mocked_csrf_token")

webapp/config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ class ConfigurationError(Exception):
2121
SENTRY_DSN = os.getenv("SENTRY_DSN", "").strip()
2222
SENTRY_CONFIG = {"release": COMMIT_ID, "environment": ENVIRONMENT}
2323
DNS_VERIFICATION_SALT = os.getenv("DNS_VERIFICATION_SALT")
24+
25+
# Vite integration config values
26+
VITE_MODE = "development" if IS_DEVELOPMENT else "production"
2427
VITE_PORT = os.getenv("VITE_PORT", 5173)
25-
VITE_OUTPUT_DIR = os.getenv("VITE_OUTPUT_DIR", "static/js/dist/vite")
28+
VITE_OUTDIR = os.getenv("VITE_OUTDIR", "static/js/dist/vite")
2629

2730
if ENVIRONMENT != "devel":
2831
SESSION_COOKIE_SAMESITE = "None"

webapp/extensions.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from flask_wtf.csrf import CSRFProtect
2-
2+
from webapp.vite_integration import FlaskVite
33

44
csrf = CSRFProtect()
5+
vite = FlaskVite()

webapp/handlers.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,6 @@ def snapcraft_utility_processor():
179179
"join": template_utils.join,
180180
"static_url": template_utils.static_url,
181181
"IS_DEVELOPMENT": IS_DEVELOPMENT,
182-
"vite_import": template_utils.vite_import,
183-
"vite_dev_tools": template_utils.vite_dev_tools,
184182
"format_number": template_utils.format_number,
185183
"format_display_name": template_utils.format_display_name,
186184
"display_name": template_utils.display_name,

webapp/template_utils.py

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22
import hashlib
33
import os
44
from dateutil import parser
5-
from markupsafe import Markup
65
from emoji import replace_emoji
76

8-
from webapp.vite_integration import ViteIntegration
9-
107

118
# generator functions for templates
129
def generate_slug(path):
@@ -90,38 +87,6 @@ def static_url(filename):
9087
return url + "?v=" + file_hash.hexdigest()[:7]
9188

9289

93-
def vite_import(entrypoint: str):
94-
"""
95-
Template function that takes a .js/.ts source file as an argument and
96-
returns the <script> tags with the correct src URL based on Vite's
97-
output (a localhost URL in dev mode, or a static url in prod mode)
98-
"""
99-
entry_url = ViteIntegration.get_asset_url(entrypoint)
100-
is_css = entry_url.endswith((".css"))
101-
102-
if is_css:
103-
return Markup(f'<link rel="stylesheet" href="{entry_url}" />')
104-
105-
entry_script = f'<script type="module" src="{entry_url}"></script>'
106-
107-
chunks_urls = ViteIntegration.get_imported_chunks(entrypoint)
108-
chunks_scripts = [
109-
f'<link rel="modulepreload" href="{c}" />' for c in chunks_urls
110-
]
111-
css_urls = ViteIntegration.get_imported_css(entrypoint)
112-
css_scripts = [f'<link rel="stylesheet" href="{c}" />' for c in css_urls]
113-
114-
return Markup(entry_script + "".join(chunks_scripts + css_scripts))
115-
116-
117-
def vite_dev_tools():
118-
"""
119-
Template function that returns <script> tags for Vite's dev server
120-
integration (or an empty string in prod mode)
121-
"""
122-
return Markup(ViteIntegration.get_dev_tools())
123-
124-
12590
def install_snippet(
12691
package_name, default_track, lowest_risk_available, confinement
12792
):
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
from webapp.config import IS_DEVELOPMENT
2-
from webapp.vite_integration.impl import (
3-
DevViteIntegration,
4-
ProdViteIntegration,
5-
)
1+
from .extension import FlaskVite
62

73

8-
ViteIntegration = (
9-
DevViteIntegration if IS_DEVELOPMENT else ProdViteIntegration
10-
)()
4+
__all__ = ["FlaskVite"]

0 commit comments

Comments
 (0)