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 typing import Self

from flask import request
from flasklet import FlaskAppState, FlaskRequestState, TypedFlask, config


class Mode(StrEnum):
	DEVELOPMENT = "development"
	PRODUCTION = "production"


class AppState(FlaskAppState, prefix="MYAPP_"):
	secret_key: str = config.from_env(in_app_config=True, secret=True)
	mode: Mode = config.from_env(default=Mode.DEVELOPMENT)


class RequestState(FlaskRequestState):
	is_htmx: bool

	@classmethod
	def load(cls) -> Self:
		return cls(is_htmx=request.headers.get("hx-request") == "true")


current_app = TypedFlask.current_app(AppState, RequestState)

myapp/__init__.py:

from flasklet import TypedFlask, init_template_helpers, register_blueprints

from myapp.state import AppState, RequestState


def create_app() -> TypedFlask[AppState, RequestState]:
	app = TypedFlask(__name__, app_state=AppState.load_from_env(), request_state=RequestState)
	init_template_helpers(app)
	register_blueprints(app, auto_import_routes=True)
	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.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 application and request 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 two classes you define:

  • app_state: an instance of a FlaskAppState subclass. It is frozen, created once at startup, and doubles as the app's configuration.
  • request_state: a FlaskRequestState subclass. TypedFlask calls its load classmethod at the start of every request to create a fresh, mutable instance.

Both base classes turn subclasses into keyword-only dataclasses. The type checker reports an error at TypedFlask(...) if the request state class has no load, or if load returns something else. The state types are inferred from the constructor arguments, so app is a TypedFlask[AppState, RequestState]. Omit request_state if the app needs no request state. Other Flask constructor options are passed as keywords, and Flask's g keeps working for extensions.

Application state and configuration

FlaskAppState is also the app's configuration. Declare fields with the helpers in flasklet.config:

from pathlib import Path


class AppState(FlaskAppState, prefix="MYAPP_"):
	secret_key: str = config.from_env(in_app_config=True, secret=True)
	mode: Mode = config.from_env(default=Mode.DEVELOPMENT)
	data_dir: Path | None = config.from_env(default=None)
	base_dir: Path = config.derived(lambda s: (s.data_dir or Path.cwd()).resolve())
	session_cookie_samesite: str = config.constant(default="Lax", in_app_config=True)


app = TypedFlask(__name__, app_state=AppState.load_from_env())
  • config.from_env binds a field to an environment variable.
  • config.derived computes a field from the others, once, in declaration order.
  • config.constant is a fixed value; a subclass may redeclare it.
  • in_app_config=True also copies the value into app.config when the app is created, under the uppercased field name: secret_key becomes SECRET_KEY. Use it for keys that Flask and extensions read, and name the field after the key they expect. Your own code reads app_state, never app.config.
  • secret=True leaves the value out of the repr.

load_from_env() reads each from_env field from its variable: the class prefix plus the uppercased field name, or the name passed to from_env. load_from_env(prefix=...) adds a prefix in front of the class prefix. A field without a default is required.

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.

Because you create the instance, a test or another deployment can build a different one. dataclasses.replace gives a changed copy, with derived fields computed again. A subclass adds fields, for example Flask keys only tests need:

class TestAppState(AppState):
	testing: bool = config.constant(default=True, in_app_config=True)


state = replace(TestAppState(secret_key="test"), mode=Mode.PRODUCTION)
app = TypedFlask(__name__, app_state=state)

Using the app from other modules

Flask's current_app is typed as a plain Flask, so it knows nothing about your state. Create a typed reference once, next to your state classes, as in myapp/state.py above, and import it from there. Creating the reference does not need an app. Using it needs an active application context, and checks that the active app's state types match (subclasses are accepted).

Common Flask patterns

Per-request values. In Flask you set attributes on g in a before_request hook and read them with g.get("name", default). With flasklet, load builds the whole request state at once:

# Flask
@app.before_request
def load_user() -> None:
	g.user_id = authenticate(request)
	g.is_htmx = request.headers.get("hx-request") == "true"


user_id = g.get("user_id")  # Any


# flasklet
class RequestState(FlaskRequestState):
	user_id: int | None
	is_htmx: bool
	error_status: int | None = None

	@classmethod
	def load(cls) -> Self:
		return cls(
			user_id=authenticate(request),
			is_htmx=request.headers.get("hx-request") == "true",
		)


user_id = current_app.request_state.user_id  # int | None

Fields need no placeholder defaults, and a forgotten field is a type error. Give defaults only to fields that code changes later in the request, such as error_status.

Configuration values. In Flask you read current_app.config["KEY"]. With flasklet, the value is a typed field of AppState, converted to its real type (enums, paths, numbers):

# Flask
if current_app.config["ENVIRONMENT"] == "production":
	...

# flasklet
if current_app.app_state.mode is Mode.PRODUCTION:
	...

Timing and cleanup. RequestState.load runs at the start of each request, so started_ns=time.time_ns() replaces a before_request hook that sets g.start. request_state stays available in the teardown callbacks, so resources opened for a request can be closed there:

@app.teardown_request
def close_connection(_exc: BaseException | None) -> None:
	if (conn := current_app.request_state.connection) is not None:
		conn.close()

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.

Lifetime

The TypedFlask constructor copies the app state's in_app_config fields into app.config before any extension is initialized, so extensions see them.

RequestState.load is registered in the constructor, so it runs before every other before_request function, including those of extensions and blueprints. Its result is stored on g. The state is then available in all later before_request functions, views, error handlers, after_request, teardown_request, and teardown_appcontext. URL value preprocessors run before before_request, so they cannot read it.

Outside a request, such as in CLI commands and bare app.app_context() blocks, reading request_state raises RuntimeError. app.test_request_context() does not run before_request either; call app.preprocess_request() inside it to load the state:

with app.test_request_context("/?user=3"):
	app.preprocess_request()
	assert app.request_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. load still runs for every request, so each request sees fresh state. After the request, the outer context keeps the state of the last request.

If RequestState.load raises, the request fails like any other unhandled exception. Error handlers that read request_state then raise as well.

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.

Pass auto_import_routes=True to import modules next to each discovered blueprint and in its routes/ directory before registration. This is opt-in because importing a module runs its top-level code. The quick start uses this option.

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 a delivery type to Flask's ordinary flash category. 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

init_template_helpers(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 2.0.0

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 2.0.0
File Size Uploaded
flasklet-2.0.0.tar.gz 17.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for flasklet 2.0.0
File Interpreter ABI Platform
flasklet-2.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 38.6 kB

Release files / flasklet-2.0.0.tar.gz

Download URL flasklet-2.0.0.tar.gz
Size 17.4 kB
Tags Source
SHA-256 checksum
How to use checksums
f0242529e1a7b77818799009343d3790cd1c5c335869d6f3749e3c71c8d8287a
BLAKE2b-256 checksum
How to use checksums
195b02d0e72397ec5020dbcba411424c5dd6f468a626e98ecc84b034cfa9056f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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-2.0.0-py3-none-any.whl

Download URL flasklet-2.0.0-py3-none-any.whl
Size 21.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7653d5ef9899555a35c189442b9e0ef0dae774118ec36b0c826d935186674422
BLAKE2b-256 checksum
How to use checksums
807cfa495bac4e555f2a8e051bcf925210d482a17047a944e6e22549449bb832
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.12.19 {"installer":{"name":"uv","version":"0.12.19","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

3.0.1

2 release files

3.0.0

2 release files

2.0.1

2 release files

This release

2.0.0 This release

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