Configuration¶
Every arvel app needs values that change between environments — the app name, debug flag, database
URL, cache driver, secret keys. arvel keeps these in a single configuration repository: a
dotted-key store you read with the config() helper. Environment-specific secrets come from the
environment (and .env files); everything else is plain config data.
Reading configuration¶
config() is the front door. Call it with a dotted key to read a value, with a key and a default
for a fallback, or with no argument to get the repository itself:
from arvel import config
config("app.name") # "arvel"
config("app.timezone", "UTC") # the value, or "UTC" if unset
config() # the Repository instance
Keys are dotted paths into a nested dict — config("database.connections.pg.host") walks
database → connections → pg → host. A miss at any level returns the default (None if you didn't
pass one); it never raises for an absent key.
Typed reads¶
The permissive get never raises; the typed getters do the opposite — the value must be exactly
the type you asked for (a bool is not an integer), or a ConfigTypeError names the key, the
expected type, and what was actually there. Reach for these when a wrong type should fail loud at
boot instead of surfacing as a confusing error later:
config().string("app.name") # "arvel"
config().integer("session.lifetime") # 120 — raises if it's "120"
config().boolean("app.debug")
config().float("throttle.rate") # ints widen to float; nothing else does
config().array("cors.origins") # list (a tuple converts); raises on anything else
config().string("missing.key", "x") # defaults work; a wrong TYPE still raises
Writing at runtime¶
You can set values too — useful in tests or at boot. Passing a mapping to config() sets each
dotted key:
config().set("services.stripe.key", "sk_test_…")
config({"services.stripe.key": "sk_test_…", "app.debug": True}) # same, in bulk
config().has("services.stripe.key") # True
Config is read-only at runtime
Treat configuration as immutable once the app has booted. set() exists for boot-time
assembly and tests; the repository is not synchronised for concurrent writes, so mutating it
while requests are in flight is a race. Read freely; write only during setup.
For the same reason, config().all() returns a deep-copy snapshot — mutating it does not
change the live config. Values returned by config("some.key") should be treated as read-only;
don't mutate a dict or list you read back.
Setting through a scalar creates a section
set("a.b", 1) when a is currently a scalar (say the string "x") replaces that scalar with a
new section {"b": 1} — the old value is discarded (a debug line is logged on the
arvel.config logger). This is convenient but lossy; don't set() into a key you also use as
a leaf value.
Which environment am I in?¶
app().environment() answers from config("app.env") — no args for the name, names (or
fnmatch patterns) for a membership check; is_local() / is_production() cover the two
everyday cases:
app().environment() # "local"
app().environment("staging", "production") # False
app().environment("*-testing") # pattern match
app().is_production() # False
Environment variables: env()¶
Configuration that differs per environment — or is secret — belongs in the environment, not in code.
Read it with env():
from arvel import env
env("APP_DEBUG") # the value, coerced (see below)
env("MAIL_PORT", 25) # default when the variable is unset
env() applies literal coercion so common boolean/null spellings come back as real
Python values rather than strings:
| Env value (case-insensitive) | env() returns |
|---|---|
true, (true) |
True |
false, (false) |
False |
null, (null) |
None |
empty, (empty) |
"" (empty string) |
"quoted" or 'quoted' |
the inner text, verbatim — quotes are stripped and no coercion runs |
| anything else | the raw string, unchanged |
Wrapping quotes make a value literal: KEY="true" returns the string "true", not True — quote a
value when you need it to survive coercion (or to keep leading/trailing spaces).
Coercion can surprise you
Coercion is case-insensitive and matches the whole value, so a variable whose literal value
is null or false comes back as None/False — not the string. If a secret or token could
legitimately be one of these words, don't rely on env() for it. Also note env() returns the
raw string for everything else: env("PORT") is "8000", not 8000. To get a typed,
validated value, use typed settings.
env() reads from the process environment (os.environ), which .env files feed at boot.
Environment files¶
In development you keep environment variables in a .env file at the project root (never commit it —
it holds secrets). arvel loads it at boot via load_dotenv, parsed by
python-dotenv, so the full .env syntax works:
# .env
APP_NAME="My App" # quotes and inline comments are handled
export DATABASE_URL=postgres://localhost/app # `export` prefix is fine
SECRET_KEY='a#literal#value' # special chars inside quotes are preserved
GREETING=Hello ${APP_NAME} # ${VAR} expansion
Three rules matter:
- Real environment variables win. Loading is no-override — if a variable is already set in the
process environment (e.g. injected by your container/orchestrator in production), the
.envvalue is ignored. This is what lets production override development defaults safely. ${VAR}is expanded. python-dotenv interpolates${OTHER}references against the environment. If a secret's literal text contains a$, quote/escape it (PASSWORD='a\$literal') so it isn't treated as a reference.- A missing
.envis a no-op. Production typically has no file and relies on real env vars.
Read the loaded values with env() or, for typed/validated access, typed settings.
The config directory¶
For app-level configuration, put Python files in a config/ directory at your project root. At boot
arvel auto-loads every config/*.py into the repository under the file's stem — so
config/app.py populates the app.* namespace, config/database.py populates database.*, and so
on. A file exposes its values either as a config mapping or as UPPERCASE module variables:
# config/app.py
from arvel import env
config = {
"name": env("APP_NAME", "arvel"),
"env": env("APP_ENV", "local"),
"debug": env("APP_DEBUG", False),
"timezone": "UTC",
}
Because config files are plain Python, they can call env() to pull per-environment values (the
.env file is loaded before the config directory, so it's available here). Files whose name starts
with _ are skipped (use them for shared helpers); a missing config/ directory is fine.
Config files are executed
Loading a config file runs it as Python (that's how env() calls work) — it is not sandboxed.
Only ever load config from a trusted project tree, never from an untrusted or user-supplied path;
treat a config path with the same care as any code you import. Loaders restrict to .py files as
light defense-in-depth, but the real boundary is where the path comes from.
Prefer a different location? Application.configure(...) has an override for each conventional
directory arvel looks for at your project root:
Application.configure(base_path=".")
.with_config_dir("settings") # instead of {base_path}/config
.with_public_dir("public") # static/SPA front door — see Routing
.with_lang_dir("resources/lang") # instead of {base_path}/lang — see Localization
.create()
Precedence¶
When the same key is set in more than one place, the winner is, highest first:
with_config({...})/config().set(...)— explicit programmatic values always win.config/*.py— your app's config files.- Package defaults — values a service provider contributes via
merge_config_from.
In other words: programmatic config overrides your files, and your files override third-party package defaults. Each layer only fills gaps the higher layers didn't set (deep-merged for nested dicts).
Package configuration¶
Reusable packages ship their own config defaults and merge them under a namespace from a service
provider's register(), using merge_config_from:
from pathlib import Path
class WidgetServiceProvider(ServiceProvider):
def register(self) -> None:
self.merge_config_from({"size": "L", "color": "red"}, "widget")
# or from a file shipped beside the provider:
self.merge_config_from(str(Path(__file__).parent / "config" / "widget.py"), "widget")
Existing app values always win. If the app has already configured the namespace, the package's defaults only fill the gaps — the app's values are never overwritten:
- App set
config["widget"] = {"size": "S"}→ result{"size": "S", "color": "red"}(the package'scoloris merged in; the app'ssizewins). - App set
config["widget"] = "custom"(a deliberate scalar override) → result stays"custom"; the defaults do not clobber it.
The merge is deep (nested dicts are merged recursively) and copies the package defaults, so a provider's module-level default constant is never mutated by later runtime changes.
Typed settings¶
config() is dynamic and untyped. When you want a validated, typed handle on a section of it —
with defaults, coercion, and IDE autocomplete — define a Settings subclass, point its
__config_key__ at a config section, and just instantiate it. Crucially, this is a typed view
over config(), not a second config source: the values come from the one pipeline (config files /
with_config / env via config files), so a typed setting can never disagree with config(). It's
built on msgspec (core, fast, no extra dependency):
from arvel.kernel import Settings
class BillingSettings(Settings):
__config_key__ = "billing" # the config() section this maps to
currency: str = "usd"
trial_days: int = 14
dunning: bool = False
billing = BillingSettings() # reads + validates config("billing")
billing.trial_days # int, coerced (e.g. "30" → 30) — and == config("billing.trial_days")
billing.dunning # bool, coerced from the config value
Instantiating reads the billing section from config(); absent fields fall back to the struct's
defaults, a missing section yields all-defaults, and explicit keyword args override the section
(BillingSettings(trial_days=30)). Values are coerced and validated through msgspec.convert — a
bad value (e.g. trial_days set to "soon") raises msgspec.ValidationError, so misconfiguration
fails fast rather than surfacing as a confusing error later.
import msgspec
try:
BillingSettings()
except msgspec.ValidationError:
... # config("billing.trial_days") wasn't a valid integer
With no application running (a pure unit test), instantiation skips the config read and uses defaults
plus any explicit kwargs — so BillingSettings(trial_days=30) is a plain, app-free way to build one.
One source of truth
Settings deliberately does not read the environment itself — that would be a second config
path competing with config(). Put your environment reads in a config/*.py file (env(...)),
and let Settings give you a typed, validated lens over the result.
The framework's own modules ship typed settings built this exact way:
MailSettings (mail, with a nested SmtpSettings for host/port/auth/encryption), CacheSettings,
DatabaseSettings, SessionSettings (session), FilesystemSettings, BroadcastingSettings,
SearchSettings, ViewSettings, and AppSettings (app). So a bad mail.smtp.port or
session.lifetime fails fast at startup. Define your own for your app's sections the same way.
The app section has both a typed view and raw reads
AppSettings gives you a typed view of app.*, but a few of the framework's own foundational
reads (app.timezone in arvel.dates, app.name in arvel.contracts) stay on raw config() —
those modules sit below the settings layer in the import graph and can't depend on it.
Debug mode¶
config("app.debug") (from APP_DEBUG) controls how much error detail the app exposes. In
local development, true surfaces tracebacks and rich error pages. In production it must
be false — a true debug flag leaks stack traces, config, and schema to clients. See
Error Handling.
Maintenance mode¶
Take the application offline during a deploy or migration without stopping the server. arvel down
flips a flag (stored in the app's default cache, so a Redis/Valkey store reaches every worker and
host); every request then gets a 503 with a Retry-After header until you run arvel up:
arvel down # 503 for everyone
arvel down --message "Back at 14:00 UTC" --retry 120
arvel up # back online
You can keep working while the public is locked out:
arvel down --secret my-token # ?secret=my-token grants a bypass cookie
arvel down --allow 203.0.113.4 # this IP is let through (repeatable)
arvel down --render maintenance # pre-render resources/views/maintenance.html
Because the flag lives in the cache, an app using the default in-process cache only takes its own
process offline — use a shared cache store (Redis/Valkey) in production so down reaches the
whole fleet.
How it works¶
At boot the application assembles a single configuration Repository from layered sources, lowest
priority first: package defaults merged by each provider's merge_config_from, then every
config/*.py file loaded under its filename stem, then any programmatic with_config(...) /
config().set(...). Each layer is deep-merged onto the ones below, so a higher layer overrides only
the keys it actually sets — which is exactly the precedence order above. Crucially, .env is loaded
before the config directory (no-override: a real environment variable always wins), so the env(...)
calls inside your config/*.py files read an environment that's already resolved.
config("a.b.c") then just walks that dotted key into the assembled nested dict, returning the default
on any miss rather than raising. A Settings subclass isn't a second store — it's a typed
msgspec view over the same repository: it reads its
__config_key__ section, validates and coerces it, and hands back a typed struct. That's why a typed
setting can never disagree with config(); there's one pipeline, and everything reads from it.
Common mistakes & gotchas¶
- Keys can't contain literal dots. A dotted key is always a path:
config("a.b")readsbinsidea. There's no way to address a single key literally named"a.b". env()returns strings. Apart from the literal-coercion table above,env("PORT")is the string"8000", not the integer8000. For typed, validated values use aSettingssubclass.- A value that looks like a literal is coerced.
env()turnsnull/false/true/empty(any case) intoNone/False/True/"". Don't store a secret whose literal text is one of these and expect the string back. - Don't mutate what you read.
config(...)may hand back the live nested structure; treat it as read-only and useset()to change config (and only at boot — see the read-only warning above). - Config holds secrets. The repository's
repr()redacts values for exactly this reason — don't logconfig().all()or individual secret values yourself.
See also¶
- Service Providers —
merge_config_fromfor package config defaults, and boot-time setup. - Service Container — how the
configrepository itself is bound and resolved. - Packaging & Extras — how a capability's config is published into an app.
- Encryption · Hashing — the
app.keyand secrets that config carries.