Metadata-Version: 2.3
Name: flasklet
Version: 2.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 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`:

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

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

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

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

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

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

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

```python
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](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.

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.
