Validators & errors¶
Which validator, in which place¶
Reach for the cheapest mechanism that expresses the rule:
Constraints on the Field — ranges,
strict, lengths. Not a validator at all; see Model patterns.Annotated validators — per-value rules, reusable across models. Put them in
flync.core.validatorsorflync.core.datatypesand reference them from the annotation:from pydantic import AfterValidator, BeforeValidator id: Annotated[int, AfterValidator(validate_vlan_id)] = Field(...) state_memberships: Annotated[ list[StateMembershipRef] | None, BeforeValidator(none_to_empty_list), ] = Field(default_factory=list)
none_to_empty_listandvalidate_or_removelive inflync.core.validators.generic.@field_validator— a field-level rule used in exactly one model. This is the single-use counterpart to an annotated validator: same per-value semantics, but defined next to the field that needs it instead of in a shared module. Mark it@classmethod(the convention inflync.model):from pydantic import field_validator, ValidationInfo @field_validator("baud_rate") # default mode is "after" @classmethod def validate_baud_rate(cls, value: int) -> int: if value not in _ALLOWED_CAN_BAUD_RATES: raise err_minor( "baud_rate {value} is not a valid CAN baud rate. Allowed values: {allowed}", value=value, allowed=sorted(_ALLOWED_CAN_BAUD_RATES), category=Category.VALUE_RANGE, error_number="049", ) return value
One validator may name several fields that share the rule (
@field_validator("vlanid", "pcp")). Three shapes cover the field-level cases:value rule (default
mode="after") — validate and return the parsed value;mode="before"— normalize raw input for a single field before parsing (e.g. wrap a bare string, coerceNoneto[]);single-field cross-check — keep
mode="after"and addinfo: ValidationInfo; read other fields frominfo.datawhen the rule involves one field but depends on another:@field_validator("length_of_length_field", mode="after") @classmethod def validate(cls, value: int, info: ValidationInfo) -> int: if info.data["kind"] == "dynamic" and value == 0: raise err_major( "length_of_length_field must be > 0 for dynamic arrays", category=Category.VALUE_RANGE, error_number="140", ) return value
Rule of thumb: a field-level rule used in exactly one model is a
@field_validator. The moment the same rule is needed in a second model, extract it toflync.core.validatorsorflync.core.datatypesand reference it from theAnnotated[...]type instead — do not copy the validator body. Cross-field rules that touch multiple distinct fields stay@model_validator(mode="after"); only a single-field check that reads another field viainfo.datawarrants a field validator.@model_validator(mode="before")— shape-of-input fixes: defaults for absent blocks and legacy-key migration. Takes and returns the rawdict; mark it@classmethod(the convention inflync.model):@model_validator(mode="before") @classmethod def drop_legacy_role(cls, data: Any) -> Any: """Drop a ``role`` left over from an earlier FLYNC version, with a warning.""" if not isinstance(data, dict) or "role" not in data: return data warn( f"10BASE-T1S MDI config declares 'role' ({data['role']!r}), which FLYNC no longer models and ignores.", category=Category.LIFECYCLE, error_number="338", ) return {key: value for key, value in data.items() if key != "role"}
@model_validator(mode="after")— cross-field and cross-object rules. Takes/returnsselfand is annotated-> Self:@model_validator(mode="after") def validate_topology_consistency(self) -> Self: if self.topology == "multidrop" and self.duplex == "full": raise err_major( "10BASE-T1S PHY declares duplex 'full' on topology '{topology}'. " "A shared multidrop segment has to be half duplex.", category=Category.CONSISTENCY, error_number="324", topology=self.topology, ) return self
Uniqueness is enforced on the owner, not on the owned item — a child model just declares
name; the parent deduplicates its list:
from flync.core.validators.generic import validate_list_items_unique
@model_validator(mode="after")
def validate_unique_ecu_names(self) -> Self:
validate_list_items_unique([ecu.name for ecu in self.ecus], "ECU names")
return self
The error catalog¶
Never raise bare ValueError / assert from a model — every finding carries a globally
unique, documented id:
FLYNC-<MODULE>-<SEVERITY>-<CATEGORY>-<NUMBER> e.g. FLYNC-ECU-MAJ-VAL-001
MODULE — auto-resolved from the
KEYvariable in the domain package’s__init__.py(ECU,SOM,TSN…). Never pass it manually.SEVERITY —
WARN/MIN/MAJ/FAT, from the factory you choose.CATEGORY — the code for the
Categorymember you pass. The two spellings differ: you write the member, the id carries the code (flync.core.utils.exceptions).You pass
Id shows
Category.VALUE_RANGEVALCategory.REQUIREDREQCategory.CONSISTENCYCONSCategory.UNIQUENESSUNIQCategory.REFERENCEREFCategory.FORMATFMTCategory.COMPATIBILITYCOMPCategory.STRUCTURALSTRUCTCategory.LIFECYCLELIFENUMBER — three digits, unique across the whole codebase, never reused.
from flync.core.utils.exceptions import Category, err_major, err_minor, warn
# the offending object is rejected, loading continues — raise the returned PydanticCustomError
raise err_major(
"Port name '{port_name}' is not unique within ECU '{ecu_name}'",
category=Category.UNIQUENESS,
error_number="042",
port_name=port_name,
ecu_name=ecu_name,
)
# field stays usable — warn() is a side effect, do NOT raise it
warn(
"Deprecated field '{field}' used",
category=Category.LIFECYCLE,
error_number="099",
field=field,
)
Message arguments are interpolated into both the message and the catalog entry — name the offending value, not just the rule.
Choosing a severity¶
The severity is not a mood — it selects what the loader does with the finding. Pick it from the consequence you want, not from how bad the mistake feels:
Factory |
What the loader does |
Choose it when |
|---|---|---|
|
Records the finding and keeps the value. Nothing is raised — call it as a side effect. |
The config is usable as written: a deprecated key, an ignored leftover from an older FLYNC version. |
|
The component carrying the bad value is not created; validation continues and collects. A model may still be returned if nothing worse was found. |
One field value is wrong and the rest of the object is independent of it. |
|
Collected like a minor, but no model is returned — the caller gets |
The object is unusable, but the rest of the workspace is still worth validating so the author sees every problem in one run. |
|
Stops validation immediately and re-raises pydantic’s |
Nothing further can be validated meaningfully — reserve it for genuinely unrecoverable structure. |
Prefer err_major over err_fatal by default: aborting on the first problem makes the
author fix the config one error per run. The propagation machinery behind this table is
documented at Error Propagation.
Adding or changing an error¶
flync errors get-next-number # claim the next free number
# ... use it in your err_*/warn factory call ...
flync errors sync # renumber collisions + regenerate error_catalog.rst
docs/source/error_catalog.rst is generated from the source — never hand-edit it. On a branch
that collided with an id on the base branch, flync errors sync --base <ref> keeps the base’s
number and rewrites yours (including ids pinned in tests).
Testing a rule¶
Negative tests pin the exact error via assert_single_error or assert_single_warning
(tests/error_assertions.py) — a substring assert passes on any unrelated error:
from tests.error_assertions import assert_single_error
with pytest.raises(ValidationError) as exc_info:
Bitfield(name="corrupt_bitfield", length=8, fields=nine_fields)
assert_single_error(exc_info, "FLYNC-SOM-MIN-CONS-138", "exceeds the bitfield length (8)")
One fixture triggers exactly one defect; accepted and rejected boundary cases live side by side
in one parametrize — see Writing tests in AGENTS.md.