Model patterns¶
The base class¶
Every model extends FLYNCBaseModel. It already sets
extra="forbid", validate_by_name=True and validate_assignment=True, and overrides
model_dump to drop None values and dump aliases — so do not re-declare the config
and do not pass exclude_none/by_alias at every call site.
from typing import Literal
from pydantic import Field
from flync.core.base_models import FLYNCBaseModel
class BASET1(FLYNCBaseModel):
"""..."""
mode: Literal["base_t1"] = Field(default="base_t1")
speed: Literal[100, 1000] = Field(default=100)
duplex: Literal["full"] = Field(default="full")
role: Literal["master", "slave"] = Field(default="slave")
autonegotiation: bool = Field(default=False)
A polymorphic family may instead inherit from an abstract base (class X(abc.ABC, FLYNCBaseModel))
that declares the shared fields and the tag — see SOMEIPServiceDeployment.
Fields¶
The shapes below are written in the current conventions rather than copied from source — the tree still carries older spellings of the same declarations (see Typing rules).
name: str = Field() # required
description: str | None = Field(default=None) # optional, immutable default
frames: list[CANFrame] = Field(default_factory=list) # optional, mutable default
service: int = Field(gt=0, lt=0xFFFF, strict=True) # constrained scalar
multicast_groups: list[MulticastGroupMembership] = Field(default_factory=list, exclude=True)
Use
strict=Trueon integer ids and protocol values so a YAML string is an error, not a coercion.Put range constraints (
gt/ge/lt/le) on theField— not in a hand-written validator.exclude=Truekeeps internally derived fields (populated after load) out of the dumped YAML.
The live counterparts are worth reading next to them: multicast_groups in
flync_4_ecu/ecu.py, the constrained service id in flync_4_someip/deployment.py (which
also carries a Reference annotation), and CANBus.frames in flync_4_bus/can_bus.py,
whose element type is a discriminated union — see Structure & polymorphism.
Docstrings are the API reference¶
The Sphinx API docs and the model reference are generated from the class docstring, so every
field the class itself declares needs an entry in its NumPy-style Parameters section — name,
type, and a statement of the default where it has one. Inherited fields stay documented on the
base class. Refer to other models with fully-qualified :class: roles.
The example below is the real docstring of
BASET1 (src/flync/model/flync_4_ecu/phy.py):
class BASET1(FLYNCBaseModel):
"""
Represents a BASE-T1 Ethernet interface configuration.
Parameters
----------
mode : Literal["base_t1"]
Interface mode. Defaults to ``"base_t1"``.
speed : int
Supported link speed in megabits per second. Valid values are 100 or 1000.
duplex : Literal["full"]
Duplex mode. Defaults to ``"full"``.
role : Literal["master", "slave"]
Role of the PHY, either master or slave.
autonegotiation : bool, optional
Indicates whether autonegotiation is enabled (defaults to ``False``).
"""
How the default is stated¶
Either form satisfies the gate: the ", optional" suffix on the type line, or the word
default in the description (for example “Defaults to False.”). Two kinds of default need no
statement at all:
a
default_factory—list of Xalready says the field may be omitted;a
Literal-annotated default — the documented type spells out the only legal value, which is whymode,duplexandroleabove carry no", optional".
scripts/ci/check_model_docstrings.py is the gate. It reports five kinds of finding:
missing (an undocumented field), unknown (a documented non-field), type (documented
type disagrees with the annotation), optional (the default statement disagrees with the
field), and bounds (a Field constraint the description never mentions). Its module
docstring carries the authoritative template.
Private attributes¶
A leading-underscore, annotated class attribute is already a private attribute in Pydantic v2:
it is excluded from model_fields and model_dump, and copied by model_copy.
# ✔ the convention
_service_ref: SOMEIPServiceInterface | None = None
_state_effective_members: list[EffectiveMember] = []
# ✘ do not write this — the PrivateAttr default never matches the Optional[T] annotation
_service_ref: Optional[SOMEIPServiceInterface] = PrivateAttr(default=None)
Private attributes hold back-references wired in during workspace resolution (paired with a
Reference annotation — see Structure & polymorphism) and derived caches. Expose them
through read-only @property accessors rather than making them public.
_ecu: ECU | None = None
@property
def ecu(self) -> ECU | None:
return self._ecu
Some classes carry a _type tag this way because the workspace binding code reads
obj.type through a property. That is fine — the tag is a private attribute like any other:
_type: Literal["ecu_port"] = "ecu_port"
@property
def type(self) -> str:
return self._type
Typing rules¶
Two conventions, two different enforcers — they are often quoted as one rule, and are not.
PEP 604 unions¶
Write X | Y and X | None rather than typing.Union / typing.Optional; SonarQube
rule S6546 reports the legacy spellings.
def find_ecu(self) -> ECU | None: ... # ✔
def find_ecu(self) -> Optional[ECU]: ... # ✘
Note
This is the rule for new and touched code — it does not describe the tree as it stands.
src/ still holds several hundred Optional[...] / List[...] annotations predating
the convention, so do not read the surrounding code as the example. Equally, do not convert
unrelated files while you are in there: a typing sweep is its own change.
Optional[...] stays the only legal spelling where the inner name must remain quoted — see
the TYPE_CHECKING case below.
Quoted annotations¶
scripts/ci/check_lazy_typing.py runs as a pre-commit hook and as a CI gate. It has no opinion
on Union/Optional; it fails on exactly three findings:
1 — redundant: do not quote an annotation whose names are already in scope.
def locator(self, controller: Controller, socket: Socket) -> str: ... # ✔
def locator(self, controller: "Controller", socket: "Socket") -> str: ... # ✘ redundant
2 — whole-quote: quote only the genuinely-unbound name, never the whole expression.
def _collect(self, model: "FLYNCModel") -> list[str]: ... # ✔ forward/cyclic reference
def _collect(self, model: "FLYNCModel") -> "list[str]": ... # ✘ whole-quote
3 — self-return: a method returning its own class name in quotes — use typing.Self
instead, see Self-typed methods.
For a private attribute whose type is only imported under if TYPE_CHECKING: the name is not
bound at runtime, so it must stay quoted — but only the name: "EthernetMultidropNode" | None
is invalid syntax, hence Optional["EthernetMultidropNode"] remains legal there.
For the rare unavoidable exception, put # lazy-typing: allow on the annotation’s line.
Self-typed methods¶
mode="after" validators take self and return the model — annotate -> Self
(from typing import Self); never the enclosing class name in quotes.
from typing import Self
from pydantic import model_validator
class ECUPort(FLYNCBaseModel):
...
@model_validator(mode="after")
def verify_mdi_and_mii_config_have_same_speed(self) -> Self:
...
return self