flasklet
A few nice things for Flask: typed application and request state, flash messages with delivery types, convention-based blueprint discovery, route imports, and template rendering helpers.
Quick start
Install with uv add flasklet. Create these files:
myapp/
__init__.py
state.py
admin/
__init__.py
routes.py
templates/
index.html
components.html
partial.html
myapp/state.py:
from enum import StrEnum
from flask import request
from flasklet import FlaskState, TypedFlask, constant, request_scope
class Mode(StrEnum):
DEVELOPMENT = "development"
PRODUCTION = "production"
class State(FlaskState, prefix="MYAPP_"):
secret_key: str = constant(from_env=True, in_app_config=True, secret=True)
mode: Mode = constant(default=Mode.DEVELOPMENT, from_env=True)
@request_scope
def is_htmx(self) -> bool:
return request.headers.get("hx-request") == "true"
current_app = TypedFlask.current_app(State)
myapp/__init__.py:
from flasklet import TypedFlask, register_blueprints, register_template_globals
from myapp.state import State
def create_app() -> TypedFlask[State]:
app = TypedFlask(__name__, state=State())
register_template_globals(app)
register_blueprints(app)
return app
myapp/admin/__init__.py:
from flask import Blueprint
bp = Blueprint("admin", __name__)
myapp/admin/routes.py:
from flask import Response, redirect, render_template, url_for
from flasklet import flash_message, parse_flashed_messages
from myapp.state import current_app
from . import bp
@bp.get("/")
def index() -> str:
return render_template(
"index.html",
mode=current_app.state.mode.value,
messages=parse_flashed_messages(),
)
@bp.post("/notice")
def notice() -> Response:
flash_message("Saved", type="toast", category="success")
return redirect(url_for("admin.index"))
myapp/templates/index.html:
{{ render_macro("components.html", "badge", value=mode) }}
{{ render_block("components.html", "notice", text="Welcome") }}
{{ render_partial("partial.html", text="Rendered separately") }}
{% for message in messages %}
<p data-type="{{ message.type }}" class="{{ message.category }}">{{ message.text }}</p>
{% endfor %}
<form action="{{ url_for('admin.notice') }}" method="post">
<button type="submit">Flash a message</button>
</form>
myapp/templates/components.html:
{% macro badge(value) %}<strong>{{ value }}</strong>{% endmacro %}
{% block notice %}<p>{{ text }}</p>{% endblock %}
myapp/templates/partial.html:
<aside>{{ text }}</aside>
Run MYAPP_SECRET_KEY=local-example-only uv run flask --app myapp:create_app run,
then open http://127.0.0.1:5000/.
Typed state
Flask gives you untyped places for state: app.config for values set at
startup and flask.g for values of the current request. Both accept any key
and return Any, so typos and wrong types surface at runtime. TypedFlask
replaces them with one FlaskState subclass you define. It declares three
kinds of members:
| Member | Lifetime | Computed | Settable |
|---|---|---|---|
| annotated attribute | the state instance | when the state is created | no |
@app_scope |
the state instance | on first access, or at creation with eager=True |
no |
@request_scope, request_value |
one request | on first access, or at request start with eager=True |
yes |
The state is frozen: only request_scope values can be set, and setting
anything else raises AttributeError. The state type is inferred from the
constructor, so app is a TypedFlask[State]. Other Flask
constructor options are passed as keywords, and Flask's g keeps working for
extensions.
Constants and configuration
from pathlib import Path
from flasklet import FlaskState, TypedFlask, app_scope, constant
class State(FlaskState, prefix="MYAPP_"):
secret_key: str = constant(from_env=True, in_app_config=True, secret=True)
mode: Mode = constant(default=Mode.DEVELOPMENT, from_env=True)
data_dir: Path | None = constant(default=None, from_env="DATA")
session_cookie_samesite: str = constant(default="Lax", in_app_config=True)
admins: list[str] = constant(default_factory=list, from_env=True)
aws_region: str = constant(default="eu-central-1", from_env=True, prefixed=False)
greeting: str = "Hello"
@app_scope
def base_dir(self) -> Path:
return (self.data_dir or Path.cwd()).resolve()
app = TypedFlask(__name__, state=State())
- Annotated attributes are constants, as with dataclass fields:
greeting: str = "Hello"has a default, andtoken: stris required. Useconstant(...)as the value for options. A subclass may redeclare a constant, with its annotation. from_env=Truereads the value from the class prefix plus the uppercased attribute name,MYAPP_MODE, when the state is created; a string names the variable,MYAPP_DATA. The value is converted to the annotated type.prefixed=Falseleaves out the class prefix, for variables whose name another program fixes, such asAWS_REGION.default_factory=listgives each instance a new empty list. Use it instead ofdefault=[], whose item type pyrefly cannot infer.@app_scopecomputes a value from the others once, on first access. Witheager=True, it is computed when the state is created, so errors surface at startup. It may read constants and other app_scope values, but not request_scope values: that raisesRuntimeError, because the value would keep the first request's data for the app's lifetime.in_app_config=True, on a constant or an app_scope value, also copies the value intoapp.configwhen the app is created, under the uppercased name:secret_keybecomesSECRET_KEY. Use it for keys that Flask and extensions read, and name the member after the key they expect. Your own code readsapp.state, neverapp.config.secret=Trueon a constant leaves it out of the repr. The repr shows constants only.
Supported types are str, int, float, bool, Path, Enum subclasses,
Optional[T], list[T], and NewType wrappers. Enum variables match the
string representation of a member's value exactly. For example,
MYAPP_MODE=production selects Mode.PRODUCTION. An invalid value reports the
environment variable and allowed values. Booleans accept only true and
false. Lists are comma-separated; an empty variable gives an empty list. For
Optional[T], an empty variable gives None. Mutable defaults are
deep-copied.
The constructor works like a dataclass's: constants are keyword arguments,
and type checkers check their names and types. A keyword argument for a
from_env constant wins over its variable, so a test sets one with
State(data_dir=tmp_path). A from_env constant without a default, such as
secret_key above, has init=False: it comes only from its variable, so
State() type-checks even though it is required. A test sets one by
redeclaring it without from_env in a subclass. A subclass also adds members,
for example Flask keys only tests need; redeclaring an app_scope value needs
@override under @app_scope:
class TestState(State):
secret_key: str = "test"
testing: bool = constant(default=True, in_app_config=True)
@app_scope
@override
def base_dir(self) -> Path:
return Path("/tmp/test")
app = TypedFlask(__name__, state=TestState(greeting="Hi"))
Request values
class State(FlaskState):
@request_scope(eager=True)
def started_ns(self) -> int:
return time.time_ns()
@request_scope
def user_id(self) -> int | None:
return authenticate(request)
error_status: int | None = request_value(default=None)
trail: list[str] = request_value(default_factory=list)
user_id = current_app.state.user_id # int | None
current_app.state.error_status = 404
A @request_scope method computes the initial value for each request. It
runs on first access, so a request only pays for the values it reads. Use
eager=True for values that must be taken at the start of the request, such
as a start time. request_value declares a value that starts from its
default in each request, deep-copied, or from default_factory; its type is
the annotation. Request values are not constructor arguments. Assigning
replaces a value for the rest of the request.
Using the app from other modules
Flask's current_app is typed as a plain Flask, so it knows nothing about
your state. TypedFlask.current_app(State) creates a typed reference: create
it once, next to your state class, as in myapp/state.py above, and read
current_app.state.X from views, helpers, and hooks. Creating the reference
does not need an app. Using it needs an active application context, and checks
that the active app's state is an instance of the class (subclasses are
accepted). Background threads have no app context, so pass them app.state.
Lifetime
The TypedFlask constructor copies the in_app_config members into
app.config before any extension is initialized, so extensions see them.
TypedFlask starts the request values in preprocess_request, before URL
value preprocessors and every before_request function, including those of
extensions and blueprints. It resets the request values, which are stored on
g, and computes the eager ones. Request values are then available in URL
value preprocessors, before_request functions, views, error handlers,
after_request, and teardown_request. TypedFlask removes them after every
other teardown_request function, so teardown_appcontext cannot read them.
Outside a request, such as in CLI commands and bare app.app_context()
blocks, reading a request value raises RuntimeError. Constants and app_scope
values work anywhere, including in background threads.
app.test_request_context() does not preprocess the request either; call
app.preprocess_request() inside it to load the request values:
with app.test_request_context("/?user=3"):
app.preprocess_request()
assert app.state.user_id == 3
If an app context is already active when a request starts, as in a test that
pushes one around the test client, Flask reuses it and its g. Request
values are still started and removed for every request, so each request sees
fresh values and none remain after it.
Neither teardown_request nor teardown_appcontext runs at application
shutdown. Resources shared by the whole app (connection pools, clients) belong
to their owner and are closed at shutdown. TypedFlask closes nothing
automatically.
See the runnable example.
Blueprint and route discovery
register_blueprints(app) scans subpackages of the app package for a module
attribute named bp. It registers each discovered blueprint on its parent
blueprint or on the app. A subpackage without bp and its children are skipped.
Call it during app creation, before handling requests. The same imported
blueprints can be registered on separate app instances.
By default it also imports the modules next to each discovered blueprint and in
its routes/ directory before registration, so their route decorators run.
Pass auto_import_routes=False to import route modules yourself, for example
with import_routes.
import_routes(__file__, __name__) imports every Python module in the current
package directory and its routes/ directory, in sorted order. Route
decorators run as those modules are imported. It returns None. Use it in a
package's __init__.py when you want explicit route imports instead.
Flash messages
flash_message(text, type="toast", category="success") adds an
application-defined type to Flask's ordinary flash category. category
defaults to "", not Flask's "message". Read messages with parse_flashed_messages();
each result has text, type, and category. Messages from Flask's own
flash() are returned with type="message". Use parse_flashed_messages()
when mixing the two functions.
Template helpers
register_template_globals(app) registers render_block, render_macro,
render_macro_unsafe, and render_partial as Jinja globals. Call it in the
app factory before rendering templates. You can also register individual
functions with app.add_template_global.
render_partial renders a complete template. render_block renders a block
defined in the named template; it does not resolve a block that exists only in
a parent template, and the selected block cannot use super().
render_macro renders only the selected macro. Other top-level expressions in
the source template are not evaluated. Its keyword arguments serve two roles:
arguments with names declared by the macro are passed to it, and all arguments
are available as template context. A misspelled or unused keyword argument
has no effect, so check argument names when output is missing.
render_macro follows the app's Jinja autoescaping. render_macro_unsafe disables
autoescaping and returns an ordinary string. Use it only when the macro values
are trusted HTML. When calling it from a Jinja template, the result needs
|safe to avoid escaping the rendered HTML.
To include a file's source without Jinja processing, import
IncludeRawExtension from flasklet, register it with
app.jinja_env.add_extension(IncludeRawExtension), and use
{% include_raw "path/to/file" %} in a template.
Metadata
Release files for flasklet 3.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| flasklet-3.0.1.tar.gz | 18.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flasklet-3.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.5 kB
Release files / flasklet-3.0.1.tar.gz
| Download URL | flasklet-3.0.1.tar.gz |
|---|---|
| Size | 18.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ac930b31353b1c5eeffb29252f7f47607aa945725a6eaa1b050463d5220deaad
|
|
BLAKE2b-256 checksum How to use checksums |
e273e0d2e75050495c42242e5c2ea187972c00866991fd60aedd1f245f63f49f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.21 {"installer":{"name":"uv","version":"0.12.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / flasklet-3.0.1-py3-none-any.whl
| Download URL | flasklet-3.0.1-py3-none-any.whl |
|---|---|
| Size | 24.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7515a4107a785fbaec13d18292a59e19b7010f2d008a4b72a34c1b83d8198af5
|
|
BLAKE2b-256 checksum How to use checksums |
d5f5d85fe2700abc7aaa0d830d11600d0957ac6531585d05b69ee5231fd36fb3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.21 {"installer":{"name":"uv","version":"0.12.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|