Metadata-Version: 2.3
Name: flasklet
Version: 3.0.0
Summary: A few nice things for Flask
Author: Marcel Kröker
Author-email: Marcel Kröker <kroeker.marcel@gmail.com>
License: MIT License
         
         Copyright (c) 2026 mkrd
         
         Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
         associated documentation files (the "Software"), to deal in the Software without restriction, including
         without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
         copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
         following conditions:
         
         The above copyright notice and this permission notice shall be included in all copies or substantial
         portions of the Software.
         
         THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
         LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
         EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
         IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
         USE OR OTHER DEALINGS IN THE SOFTWARE.
Classifier: Programming Language :: Python :: 3
Classifier: Operating System :: OS Independent
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python
Classifier: Framework :: Flask
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Dist: flask>=3.1
Requires-Dist: jinja2>=3.1
Requires-Dist: markupsafe>=2.1
Requires-Python: >=3.13, <3.15
Description-Content-Type: text/markdown

# 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:

```text
myapp/
  __init__.py
  state.py
  admin/
    __init__.py
    routes.py
  templates/
    index.html
    components.html
    partial.html
```

`myapp/state.py`:

```python
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`:

```python
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`:

```python
from flask import Blueprint

bp = Blueprint("admin", __name__)
```

`myapp/admin/routes.py`:

```python
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`:

```jinja
{{ 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`:

```jinja
{% macro badge(value) %}<strong>{{ value }}</strong>{% endmacro %}
{% block notice %}<p>{{ text }}</p>{% endblock %}
```

`myapp/templates/partial.html`:

```jinja
<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

```python
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`:

```python
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

```python
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:

```python
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](examples/typed_app.py).

## 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.
