Flask-Themes2 2.0.0__py3-none-any.whl
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,666 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask-Themes2
|
|
3
|
+
=============
|
|
4
|
+
|
|
5
|
+
This provides infrastructure for theming support in your Flask applications.
|
|
6
|
+
It takes care of:
|
|
7
|
+
|
|
8
|
+
- Loading themes
|
|
9
|
+
- Rendering their templates
|
|
10
|
+
- Serving their static media
|
|
11
|
+
- Letting themes reference their templates and static media
|
|
12
|
+
|
|
13
|
+
:copyright: 2013-2026 Chris Carter, <sh4nks>
|
|
14
|
+
2023 <ruby0b>
|
|
15
|
+
2021 Peter Justin
|
|
16
|
+
2012 Drew Lustro
|
|
17
|
+
2010 Matthew "LeafStorm" Frazier
|
|
18
|
+
:license: MIT/X11, see LICENSE for details
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Container
|
|
27
|
+
|
|
28
|
+
# Yarg, here be pirates!
|
|
29
|
+
from operator import attrgetter
|
|
30
|
+
from typing import Any
|
|
31
|
+
from typing import cast
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
from typing import TypeVar
|
|
34
|
+
|
|
35
|
+
from flask import abort
|
|
36
|
+
from flask import Blueprint
|
|
37
|
+
from flask import current_app
|
|
38
|
+
from flask import json
|
|
39
|
+
from flask import render_template
|
|
40
|
+
from flask import send_from_directory
|
|
41
|
+
from flask import url_for
|
|
42
|
+
from jinja2 import pass_context
|
|
43
|
+
from jinja2 import TemplateNotFound
|
|
44
|
+
from jinja2.loaders import BaseLoader
|
|
45
|
+
from jinja2.loaders import FileSystemLoader
|
|
46
|
+
from werkzeug.utils import cached_property
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from collections.abc import Iterable
|
|
51
|
+
from collections.abc import Iterator
|
|
52
|
+
|
|
53
|
+
from flask import Flask
|
|
54
|
+
from flask import Response
|
|
55
|
+
from jinja2 import Environment
|
|
56
|
+
from jinja2.runtime import Context
|
|
57
|
+
|
|
58
|
+
#: A theme loader is a callable that takes an app and returns an iterable
|
|
59
|
+
#: of the `Theme` instances it could find for it.
|
|
60
|
+
ThemeLoader = Callable[[Flask], Iterable["Theme"]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__version__ = "2.0.0"
|
|
64
|
+
|
|
65
|
+
_T = TypeVar("_T")
|
|
66
|
+
|
|
67
|
+
IDENTIFIER = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def containable(i: Iterable[_T]) -> Container[_T]:
|
|
71
|
+
return i if isinstance(i, Container) else tuple(i)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def starchain(i: Iterable[Iterable[_T]]) -> Iterator[_T]:
|
|
75
|
+
return itertools.chain(*i)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_theme_manager(app: Flask | None = None) -> ThemeManager:
|
|
79
|
+
"""Gets the application-specific ThemeManager.
|
|
80
|
+
|
|
81
|
+
:param app: The `~flask.Flask` application. Defaults to the current app.
|
|
82
|
+
"""
|
|
83
|
+
if app is None:
|
|
84
|
+
app = current_app
|
|
85
|
+
|
|
86
|
+
if "themes" not in app.extensions:
|
|
87
|
+
raise RuntimeError("Flask-Themes2 not configured against current app")
|
|
88
|
+
|
|
89
|
+
manager: ThemeManager = app.extensions["themes"]
|
|
90
|
+
return manager
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def active_theme(ctx: Context) -> str:
|
|
94
|
+
if "_theme" in ctx:
|
|
95
|
+
return cast("str", ctx["_theme"])
|
|
96
|
+
elif ctx.name is not None and ctx.name.startswith("_themes/"):
|
|
97
|
+
return ctx.name[8:].split("/", 1)[0]
|
|
98
|
+
else:
|
|
99
|
+
raise RuntimeError("Could not find the active theme")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pass_context
|
|
103
|
+
def global_theme_template(
|
|
104
|
+
ctx: Context, templatename: str, fallback: bool = True
|
|
105
|
+
) -> str:
|
|
106
|
+
theme = active_theme(ctx)
|
|
107
|
+
if not fallback:
|
|
108
|
+
return get_theme_template_name(theme, templatename)
|
|
109
|
+
return get_theme_manager().get_template(theme, templatename)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pass_context
|
|
113
|
+
def global_theme_static(ctx: Context, filename: str, external: bool = False) -> str:
|
|
114
|
+
theme = active_theme(ctx)
|
|
115
|
+
return static_file_url(theme, filename, external)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pass_context
|
|
119
|
+
def global_theme_get_info(ctx: Context, attribute_name: str, fallback: Any = "") -> Any:
|
|
120
|
+
theme = get_theme(active_theme(ctx))
|
|
121
|
+
try:
|
|
122
|
+
info = getattr(theme, attribute_name)
|
|
123
|
+
if info is None:
|
|
124
|
+
raise AttributeError(f"Got None for getattr(theme, '{attribute_name}')")
|
|
125
|
+
return info
|
|
126
|
+
except AttributeError:
|
|
127
|
+
pass
|
|
128
|
+
return theme.options.get(attribute_name, fallback)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def static_file_url(theme: Theme | str, filename: str, external: bool = False) -> str:
|
|
132
|
+
"""
|
|
133
|
+
This is a shortcut for getting the URL of a static file in a theme.
|
|
134
|
+
|
|
135
|
+
:param theme: A `Theme` instance or identifier.
|
|
136
|
+
:param filename: The name of the file.
|
|
137
|
+
:param external: Whether the link should be external or not. Defaults to
|
|
138
|
+
`False`.
|
|
139
|
+
"""
|
|
140
|
+
if isinstance(theme, Theme):
|
|
141
|
+
theme = theme.identifier
|
|
142
|
+
|
|
143
|
+
# raises KeyError if the theme is not loaded
|
|
144
|
+
get_theme(theme)
|
|
145
|
+
|
|
146
|
+
if get_theme_manager().static_folder:
|
|
147
|
+
return url_for(
|
|
148
|
+
"_themes.static",
|
|
149
|
+
filename=theme + "/" + filename,
|
|
150
|
+
_external=external,
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
return url_for(
|
|
154
|
+
"_themes.static",
|
|
155
|
+
themeid=theme,
|
|
156
|
+
filename=filename,
|
|
157
|
+
_external=external,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_theme_template(
|
|
162
|
+
theme: Theme | str, template_name: str, _fallback: bool = True, **context: Any
|
|
163
|
+
) -> str:
|
|
164
|
+
"""
|
|
165
|
+
This renders a template from the given theme. For example::
|
|
166
|
+
|
|
167
|
+
return render_theme_template(g.user.theme, 'index.html', posts=posts)
|
|
168
|
+
|
|
169
|
+
If `_fallback` is True and the template does not exist within the theme,
|
|
170
|
+
it will fall back on trying to render the template using the application's
|
|
171
|
+
normal templates. (The "active theme" will still be set, though, so you
|
|
172
|
+
can try to extend or include other templates from the theme.)
|
|
173
|
+
|
|
174
|
+
:param theme: Either the identifier of the theme to use, or an actual
|
|
175
|
+
`Theme` instance.
|
|
176
|
+
:param template_name: The name of the template to render.
|
|
177
|
+
:param _fallback: Whether to fall back to the default
|
|
178
|
+
"""
|
|
179
|
+
if isinstance(theme, Theme):
|
|
180
|
+
theme = theme.identifier
|
|
181
|
+
context["_theme"] = theme
|
|
182
|
+
if not _fallback:
|
|
183
|
+
return render_template(get_theme_template_name(theme, template_name), **context)
|
|
184
|
+
return render_template(
|
|
185
|
+
get_theme_manager().get_template(theme, template_name), **context
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def get_theme_template_name(theme: Theme | str, template_name: str) -> str:
|
|
190
|
+
"""
|
|
191
|
+
This returns the name under which the given theme's version of
|
|
192
|
+
``template_name`` is exposed to Jinja, whether or not it exists.
|
|
193
|
+
"""
|
|
194
|
+
if isinstance(theme, Theme):
|
|
195
|
+
theme = theme.identifier
|
|
196
|
+
return f"_themes/{theme}/{template_name}"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
### convenience #########################################################
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_theme(ident: str) -> Theme:
|
|
203
|
+
"""
|
|
204
|
+
This gets the theme with the given identifier from the current app's
|
|
205
|
+
theme manager.
|
|
206
|
+
|
|
207
|
+
:param ident: The theme identifier.
|
|
208
|
+
"""
|
|
209
|
+
return get_theme_manager().themes[ident]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_themes_list() -> list[Theme]:
|
|
213
|
+
"""
|
|
214
|
+
This returns a list of all the themes in the current app's theme manager,
|
|
215
|
+
sorted by identifier.
|
|
216
|
+
"""
|
|
217
|
+
return list(get_theme_manager().list_themes())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def static(themeid: str, filename: str) -> Response:
|
|
221
|
+
try:
|
|
222
|
+
theme = get_theme_manager().themes[themeid]
|
|
223
|
+
except KeyError:
|
|
224
|
+
abort(404)
|
|
225
|
+
return send_from_directory(theme.static_path, filename)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def template_exists(templatename: str) -> bool:
|
|
229
|
+
"""
|
|
230
|
+
This checks whether the current app can load a template with the given
|
|
231
|
+
name. The result is cached by the app's `ThemeManager` unless the Jinja
|
|
232
|
+
environment has ``auto_reload`` enabled.
|
|
233
|
+
|
|
234
|
+
:param templatename: The full template name, including any
|
|
235
|
+
``_themes/<identifier>/`` prefix.
|
|
236
|
+
"""
|
|
237
|
+
return get_theme_manager().template_exists(templatename)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _loader_has_template(env: Environment, templatename: str) -> bool:
|
|
241
|
+
if env.loader is None:
|
|
242
|
+
return False
|
|
243
|
+
try:
|
|
244
|
+
env.loader.get_source(env, templatename)
|
|
245
|
+
except TemplateNotFound:
|
|
246
|
+
return False
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
### loaders #############################################################
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def list_folders(path: str | os.PathLike[str]) -> Iterator[str]:
|
|
254
|
+
"""
|
|
255
|
+
This is a helper function that only returns the directories in a given
|
|
256
|
+
folder.
|
|
257
|
+
|
|
258
|
+
:param path: The path to list directories in.
|
|
259
|
+
"""
|
|
260
|
+
return (
|
|
261
|
+
name for name in os.listdir(path) if os.path.isdir(os.path.join(path, name))
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def load_themes_from(path: str | os.PathLike[str]) -> Iterator[Theme]:
|
|
266
|
+
"""
|
|
267
|
+
This is used by the default loaders. You give it a path, and it will find
|
|
268
|
+
valid themes and yield them one by one.
|
|
269
|
+
|
|
270
|
+
:param path: The path to search for themes in.
|
|
271
|
+
"""
|
|
272
|
+
for basename in (b for b in list_folders(path) if IDENTIFIER.match(b)):
|
|
273
|
+
try:
|
|
274
|
+
t = Theme(os.path.join(path, basename))
|
|
275
|
+
except: # noqa: E722, S110
|
|
276
|
+
pass
|
|
277
|
+
else:
|
|
278
|
+
if t.identifier == basename:
|
|
279
|
+
yield t
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def packaged_themes_loader(app: Flask) -> Iterable[Theme]:
|
|
283
|
+
"""
|
|
284
|
+
This theme will find themes that are shipped with the application. It will
|
|
285
|
+
look in the application's root path for a ``themes`` directory - for
|
|
286
|
+
example, the ``someapp`` package can ship themes in the directory
|
|
287
|
+
``someapp/themes/``.
|
|
288
|
+
"""
|
|
289
|
+
themes_path = os.path.join(app.root_path, "themes")
|
|
290
|
+
if os.path.exists(themes_path):
|
|
291
|
+
return load_themes_from(themes_path)
|
|
292
|
+
else:
|
|
293
|
+
return ()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def theme_paths_loader(app: Flask) -> Iterable[Theme]:
|
|
297
|
+
"""
|
|
298
|
+
This checks the app's `THEME_PATHS` configuration variable to find
|
|
299
|
+
directories that contain themes. The theme's identifier must match the
|
|
300
|
+
name of its directory.
|
|
301
|
+
"""
|
|
302
|
+
theme_paths: Iterable[str | os.PathLike[str]] = app.config.get("THEME_PATHS", ())
|
|
303
|
+
|
|
304
|
+
if isinstance(theme_paths, str):
|
|
305
|
+
theme_paths = [p.strip() for p in theme_paths.split(";")]
|
|
306
|
+
return starchain(load_themes_from(path) for path in theme_paths)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class ThemeTemplateLoader(BaseLoader):
|
|
310
|
+
"""
|
|
311
|
+
This is a template loader that loads templates from the current app's
|
|
312
|
+
loaded themes.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self, as_blueprint: bool = False) -> None:
|
|
316
|
+
self.as_blueprint = as_blueprint
|
|
317
|
+
BaseLoader.__init__(self)
|
|
318
|
+
|
|
319
|
+
def get_source(
|
|
320
|
+
self, environment: Environment, template: str
|
|
321
|
+
) -> tuple[str, str | None, Callable[[], bool] | None]:
|
|
322
|
+
if self.as_blueprint and template.startswith("_themes/"):
|
|
323
|
+
template = template[8:]
|
|
324
|
+
try:
|
|
325
|
+
themename, templatename = template.split("/", 1)
|
|
326
|
+
theme = get_theme_manager().themes[themename]
|
|
327
|
+
except (ValueError, KeyError):
|
|
328
|
+
raise TemplateNotFound(template) from None
|
|
329
|
+
try:
|
|
330
|
+
return theme.jinja_loader.get_source(environment, templatename)
|
|
331
|
+
except TemplateNotFound:
|
|
332
|
+
raise TemplateNotFound(template) from None
|
|
333
|
+
|
|
334
|
+
def list_templates(self) -> list[str]:
|
|
335
|
+
res: list[str] = []
|
|
336
|
+
fmt = "_themes/%s/%s"
|
|
337
|
+
for ident, theme in get_theme_manager().themes.items():
|
|
338
|
+
res.extend((fmt % (ident, t)) for t in theme.jinja_loader.list_templates())
|
|
339
|
+
return res
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
#########################################################################
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class _ThemesBlueprint(Blueprint):
|
|
346
|
+
@cached_property
|
|
347
|
+
def jinja_loader(self) -> BaseLoader:
|
|
348
|
+
return ThemeTemplateLoader(True)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
themes_blueprint = _ThemesBlueprint("_themes", __name__)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
class Themes:
|
|
355
|
+
"""
|
|
356
|
+
This is the main class you will use to interact
|
|
357
|
+
with Flask-Themes2 on your app.
|
|
358
|
+
|
|
359
|
+
It really only implements the bare minimum, the rest
|
|
360
|
+
is passed through to other methods and classes.
|
|
361
|
+
"""
|
|
362
|
+
|
|
363
|
+
def __init__(self, app: Flask | None = None, **kwargs: Any) -> None:
|
|
364
|
+
"""
|
|
365
|
+
If given an app, this will simply call init_themes,
|
|
366
|
+
and pass through all kwargs to init_themes,
|
|
367
|
+
making it super easy.
|
|
368
|
+
|
|
369
|
+
:param app: the `~flask.Flask` instance to setup themes for.
|
|
370
|
+
:param kwargs: keyword args to pass through to init_themes
|
|
371
|
+
"""
|
|
372
|
+
self._app: Flask | None = app
|
|
373
|
+
if app is not None:
|
|
374
|
+
self.init_themes(app, **kwargs)
|
|
375
|
+
|
|
376
|
+
def init_themes(
|
|
377
|
+
self,
|
|
378
|
+
app: Flask,
|
|
379
|
+
loaders: Iterable[ThemeLoader] | None = None,
|
|
380
|
+
app_identifier: str | None = None,
|
|
381
|
+
manager_cls: type[ThemeManager] | None = None,
|
|
382
|
+
theme_url_prefix: str = "/_themes",
|
|
383
|
+
static_folder: str | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""This sets up the theme infrastructure by adding a `ThemeManager`
|
|
386
|
+
to the given app and registering the module/blueprint containing the
|
|
387
|
+
views and templates needed.
|
|
388
|
+
|
|
389
|
+
:param app: The `~flask.Flask` instance to set up themes for.
|
|
390
|
+
:param loaders: An iterable of loaders to use. It defaults to
|
|
391
|
+
`packaged_themes_loader` and `theme_paths_loader`.
|
|
392
|
+
:param app_identifier: The application identifier to use. If not given,
|
|
393
|
+
it defaults to the app's import name.
|
|
394
|
+
:param manager_cls: If you need a custom manager class, you can pass it
|
|
395
|
+
in here.
|
|
396
|
+
:param theme_url_prefix: The prefix to use for the URLs on the themes
|
|
397
|
+
module. (Defaults to ``/_themes``.)
|
|
398
|
+
"""
|
|
399
|
+
if app_identifier is None:
|
|
400
|
+
app_identifier = app.import_name
|
|
401
|
+
if manager_cls is None:
|
|
402
|
+
manager_cls = ThemeManager
|
|
403
|
+
manager_cls(app, app_identifier, loaders=loaders, static_folder=static_folder)
|
|
404
|
+
|
|
405
|
+
app.jinja_env.globals["theme"] = global_theme_template
|
|
406
|
+
app.jinja_env.globals["theme_static"] = global_theme_static
|
|
407
|
+
app.jinja_env.globals["theme_get_info"] = global_theme_get_info
|
|
408
|
+
|
|
409
|
+
if static_folder:
|
|
410
|
+
themes_blueprint.static_folder = static_folder
|
|
411
|
+
themes_blueprint.static_url_path = (
|
|
412
|
+
f"{app.static_url_path or ''}{theme_url_prefix}"
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
themes_blueprint.url_prefix = theme_url_prefix
|
|
416
|
+
themes_blueprint.add_url_rule(
|
|
417
|
+
"/<themeid>/<path:filename>", "static", view_func=static
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
app.register_blueprint(themes_blueprint)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class ThemeManager:
|
|
424
|
+
"""
|
|
425
|
+
This is responsible for loading and storing all the themes for an
|
|
426
|
+
application. Calling `refresh` will cause it to invoke all of the theme
|
|
427
|
+
loaders.
|
|
428
|
+
|
|
429
|
+
A theme loader is simply a callable that takes an app and returns an
|
|
430
|
+
iterable of `Theme` instances. You can implement your own loaders if your
|
|
431
|
+
app has another way to load themes.
|
|
432
|
+
|
|
433
|
+
:param app: The app to bind to. (Each instance is only usable for one
|
|
434
|
+
app.)
|
|
435
|
+
:param app_identifier: The value that the info.json's `application` key
|
|
436
|
+
is required to have. If you require a more complex
|
|
437
|
+
check, you can subclass and override the
|
|
438
|
+
`valid_app_id` method.
|
|
439
|
+
:param loaders: An iterable of loaders to use. The defaults are
|
|
440
|
+
`packaged_themes_loader` and `theme_paths_loader`, in that
|
|
441
|
+
order.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
def __init__(
|
|
445
|
+
self,
|
|
446
|
+
app: Flask,
|
|
447
|
+
app_identifier: str,
|
|
448
|
+
loaders: Iterable[ThemeLoader] | None = None,
|
|
449
|
+
static_folder: str | None = None,
|
|
450
|
+
) -> None:
|
|
451
|
+
self.bind_app(app)
|
|
452
|
+
self.app_identifier = app_identifier
|
|
453
|
+
self.static_folder = static_folder
|
|
454
|
+
|
|
455
|
+
self._themes: dict[str, Theme] | None = None
|
|
456
|
+
self._template_exists_cache: dict[str, bool] = {}
|
|
457
|
+
|
|
458
|
+
#: This is a list of the loaders that will be used to load the themes.
|
|
459
|
+
self.loaders: list[ThemeLoader] = []
|
|
460
|
+
if loaders:
|
|
461
|
+
self.loaders.extend(loaders)
|
|
462
|
+
else:
|
|
463
|
+
self.loaders.extend((packaged_themes_loader, theme_paths_loader))
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def themes(self) -> dict[str, Theme]:
|
|
467
|
+
"""
|
|
468
|
+
This is a dictionary of all the themes that have been loaded. The keys
|
|
469
|
+
are the identifiers and the values are `Theme` objects.
|
|
470
|
+
"""
|
|
471
|
+
themes = self._themes
|
|
472
|
+
if themes is None:
|
|
473
|
+
themes = self._themes = self._load_themes()
|
|
474
|
+
return themes
|
|
475
|
+
|
|
476
|
+
def list_themes(self) -> list[Theme]:
|
|
477
|
+
"""
|
|
478
|
+
This yields all the `Theme` objects, in sorted order.
|
|
479
|
+
"""
|
|
480
|
+
return sorted(self.themes.values(), key=attrgetter("identifier"))
|
|
481
|
+
|
|
482
|
+
def bind_app(self, app: Flask) -> None:
|
|
483
|
+
"""
|
|
484
|
+
If an app wasn't bound when the manager was created, this will bind
|
|
485
|
+
it. The app must be bound for the loaders to work.
|
|
486
|
+
|
|
487
|
+
:param app: A `~flask.Flask` instance.
|
|
488
|
+
"""
|
|
489
|
+
self.app = app
|
|
490
|
+
app.extensions["themes"] = self
|
|
491
|
+
|
|
492
|
+
# kept for backwards compatibility, use get_theme_manager() instead
|
|
493
|
+
app.theme_manager = self # type: ignore[attr-defined]
|
|
494
|
+
|
|
495
|
+
def valid_app_id(self, app_identifier: str) -> bool:
|
|
496
|
+
"""
|
|
497
|
+
This checks whether the application identifier given will work with
|
|
498
|
+
this application. The default implementation checks whether the given
|
|
499
|
+
identifier matches the one given at initialization.
|
|
500
|
+
|
|
501
|
+
:param app_identifier: The application identifier to check.
|
|
502
|
+
"""
|
|
503
|
+
return self.app_identifier == app_identifier
|
|
504
|
+
|
|
505
|
+
def refresh(self) -> None:
|
|
506
|
+
"""
|
|
507
|
+
This loads all of the themes into the `themes` dictionary. The loaders
|
|
508
|
+
are invoked in the order they are given, so later themes will override
|
|
509
|
+
earlier ones. Any invalid themes found (for example, if the
|
|
510
|
+
application identifier is incorrect) will be skipped.
|
|
511
|
+
"""
|
|
512
|
+
self._themes = self._load_themes()
|
|
513
|
+
self._template_exists_cache.clear()
|
|
514
|
+
|
|
515
|
+
def template_exists(self, templatename: str) -> bool:
|
|
516
|
+
"""
|
|
517
|
+
This checks whether the bound app can load a template with the given
|
|
518
|
+
name. Lookups are cached, because Jinja only caches templates it
|
|
519
|
+
found and every miss would otherwise walk the app's and every
|
|
520
|
+
blueprint's loader. The cache is bypassed while the Jinja environment
|
|
521
|
+
has ``auto_reload`` enabled, so new theme overrides are picked up in
|
|
522
|
+
debug mode, and cleared by `refresh`.
|
|
523
|
+
|
|
524
|
+
:param templatename: The full template name, including any
|
|
525
|
+
``_themes/<identifier>/`` prefix.
|
|
526
|
+
"""
|
|
527
|
+
env = self.app.jinja_env
|
|
528
|
+
if env.auto_reload:
|
|
529
|
+
return _loader_has_template(env, templatename)
|
|
530
|
+
cache = self._template_exists_cache
|
|
531
|
+
try:
|
|
532
|
+
return cache[templatename]
|
|
533
|
+
except KeyError:
|
|
534
|
+
exists = cache[templatename] = _loader_has_template(env, templatename)
|
|
535
|
+
return exists
|
|
536
|
+
|
|
537
|
+
def get_template(self, theme: Theme | str, template_name: str) -> str:
|
|
538
|
+
"""
|
|
539
|
+
This returns the name to render for ``template_name`` in the given
|
|
540
|
+
theme: the theme's own version if it has one, otherwise the
|
|
541
|
+
application's ``template_name``.
|
|
542
|
+
|
|
543
|
+
:param theme: Either the identifier of the theme or a `Theme`.
|
|
544
|
+
:param template_name: The name of the template to look up.
|
|
545
|
+
"""
|
|
546
|
+
themed_name = get_theme_template_name(theme, template_name)
|
|
547
|
+
if self.template_exists(themed_name):
|
|
548
|
+
return themed_name
|
|
549
|
+
return template_name
|
|
550
|
+
|
|
551
|
+
def _load_themes(self) -> dict[str, Theme]:
|
|
552
|
+
themes: dict[str, Theme] = {}
|
|
553
|
+
for theme in starchain(ldr(self.app) for ldr in self.loaders):
|
|
554
|
+
if self.valid_app_id(theme.application):
|
|
555
|
+
themes[theme.identifier] = theme
|
|
556
|
+
return themes
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
class Theme:
|
|
560
|
+
"""
|
|
561
|
+
This contains a theme's metadata.
|
|
562
|
+
|
|
563
|
+
:param path: The path to the theme directory.
|
|
564
|
+
"""
|
|
565
|
+
|
|
566
|
+
def __init__(self, path: str | os.PathLike[str]) -> None:
|
|
567
|
+
#: The theme's root path. All the files in the theme are under this
|
|
568
|
+
#: path.
|
|
569
|
+
self.path = os.path.abspath(path)
|
|
570
|
+
|
|
571
|
+
with open(os.path.join(self.path, "info.json")) as fd:
|
|
572
|
+
i: dict[str, Any] = json.load(fd)
|
|
573
|
+
|
|
574
|
+
#: The raw contents of the theme's info.json.
|
|
575
|
+
self.info = i
|
|
576
|
+
|
|
577
|
+
#: The theme's name, as given in info.json. This is the human
|
|
578
|
+
#: readable name.
|
|
579
|
+
self.name: str = i["name"]
|
|
580
|
+
|
|
581
|
+
#: The application identifier given in the theme's info.json. Your
|
|
582
|
+
#: application will probably want to validate it.
|
|
583
|
+
self.application: str = i["application"]
|
|
584
|
+
|
|
585
|
+
#: The theme's identifier. This is an actual Python identifier,
|
|
586
|
+
#: and in most situations should match the name of the directory the
|
|
587
|
+
#: theme is in.
|
|
588
|
+
self.identifier: str = i["identifier"]
|
|
589
|
+
|
|
590
|
+
#: The human readable description. This is the default (English)
|
|
591
|
+
#: version.
|
|
592
|
+
self.description: str | None = i.get("description")
|
|
593
|
+
|
|
594
|
+
#: This is a dictionary of localized versions of the description.
|
|
595
|
+
#: The language codes are all lowercase, and the ``en`` key is
|
|
596
|
+
#: preloaded with the base description.
|
|
597
|
+
self.localized_desc: dict[str, str | None] = {
|
|
598
|
+
k.split("_", 1)[1].lower(): v
|
|
599
|
+
for k, v in i.items()
|
|
600
|
+
if k.startswith("description_")
|
|
601
|
+
}
|
|
602
|
+
self.localized_desc.setdefault("en", self.description)
|
|
603
|
+
|
|
604
|
+
#: The author's name, as given in info.json. This may or may not
|
|
605
|
+
#: include their email, so it's best just to display it as-is.
|
|
606
|
+
self.author: str = i["author"]
|
|
607
|
+
|
|
608
|
+
#: A short phrase describing the license, like "GPL", "BSD", "Public
|
|
609
|
+
#: Domain", or "Creative Commons BY-SA 3.0".
|
|
610
|
+
self.license: str | None = i.get("license")
|
|
611
|
+
|
|
612
|
+
#: A URL pointing to the license text online.
|
|
613
|
+
self.license_url: str | None = i.get("license_url")
|
|
614
|
+
|
|
615
|
+
#: The URL to the theme's or author's Web site.
|
|
616
|
+
self.website: str | None = i.get("website")
|
|
617
|
+
|
|
618
|
+
#: The theme's preview image, within the static folder.
|
|
619
|
+
self.preview: str | None = i.get("preview")
|
|
620
|
+
|
|
621
|
+
#: The theme's doctype. This can be ``html4``, ``html5``, or ``xhtml``
|
|
622
|
+
#: with html5 being the default if not specified.
|
|
623
|
+
self.doctype: str = i.get("doctype", "html5")
|
|
624
|
+
|
|
625
|
+
#: The theme's version string.
|
|
626
|
+
self.version: str | None = i.get("version")
|
|
627
|
+
|
|
628
|
+
#: Any additional options. These are entirely application-specific,
|
|
629
|
+
#: and may determine other aspects of the application's behavior.
|
|
630
|
+
self.options: dict[str, Any] = i.get("options", {})
|
|
631
|
+
|
|
632
|
+
@cached_property
|
|
633
|
+
def static_path(self) -> str:
|
|
634
|
+
"""
|
|
635
|
+
The absolute path to the theme's static files directory.
|
|
636
|
+
"""
|
|
637
|
+
return os.path.join(self.path, "static")
|
|
638
|
+
|
|
639
|
+
@cached_property
|
|
640
|
+
def templates_path(self) -> str:
|
|
641
|
+
"""
|
|
642
|
+
The absolute path to the theme's templates directory.
|
|
643
|
+
"""
|
|
644
|
+
return os.path.join(self.path, "templates")
|
|
645
|
+
|
|
646
|
+
@cached_property
|
|
647
|
+
def license_text(self) -> str | None:
|
|
648
|
+
"""
|
|
649
|
+
The contents of the theme's license.txt file, if it exists. This is
|
|
650
|
+
used to display the full license text if necessary. (It is `None` if
|
|
651
|
+
there was not a license.txt.)
|
|
652
|
+
"""
|
|
653
|
+
lt_path = os.path.join(self.path, "license.txt")
|
|
654
|
+
if os.path.exists(lt_path):
|
|
655
|
+
with open(lt_path) as fd:
|
|
656
|
+
return fd.read()
|
|
657
|
+
else:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
@cached_property
|
|
661
|
+
def jinja_loader(self) -> FileSystemLoader:
|
|
662
|
+
"""
|
|
663
|
+
This is a Jinja2 template loader that loads templates from the theme's
|
|
664
|
+
``templates`` directory.
|
|
665
|
+
"""
|
|
666
|
+
return FileSystemLoader(self.templates_path)
|
flask_themes2/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: Flask-Themes2
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Provides infrastructure for theming Flask applications
|
|
5
|
+
Author-email: Chris Carter <chris@gibsonsec.org>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Framework :: Flask
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: flask>=2.0
|
|
19
|
+
Project-URL: Documentation, https://flask-themes2.readthedocs.io
|
|
20
|
+
Project-URL: Source, https://github.com/sysr-q/flask-themes2
|
|
21
|
+
Import-Name: flask_themes2
|
|
22
|
+
|
|
23
|
+
# Flask-Themes2
|
|
24
|
+
|
|
25
|
+
[docs]: https://flask-themes2.readthedocs.io/en/latest/
|
|
26
|
+
[GitHub]: https://github.com/sysr-q/flask-themes2
|
|
27
|
+
|
|
28
|
+
Flask-Themes2 is a fork of Spirits, which is in turn a fork of Flask-Themes.
|
|
29
|
+
|
|
30
|
+
- [Flask-Themes2](https://github.com/sysr-q/flask-themes2) maintained by Chris Carter et al.
|
|
31
|
+
- [Spirits](https://github.com/drewlustro/spirits) maintained by Drew Lustro
|
|
32
|
+
- [Flask-Themes](https://bitbucket.org/leafstorm/flask-themes) by Matthew "LeafStorm" Frazier
|
|
33
|
+
- Many more contributors to this fork and all others! It wouldn't be this good without you.
|
|
34
|
+
|
|
35
|
+
This provides infrastructure for themes in Flask >= 2.0.
|
|
36
|
+
|
|
37
|
+
> [!NOTE]
|
|
38
|
+
> Flask-Themes2 2.x requires Python 3.11 or newer.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
To install normally:
|
|
44
|
+
```sh
|
|
45
|
+
$ pip install flask-themes2
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
To install the development version from the latest commit on `master`:
|
|
49
|
+
```sh
|
|
50
|
+
$ pip install "Flask-Themes2 @ git+https://github.com/sysr-q/flask-themes2.git@master"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Documentation
|
|
54
|
+
|
|
55
|
+
The documentation is on the Flask-Themes2 [docs].
|
|
56
|
+
|
|
57
|
+
## Development
|
|
58
|
+
|
|
59
|
+
If you're interested in helping, the source is available on [GitHub], feel free to contribute.
|
|
60
|
+
|
|
61
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then clone
|
|
62
|
+
the repository and install the project and development dependencies:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
git clone https://github.com/sysr-q/flask-themes2.git
|
|
66
|
+
cd flask-themes2
|
|
67
|
+
uv sync --locked
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Run the tests on the development Python version:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
uv run --locked pytest
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Run the full test matrix (Python 3.11–3.14), formatting and lint checks,
|
|
77
|
+
type checks, and documentation build:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
uv python install 3.11 3.12 3.13 3.14
|
|
81
|
+
uv run --locked tox
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You can also run individual checks, for example:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
uv run --locked tox run -e py3.11,typing,docs
|
|
88
|
+
uv run --locked pre-commit run --all-files
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Releases
|
|
92
|
+
|
|
93
|
+
Update `__version__` in `src/flask_themes2/__init__.py`, commit the release
|
|
94
|
+
changes, and push them to `master`. Release tags use the plain `MAJOR.MINOR.PATCH`
|
|
95
|
+
format, for example `2.0.0`, without a `v` prefix or prerelease suffix.
|
|
96
|
+
|
|
97
|
+
Pushing a release tag triggers the publish workflow. It checks that the tag
|
|
98
|
+
matches `__version__`, runs the full test matrix and other checks, and builds and
|
|
99
|
+
validates the distributions before publishing to PyPI through the `publish`
|
|
100
|
+
environment. After successful publication, it creates a draft GitHub release
|
|
101
|
+
for you to add release notes and publish; pushing commits to `master` alone
|
|
102
|
+
does not publish a release.
|
|
103
|
+
|
|
104
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
flask_themes2/__init__.py,sha256=E_YC5QdLWzVfHivtd7OYvONHy-WVmLBdnvvmScZemK8,22816
|
|
2
|
+
flask_themes2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
flask_themes2-2.0.0.dist-info/licenses/LICENSE,sha256=0lVBI2sEJ_-qvXPTGcJ78bw8XAkbG_oy6yGpscO5juo,1146
|
|
4
|
+
flask_themes2-2.0.0.dist-info/WHEEL,sha256=lqN8jXt_QloFOGcx9kStvWbryhfEXoI2F2z-YURm350,81
|
|
5
|
+
flask_themes2-2.0.0.dist-info/METADATA,sha256=Ymx6gC7aIP3L2gR-GNpV_gN_ckVAYReEzjen7ua3gdY,3295
|
|
6
|
+
flask_themes2-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2013-2021 Chris Carter, 2021 Peter Justin,
|
|
2
|
+
2012 Drew Lustro, 2010 Matthew "LeafStorm" Frazier
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person
|
|
5
|
+
obtaining a copy of this software and associated documentation
|
|
6
|
+
files (the "Software"), to deal in the Software without
|
|
7
|
+
restriction, including without limitation the rights to use,
|
|
8
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the
|
|
10
|
+
Software is furnished to do so, subject to the following
|
|
11
|
+
conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be
|
|
14
|
+
included in all copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
18
|
+
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
20
|
+
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
21
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
22
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
23
|
+
OTHER DEALINGS IN THE SOFTWARE.
|