Validation¶
Besides type conversion, a member can declare value constraints. They are keyword parameters of the setting declaration:
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class ServerSettings(adv_settings.Settings):
port: int = js.json_setting(name="port", default=8080, ge=1, le=65535)
# the pattern is unanchored: you place ^ and $ yourself
host: str = js.json_setting(
default="localhost", min_length=1, regex=r"^\w+$"
)
mode: str = js.json_setting(
name="mode", default="primary", ne="backup"
)
- Ordering bounds (
gt,ge,lt,le): greater than, greater than or equal to, less than, and less than or equal to. The bound can be any value that the member's value can be compared with: numbers, dates, or your own comparable types. Each of these parameters also accepts a reference to another member. See Rules across members. - Equality (
eq,ne): the value must equal the bound (eq) or must not equal it (ne). A member reference works here too. - Length (
min_length,max_length): bounds onlen(value). They work for strings and collections. regex: an unanchored pattern that astrvalue must match. Add^and$yourself if you need them.validator: a callable that receives the value and returns whether it passes. If the callable raises an exception, the exception propagates unchanged, because this is a bug in the validator, not a property of the value. During a load, the crash first writes anERRORrecord to the package's log, so the bug stays visible even if the application swallows the traceback.when: a reference to another member that gates this member. While the referenced member is falsy, none of this member's constraints apply.
None passes the ordering, length, and regex constraints without any
message, because it is the value of an unset Optional member. However,
None does take part in eq and ne. If a constraint cannot be applied to
a value, the library reports a violation instead of crashing. Examples are
gt=0 on a value that cannot be compared, min_length= on a value that has
no length, and regex= on a value that is not a string.
A member declared without default= serves None until a value arrives.
This placeholder None is checked at class creation, like a declared
default. If the constraints reject it (for example, eq= refuses None, or
a validator returns false for None), the class statement fails. Such a
member could never hold a valid value without an assignment, so declare it
with REQUIRED instead. A validator on a member declared without
default= must also be able to receive None.
Where constraints are checked¶
- Declared defaults are checked at class creation, after conversion. A
default that violates a constraint makes the
classstatement fail with aTypeErrorthat lists every offending member. TheNonethat a member without a default serves is checked in the same way. - An assignment such as
ServerSettings.port = 70000raisesSettingValidationError(a subclass ofValueError, importable fromadvexp.settings). The value is not stored in the class. This also holds for assignments on instances and onto_object()snapshots. Advexp.Configuration has no assignment at all, because its values are read-only. It reports violations through the same load error mechanisms that both packages share. See Configuration. update_from_dictandupdate_from_objectraise an error before anything is applied. A rejected update leaves every value untouched.- A load is lenient for optional members. A stored value that
violates a constraint is treated like a value that cannot be converted: a
warning is logged, the default is used, and the fallback is added to the
report that
load_settingsreturns. For aREQUIREDmember, the violation becomes an entry in the abort error instead. - A save checks the whole class first. If there is a violation, it
raises
SettingValidationErrorand writes nothing.save_setting, which saves a single setting, is the narrow path. It runs no cross-member checks, so a member whose rule involves another member is written like any other member. The member is skipped, and the failure is logged, only if its own storage fails.
The declaration itself is also validated:
- A
regexthat does not compile fails withValueError. - A
validatorthat is not callable fails withTypeError. - Combining ordering bounds with
min_length,max_length, orregexalso fails withTypeError.
Rules across members¶
A comparison bound can refer to another member of the same class instead of a fixed value. To do this, pass the attribute that was declared earlier in the class body. Whenever the whole class is checked at once, the comparison uses the current value of that member.
The when= parameter gates all constraints of a member. While the
referenced member is falsy, none of this member's constraints apply.
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class NetSettings(adv_settings.Settings):
min_port: int = js.json_setting(default=1024)
# compared with min_port
max_port: int = js.json_setting(default=65535, ge=min_port)
ssl: bool = js.json_setting(default=False)
# eq=443 applies only when ssl is true
port: int = js.json_setting(default=80, eq=443, when=ssl)
Some rules cannot be written as comparisons. Examples are conditional
requirements, "at least one of", and invariants over several members. Put
such rules in the class hook __validate__:
- The hook runs wherever the cross-member checks run, with one exception: it
does not run at the
classstatement. At class creation, only the declared defaults are checked, and only against the comparison rules. - The hook always runs on a fully built settings object.
- It returns a dictionary that maps a member name to a description of the problem. An empty dictionary means that the class is valid.
- The hook only reads and reports. It must not try to fix values, because fixing is the job of the load pass. When the hook reports a problem, the load pass resets the member that the problem names. If that member is already at its default, the reset changes nothing, and the load aborts. A load never finishes in a state that the hook rejects.
An exception raised in the hook propagates unchanged. This is the same rule
as for a custom validator: it is a bug in your code, not a property of the
values.
There is one exception to this rule. If the hook reads a required member
that has no value, this is treated as a finding, not as a crash. The member
is reported at every checkpoint where the hook runs: it becomes an entry in
the load abort, next to the member's own "missing value" error, or a problem
line in SettingValidationError at the strict checkpoints. As a result, the
hook can read the whole object without guarding against a member that its
source never provided.
from typing import Optional
import advexp.settings as adv_settings
import advexp.settings.plugins.json_settings as js
class NetSettings(adv_settings.Settings):
mode: str = js.json_setting(default="dev")
ssl: Optional[str] = js.json_setting(default=None)
def __validate__(self) -> dict:
if self.mode == "prod" and self.ssl is None:
return {"ssl": "ssl is required when mode is 'prod'"}
return {}
The checks run at five checkpoints:
- Class creation. Declared defaults must satisfy the comparison rules.
A violation makes the
classstatement fail with aTypeErrorthat lists every finding. This is what makes the load fallback safe: the state in which every member has its default is consistent by construction. The hook does not run here, because it needs a fully built object. - A load. The check is lenient, like a check of a single member's
constraints. The members that take part in a violated rule fall back to
their defaults, and a warning is logged. These are the member itself, the
members it references, and the
when=member. If no reset can fix a violation, the load aborts. Examples are a hook finding that names a member that is already at its default, and a rule that involves a required member holding its loaded value. As a result, a completed load is always consistent: either the state is valid, or the load did not complete. update_from_dictandupdate_from_object. The check is strict. The new values are checked next to the current values of the other members, so a rule can involve members the update does not touch. A violation raisesSettingValidationError, and the whole update is rejected.- A save. The check is strict. A violation raises an error, and nothing is written.
validate_settings(). This is the manual strict check. It collects every finding for every member (field constraints, member rules, and the hook) into oneSettingValidationError. Theproblemsattribute of the error is a dictionary that maps each member name to a list of problems. The check does not change anything.
A single assignment never runs the cross-member checks. When you change
a pair of related members one by one, the class passes through an
intermediate state that may violate a rule. Therefore, running the check is
your responsibility: call validate_settings() after the series of
assignments, or let the next update or save do it.
Messages about member rules name both members and keep secret values masked. Each finding is attributed to the member that carries the rule.
A member that has no value yet (nothing stored and no default) is skipped by
the field checks at the runtime checkpoints. At class creation, the library
has already checked the placeholder None against the member's own
constraints. A rule that refers to such a member is not checked until the
member has a value.
Advexp.Configuration applies the same rules at its own two points: class creation and the end of a load. Violations are reported through the same load error mechanisms as the other load errors: as an entry in the abort error for a required member, and as an entry in the returned report for an optional member. See Configuration.