Metadata-Version: 2.4
Name: splent_framework
Version: 1.21.0
Summary: SPLENT-FRAMEWORK is a set of libraries for agile product development within SPLENT.
Author-email: DiversoLab <diversolab@us.es>
License-Expression: LGPL-3.0-or-later
Project-URL: Homepage, https://github.com/diverso-lab/splent_framework
Project-URL: Issues, https://github.com/diverso-lab/splent_framework/issues
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: python-dotenv==1.0.1
Requires-Dist: sqlalchemy==2.0.37
Requires-Dist: flask==3.1.0
Requires-Dist: flask_sqlalchemy==3.1.1
Requires-Dist: flask_migrate==4.1.0
Requires-Dist: flask_session==0.8.0
Requires-Dist: flask_mail==0.10.0
Requires-Dist: flask_login==0.6.3
Requires-Dist: flask_wtf==1.2.2
Requires-Dist: redis==5.2.1
Requires-Dist: pymysql==1.1.1
Requires-Dist: psutil==6.1.1
Requires-Dist: pytz==2024.2
Requires-Dist: flask-babel==4.0.0
Requires-Dist: markdown-it-py[linkify]>=3.0
Requires-Dist: mdit-py-plugins>=0.4
Requires-Dist: nh3>=0.3
Requires-Dist: pygments>=2.17
Provides-Extra: rq
Requires-Dist: rq<3,>=2.0; extra == "rq"
Provides-Extra: dev
Requires-Dist: pytest>=8.3; extra == "dev"
Requires-Dist: pytest-cov>=6.0; extra == "dev"
Requires-Dist: Faker>=33.0; extra == "dev"
Requires-Dist: rq<3,>=2.0; extra == "dev"
Requires-Dist: fakeredis[lua]>=2.20; extra == "dev"
Dynamic: license-file

# SPLENT Framework

Core library for building modular, feature-driven Flask applications using Software Product Line principles.

## Quick start

```python
from splent_framework import create_splent_app

app = create_splent_app(__name__, config_name="development")
```

## Configuration order

A key can be set by four layers, and the stronger one wins whatever order
the code runs in: the framework's defaults, then a feature's
`inject_config`, then the keys the product's `config.py` sets itself, then
the environment (a variable named like the key, and the operator settings
below). Before, a feature's default replaced what the product's `config.py`
chose, because the features load after it. Every key is traced in
`app.extensions["splent_config_trace"]` with its `layer`, and a value that
held against a weaker one carries `"action": "kept"` and the value it
overruled. The environment winning over the product is accepted but marked
as drift: the entry carries `"drift": true` and, in `overruled_source` and
`overruled_value`, what the product had chosen. A key the framework's
config reads from a variable of the same name (`TIMEZONE`, the `BABEL_`
locales, the `TASKS_` settings and the operator settings below) is traced
as the environment when that variable chose it, and otherwise keeps the
value the product's `config.py` gives it. `product:config` shows the
trace.

In development `product:env --merge` writes every feature's declared
default into the product's `.env`, so there the environment wins over
`config.py` for any key a feature declares. A product decides a feature's
key in its own `[tool.splent.config]`, which that merge puts above the
feature's; `config.py` is for keys no feature reads from the environment.

## Production defaults

A product built with `create_splent_app` gets these without any code. Each
one is a variable in the deployment's `.env`. The header and asset settings
can also be fixed in the product's `config.py`, which then wins over the
`.env`. For `SPLENT_COOKIE_SECURE`, the cookie variables and
`MAX_CONTENT_LENGTH` the `.env` wins over the product and over any
feature's `inject_config`, because only the deployment knows whether it
runs over HTTPS and how large its uploads are. The factory applies them
again after the features load.

