flasklet 0.1.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.
flasklet/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from flasklet.blueprints import register_blueprints
2
+ from flasklet.flashing import FlashMessage, parse_flashed_messages
3
+ from flasklet.imports import import_routes
4
+
5
+ __all__ = [
6
+ "FlashMessage",
7
+ "import_routes",
8
+ "parse_flashed_messages",
9
+ "register_blueprints",
10
+ ]
flasklet/blueprints.py ADDED
@@ -0,0 +1,74 @@
1
+ import time
2
+ from importlib import import_module
3
+ from pathlib import Path
4
+ from pkgutil import iter_modules
5
+ from types import ModuleType
6
+ from typing import Any
7
+
8
+ from flask import Blueprint, Flask
9
+
10
+ BlueprintTree = tuple[Blueprint, list[Any]]
11
+
12
+
13
+ def _find_blueprints(module: ModuleType) -> list[BlueprintTree]:
14
+ """
15
+ Recursively find blueprints in the given module and its sub-modules.
16
+ """
17
+ if module.__file__ is None: # path of the __init__.py file
18
+ msg = f"Module {module.__name__} does not have an __init__.py file"
19
+ raise ValueError(msg)
20
+
21
+ found_blueprints: list[BlueprintTree] = []
22
+ for _, sub_package_name, is_package in iter_modules([Path(module.__file__).parent]):
23
+ if not is_package:
24
+ continue
25
+
26
+ sub_module = import_module(f"{module.__name__}.{sub_package_name}")
27
+ sub_blueprint = getattr(sub_module, "bp", None)
28
+
29
+ if not isinstance(sub_blueprint, Blueprint):
30
+ continue
31
+
32
+ found_blueprints += [(sub_blueprint, _find_blueprints(sub_module))]
33
+
34
+ return found_blueprints
35
+
36
+
37
+ def _register_blueprints(blueprint: Blueprint, blueprint_trees: list[BlueprintTree]) -> None:
38
+ """
39
+ Recursively register blueprints on the given root (app or blueprint), using a
40
+ depth-first traversal.
41
+ """
42
+
43
+ # Avoid registering the same blueprint multiple times
44
+ if blueprint._got_registered_once: # noqa: SLF001
45
+ return
46
+
47
+ for sub_bp, sub_trees in blueprint_trees:
48
+ _register_blueprints(sub_bp, sub_trees) # Depth first
49
+
50
+ blueprint.register_blueprint(sub_bp)
51
+ print(f"๐Ÿ“˜ {sub_bp.name} registered on {blueprint.name}")
52
+
53
+
54
+ def register_blueprints(app: Flask) -> None:
55
+ """
56
+ Find and register all blueprints starting from the root module.
57
+
58
+ Recursively walks the sub-packages of `app`'s import module. Any sub-package
59
+ exposing a module-level `bp: Blueprint` attribute is registered on its parent
60
+ package's blueprint (or on `app` itself for top-level packages), depth-first.
61
+ Sub-packages without a `bp` attribute are skipped, along with their children.
62
+ """
63
+ t0 = time.time()
64
+
65
+ print("๐Ÿ“˜ Registering Blueprints ...")
66
+
67
+ finded_blueprints = _find_blueprints(import_module(app.name))
68
+ for blueprint, sub_trees in finded_blueprints:
69
+ _register_blueprints(blueprint, sub_trees) # Depth first
70
+
71
+ app.register_blueprint(blueprint)
72
+ print(f"๐Ÿ“˜ {blueprint.name} registered on {app.name}")
73
+
74
+ print(f"๐Ÿ“˜ done in {time.time() - t0:.3f}s")
flasklet/flashing.py ADDED
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+
3
+ from flask import get_flashed_messages
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class FlashMessage:
8
+ message: str
9
+ category: str
10
+ type: str
11
+
12
+
13
+ def parse_flashed_messages() -> list[FlashMessage]:
14
+ """
15
+ From Flask's "get_flashed_messages", parse the messages into a
16
+ list of FlashMessage objects.
17
+
18
+ The message type is derived from the flash category: a category containing
19
+ "toast", "modal", or "oob_swap" sets `type` accordingly (and that keyword is
20
+ removed from `category`); otherwise `type` defaults to "message".
21
+ """
22
+
23
+ messages = [x for x in get_flashed_messages(with_categories=True) if isinstance(x, tuple)]
24
+
25
+ res = []
26
+ for category, message in messages:
27
+ message_type = "message"
28
+ if "toast" in category:
29
+ message_type = "toast"
30
+ elif "modal" in category:
31
+ message_type = "modal"
32
+ elif "oob_swap" in category:
33
+ message_type = "oob_swap"
34
+
35
+ category_no_type = " ".join(c for c in category.split(" ") if c != message_type)
36
+ res.append(FlashMessage(message, category_no_type, message_type))
37
+ return res
flasklet/imports.py ADDED
@@ -0,0 +1,29 @@
1
+ import importlib
2
+ from pathlib import Path
3
+
4
+
5
+ def import_routes(file: str, module_name: str) -> None:
6
+ """
7
+ Call with: `import_routes(__file__, __name__)`
8
+
9
+ Imports all python files in:
10
+ - All .py files in the `./routes` directory relative to the calling file
11
+ - All .py files in the current directory relative to the calling file
12
+ """
13
+
14
+ # Import all routes in the ./routes directory
15
+
16
+ if (routes_dir := Path(file).parent / "routes").is_dir():
17
+ for path in routes_dir.iterdir():
18
+ if not path.is_file() or path.suffix != ".py" or path.stem == "__init__":
19
+ continue
20
+
21
+ importlib.import_module(f"{module_name}.routes.{path.stem}")
22
+
23
+ # Import all routes in the current directory
24
+
25
+ for path in Path(file).parent.iterdir():
26
+ if not path.is_file() or path.suffix != ".py" or path.stem == "__init__":
27
+ continue
28
+
29
+ importlib.import_module(f"{module_name}.{path.stem}")
flasklet/py.typed ADDED
File without changes
@@ -0,0 +1,22 @@
1
+ from flasklet.render.block import render_block
2
+ from flasklet.render.jinja import (
3
+ HighlightBlockExtension,
4
+ IncludeRawExtension,
5
+ JinjaTemplateMetadata,
6
+ get_template_metadata,
7
+ load_jinja_env,
8
+ )
9
+ from flasklet.render.macro import render_macro, render_macro_unsafe
10
+ from flasklet.render.partial import render_partial
11
+
12
+ __all__ = [
13
+ "HighlightBlockExtension",
14
+ "IncludeRawExtension",
15
+ "JinjaTemplateMetadata",
16
+ "get_template_metadata",
17
+ "load_jinja_env",
18
+ "render_block",
19
+ "render_macro",
20
+ "render_macro_unsafe",
21
+ "render_partial",
22
+ ]
@@ -0,0 +1,60 @@
1
+ from typing import Any
2
+
3
+ from markupsafe import Markup
4
+
5
+ from flasklet.render.jinja import get_template_metadata
6
+
7
+
8
+ def render_block(
9
+ template_name: str,
10
+ block_name: str,
11
+ **context: dict[str, Any],
12
+ ) -> str:
13
+ """
14
+ Render a specific block from a given template.
15
+
16
+ This function extracts and renders a named block from a Jinja2 template,
17
+ similar to how template inheritance works, but without requiring extension.
18
+
19
+ Raises:
20
+ ValueError: If the specified block is not found in the template
21
+
22
+ Example:
23
+ Template file "components/card.html":
24
+ ```
25
+ {% block header %}
26
+ <h2>{{ title }}</h2>
27
+ {% endblock %}
28
+
29
+ {% block content %}
30
+ <p>{{ description }}</p>
31
+ {% endblock %}
32
+ ```
33
+
34
+ Usage in Python:
35
+ ```python
36
+ rendered = render_block(
37
+ "components/card.html",
38
+ "header",
39
+ title="Welcome"
40
+ )
41
+ # Returns: <h2>Welcome</h2>
42
+ ```
43
+
44
+ Usage in Jinja template:
45
+ ```
46
+ {{ render_block("components/card.html", "content", description="Hello World") }}
47
+ ```
48
+ """
49
+
50
+ template = get_template_metadata(template_name).template
51
+ rendered_blocks = template.blocks.get(block_name)
52
+
53
+ if not rendered_blocks:
54
+ msg = f"Block {block_name} not found in template {template_name}"
55
+ raise ValueError(msg)
56
+
57
+ # Render the block
58
+ rendered_content = "".join(rendered_blocks(template.new_context(context)))
59
+
60
+ return Markup(rendered_content) # noqa: S704
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING, override
5
+
6
+ from flask import current_app
7
+ from jinja2 import BaseLoader, Environment, Template, nodes
8
+ from jinja2.ext import Extension
9
+ from markupsafe import Markup
10
+ from pygments import highlight
11
+ from pygments.formatters import HtmlFormatter
12
+ from pygments.lexers import get_lexer_by_name
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ from jinja2.parser import Parser
18
+
19
+ MSG_JINJA_ENV_MUST_HAVE_LOADER = "Jinja environment must have a loader"
20
+
21
+
22
+ ########################################################################################
23
+ #### MARK: Environment and templates
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class JinjaTemplateMetadata:
28
+ template: Template
29
+ name: str
30
+ source: str
31
+ ast: nodes.Template
32
+
33
+
34
+ def load_jinja_env() -> tuple[Environment, BaseLoader]:
35
+ env = current_app.jinja_env
36
+ if env.loader is None:
37
+ raise ValueError(MSG_JINJA_ENV_MUST_HAVE_LOADER)
38
+ return env, env.loader
39
+
40
+
41
+ def get_template_metadata(template_name_or_list: str) -> JinjaTemplateMetadata:
42
+ env, env_loader = load_jinja_env()
43
+
44
+ template = env.get_or_select_template(template_name_or_list)
45
+
46
+ if template.name is None:
47
+ msg = "Template name not found"
48
+ raise ValueError(msg)
49
+
50
+ template_source, _, _ = env_loader.get_source(env, template.name)
51
+ ast = env.parse(template_source)
52
+
53
+ return JinjaTemplateMetadata(
54
+ template=template,
55
+ name=template.name,
56
+ source=template_source,
57
+ ast=ast,
58
+ )
59
+
60
+
61
+ ########################################################################################
62
+ #### MARK: Include raw file without Jinja processing extension
63
+
64
+
65
+ class IncludeRawExtension(Extension):
66
+ tags = {"include_raw"} # noqa: RUF012
67
+
68
+ @override
69
+ def parse(self, parser: Parser) -> nodes.Output:
70
+ lineno = parser.stream.expect("name:include_raw").lineno
71
+ template = parser.parse_expression()
72
+ result = self.call_method("_render", [template], lineno=lineno)
73
+ return nodes.Output([result], lineno=lineno)
74
+
75
+ def _render(self, filename: str) -> Markup:
76
+ if not self.environment.loader:
77
+ raise ValueError(MSG_JINJA_ENV_MUST_HAVE_LOADER)
78
+
79
+ template_source, _, _ = self.environment.loader.get_source(self.environment, filename)
80
+ return Markup(template_source) # noqa: S704
81
+
82
+
83
+ ########################################################################################
84
+ #### MARK: Highlight code blocks extension
85
+
86
+
87
+ class HighlightBlockExtension(Extension):
88
+ tags = {"highlight"} # noqa: RUF012
89
+
90
+ @override
91
+ def parse(self, parser: Parser) -> nodes.Node:
92
+ # Parse the block
93
+ lineno = next(parser.stream).lineno
94
+ args = [parser.parse_expression()] # Language argument
95
+ body = parser.parse_statements(("name:endhighlight",), drop_needle=True)
96
+ call_block = nodes.CallBlock(self.call_method("_render", args), [], [], body)
97
+ return call_block.set_lineno(lineno)
98
+
99
+ def _render(self, language: str, caller: Callable[[], str]) -> Markup:
100
+ def unindent(code: str) -> str:
101
+ lines = code.splitlines()
102
+
103
+ while all(x.startswith(" ") for x in lines if x != "") or all(
104
+ x.startswith("\t") for x in lines if x != ""
105
+ ):
106
+ lines = [x[1:] if x.startswith((" ", "\t")) else x for x in lines]
107
+
108
+ return "\n".join(lines)
109
+
110
+ # Get the block content
111
+
112
+ code = unindent(caller())
113
+
114
+ # Apply Pygments highlighting
115
+
116
+ lexer = get_lexer_by_name(language)
117
+ formatter = HtmlFormatter(style="monokai")
118
+ highlighted_code = highlight(code, lexer, formatter)
119
+
120
+ # Adjust CSS
121
+
122
+ css = formatter.get_style_defs(".highlight")
123
+ css += """
124
+ pre {
125
+ background-color: unset; /* Remove host stylesheet's background color */
126
+ color: unset; /* Remove host stylesheet's text color */
127
+ margin: 0;
128
+ }
129
+ .highlight {
130
+ border-radius: 10px;
131
+ padding: 14px;
132
+ margin: 14px 0;
133
+ }
134
+ """
135
+
136
+ return Markup(f"<style>{css}</style>{highlighted_code}") # noqa: S704
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import TYPE_CHECKING, Any, cast
5
+
6
+ from jinja2 import Template
7
+ from jinja2.nodes import Macro, Name, Output, TemplateData
8
+ from markupsafe import Markup
9
+
10
+ if TYPE_CHECKING:
11
+ from jinja2.nodes import Expr
12
+
13
+ from flasklet.render.jinja import load_jinja_env
14
+
15
+
16
+ def _extract_macro_source(template_source: str, macro_name: str) -> str:
17
+ """
18
+ Extracts the source code of a macro from the template source code.
19
+ """
20
+
21
+ # Pattern to match the macro definition, including parameters and content
22
+ macro_pattern = re.compile(
23
+ r"{%\s*macro\s+"
24
+ + re.escape(macro_name)
25
+ + r"\s*\((.*?)\)\s*%}(.*?)"
26
+ + r"{%\s*endmacro\s*%}",
27
+ re.DOTALL,
28
+ )
29
+
30
+ if not (match := macro_pattern.search(template_source)):
31
+ msg = f"Macro '{macro_name}' not found in template."
32
+ raise ValueError(msg)
33
+
34
+ # Return macro source code, including the {% macro %} and {% endmacro %} lines
35
+ return match.group(0)
36
+
37
+
38
+ def _flatten_macro_source_nodes(nodes: list[Expr]) -> str:
39
+ """Flattens the nodes of a macro's body into a single string of source code."""
40
+ res = ""
41
+ for node in nodes:
42
+ if isinstance(node, TemplateData):
43
+ res += node.data
44
+ elif isinstance(node, Name):
45
+ res += r"{{ " + node.name + r" }}"
46
+ else:
47
+ msg = f"Unsupported node type: {type(node)}"
48
+ raise TypeError(msg)
49
+ return res
50
+
51
+
52
+ def render_macro_unsafe(template_name: str, macro_name: str, **kwargs: dict[str, Any]) -> str:
53
+ """
54
+ Render a specific macro from a given template.
55
+ It returns unescaped HTML, meaning that it is not safe to use with user input.
56
+ """
57
+ env, env_loader = load_jinja_env()
58
+
59
+ template_source, _, _ = env_loader.get_source(env, template_name)
60
+ parsed_template = env.parse(template_source)
61
+ macros = parsed_template.find_all(Macro)
62
+
63
+ if not (macro := next((m for m in macros if m.name == macro_name), None)):
64
+ msg = f"Macro {macro_name} not found in template {template_name}"
65
+ raise ValueError(msg)
66
+
67
+ macro_source = _flatten_macro_source_nodes(cast("Output", macro.body[0]).nodes)
68
+
69
+ return Template(macro_source).render(**kwargs, g=env.globals)
70
+
71
+
72
+ def render_macro(template_name: str, macro_name: str, **kwargs: dict[str, Any]) -> str:
73
+ """
74
+ Render a specific macro from a given template.
75
+ """
76
+ env, env_loader = load_jinja_env()
77
+
78
+ # Load the template source code
79
+ template_source, _, _ = env_loader.get_source(env, template_name)
80
+
81
+ # Extract the macro source code
82
+ macro_source = _extract_macro_source(template_source, macro_name)
83
+
84
+ # Create a new template that includes the macro and calls it
85
+ macro_call_template_source = macro_source + "\n{{ " + macro_name + "()" + " }}"
86
+
87
+ # Compile the new template
88
+ macro_template = env.from_string(macro_call_template_source)
89
+
90
+ # Render the template with the provided arguments
91
+ rendered = macro_template.render(**kwargs)
92
+
93
+ # Return the rendered macro content
94
+ return Markup(rendered) # noqa: S704
@@ -0,0 +1,16 @@
1
+ from typing import Any
2
+
3
+ from flask import render_template
4
+ from markupsafe import Markup
5
+
6
+
7
+ def render_partial(template_name: str, **context: dict[str, Any]) -> str:
8
+ """
9
+ Render a template to be used as a partial within another template.
10
+
11
+ Example usage:
12
+ In a Jinja template, you can use:
13
+ {{ render_partial("my_template.html", var1=value1, var2=value2) }}
14
+ """
15
+
16
+ return Markup(render_template(template_name, **context)) # noqa: S704
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.3
2
+ Name: flasklet
3
+ Version: 0.1.0
4
+ Summary: A few nice things for Flask
5
+ Author: Marcel Krรถker
6
+ Requires-Dist: flask>=3.1
7
+ Requires-Dist: jinja2>=3.1
8
+ Requires-Dist: markupsafe>=2.1
9
+ Requires-Dist: pygments>=2.18
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # flasklet
14
+
@@ -0,0 +1,13 @@
1
+ flasklet/__init__.py,sha256=Iuk8Qs4kyaihC8WPDSG3wePKdjwW70qeKTNFnzXpPLk,263
2
+ flasklet/blueprints.py,sha256=CCOYii8QfoT5-1nRKA9DUSRwf191weaLXHNr397612Y,2329
3
+ flasklet/flashing.py,sha256=DA9Fjaw0ILRiYFQYyIcrhaxrzIBOSFDUUoViaAPaDZk,1050
4
+ flasklet/imports.py,sha256=9PGOFAIgDQXItq6RCB4ZG5pBUGIJ5789M5FfqXXAy1Q,868
5
+ flasklet/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ flasklet/render/__init__.py,sha256=trVaqTVg5xFiuw4-V_vpA5ORARwOHcCCwwKJyr3ckxo,532
7
+ flasklet/render/block.py,sha256=uLfMxH6TEKEzWgN5OecdwNiRQxYX3NvwwmPDR1NlI0o,1341
8
+ flasklet/render/jinja.py,sha256=pjPhE2CUTnH6fu-Q3JB0ShQ_xlW8dEaZjxC_rWUdqy4,3840
9
+ flasklet/render/macro.py,sha256=idWxt5WWE1TPbnCQrJ3F47LG0TgoUNyzRnodAurPqs0,2852
10
+ flasklet/render/partial.py,sha256=swJ7nzmnBPaZjSgKV0jXjK3fkqMRSVJrBz6oEp7f2ho,436
11
+ flasklet-0.1.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
12
+ flasklet-0.1.0.dist-info/METADATA,sha256=fmjeouAHNyb0uT6q5l_YBeC0aygmPC4ZS58CB3LOVbA,303
13
+ flasklet-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.32
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any