Backends¶
Backends are provided as plugins. Importing a backend's module enables it. No registration calls are needed. Each module contains everything its backend needs, including its source builder. The bundled backends are:
- the environment family: the process environment
(
advexp.settings.plugins.environment.variables) and a.envfile (advexp.settings.plugins.environment.dotenv); - the JSON, TOML, and YAML file backends;
- the dictionary storage (
advexp.settings.plugins.mapping_source).
A save changes only the settings that it writes. In a file backend, the rest of the file stays as the user left it: comments, layout, and keys that the library does not recognize survive a save. The section for each backend lists exactly what it keeps and what it rewrites.
Aliases: one member, several addresses¶
A member has one primary address: the explicit name= or the name built
from the pattern. The member can also declare more full addresses with
aliases=. Aliases are useful when you rename something. If a variable or a
document key gets a new spelling, the old spelling keeps working, so a
deployment that still uses it loads as before.
from datetime import timedelta
import advexp.settings as adv_settings
import advexp.settings.plugins.environment.variables as env
class WorkerSettings(
adv_settings.Settings,
options=env.env_source(
name_prefix="APP_", name_case=env.NameCase.UPPER
),
):
# the primary address is APP_SESSION_TTL (prefix + the member
# name in upper case); the old name APP_TTL keeps serving the
# member
session_ttl: timedelta = env.environment_variable_setting(
aliases=["APP_TTL"], default=timedelta(minutes=30)
)
The rules:
- Every alias is a full address and has the same status as an explicit
name=.name_prefix,name_case, and the JSON/TOML name patterns never change it, whatever spelling the primary address uses. - A load looks up the primary address first, then the aliases in the order in which they were declared. The first address that has a value wins.
contains_settingrecognizes any of the addresses.- Saving writes only to the primary address, so the rename is complete after the next save. Deleting removes the value from every address of the member, so a deleted value cannot come back through an old name.
- In JSON and YAML, an alias is placed under the member's
path=, like the primary key. In TOML, an alias is placed under the member'spath=too, nested by its own dots. - Diagnostics list every address that was checked. This applies to the debug
record of a fallback and to the abort entry of a required member, for
example:
environment variable 'APP_SESSION_TTL' (aliases: 'APP_TTL'). - The field-by-field addressing of the environment family is resolved from the primary name only. Aliases take no part in it.
An invalid alias list makes the class statement fail before any storage
pass. Examples of invalid lists are a wrong type, an empty string, and an
alias that repeats the name= address or another alias. The read-only
sources of advexp.configuration support aliases= in the same way
(Configuration: Aliases). A
backend of your own can support aliases through the attribute contract
(attr.aliases) and the setting_name_candidates helper from plugin_api.
Members without storage¶
non_storage_setting(...) is provided by the core package, directly in
advexp.settings. It declares a member that no storage ever serves:
default=is required.- The value is either the declared default or the last assigned value.
- A load resets the member to its declared default.
- Saving and deleting skip the member.
- Bulk updates, snapshots, constraints, and validation treat it like any other member.
import advexp.settings as adv_settings
class Limits(adv_settings.Settings):
retries: int = adv_settings.non_storage_setting(default=3, ge=0)
An annotation wrapped in typing.ClassVar declares no member at all: the
class does not treat the name as a member, no storage ever sees it, and
to_dict() does not include it. ClassVar is the standard Python way to
mark a value that simply lives on the class. Use non_storage_setting(...)
for values that should behave as members.
The read-only counterpart has its own version of this concept:
advexp.configuration provides constant_setting(default=...). It declares
a member that neither the sources nor the load overrides can change, so its
value is always the declared one, on every load. See
Configuration: Members that do not use the source chain.
Environment variables¶
Each member maps to one variable, and name= is the full address:
from datetime import timedelta
import advexp.settings as adv_settings
import advexp.settings.plugins.environment.variables as env
class AppSettings(adv_settings.Settings):
session_ttl: timedelta = env.environment_variable_setting(
name="APP_SESSION_TTL", default="0:30:00"
)
debug: bool = env.environment_variable_setting(
name="APP_DEBUG", default=False
)
All environment backends share the same naming options: the prefix, the
name case, and the nested delimiter for dataclass members. They are
arguments of the family's source builder, which you pass through the
options= keyword of the class statement:
from datetime import timedelta
import advexp.settings as adv_settings
import advexp.settings.plugins.environment.variables as env
class AppSettings(
adv_settings.Settings,
options=env.env_source(
name_prefix="APP_", name_case=env.NameCase.UPPER
),
):
# APP_SESSION_TTL
session_ttl: timedelta = timedelta(minutes=30)
# untouched: an explicit name is the full address
legacy: str = env.environment_variable_setting(
name="legacy_flag", default="off"
)
The variable name is name_prefix + name_case(member name):
- The prefix is added exactly as written.
NameCase.UPPERandNameCase.LOWERchange the case of the member name only. There is no conversion between camelCase and snake_case.None, the default, changes nothing.- An explicit
name=is the full address in the storage. The prefix and the case never change it.
A member can also declare more full addresses next to the primary one. See Aliases.
NameCase.ANY changes the lookup, not the name. The name is built as
declared, and the lookup accepts any spelling of it:
- An exact match wins silently.
- If two spellings differ only in case and neither matches exactly, the load fails with an error that names the source and both spellings.
- An explicit
name=is also looked up without regard to case. - When saving with
ANY, the library updates the spelling that already exists in the environment. If no spelling exists, it writes the variable under its built name.
Nested objects¶
Nested objects are addressed field by field. A member annotated with a
dataclass holds one nested object. The nested_delimiter option of the
family opens its fields to the environment. With the delimiter "__",
APP_DB__PORT is the port field of the db member, and
APP_DB__DEEP__V4 is the v4 field of db.deep. A nested address consists
of the member's own variable name, then the delimiter, then the field name
with its case applied. This works at any depth, and name_case applies to
every part:
from dataclasses import dataclass
import advexp.settings as adv_settings
import advexp.settings.plugins.environment.variables as env
@dataclass
class DbConfig:
host: str = "localhost"
port: int = 5432
class AppSettings(
adv_settings.Settings,
options=env.env_source(
name_prefix="APP_",
name_case=env.NameCase.UPPER,
nested_delimiter="__",
),
):
db: DbConfig = DbConfig(host="db.local")
The whole-object form still works: the member's own variable can hold the
object as JSON, and so can the own variable of any nested object
(APP_DB__DEEP holds the whole deep object). A per-field variable takes
precedence over a whole-object variable that overlaps with it.
If APP_DB__PORT=6543 is set and nothing else, db loads as
DbConfig(host="db.local", port=6543):
- Loading updates the object partially. The declared default instance is the base, and every field that no variable addresses keeps its declared value. If there is no declared default, the dataclass's own field defaults are used.
- A whole-object variable that is not a JSON object is handled in the same way as a failed flat member: a warning is reported, and the member falls back to its effective default.
- Saving mirrors the addressing. Each field is written to its own variable, and the whole-object variables are removed, so a saved object loads back exactly as it was saved.
- The delimiter is off by default (
nested_delimiter=None). Without it, every member stays one flat variable, andAPP_DB__PORTaddresses nothing.
Saving writes the variables back through os.environ. A None value has no
string form, so saving it removes the entry, and the next load falls back to
the default.
JSON files¶
The JSON backend stores settings as one flat JSON object. The keys are full
setting names (the explicit name= or the {module}.{class}.{member}
pattern), and the values are stored as native JSON. The optional path= of
JSONSetting places the key inside the document. It is a JSON Pointer
(RFC 6901):
path="/net/proxy"stores the key undernet, thenproxy.path="/mirrors/1"stores it inside the second element of themirrorsarray.path="/endpoints/api.internal"stores it under an object key that contains a dot.- In a pointer,
~1escapes/and~0escapes~.
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class LocalSettings(adv_settings.Settings):
host: str = js.json_setting(name="host", default="localhost")
port: int = js.json_setting(default=8080)
# net -> timeout
timeout: int = js.json_setting(name="timeout", path="/net", default=30)
region: str = js.json_setting(
name="region", path="/replicas/0", default="eu"
)
# a path segment may contain a dot: it names the object key
# api.internal
api: str = js.json_setting(path="/endpoints/api.internal", default="")
A path segment made only of digits indexes an array if the node it steps
into is an array. Otherwise, it names an object key. If an array is missing
or too short, saving pads it with JSON null values up to the indexed
position.
Each pass reads the file once and writes it back atomically at the end: a
temporary file replaces the old one. Because the replacement creates a new
file, the write copies the permission mode from the old file. A mode that
was set by hand (for example, 600 on a secrets file) therefore survives a
save. A file in another encoding is rejected with a read error that names
the file, instead of a bare codec message.
Keys that the library does not know are kept, and so is the file's layout. Existing keys stay where they are, and new keys are added at the end of their container. The file keeps the formatting it already uses:
- its indentation (four spaces, tabs, or a one-line compact form);
- raw non-ASCII text where the file already has it (an ASCII-only file keeps
its
\uescapes); - a UTF-8 BOM, if the file starts with one;
- its line endings, so a CRLF file stays CRLF.
A new file gets two-space indentation, members in declaration order, plain UTF-8 for non-ASCII values, and LF line endings.
Deleting a setting
Deleting a setting also removes the path objects that it left empty. An
emptied array element becomes a null, so its siblings keep their
positions. An array in which every element is a null is removed.
The file itself is configured outside the class, on several levels, from the
widest to the most specific. The more specific level wins. The process-wide
level sets one value for every settings class in the application. You assign
js.JSONSettingsConfiguration.file_path once at startup. Alternatively, a
module can keep its file to itself. Declare the file once, at the top level
of the module. The declaration then applies only to the classes of that
module:
from pathlib import Path
from advexp.settings import for_this_module
import advexp.settings.plugins.json_settings as js
js.JSONSettingsConfiguration.file_path = for_this_module(
Path("app-settings.json")
)
Every module can declare its own file this way, and the declarations
coexist. A module's declaration wins over the process-wide value for the
classes of that module, while classes in other modules keep the
process-wide value. The line is an ordinary module-level statement that takes
effect when the module is imported. Therefore, a tool that imports the
settings modules without running the application can collect every
declaration with declared_module_defaults(). If no file is configured at
any level, the first storage access raises an error that names the
available options.
A class can be bound to its own file through the options= keyword of the
class statement. A subclass inherits the binding unless it declares its
own:
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class WindowSettings(
adv_settings.Settings,
options=js.json_source(file="window-settings.json"),
):
width: int = js.json_setting(default=800)
At runtime, the file_path property of the plugin handle reads and changes
the file while the program is running. The class's own binding wins over
both the module level and the process-wide level.
By default, JSON keys follow the full pattern {module}.{class}.{member}.
This keeps the keys of two classes apart when they share one document. You
can change the pattern with name_pattern= on the same builder. For
example, plain member names suit a file that a person edits by hand:
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class EditorSettings(
adv_settings.Settings,
options=js.json_source(
file="editor-settings.json", name_pattern="{FieldName}"
),
):
tab_width: int = js.json_setting(default=4) # "tab_width" in the file
# an explicit name= is the full address, as always
ui_font: str = js.json_setting(name="font", default="monospace")
The pattern can use the slots {NamespaceName} (the module), {ClassName},
{FieldName}, and {Delimiter}. It must contain {FieldName}, because
otherwise every member of the class would collapse onto one key. The pattern
applies to the whole class that reads through the builder. A subclass's own
binding replaces the pattern for that subclass and its subclasses. An
explicit name= stays the full address in the document, and a member's
path= still places its key in the tree. Declared aliases= extend the
lookup in the same way for the whole family
(Aliases). A class with no binding
can still change the pattern at runtime through the family entry of the raw
metadata (JSON_METADATA_OPTIONS).
A short pattern trades safety for readability. If two classes use the same
short form in one file, their keys collide, and the later write overwrites
the earlier key. The overwrite is not completely silent: a debug-level
record names the key. To avoid the collision, give one of the classes a
different pattern, its own file (json_source(file=...)), or explicit
names.
The file declaration json_file(...) names the class's file when the
binding is held by the builder of another family. A plain member requires
the class to have a single source, and the file declaration does not count
as a second source. The factory members of the JSON family read the named
file:
import advexp.settings as adv_settings
import advexp.settings.plugins.environment.variables as env
import advexp.settings.plugins.json_settings as js
class AppSettings(
adv_settings.Settings,
options=(
env.env_source(name_prefix="APP_", name_case=env.NameCase.UPPER),
js.json_file("app-settings.json"),
),
):
mode: str = "production"
retries: int = js.json_setting(name="retries", default=3)
The file declaration belongs to the class that declares it, like the
binding. A subclass without its own declaration reads the nearest one above
it. A subclass with its own declaration switches to it for itself and its
subclasses. A file declaration next to a source builder of the same family
makes the class statement fail, because the pair would name one storage
twice, and the file of a bound family belongs on the source builder. The
file_path of the plugin handle reads and writes the declaration at
runtime. The TOML and dotenv families spell their file declarations in the
same way (toml_file(...), dotenv_file(...)). The variable name of a
dotenv member is still built by the naming rules shared with the
environment family.
Like the family's binding, a file declaration governs the members that its class declared. It is stored per declaring class and can be changed at runtime through the same channel as the options of the environment family.
TOML files¶
The TOML backend stores settings in one TOML document, using native TOML
values. datetime, date, and time values keep the form that TOML itself
uses, not ISO strings. The backend uses
tomlkit, an editor that
preserves formatting. Install it with the toml extra
(pip install "advexp-settings[toml]").
A setting is stored at the address that its key names. Every dot in the key
(the explicit name= or the pattern-built one) separates table levels, and
the last part is the key itself. This is the same address that TOML itself
reads: [app.LocalSettings] port = 8080, never a quoted flat key such as
"app.LocalSettings.port". A name without a dot is a plain key. A path=
segment never splits at a dot, so path="/a.b" steps into the table whose
key is a.b. The optional path= of TOMLSetting places the key in the
tree as a JSON Pointer (RFC 6901), in the same way as in the JSON backend:
import advexp.settings as adv_settings
import advexp.settings.plugins.toml_settings as ts
class LocalSettings(adv_settings.Settings):
host: str = ts.toml_setting(name="host", default="localhost")
# [app.LocalSettings] port: the dots of the pattern are levels
port: int = ts.toml_setting(default=8080)
# [net] -> timeout
timeout: int = ts.toml_setting(name="timeout", path="/net", default=30)
region: str = ts.toml_setting(
name="region", path="/replicas/0", default="eu"
)
# a path segment may contain a dot: it names the table key api.internal
api: str = ts.toml_setting(path="/endpoints/api.internal", default="")
A path segment made only of digits indexes an array if the node it steps into is an array. Otherwise, it names a table key. Arrays follow these rules:
- An array on a path can grow only by one element at its end. Writing to a position further out would leave a gap, so the save refuses it.
- A new array of tables is written as
[[name]]rows, which is how TOML itself represents a list of tables. - An inline array that the file already contains keeps its inline form when it grows.
- A slot that is itself an array stays inline, because only that form can hold it.
Each pass reads the file once and writes it back atomically at the end. A temporary file replaces the old file, and the write copies the permission mode, so a mode that was set by hand survives a save. The write preserves the file's format:
- Comments, blank lines, key order, and line endings survive.
- A new key goes to the end of its table.
- A value that the pass leaves unchanged keeps the file's own spelling.
- Keys that the library does not know are kept.
A file in another encoding is rejected with a read error that names the file. A file that starts with a UTF-8 BOM is also rejected, with a read error that names the BOM as the cause, because TOML requires plain UTF-8.
TOML has no null
TOML has no null. Saving None removes the entry, and the next load
falls back to the default. A None nested inside a container (for
example, in a List[Optional[int]]) has no TOML form either. In that
case, the save names the member and skips it, because dropping one
position in an array would change the address of every sibling.
Deleting a setting
Deleting a setting also removes the path tables that it left empty. An emptied array element stays as the empty table that it becomes, so its siblings keep their positions. An array in which every element is emptied is removed as well.
The per-class binding and the key pattern (ts.toml_source(file=...,
name_pattern=...)) work exactly as in the JSON family. See the
JSON section above. The same is true for the file declaration
(ts.toml_file(...)) and the module declaration
(TOMLSettingsConfiguration.file_path = for_this_module(...)).
YAML files¶
The YAML backend stores settings in one YAML document, using native YAML values:
- Dates and timestamps are
dateanddatetimeobjects. nullisNone.- Integers may contain underscores (
1_000). true/falseare booleans, whileyes/noremain strings, because the backend follows YAML 1.2.
The backend uses ruamel.yaml in its
round-trip mode, so the comments, key order, quoting style, and anchors that
a file already contains survive a save. Install it with the yaml extra
(pip install "advexp-settings[yaml]"). The backend ignores the file
extension and uses whatever path you declare, so an existing .yml file
needs no renaming.
A setting's key (the explicit name= or the pattern-built one) is a flat
mapping key at the root of the document. A dot in it stays part of the key,
exactly as in the JSON backend. The optional path= of YAMLSetting places
the key in the tree as a JSON Pointer (RFC 6901), also in the same way as in
the JSON backend:
from datetime import date
import advexp.settings as adv_settings
import advexp.settings.plugins.yaml_settings as ys
class LocalSettings(adv_settings.Settings):
host: str = ys.yaml_setting(name="host", default="localhost")
# a flat pattern key: app.LocalSettings.port, dots and all
port: int = ys.yaml_setting(default=8080)
# net: -> timeout
timeout: int = ys.yaml_setting(name="timeout", path="/net", default=30)
# a sequence position: mirrors -> 0 -> region
region: str = ys.yaml_setting(
name="region", path="/mirrors/0", default="eu"
)
# stored native: launch_on: 2026-12-01
launch_on: date = ys.yaml_setting(
name="launch_on", default=date(2026, 12, 1)
)
A path segment made only of digits indexes a sequence if the node it steps into is a sequence. Otherwise, it names a mapping key. A missing sequence is padded with nulls up to the position that the write fills.
Each pass reads the file once and writes it back atomically at the end. A temporary file replaces the old file, and the write copies the permission mode, so a mode that was set by hand survives a save. The write preserves the file's own form: comments, key order, quoting style, anchors, and line endings (a CRLF file stays CRLF) survive, and so does a UTF-8 BOM at the start of the file. A new key goes to the end of its container, and keys that the library does not know are kept. A file created by a save uses the library's layout: two spaces per mapping level, and sequence dashes indented by two spaces.
One document per file, and no unknown tags
A --- separator followed by a second document makes parsing fail,
because a save must not drop what comes after the separator. A tag that
the YAML core schema does not recognize also makes parsing fail, so no
tag can construct arbitrary objects: the settings file is data, not
code.
A blank file is an empty document
A file that is empty or contains only whitespace is parsed as a document with no keys: a load reads nothing from it, and a save starts the file over. In some other formats, an empty file is a parse error. Here it is not.
Null is a value
None is stored as the document's null and is read back as None,
which is different from a missing key. This follows the JSON rule, not
the TOML one. Removing a value is the job of the delete operations.
Deleting a setting
Deleting a setting also removes the path containers that it left empty. An emptied sequence element becomes a null, so its siblings keep their positions. A sequence in which every element is a null is removed as well.
The per-class binding and the key pattern (ys.yaml_source(file=...,
name_pattern=...)) work exactly as in the JSON family. See the
JSON section above. The same is true for the file declaration
(ys.yaml_file(...)) and the module declaration
(YAMLSettingsConfiguration.file_path = for_this_module(...)).
The dictionary storage¶
mapping_source(...) binds the class to its own dictionary. Plain members
read from this storage, and the class's saves write to it. Import it from
advexp.settings.plugins.mapping_source:
import advexp.settings as adv_settings
from advexp.settings.plugins.mapping_source import mapping_source
BASE_DEFAULTS = {"token": "dev-token", "port": 8080}
class AppSettings(
adv_settings.Settings,
options=mapping_source(BASE_DEFAULTS),
):
token: str
The dictionary uses member names as keys, and only member names. All
dictionary-based APIs in the library use keys in the same way. An explicit
name= and declared aliases= have no effect on it. Keys that match no
member are dropped, and a debug record is written. The dictionary is copied
when the class is declared, and every value that is served or stored is a
fresh copy, so changes on either side never reach the other. A save writes
only the member keys and leaves the rest of the dictionary as it is. A
delete removes the key of its member in the same way. This is the same rule
that a shared JSON file follows.
Called without a dictionary, the builder binds an empty storage. The
resolver mapping_source_for(AppSettings), which is imported next to the
builder, returns the storage. Its set(mapping) method replaces the whole
dictionary, and get() returns a deep copy of the current contents:
from advexp.settings.plugins.mapping_source import mapping_source_for
mapping_source_for(AppSettings).set({"token": "runtime-token"})
AppSettings.load_settings()
snapshot = mapping_source_for(AppSettings).get()
A storage that nothing has filled is a silent miss: the load moves on to the
declared defaults. A subclass without its own mapping_source shares the
storage of its base class: one dictionary, one set, one get.
The storage is the class's only source binding. If a class declares it
together with another source builder (json_source(...), env_source(...),
and so on), the class statement fails, because there is no defined order
between the bindings that a plain member could follow. A family's file
declaration (for example, json_file(...)) and the family factory members
can be used next to it without restrictions.
Mixing backends in one class¶
Members of one class can come from different backends. Each member is served
by the backend that its attribute selects. The options= keyword carries
the binding of every family that needs one: a single binding, or a sequence
when several families need bindings:
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
import advexp.settings.plugins.environment.variables as env
class MixedSettings(
adv_settings.Settings, options=js.json_file("app-settings.json")
):
host: str = js.json_setting(name="host", default="localhost")
# MIXED: the explicit name is the full address, untouched
mode: str = env.environment_variable_setting(
name="MIXED", default="production"
)
# mode comes from the environment, host from the file
MixedSettings.load_settings()
# each member is written by its own backend
MixedSettings.save_settings()
Class-level passes still cover the whole class, and members are visited in declaration order. A storage error that the user must see, such as a corrupt file or an inaccessible directory, aborts the pass. The class then stays exactly as it was before the call, and nothing that the pass loaded is applied.
A load reports each member's problem according to the member's declaration.
This is the same logic that advexp.configuration uses:
- A required member (declared with
REQUIRED) has no default to fall back to. If its storage provides nothing usable, the pass records an error that names the cause, the member, its storage name, and the class. At the end, oneAggregateLoadErrorcarries every failure in itserrorstuple. - Every other member never makes a load fail by itself. It keeps its
default, and the same error goes into the report that
load_settingsreturns. - A plain miss is not an error. For a member with a default, a miss is the normal way in which the default gets used.
- The report is an empty tuple if every member loaded cleanly.
- If a required member failed and an optional member fell back in the same
run, the abort error also carries the fallbacks, kept separately in its
fallbackstuple.
The single-setting form reports in the same way. load_setting(...) returns
this member's row of the report:
None, if the member loaded cleanly or took its default through a plain miss;- the same entry that a class pass would collect, if the member fell back to its default because of a problem.
A REQUIRED member raises an error instead. A single member has no
aggregate, so you see the member's own error, for example a source failure
or a read of a member that was never loaded.
A load builds the new state of the class from scratch and applies it in one step. A finished pass fully determines the state: every member gets either the value that its storages produced or its declared default. A value from an earlier pass never survives if the current storages hold nothing for that member. A failed pass changes nothing: if a pass ends in an abort or a storage error is raised, the class stays exactly as it was before the call. No values are partly applied, and no defaults are applied from the failed run. See Validation for the full description.
Writing your own¶
A custom backend is built on the contracts of the plugin API: an attribute,
a serializer, and a plugin class. See Extending and the
samples/settings/custom_backend.py sample, which contains a complete
backend built on the public contracts.