Skip to main content

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, and token: str is required. Use constant(...) as the value for options. A subclass may redeclare a constant, with its annotation.
  • from_env=True reads 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=False leaves out the class prefix, for variables whose name another program fixes, such as AWS_REGION.
  • default_factory=list gives each instance a new empty list. Use it instead of default=[], whose item type pyrefly cannot infer.
  • @app_scope computes a value from the others once, on first access. With eager=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 raises RuntimeError, 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 into app.config when the app is created, under the uppercased name: secret_key becomes SECRET_KEY. Use it for keys that Flask and extensions read, and name the member after the key they expect. Your own code reads app.state, never app.config.
  • secret=True on 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)

Source distribution for flasklet 3.0.1
File Size Uploaded
flasklet-3.0.1.tar.gz 18.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for flasklet 3.0.1
File Interpreter ABI Platform
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}

Release history Release notifications | RSS feed

This release

3.0.1 This release

2 release files

3.0.0

2 release files

2.0.1

2 release files

2.0.0

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.2.0

2 release files

1.1.1

2 release files

1.0.0

2 release files

0.3.2

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page