Feature settings

A feature declares the variables it reads, and their defaults, in its own pyproject.toml. A product declares only the ones it decides differently. Neither writes them into a .env.

Table of contents

Why it exists

A product’s docker/.env.example is about infrastructure: where the database is, which port it listens on, what the app is called. Feature settings used to end up in there too, and that had two consequences.

The product carried variables it had no opinion about. A wiki that was perfectly happy with SEARCH_PATH=search still had to state it, so reading the file told you nothing about what that product had actually decided.

And a feature with no container had nowhere to state a default at all. .env.example lives under docker/, which only exists for features that bring a service. Features without one were creating an empty docker/ directory containing a single .env.example and no compose file, just to have somewhere to put settings.

Both sides now declare in their own pyproject.toml, which is versioned, reviewed, and already the file that describes the feature.


A feature declares

# splent_feature_search/pyproject.toml

[tool.splent.config]
# The word the search is served under.
SEARCH_PATH = "search"
# Candidates taken from each source, per source rather than in total.
SEARCH_LIMIT = 20
# Whether the header carries a search box.
SEARCH_NAV = true
# What the empty box invites a reader to do. Empty means the feature's own
# translated default.
SEARCH_PLACEHOLDER = ""

The value is the default, so the block doubles as the list of what the feature reads. Keep it in step with config.py: a default written here that disagrees with the os.getenv fallback is a setting whose behaviour depends on whether the merge has run.

Declare the variable even when its default is empty. An empty default still tells a reader the knob exists, which is the difference between a setting and a secret.


A product decides

# egc_wiki/pyproject.toml

[tool.splent.config]
SITE_NAME = "egc.us.es"
SEARCH_PLACEHOLDER = "es:Buscar en esta wiki|en:Search this wiki"
AUTH_LOGIN_LAYOUT = "public_base.html"
COURSES_NAME_PREFIX = "EGC"

Only the differences. A product that agrees with a feature’s default says nothing, and its file stays short enough to read as a statement of intent.


Precedence

Weakest first.

Source Who owns it
feature docker/.env.example the feature, for settings that sit next to its compose file
feature [tool.splent.config] the feature
product [tool.splent.config] the product
product .env.<env>.example the deployment

A feature that states the same default in both of its own files is saying it twice; the pyproject wins, because that is where a product’s override sits and the two should be readable side by side.

The product’s env file is the last word. product:env --merge rebuilds .env from .env.<env>.example every run, so that example is where a value gets forced when a particular deployment needs it forced, whatever any pyproject says.


Types

An env file has no types, so values are stringified on the way in.

TOML Reaches the environment as
"cursos" cursos
20 20
true true
false false
["Teoría", "Prácticas"] Teoría,Prácticas

true and false become the words every SPLENT config.py already parses, so nothing in a feature needs to change to read a boolean declared this way.


Ports

A key ending in _HOST_PORT gets the product’s port offset, exactly as it would if it came from a .env.example. A port declared in a pyproject collides between two products just as readily as one declared anywhere else.


Where it is read

Both paths, and they agree by construction.

  • splent product:env --merge for development, writing docker/.env.
  • splent product:build for deployment, writing docker/.env.deploy.example.

A deploy template that disagreed with dev would only be discovered in production. This is the reason the two commands share the same precedence rules.


Checking what a product decided

splent product:env --merge --dev
  product  resolved __PRODUCT__ in 1 variable(s) → egc_wiki
  declared 19 default(s) from feature pyprojects
  product  9 value(s) this product decides
  ports    adjusted 1 feature port(s) (+281)

Nine values is a number worth reading. A product that decides forty things about its features is usually a product that has copied defaults it agrees with.


Editable from the admin

Some settings are a deployment fact: a hostname, a port, a path tied to a volume, a credential. Others are an editorial decision that whoever runs the site revisits without wanting to ask a developer, like what the search box says or what a new academic year is called.

A feature marks the second kind by declaring a schema, and the settings feature renders a panel for it automatically. There is no per-feature panel code.

# In init_feature(app)
from splent_framework.settings.settings_schema import register_settings

register_settings(
    "search",
    "Search",
    [
        {
            "key": "placeholder",
            "env": "SEARCH_PLACEHOLDER",   # the variable this field shadows
            "type": "text",
            "default": "",
            "label": "Placeholder",
            "help": "What the empty box says.",
        },
    ],
    icon="search",
)

env names the variable the field shadows. State it whenever the variable is not named after the feature: the theme’s site_name field shadows SITE_NAME, not THEME_SITE_NAME. Left out, it is <FEATURE>_<KEY> uppercased.

Which value wins

Three places can answer, and they are consulted in the order of how recently somebody decided.

Source When it was decided
the stored setting somebody typed it into the panel, after the app started
app.config the product set the variable, before the app started
the schema default nobody decided

The middle step is the one that makes both halves work. Without it a product setting SITE_NAME in its environment would see the panel offer an empty box, and filling that box in would silently disagree with the file.

A field submitted unchanged is stored empty, meaning “follow the environment”. So pressing Save without touching anything does not quietly freeze the current values into the database, where a later change to the .env would be shadowed by a copy nobody remembers making.

Reading it back

The read path has to go through the resolver, or a value typed into the panel does nothing.

from splent_framework.settings.settings_schema import setting_value, get_config

setting_value("search", "limit")   # one field
get_config("search")               # the whole schema, typed
{{ feature_setting('search', 'placeholder') }}

config.SEARCH_PLACEHOLDER in a template reads the environment alone. For a setting declared editable, that is the value before anybody changed it.

The whole store is read once per request and memoised, so a schema with six fields costs one query, not six. With no settings feature installed, every read falls back to the environment.

What not to make editable

A URL segment, a path, a port, or anything read while the app is starting up. Routes are built from SEARCH_PATH at registration time, so a value typed into a panel would change nothing until a restart and would look broken in the meantime. Credentials never belong in a form either: a panel that can read a password back is a panel that shows it to whoever gets in.


What stays in the env file

Secrets, hostnames, ports, paths tied to a volume, and anything that differs between machines. pyproject.toml is committed; docker/.env is not.

A URL segment is worth singling out, because getting it wrong is invisible until somebody clicks. COURSES_PAGE_SEGMENT decides the word pages are served under, and a wiki replacing an older one has to answer to whatever that one served: EGC’s material links between its own pages 1,388 times, every link written as /documento/, and taking the feature’s default of /pagina/ turned every one of them into a 404 while the site itself looked perfectly healthy.

ARCHIVE_DIR is the example worth keeping in mind. In development it is a plain path and belongs in the product’s [tool.splent.config]. In production it is also the mount target of a named volume declared in docker-compose.prod.yml, so it stays in .env.prod.example next to the volume it has to match, and wins there.


Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es