| Variable | Default | What it does |
| --- | --- | --- |
| `SPLENT_TRUSTED_PROXIES` | `0` | Number of reverse proxies in front of the app. Set it to `1` behind a TLS proxy (nginx, Caddy, Traefik) so the app sees the client address and the `https` the proxy was reached on, builds `https://` links and sends HSTS. Leave it at `0` when the app port is reachable without the proxy. |
| `SPLENT_COOKIE_SECURE` | `auto` | Session and remember-me cookies are always HttpOnly. Left unset, every cookie is Secure when the request arrived over HTTPS, directly or through a trusted proxy, so behind TLS set `SPLENT_TRUSTED_PROXIES`. `true` forces Secure, `false` never sets it. |
| `SESSION_COOKIE_SAMESITE` | `Lax` | `Lax`, `Strict` or `None`, in any case. `None` is for a client on another site that signs in with the cookie, and it needs Secure: the app warns at start when the cookie may go out without it. `None` also lets any site post with the user's session, so every view exempt from CSRF that takes the session needs a defence of its own first; the app names each one in the log, at start and when a feature exempts it later. Anything else stops the app at start. `REMEMBER_COOKIE_SAMESITE` follows it unless set. |
| `SESSION_COOKIE_SECURE` | unset | `true` marks the session cookie Secure on every request, even under `auto`. `REMEMBER_COOKIE_SECURE` follows it unless set. `SPLENT_COOKIE_SECURE=false` still wins for the session cookie. |
| `MAX_CONTENT_LENGTH` | `16M` | Largest request body. Bigger ones get 413, as JSON for a client that sent or asked for JSON. Accepts bytes or `K`, `M`, `G`, and `0` for no limit. |
| `SPLENT_SECURITY_HEADERS` | `true` | `nosniff`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy` and `Cache-Control: no-store` for signed-in users. `false` leaves them all to the proxy. |
| `SPLENT_FRAME_OPTIONS` | `SAMEORIGIN` | `DENY`, or `off` to send nothing. |
| `SPLENT_REFERRER_POLICY` | `strict-origin-when-cross-origin` | Any Referrer-Policy value. |
| `SPLENT_PERMISSIONS_POLICY` | camera, microphone, geolocation, payment and USB off | The whole Permissions-Policy value. Embedded maps lose their location button under the default; allow geolocation here if a product needs it. |
| `SPLENT_NO_STORE_SIGNED_IN` | `true` | `Cache-Control: no-store` on pages for a signed-in user. |
| `SPLENT_HSTS_MAX_AGE` | `31536000` | Sent only over HTTPS. `0` turns HSTS off. |
| `SPLENT_HSTS_INCLUDE_SUBDOMAINS`, `SPLENT_HSTS_PRELOAD` | `false` | The optional HSTS directives. |
| `SPLENT_CONTENT_SECURITY_POLICY` | none | A Content-Security-Policy to send. Opt in, since existing templates use inline scripts. |
| `SPLENT_CSP_REPORT_ONLY` | `false` | Send that policy as `Content-Security-Policy-Report-Only` to try it first. |
| `SPLENT_ASSET_MAX_AGE` | `31536000` | How long a versioned asset (`?v=<mtime>`) may be cached. `0` turns it off. |
| `SPLENT_ASSET_VERSIONING` | `true` | Add `?v=<mtime>` to the asset URLs `url_for` builds. |

Database connections are pinged when they leave the pool and recycled after
an hour (`SQLALCHEMY_ENGINE_OPTIONS`), so a database restart or MariaDB's
eight hour `wait_timeout` never reaches a visitor as a 500. A product changes
them in its `config.py`.

## Health probes

`GET /healthz` answers `{"status": "ok"}` while the process is alive, and
`GET /readyz` answers the same once every readiness check passes, or 503
with `{"status": "unavailable"}`. Both are answered by a middleware around
the whole app, so a probe opens no session, sets no cookie, runs no
feature's hook and is never subject to CSRF. Which check failed, and why,
goes to the log and not into the response.

The framework checks the database (`SELECT 1`), the session store when
it answers a ping (the Redis of splent_feature_session_redis: with it down
every page fails), and the task executor checks its Redis in `rq` mode. A
feature adds its own service from `init_feature`:

```python
from splent_framework.health import register_readiness_check

