quart-assets 0.1.0__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.
@@ -0,0 +1,28 @@
1
+ Copyright (c) 2010, Michael Elsdörfer <http://elsdoerfer.name>
2
+ Copyright (c) 2025, Sasha Gerrand <https://sgerrand.com>
3
+ All rights reserved.
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions
7
+ are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright
10
+ notice, this list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above
13
+ copyright notice, this list of conditions and the following
14
+ disclaimer in the documentation and/or other materials
15
+ provided with the distribution.
16
+
17
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
20
+ FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
21
+ COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
22
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
23
+ BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
26
+ LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
27
+ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28
+ POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.3
2
+ Name: quart-assets
3
+ Version: 0.1.0
4
+ Summary: Asset management for Quart apps
5
+ License: BSD-2-Clause
6
+ Author: Sasha Gerrand
7
+ Author-email: quart-assets@sgerrand.dev
8
+ Requires-Python: >=3.9
9
+ Classifier: Environment :: Web Environment
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: BSD License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Dist: pyscss
22
+ Requires-Dist: pyyaml
23
+ Requires-Dist: quart (>=0.20.0,<0.21.0)
24
+ Requires-Dist: webassets (>=2.0)
25
+ Project-URL: Bug Tracker, https://github.com/sgerrand/quart-assets/issues
26
+ Project-URL: Documentation, https://quart-assets.readthedocs.io
27
+ Project-URL: Homepage, https://github.com/sgerrand/quart-assets
28
+ Project-URL: Repository, https://github.com/sgerrand/quart-assets
29
+ Description-Content-Type: text/markdown
30
+
31
+ # Quart-Assets
32
+
33
+ [![Build Status](https://github.com/sgerrand/quart-assets/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/sgerrand/quart-assets/actions/workflows/tests.yml)
34
+ [![License](https://img.shields.io/badge/License-BSD%202--Clause-blue.svg)](https://opensource.org/licenses/BSD-2-Clause)
35
+
36
+ Quart-Assets is an extension for [Quart][quart] that supports merging,
37
+ minifying and compiling CSS and Javascript files via the
38
+ [`webassets`][webassets] library.
39
+
40
+ ## Usage
41
+
42
+ To use Quart-Assets with a Quart app, you have to create a QuartAssets
43
+ instance and initialise it with the application:
44
+
45
+ ```python
46
+ from quart import Quart
47
+ from quart_assets import Bundle, QuartAssets
48
+
49
+ app = Quart(__name__)
50
+ assets = QuartAssets(app)
51
+
52
+ js_bundle = Bundle('alpine.js', 'main.js', 'utils.js',
53
+ filters='jsmin', output='dist/all.min.js')
54
+ assets.register('js_all', js_bundle)
55
+ ```
56
+
57
+ A bundle consists of any number of source files (it may also contain other
58
+ nested bundles), an output target, and a list of filters to apply.
59
+
60
+ All paths are relative to your app’s static directory, or the static directory of a Quart blueprint.
61
+
62
+
63
+ [quart]: https://quart.palletprojects.com
64
+ [webassets]: https://webassets.readthedocs.io
65
+
@@ -0,0 +1,34 @@
1
+ # Quart-Assets
2
+
3
+ [![Build Status](https://github.com/sgerrand/quart-assets/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/sgerrand/quart-assets/actions/workflows/tests.yml)
4
+ [![License](https://img.shields.io/badge/License-BSD%202--Clause-blue.svg)](https://opensource.org/licenses/BSD-2-Clause)
5
+
6
+ Quart-Assets is an extension for [Quart][quart] that supports merging,
7
+ minifying and compiling CSS and Javascript files via the
8
+ [`webassets`][webassets] library.
9
+
10
+ ## Usage
11
+
12
+ To use Quart-Assets with a Quart app, you have to create a QuartAssets
13
+ instance and initialise it with the application:
14
+
15
+ ```python
16
+ from quart import Quart
17
+ from quart_assets import Bundle, QuartAssets
18
+
19
+ app = Quart(__name__)
20
+ assets = QuartAssets(app)
21
+
22
+ js_bundle = Bundle('alpine.js', 'main.js', 'utils.js',
23
+ filters='jsmin', output='dist/all.min.js')
24
+ assets.register('js_all', js_bundle)
25
+ ```
26
+
27
+ A bundle consists of any number of source files (it may also contain other
28
+ nested bundles), an output target, and a list of filters to apply.
29
+
30
+ All paths are relative to your app’s static directory, or the static directory of a Quart blueprint.
31
+
32
+
33
+ [quart]: https://quart.palletprojects.com
34
+ [webassets]: https://webassets.readthedocs.io
@@ -0,0 +1,89 @@
1
+ [build-system]
2
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
3
+ build-backend = "poetry.core.masonry.api"
4
+
5
+ [project]
6
+ name = "quart-assets"
7
+ version = "0.1.0"
8
+ description = "Asset management for Quart apps"
9
+ authors = [
10
+ {name = "Sasha Gerrand",email = "quart-assets@sgerrand.dev"}
11
+ ]
12
+ license = {text = "BSD-2-Clause"}
13
+ readme = "README.md"
14
+ requires-python = ">=3.9"
15
+ dependencies = [
16
+ "quart (>=0.20.0,<0.21.0)",
17
+ "pyscss",
18
+ "pyyaml",
19
+ "webassets (>=2.0)"
20
+ ]
21
+ dynamic = ["classifiers"]
22
+
23
+ [project.entry-points."quart.commands"]
24
+ assets = "quart_assets:assets"
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/sgerrand/quart-assets"
28
+ Repository = "https://github.com/sgerrand/quart-assets"
29
+ Documentation = "https://quart-assets.readthedocs.io"
30
+ "Bug Tracker" = "https://github.com/sgerrand/quart-assets/issues"
31
+
32
+ [tool.black]
33
+ line-length = 100
34
+ target-version = ["py39"]
35
+
36
+ [tool.isort]
37
+ combine_as_imports = true
38
+ force_grid_wrap = 0
39
+ include_trailing_comma = true
40
+ known_first_party = "quart_assets, tests"
41
+ line_length = 100
42
+ multi_line_output = 3
43
+ no_lines_before = "LOCALFOLDER"
44
+ order_by_type = false
45
+ reverse_relative = true
46
+
47
+ [tool.mypy]
48
+ allow_redefinition = true
49
+ disallow_any_generics = false
50
+ disallow_subclassing_any = true
51
+ disallow_untyped_calls = false
52
+ disallow_untyped_defs = true
53
+ implicit_reexport = true
54
+ no_implicit_optional = true
55
+ show_error_codes = true
56
+ strict = true
57
+ strict_equality = true
58
+ strict_optional = false
59
+ warn_redundant_casts = true
60
+ warn_return_any = false
61
+ warn_unused_configs = true
62
+ warn_unused_ignores = true
63
+
64
+ [tool.pip-tools]
65
+ strip-extras = true
66
+
67
+ [tool.pytest.ini_options]
68
+ addopts = "--no-cov-on-fail --showlocals --strict-markers"
69
+ asyncio_default_fixture_loop_scope = "function"
70
+ asyncio_mode = "auto"
71
+ testpaths = ["tests"]
72
+
73
+ [tool.poetry]
74
+ classifiers = [
75
+ "Environment :: Web Environment",
76
+ "Intended Audience :: Developers",
77
+ "Operating System :: OS Independent",
78
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
79
+ "Topic :: Software Development :: Libraries :: Python Modules"
80
+ ]
81
+
82
+ [tool.poetry.group.dev.dependencies]
83
+ pytest = "*"
84
+ tox = "*"
85
+
86
+ [tool.poetry.group.test.dependencies]
87
+ pytest = "*"
88
+ PyYAML = "*"
89
+ tox = "*"
@@ -0,0 +1,32 @@
1
+ from __future__ import print_function
2
+
3
+ try:
4
+ from quart.globals import app_ctx, request_ctx
5
+ except ImportError:
6
+ from quart import _app_ctx_stack, _request_ctx_stack # type: ignore
7
+
8
+ request_ctx = _request_ctx_stack.top
9
+ app_ctx = _app_ctx_stack.top
10
+
11
+ from webassets import Bundle # type: ignore[import-untyped] # noqa F401
12
+
13
+ from .extension import (
14
+ assets,
15
+ AsyncAssetsExtension,
16
+ Jinja2Filter,
17
+ QuartAssets,
18
+ QuartConfigStorage,
19
+ QuartResolver,
20
+ )
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = (
25
+ "assets",
26
+ "Bundle",
27
+ "QuartAssets",
28
+ "QuartConfigStorage",
29
+ "QuartResolver",
30
+ "Jinja2Filter",
31
+ "AsyncAssetsExtension",
32
+ )
@@ -0,0 +1,498 @@
1
+ """Integration of the ``webassets`` library with Quart."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import logging
6
+ from os import path
7
+ from typing import Any, Dict, Optional, Tuple, Union
8
+
9
+ from quart import current_app, has_app_context, has_request_context
10
+ from quart.app import Quart
11
+ from quart.cli import pass_script_info, ScriptInfo
12
+ from quart.globals import app_ctx, request_ctx
13
+ from quart.templating import render_template_string
14
+ from webassets.env import ( # type: ignore[import-untyped]
15
+ BaseEnvironment,
16
+ ConfigStorage,
17
+ env_options,
18
+ Resolver,
19
+ )
20
+ from webassets.ext.jinja2 import AssetsExtension # type: ignore[import-untyped]
21
+ from webassets.filter import Filter, register_filter # type: ignore[import-untyped]
22
+ from webassets.loaders import PythonLoader, YAMLLoader # type: ignore[import-untyped]
23
+ from webassets.script import CommandLineEnvironment # type: ignore[import-untyped]
24
+
25
+
26
+ def get_static_folder(app_or_blueprint: Any) -> str:
27
+ """Return the static folder of the given Quart app
28
+ instance, or module/blueprint.
29
+ """
30
+ if not app_or_blueprint.has_static_folder:
31
+ # Use an exception type here that is not hidden by spit_prefix.
32
+ raise TypeError(f"The referenced blueprint {app_or_blueprint} has no static " "folder.")
33
+ return app_or_blueprint.static_folder
34
+
35
+
36
+ class AsyncAssetsExtension(AssetsExtension): # type: ignore[misc]
37
+ """An async-aware version of the webassets Jinja2 extension that supports async coroutines."""
38
+
39
+ def _render_assets(
40
+ self, filter: Any, output: Any, dbg: Any, depends: Any, files: Any, caller: Any = None
41
+ ) -> str:
42
+ env = self.environment.assets_environment # pyright: ignore[reportAttributeAccessIssue]
43
+ if env is None:
44
+ raise RuntimeError("No assets environment configured in " + "Jinja2 environment")
45
+
46
+ # Construct a bundle with the given options
47
+ bundle_kwargs = {
48
+ "output": output,
49
+ "filters": filter,
50
+ "debug": dbg,
51
+ "depends": depends,
52
+ }
53
+ bundle = self.BundleClass(*self.resolve_contents(files, env), **bundle_kwargs)
54
+
55
+ # Retrieve urls (this may or may not cause a build)
56
+ with bundle.bind(env):
57
+ urls = bundle.urls(calculate_sri=True)
58
+
59
+ # For each url, execute the content of this template tag (represented
60
+ # by the macro ```caller`` given to use by Jinja2).
61
+ result = ""
62
+ for entry in urls:
63
+ if isinstance(entry, dict):
64
+ caller_result = caller(entry["uri"], entry.get("sri", None), bundle.extra)
65
+ else:
66
+ caller_result = caller(entry, None, bundle.extra)
67
+
68
+ # Check if the caller returned a coroutine (async context)
69
+ if inspect.iscoroutine(caller_result):
70
+ # The caller returned a coroutine, which means we're in an
71
+ # async template context
72
+ # We need to run this coroutine to completion
73
+ try:
74
+ # Use a different approach: run the coroutine in a new
75
+ # thread with its own event loop
76
+ import queue
77
+ import threading
78
+
79
+ result_queue: "queue.Queue[Any]" = queue.Queue()
80
+ exception_queue: "queue.Queue[Exception]" = queue.Queue()
81
+
82
+ def run_coroutine() -> None:
83
+ """Run the coroutine in a separate thread with a new event loop."""
84
+ new_loop = None
85
+ try:
86
+ # Create a new event loop for this thread
87
+ new_loop = asyncio.new_event_loop()
88
+ asyncio.set_event_loop(new_loop)
89
+
90
+ # Run the coroutine
91
+ result = new_loop.run_until_complete(caller_result)
92
+ result_queue.put(result)
93
+
94
+ except Exception as e:
95
+ exception_queue.put(e)
96
+ finally:
97
+ if new_loop is not None:
98
+ try:
99
+ new_loop.close()
100
+ except Exception:
101
+ pass
102
+
103
+ # Start the thread
104
+ thread = threading.Thread(target=run_coroutine, daemon=True)
105
+ thread.start()
106
+
107
+ # Wait for the result with a timeout
108
+ thread.join(timeout=5.0)
109
+
110
+ if thread.is_alive():
111
+ # Thread is still running, this is a timeout
112
+ raise RuntimeError("Timeout waiting for async template rendering")
113
+
114
+ # Check for exceptions
115
+ if not exception_queue.empty():
116
+ raise exception_queue.get()
117
+
118
+ # Get the result
119
+ if not result_queue.empty():
120
+ caller_result = result_queue.get()
121
+ else:
122
+ raise RuntimeError("No result received from async template rendering")
123
+
124
+ except RuntimeError as e:
125
+ if "no running event loop" in str(e):
126
+ # No event loop running, close the coroutine
127
+ caller_result.close()
128
+ raise RuntimeError(
129
+ "Cannot handle async template rendering without an event loop"
130
+ )
131
+ else:
132
+ raise
133
+ except Exception as e:
134
+ # Clean up the coroutine
135
+ if inspect.iscoroutine(caller_result):
136
+ caller_result.close()
137
+ raise RuntimeError(f"Error handling async template rendering: {e}")
138
+
139
+ result += caller_result
140
+ return result
141
+
142
+
143
+ __all__ = ["Jinja2Filter"]
144
+
145
+
146
+ class Jinja2Filter(Filter): # type: ignore[misc]
147
+ """Compiles all source files as Jinja2 templates using Quart contexts."""
148
+
149
+ name = "jinja2"
150
+ max_debug_level = None
151
+
152
+ def __init__(self, context: Optional[Dict[str, Any]] = None) -> None:
153
+ super().__init__()
154
+ self.context = context or {}
155
+
156
+ def input(self, _in: Any, out: Any, **kw: Any) -> None:
157
+ out.write(render_template_string(_in.read(), **self.context))
158
+
159
+
160
+ class QuartConfigStorage(ConfigStorage): # type: ignore[misc]
161
+ """Uses the config object of a Quart app as the backend: either the app
162
+ instance bound to the extension directly, or the current Quart app on
163
+ the stack. Also provides per-application defaults for some values.
164
+ """
165
+
166
+ def __init__(self, *a: Any, **kw: Any) -> None:
167
+ self._defaults: Dict[str, Any] = {}
168
+ ConfigStorage.__init__(self, *a, **kw)
169
+
170
+ def _transform_key(self, key: str) -> str:
171
+ if key.lower() in env_options:
172
+ return f"ASSETS_{key.upper()}"
173
+
174
+ return key.upper()
175
+
176
+ def setdefault(self, key: str, value: Any) -> None:
177
+ """We may not always be connected to an app, but we still need
178
+ to provide a way to the base environment to set its defaults.
179
+ """
180
+ try:
181
+ super().setdefault(key, value)
182
+ except RuntimeError:
183
+ self._defaults[key] = value
184
+
185
+ def __contains__(self, key: str) -> bool:
186
+ return self._transform_key(key) in self.env._app.config
187
+
188
+ def __getitem__(self, key: str) -> Any:
189
+ value = self._get_deprecated(key)
190
+ if value:
191
+ return value
192
+
193
+ # First try the current app's config
194
+ public_key = self._transform_key(key)
195
+ if self.env._app:
196
+ if public_key in self.env._app.config:
197
+ return self.env._app.config[public_key]
198
+
199
+ # Try a non-app specific default value
200
+ if key in self._defaults:
201
+ return self._defaults.__getitem__(key)
202
+
203
+ # Finally try to use a default based on the current app
204
+ deffunc = getattr(self, f"_app_default_{key}", None)
205
+ if deffunc and callable(deffunc):
206
+ return deffunc()
207
+
208
+ # We've run out of options
209
+ raise KeyError()
210
+
211
+ def __setitem__(self, key: str, value: Any) -> None:
212
+ if not self._set_deprecated(key, value):
213
+ self.env._app.config[self._transform_key(key)] = value
214
+
215
+ def __delitem__(self, key: str) -> None:
216
+ del self.env._app.config[self._transform_key(key)]
217
+
218
+
219
+ class QuartResolver(Resolver): # type: ignore[misc]
220
+ """Adds support for Quart blueprints.
221
+
222
+ This resolver is designed to use the Quart staticfile system to
223
+ locate files, by looking at directory prefixes. (``foo/bar.png``
224
+ looks in the static folder of the ``foo`` blueprint. ``url_for``
225
+ is used to generate urls to these files.)
226
+
227
+ This default behaviour changes when you start setting certain
228
+ standard *webassets* path and url configuration values:
229
+
230
+ If a :attr:`Environment.directory` is set, output files will
231
+ always be written there, while source files still use the Quart
232
+ system.
233
+
234
+ If a :attr:`Environment.load_path` is set, it is used to look
235
+ up source files, replacing the Quart system. Blueprint prefixes
236
+ are no longer resolved.
237
+ """
238
+
239
+ def split_prefix(self, ctx: Any, item: str) -> Tuple[str, str, str]:
240
+ """See if ``item`` has blueprint prefix, return (directory, rel_path)."""
241
+ app = ctx._app
242
+ directory = ""
243
+ endpoint = ""
244
+
245
+ try:
246
+ if hasattr(app, "blueprints"):
247
+ blueprint, name = item.split("/", 1)
248
+ directory = get_static_folder(app.blueprints[blueprint])
249
+ endpoint = "%s.static" % blueprint
250
+ item = name
251
+ else:
252
+ # No blueprints support, use app static
253
+ directory = get_static_folder(app)
254
+ endpoint = "static"
255
+ except (ValueError, KeyError):
256
+ directory = get_static_folder(app)
257
+ endpoint = "static"
258
+
259
+ return directory, item, endpoint
260
+
261
+ def use_webassets_system_for_output(self, ctx: Any) -> bool:
262
+ return ctx.config.get("directory") is not None or ctx.config.get("url") is not None
263
+
264
+ def use_webassets_system_for_sources(self, ctx: Any) -> bool:
265
+ return bool(ctx.load_path)
266
+
267
+ def search_for_source(self, ctx: Any, item: str) -> Any:
268
+ # If a load_path is set, use it instead of the Quart static system.
269
+ #
270
+ # Note: With only env.directory set, we don't go to default;
271
+ # Setting env.directory only makes the output directory fixed.
272
+ if self.use_webassets_system_for_sources(ctx):
273
+ return Resolver.search_for_source(self, ctx, item)
274
+
275
+ # Look in correct blueprint's directory
276
+ directory, item, endpoint = self.split_prefix(ctx, item)
277
+ try:
278
+ return self.consider_single_directory(directory, item)
279
+ except IOError:
280
+ # XXX: Hack to make the tests pass, which are written to not
281
+ # expect an IOError upon missing files. They need to be rewritten.
282
+ return path.normpath(path.join(directory, item))
283
+
284
+ def resolve_output_to_path(self, ctx: Any, target: str, bundle: Any) -> Any:
285
+ # If a directory/url pair is set, always use it for output files
286
+ if self.use_webassets_system_for_output(ctx):
287
+ return Resolver.resolve_output_to_path(self, ctx, target, bundle)
288
+
289
+ # Allow targeting blueprint static folders
290
+ directory, rel_path, endpoint = self.split_prefix(ctx, target)
291
+ return path.normpath(path.join(directory, rel_path))
292
+
293
+ def resolve_source_to_url(self, ctx: Any, filepath: str, item: str) -> str:
294
+ # If a load path is set, use it instead of the Quart static system.
295
+ if self.use_webassets_system_for_sources(ctx):
296
+ return super().resolve_source_to_url(ctx, filepath, item)
297
+
298
+ return self.convert_item_to_quart_url(ctx, item, filepath)
299
+
300
+ def resolve_output_to_url(self, ctx: Any, target: str) -> str:
301
+ # With a directory/url pair set, use it for output files.
302
+ if self.use_webassets_system_for_output(ctx):
303
+ return Resolver.resolve_output_to_url(self, ctx, target)
304
+
305
+ # Otherwise, behaves like all other Quart URLs.
306
+ return self.convert_item_to_quart_url(ctx, target)
307
+
308
+ def convert_item_to_quart_url(self, ctx: Any, item: str, filepath: Optional[str] = None) -> str:
309
+ """Given a relative reference like `foo/bar.css`, returns
310
+ the Quart static url. By doing so it takes into account
311
+ blueprints, i.e. in the aformentioned example,
312
+ ``foo`` may reference a blueprint.
313
+
314
+ If an absolute path is given via ``filepath``, it will be
315
+ used instead. This is needed because ``item`` may be a
316
+ glob instruction that was resolved to multiple files.
317
+ """
318
+ from quart import has_request_context, url_for
319
+
320
+ directory, rel_path, endpoint = self.split_prefix(ctx, item)
321
+
322
+ if filepath is not None:
323
+ filename = filepath[len(directory) + 1 :]
324
+ else:
325
+ filename = rel_path
326
+
327
+ # Windows compatibility
328
+ filename = filename.replace("\\", "/")
329
+
330
+ if has_request_context():
331
+ # We're already in a request context, use it directly
332
+ url = url_for(endpoint, filename=filename)
333
+ else:
334
+ # Fallback to manual URL construction when no request context
335
+ # This handles both sync and async contexts
336
+ app = ctx.environment._app
337
+
338
+ # Handle blueprint URLs
339
+ if endpoint.endswith(".static"):
340
+ # This is a blueprint static endpoint
341
+ bp_name = endpoint[:-7] # Remove '.static' suffix
342
+ if hasattr(app, "blueprints") and bp_name in app.blueprints:
343
+ bp = app.blueprints[bp_name]
344
+ static_url_path = getattr(bp, "static_url_path", None)
345
+ if static_url_path:
346
+ url = f"{static_url_path}/{filename}"
347
+ else:
348
+ # Use blueprint name as prefix
349
+ url = f"/{bp_name}/{filename}"
350
+ else:
351
+ # Fallback to app static path
352
+ url = f"{app.static_url_path}/{filename}"
353
+ else:
354
+ # Regular app static endpoint
355
+ url = f"{app.static_url_path}/{filename}"
356
+
357
+ # In some cases, url will be an absolute url with a scheme and
358
+ # hostname. (for example, when using werkzeug's host matching).
359
+ # In general, url_for() will return a http url. During assets build,
360
+ # we don't know yet if the assets will be served over http, https
361
+ # or both. Let's use // instead. url_for takes a _scheme argument,
362
+ # but only together with external=True, which we do not want to
363
+ # force every time. Further, # this _scheme argument is not able to
364
+ # render // - it always forces a colon.
365
+ if url and url.startswith("http:"):
366
+ url = url[5:]
367
+ return url
368
+
369
+
370
+ class QuartAssets(BaseEnvironment): # type: ignore[misc]
371
+ """This object is used to hold a collection of bundles and configuration.
372
+
373
+ If initialized with a Quart app instance then a webassets Jinja2 extension
374
+ is automatically registered.
375
+ """
376
+
377
+ config_storage_class = QuartConfigStorage
378
+ resolver_class = QuartResolver
379
+
380
+ def __init__(self, app: Optional[Quart] = None) -> None:
381
+ self.app = app
382
+ super().__init__()
383
+ if app:
384
+ self.init_app(app)
385
+
386
+ @property
387
+ def _app(self) -> Quart:
388
+ """The application object; this is either the app that has been bound
389
+ to, or the current application.
390
+ """
391
+ if self.app is not None:
392
+ return self.app
393
+
394
+ if has_request_context():
395
+ return request_ctx.app
396
+
397
+ if has_app_context():
398
+ return app_ctx.app
399
+
400
+ raise RuntimeError(
401
+ "Assets instance not bound to an application, "
402
+ + "and no application in current context"
403
+ )
404
+
405
+ def set_directory(self, directory: str) -> None:
406
+ self.config["directory"] = directory
407
+
408
+ def get_directory(self) -> str:
409
+ if self.config.get("directory") is not None:
410
+ return self.config["directory"]
411
+ return get_static_folder(self._app)
412
+
413
+ directory = property(
414
+ get_directory,
415
+ set_directory,
416
+ doc="""The base directory to which all paths will be relative to.
417
+ """,
418
+ )
419
+
420
+ def set_url(self, url: str) -> None:
421
+ self.config["url"] = url
422
+
423
+ def get_url(self) -> Optional[str]:
424
+ if self.config.get("url") is not None:
425
+ return self.config["url"]
426
+ return self._app.static_url_path
427
+
428
+ url = property(
429
+ get_url,
430
+ set_url,
431
+ doc="""The base url to which all static urls will be relative to.""",
432
+ )
433
+
434
+ def init_app(self, app: Quart) -> None:
435
+ # Use our custom async-aware extension instead of the default webassets
436
+ # extension
437
+ app.jinja_env.add_extension(AsyncAssetsExtension)
438
+ app.jinja_env.assets_environment = self # type: ignore[attr-defined]
439
+
440
+ def from_yaml(self, path: str) -> None:
441
+ """Register bundles from a YAML configuration file"""
442
+ bundles = YAMLLoader(path).load_bundles()
443
+ for name, bundle in bundles.items():
444
+ self.register(name, bundle)
445
+
446
+ def from_module(self, path: Union[str, Any]) -> None:
447
+ """Register bundles from a Python module"""
448
+ bundles = PythonLoader(path).load_bundles()
449
+ for name, bundle in bundles.items():
450
+ self.register(name, bundle)
451
+
452
+
453
+ # Override the built-in ``jinja2`` filter that ships with ``webassets``. This
454
+ # custom filter uses Quart's ``render_template_string`` function to provide all
455
+ # the standard Quart template context variables.
456
+ register_filter(Jinja2Filter)
457
+
458
+
459
+ try:
460
+ import click
461
+ except ImportError:
462
+ pass
463
+ else:
464
+
465
+ def _webassets_cmd(cmd: str) -> None:
466
+ """Helper to run a webassets command."""
467
+
468
+ logger = logging.getLogger("webassets")
469
+ logger.addHandler(logging.StreamHandler())
470
+ logger.setLevel(logging.DEBUG)
471
+ cmdenv = CommandLineEnvironment(
472
+ current_app.jinja_env.assets_environment, logger # type: ignore[attr-defined]
473
+ )
474
+ getattr(cmdenv, cmd)()
475
+
476
+ @click.group()
477
+ def assets(info: ScriptInfo) -> None:
478
+ """Quart Assets commands."""
479
+
480
+ @assets.command()
481
+ @pass_script_info
482
+ def build(info: ScriptInfo) -> None:
483
+ """Build bundles."""
484
+ _webassets_cmd("build")
485
+
486
+ @assets.command()
487
+ @pass_script_info
488
+ def clean(info: ScriptInfo) -> None:
489
+ """Clean bundles."""
490
+ _webassets_cmd("clean")
491
+
492
+ @assets.command()
493
+ @pass_script_info
494
+ def watch(info: ScriptInfo) -> None:
495
+ """Watch bundles for file changes."""
496
+ _webassets_cmd("watch")
497
+
498
+ __all__.extend(["assets", "build", "clean", "watch"])