02.04.02 — Catalogue des assertions builtin#
Fichier source :
src/ocarina/dsl/invariants/assertions.py~25 prédicats.
Toutes les assertions suivent le même contrat :
- Soit un prédicat direct
(value: T) -> Nonequi lèveInvariantViolationErrorsi le contrat n’est pas respecté. - Soit une closure quand il faut passer un argument de configuration (
is_equal_to(cmp), etc.). - Soit, exceptionnellement, une higher-order function (HOF) comme
each, placée ici par pragmatisme (créer un fichier entier pour ce seul cas serait overkill).
Tableau complet#
| Assertion | Direct / closure / HOF | Domaine | Description |
|---|---|---|---|
is_str(value) | direct | Any | Lève si value n’est pas une str |
is_none(value) | direct | Any | Lève si value is not None |
is_not_none(value) | direct | Any | Lève si value is None |
is_equal_to(cmp) | closure | Any | Retourne (value) -> None qui lève si value != cmp |
is_not_equal_to(cmp) | closure | Any | Retourne (value) -> None qui lève si value == cmp |
is_less_than(cmp) | closure | float | value < cmp |
is_less_than_or_equal_to(cmp) | closure | float | value <= cmp |
is_greater_than(cmp) | closure | float | value > cmp |
is_greater_than_or_equal_to(cmp) | closure | float | value >= cmp |
is_positive(value) | direct | float | value >= 0 |
is_not_zero(value) | direct | float | value != 0 |
is_in(elements) | closure | Any | value in tuple(elements) |
is_file(value) | direct | str | Path | Path(value).is_file() |
is_dir(value) | direct | str | Path | Path(value).is_dir() |
is_iso_date_string(value) | direct | str | datetime.fromisoformat(value) |
is_iso_utc_date_string(value) | direct | str | Vérifie ISO + tzinfo == UTC |
is_email(value) | direct | str | Pas d’espace, exactement 1 @, parts non vides, domaine contient . |
has_unique_elements(*, key=None) | closure | Iterable[Any] | Détecte les doublons (lève DuplicatesError). Type-strict (1 ≠ True), supporte les unhashables. |
is_empty(value) | direct | Sized | len(value) == 0 |
is_truthy(value) | direct | Any | bool(value) is True |
is_valid_filename(value) | direct | str | Cross-platform : chars interdits, mots réservés Windows, pas leading/trailing space ni dot, longueur ≤ 255 |
each(predicate) | HOF | Iterable[Any] | Applique predicate à chaque élément |
Quelques implémentations en détail#
is_email#
def is_email(value: str) -> None:
"""Assert that the string is a valid email address (fast check)."""
if " " in value:
raise InvariantViolationError(f"'{value}' must not contain whitespace.")
if value.count("@") != 1:
raise InvariantViolationError(f"'{value}' must contain exactly one '@' character.")
local_part, domain_part = value.split("@")
if not local_part or not domain_part:
raise InvariantViolationError(f"'{value}' must have non-empty local and domain parts.")
if "." not in domain_part:
raise InvariantViolationError(f"Domain part of '{value}' must contain at least one '.'.")Quatre vérifications minimales. Pas de regex RFC5322. C’est volontaire : l’auteur fait le pari que la validation exhaustive d’un email se fait en envoyant un email, pas en regex.
has_unique_elements#
def has_unique_elements(*, key: Callable[[Any], Any] | None = None):
def unwrapped(value: Iterable[Any]) -> None:
items = list(value)
key_fn = key or (lambda x: x)
seen: list[tuple[type, Any]] = []
duplicates = []
for item in items:
keyed = key_fn(item)
typed_key = (type(keyed), keyed)
found = False
for seen_key in seen:
if typed_key[0] == seen_key[0] and typed_key[1] == seen_key[1]:
found = True
if keyed not in duplicates:
duplicates.append(keyed)
break
if not found:
seen.append(typed_key)
if duplicates:
raise DuplicatesError(duplicates)
return unwrapped- Type-strict :
(type(keyed), keyed)est la clé de comparaison. Conséquence :1,1.0, etTruesont différents (ils ont des types différents). En Python idiomatique,1 == Trueest vrai, mais ici on s’en moque : on veut différencier. - Supporte les unhashables : on compare en O(n²) avec une liste plutôt qu’un set. Mais on peut hasher (
list,dict,setcomme valeurs sont OK). - Lève un
DuplicatesError: sous-classe deInvariantViolationError, qui formate joliment la liste des doublons.
Utilisé pour valider l’unicité des noms de tests / IDs. Depuis 1.1.10, les validateurs de noms (campagnes, suites, cas) fournissent une key qui normalise le nom en NFC puis lui applique casefold : l’unicité des noms devient insensible à la casse (Login et login forment un doublon), tandis que l’unicité des IDs reste exacte.
is_valid_filename#
def is_valid_filename(value: str) -> None:
_forbidden_chars = re.compile(r'[\x00-\x1f\\/:*?"<>|]')
_windows_reserved = re.compile(
r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE
)
if not value:
raise InvariantViolationError("Filename must not be empty.")
if len(value) > 255:
raise InvariantViolationError(f"Filename '{value}' exceeds 255 characters.")
if _forbidden_chars.search(value):
raise InvariantViolationError(
f"Filename '{value}' contains forbidden characters "
'(control chars or one of: \\ / : * ? " < > |).'
)
if value[0] in (".", " ") or value[-1] in (".", " "):
raise InvariantViolationError(f"Filename '{value}' must not start or end with a dot or a space.")
stem = value.split(".", 1)[0]
if _windows_reserved.match(stem):
raise InvariantViolationError(f"Filename '{value}' uses a reserved Windows device name.")- Chars interdits (contrôle +
\ / : * ? " < > |) - Pas de leading/trailing
.ou - Pas de mot réservé par Windows (
CON,PRN,AUX,NUL,COM1-9,LPT1-9) - Longueur comprise entre 1 et 255
Utilisé pour valider les noms de tests, parce que chaque test produit un fichier .log dont le nom dérive du nom du test. Voir validate_test_runners_names
is_iso_utc_date_string#
def is_iso_utc_date_string(value: str) -> None:
try:
dt = datetime.fromisoformat(value)
except Exception as exc:
raise InvariantViolationError(f"'{value}' is not a valid ISO date string.") from exc
if dt.tzinfo != UTC:
raise InvariantViolationError(f"'{value}' is not in UTC (tz={dt.tzinfo}).")Accepte "2025-12-18T10:30:00+00:00", "2025-12-18T10:30:00Z". Refuse "2025-12-18" (pas de timezone) et "2025-12-18T10:30:00+02:00" (pas UTC).
Utilisé côté ocarina-example pour valider l’OTP_CACHE_DATE lu depuis le cache L1.
each#
def each(predicate: Predicate[Any]) -> Predicate[Iterable[Any]]:
def unwrapped(value: Iterable[Any]) -> None:
for item in value:
predicate(item)
return unwrappedvalidate(filenames).assert_that(each(is_valid_filename)).execute().raise_if_invalid()Comment écrire son propre prédicat#
Cas direct :
def is_str(value: Any) -> None:
if not isinstance(value, str):
raise InvariantViolationError("Expected value to be string.")Cas paramétré :
def is_equal_to(cmp: Any) -> Predicate[Any]:
def unwrapped(value: Any) -> None:
if value != cmp:
raise InvariantViolationError(f"{value} is not equal to {cmp}.")
return unwrapped- Lever
InvariantViolationError(jamais une autre exception). - Le message doit être diagnostiquable : inclure
value,cmp, contexte utile. - Le prédicat est une
Callable[[T], None].