register_readiness_check("search", lambda: client.ping(), app)
```

| Variable | Default | What it does |
| --- | --- | --- |
| `SPLENT_HEALTH_PATH` | `/healthz` | Where the liveness probe answers. `off` turns it off. |
| `SPLENT_READY_PATH` | `/readyz` | Where the readiness probe answers. `off` turns it off. |
| `SPLENT_READY_TIMEOUT` | `3` | Seconds the checks may take before the probe answers 503. While a slow check is still running, the next probes answer 503 at once. |

## Background tasks

`splent_framework.tasks` runs work outside the request: a feature marks a
function at the top level of a module with `@task` and enqueues it with
the ids it needs.

```python
from splent_framework.tasks import enqueue, get_run, task

@task(timeout=60, retries=2)
def transform_uvl(hubfile_id):
    ...

run = enqueue(transform_uvl, {"hubfile_id": hubfile.id}, key=hubfile.id)
get_run(run.id).status   # waiting, queued, running, succeeded, failed, timed_out, dropped
```

A task enqueued after a write that is not committed yet waits for the
commit, and is dropped if the transaction, or the savepoint it was
enqueued in, is rolled back; with nothing pending it starts at once
(`after_commit=True` or `False` decides it instead). Each run gets an app context and a database session of its
own. A `key` merges the run into a run of the same task and key that has
not started yet. A failed attempt is retried up to the task's `retries`
(at most 10), waiting `backoff` seconds (10) and twice as long each time
after. Arguments must be JSON values in every mode, and a task must do no
harm when it runs twice. The mode is read once, when the app is built: a
command or a seeder that needs the work done before it exits calls the
task, which is still a plain function, or `wait()` in thread mode.

| Variable | Default | What it does |
| --- | --- | --- |
| `TASKS_MODE` | `thread` | `sync` runs the task in the caller before `enqueue` returns, which the testing configuration always uses. `thread` runs it in a bounded pool of the web process: lost if the process stops, and a thread cannot be stopped at its timeout, so a slow run is only logged and marked `overdue`. It does not protect against a task that hangs: that task holds its thread for good and what comes after waits behind it, in memory, with a warning in the log at the timeout and every minute after. A product whose tasks read untrusted input runs `rq` in production. `rq` queues it in Redis for `python -m splent_framework.tasks.worker`, which stops an attempt at its timeout and survives a restart of the web; it needs `splent_framework[rq]`. Anything else stops the app at start. |
| `TASKS_THREAD_WORKERS` | `2` | Tasks that run at once in `thread` mode. |
| `TASKS_DEFAULT_TIMEOUT` | `180` | Seconds an attempt may take when its task names none. |
| `TASKS_REDIS_URL` | `REDIS_TASKS_URL`, then `REDIS_URL` | The Redis of the `rq` queue. Empty takes the tasks database splent_feature_redis sets apart (`REDIS_TASKS_URL`), so the queue is not flushed with a cache or the sessions. `/readyz` depends on it in that mode. |
| `TASKS_QUEUE` | the product's name | The queue, so two products on one Redis keep their work apart. |
| `TASKS_RUN_TTL` | `86400` | Seconds Redis keeps the record of an ended run. |

The worker builds the product's app from `SPLENT_APP` in the profile
`SPLENT_ENV` names. `python -m splent_framework.tasks.worker --check` is
for the container's healthcheck: it builds no app and opens no connection,
and exits 0 while the worker's last heartbeat holds. The worker writes
when the next one is due to `TASKS_WORKER_HEARTBEAT_FILE` (by default
`splent-tasks-worker.heartbeat` in the temporary directory) and removes
it when it stops. `--burst` stops when the queue is empty.

## Requirements

- Python 3.13+
- Flask 3.1+
- SQLAlchemy 2.0+

## Documentation

Full docs, tutorials, and architecture reference at **[docs.splent.io](https://docs.splent.io)**

## License

splent_framework is free software under the GNU Lesser General Public
License, version 3 or any later version (LGPL-3.0-or-later). See
`COPYING.LESSER`, and `COPYING` for the GNU General Public License it builds
on. A product built on the framework can carry any license of its own choice.
