02.04.01 — Le flot validate → assert_that → execute → raise_if_invalid

02.04.01 — Le flot validate → assert_that → execute → raise_if_invalid#

Fichier source : src/ocarina/dsl/invariants/validate.py (entry-point) + src/ocarina/dsl/invariants/internals/validation_chain.py

La machine à états#

Diagramme#

validate(value: T)
       │
       ▼
ValidationStartBlock[T]
       │
       └─ assert_that(predicate, *, msg=None)
              │
              ▼
       ValidationAssertBlock[T]   ← état qui accepte plusieurs continuations
              │
              ├─► assert_that(P)     [boucle, AND chaînable, voir le diagramme dédié]
              ├─► otherwise(P)       [OR, voir 03-otherwise-any-of.md]
              ├─► then(U, name=...)  [change de valeur, voir 04-then-chain-of-validations.md]
              └─► execute()          → _ValidationResult
                                          │
                                          ├─ is_valid: bool
                                          ├─ errors: Sequence[InvariantViolationError]
                                          ├─ validated_values: Sequence[Any]
                                          └─ raise_if_invalid() : None | raise AggregateInvariantViolationError

Boucle assert_that#

assert_that formule une assertion sur la valeur courante. Plusieurs assert_that peuvent se suivre, chacun ajoutant une condition supplémentaire à la chaîne (AND logique, ordre préservé).

02.04.02 — Catalogue des assertions builtin

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) -> None qui lève InvariantViolationError si 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#

AssertionDirect / closure / HOFDomaineDescription
is_str(value)directAnyLève si value n’est pas une str
is_none(value)directAnyLève si value is not None
is_not_none(value)directAnyLève si value is None
is_equal_to(cmp)closureAnyRetourne (value) -> None qui lève si value != cmp
is_not_equal_to(cmp)closureAnyRetourne (value) -> None qui lève si value == cmp
is_less_than(cmp)closurefloatvalue < cmp
is_less_than_or_equal_to(cmp)closurefloatvalue <= cmp
is_greater_than(cmp)closurefloatvalue > cmp
is_greater_than_or_equal_to(cmp)closurefloatvalue >= cmp
is_positive(value)directfloatvalue >= 0
is_not_zero(value)directfloatvalue != 0
is_in(elements)closureAnyvalue in tuple(elements)
is_file(value)directstr | PathPath(value).is_file()
is_dir(value)directstr | PathPath(value).is_dir()
is_iso_date_string(value)directstrdatetime.fromisoformat(value)
is_iso_utc_date_string(value)directstrVérifie ISO + tzinfo == UTC
is_email(value)directstrPas d’espace, exactement 1 @, parts non vides, domaine contient .
has_unique_elements(*, key=None)closureIterable[Any]Détecte les doublons (lève DuplicatesError). Type-strict (1 ≠ True), supporte les unhashables.
is_empty(value)directSizedlen(value) == 0
is_truthy(value)directAnybool(value) is True
is_valid_filename(value)directstrCross-platform : chars interdits, mots réservés Windows, pas leading/trailing space ni dot, longueur ≤ 255
each(predicate)HOFIterable[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.

02.04.03 — .otherwise(...) et _any_of

02.04.03 — .otherwise(...) et _any_of#

Comment Ocarina exprime un OR logique entre deux prédicats sans casser l’agrégation d’erreurs.

Le problème#

Cas concret : on veut « age == 18 OR age <= 65 ». La syntaxe ROP/invariants est strictement chaîne d’assertions ; chaque .assert_that() est un AND. Comment exprimer un OR ?

Code#

def otherwise(self, fallback: Predicate[T], *, msg: str | None = None) -> ValidationAssertBlock[T]:
    if self._last_predicate is None:
        raise RuntimeError("otherwise() must follow assert_that().")

    fallback_with_msg = _with_msg(fallback, msg, self._name)
    combined = _any_of(
        self._last_predicate, *([*self._otherwise_predicates, fallback_with_msg])
    )
    self._chain._steps.pop()                    # remplace le dernier step
    self._chain.add_assertion(self._value, combined, self._name)
    self._last_predicate = combined
    self._otherwise_predicates.append(fallback_with_msg)
    return self
  1. Guard. otherwise() doit suivre un assert_that(). Sans prédicat précédent, on lève. (En pratique, le type-checker l’interdit déjà : ValidationStartBlock.otherwise n’existe pas.)
  2. Combinaison. On combine le prédicat précédent + tous les otherwise déjà accumulés + le nouveau, via _any_of. Le résultat est un prédicat composite.
  3. Remplacement du step. On pop le dernier step du _ValidationChain et on push le step combiné. Sans ça, on aurait à la fois le prédicat original ET le combiné, donc l’erreur originale resurgirait.

_any_of#

def _any_of[T](*predicates: _PredicateWithMsg[T]) -> _PredicateWithMsg[T]:
    def _try_predicate(p: _PredicateWithMsg[T], value: T) -> InvariantViolationError | None:
        try:
            p(value)
        except InvariantViolationError as exc:
            return exc
        else:
            return None

    def combined(value: T) -> None:
        errors = []
        for p in predicates:
            error = _try_predicate(p, value)
            if error is None:
                return                              # ✅ un prédicat passe → tout passe
            errors.append(error)

        formatted_error_messages = "  | " + "\n  | ".join(str(e) for e in errors)
        msg = (
            f"All predicates failed for value {value!r}.\n"
            "» At least one of the following conditions must be satisfied:\n"
            f"{formatted_error_messages}"
        )
        raise InvariantViolationError(msg)

    return _PredicateWithMsg(combined)
  1. First success wins : dès qu’un prédicat passe, combined retourne None (succès).
  2. Si tous échouent : on construit un message agrégé listant toutes les raisons d’échec, séparées par |
  3. Le résultat est un _PredicateWithMsg : on peut le rebrancher dans la chaîne comme n’importe quel autre prédicat.
  4. L’agrégation est récursive : si on a 3 otherwise() successifs, le 3e combine (P_orig OR P_otherwise1 OR P_otherwise2 OR P_otherwise3) en un seul prédicat.

Exemple#

validate(age, name="age")
    .assert_that(is_equal_to(18), msg="Must be 18 or at most 65")
    .otherwise(is_less_than_or_equal_to(65), msg="Must be 18 or at most 65")
    .execute()
    .raise_if_invalid()
  1. Si age == 18 : is_equal_to(18) passe → OK.
  2. Si age == 30 : is_equal_to(18) lève, is_less_than_or_equal_to(65) passe → OK.
  3. Si age == 70 : les deux lèvent → InvariantViolationError avec :
age: All predicates failed for value 70.
» At least one of the following conditions must be satisfied:
  | age: Must be 18 or at most 65
  | age: Must be 18 or at most 65

(Le doublon de message vient du fait qu’on a passé le même msg= aux deux ; c’est volontaire ici, on veut exprimer une intention unique.)

02.04.04 — .then(...) et chain_validations(...)

02.04.04 — .then(...) et chain_validations(...)#

Deux mécanismes différents pour composer plusieurs validations en une seule.

.then(new_value)#

def then[U](self, new_value: U, *, name: str | None = None) -> ValidationStartBlock[U]:
    return ValidationStartBlock(new_value, self._chain, name)
  1. Change le type de T à U. Le checker accepte le passage à un autre type.
  2. Conserve la chaîne (self._chain). Toutes les assertions précédentes restent dans la chaîne ; toutes les suivantes s’ajoutent à la chaîne.
  3. Bascule le name : c’est le name fourni à .then() qui sera utilisé pour les assertions ajoutées ensuite (et non celui du block précédent).

Cas d’usage canonique#

validate(user, name="user")
    .assert_that(is_not_none)
    .then(user.email, name="user.email")
    .assert_that(is_email)
    .then(user.age, name="user.age")
    .assert_that(is_positive)
    .assert_that(is_less_than_or_equal_to(120))
    .execute()
    .raise_if_invalid()

Lecture : on valide user puis user.email puis user.age. Si toutes ces validations passent : OK. Si ne serait-ce qu’une seule échoue, toutes les erreurs sont agrégées dans le AggregateInvariantViolationError.

02.04.05 — BusinessInvariantValidator vs FrameworkInvariantValidator

02.04.05 — BusinessInvariantValidator vs FrameworkInvariantValidator#

Deux factories — strictement identiques en code — qui existent uniquement pour signaler l’intention.

Code#

class _BaseCustomInvariantValidator:
    @staticmethod
    def create[T](
        value: T,
        name: str,
        build_chain: Callable[[ValidationStartBlock[T], T], ValidationAssertBlock[T]],
    ) -> ValidationAssertBlock[T]:
        return _create_business_invariant_validator(
            value, name, lambda chain: build_chain(chain, value)
        )


@final
class BusinessInvariantValidator(_BaseCustomInvariantValidator):
    """Factory for creating business domain invariant validators.

    Use this for domain-specific validation logic (e.g., "user must be adult",
    "order total must match items").
    """


@final
class FrameworkInvariantValidator(_BaseCustomInvariantValidator):
    """Factory for creating framework-level invariant validators.

    Use this for technical/framework validation logic (e.g., "config must be valid",
    "test structure must be correct").
    """
def _create_business_invariant_validator[T](
    value: T,
    name: str,
    build_chain: ValidationChainBuilder[T],
) -> ValidationAssertBlock[T]:
    start_block = validate(value, name=name)
    return build_chain(start_block)

Pourquoi deux classes ?#

  1. BusinessInvariantValidator signale qu’on valide une règle métier ; FrameworkInvariantValidator, une règle technique du framework. À la lecture du code, l’intention saute aux yeux.
  2. Grep. On peut chercher FrameworkInvariantValidator.create pour trouver tous les invariants framework. Idem pour les business rules côté projet.
  3. Maintenabilité. Si l’on veut un jour ajouter du comportement (log différent, stack-trace différente) à l’une ou à l’autre, on peut le faire sans refactor de tous les call-sites.

Usages#

InvariantCatégorieOù il vit
validate_workers_amountFrameworksrc/ocarina/custom_invariants/testing/workers.py
validate_test_runners_idsFrameworksrc/ocarina/custom_invariants/testing/oc_test_runners_ids.py
validate_test_runners_namesFrameworksrc/ocarina/custom_invariants/testing/oc_test_runners_names.py
validate_test_suites_namesFrameworksrc/ocarina/custom_invariants/testing/oc_test_suites_names.py
validate_test_campaigns_namesFrameworksrc/ocarina/custom_invariants/testing/oc_test_campaigns_names.py
validate_test_cycle_nameFrameworksrc/ocarina/custom_invariants/testing/oc_test_cycles_names.py
Exemple métier : « l’username doit être l’un de la whitelist »BusinessÀ écrire côté projet utilisateur
Exemple métier : « la date de réservation doit être future et < 1 an »BusinessÀ écrire côté projet utilisateur

Pattern complet#

# src/ocarina/custom_invariants/testing/workers.py
from ocarina.dsl.invariants.assertions import is_not_zero, is_positive
from ocarina.dsl.invariants.validate import FrameworkInvariantValidator


def _workers_amount_chain(
    chain: ValidationStartBlock[int],
    value: int,
) -> ValidationAssertBlock[int]:
    msg = f"Value Error: Number of workers must be at least 1 (got: {value})."
    return chain.assert_that(is_positive, msg=msg).assert_that(is_not_zero, msg=msg)


def validate_workers_amount(
    *, workers_amount: int, name: str
) -> ValidationAssertBlock[int]:
    """Validate that workers amount is at least 1."""
    return FrameworkInvariantValidator.create(
        workers_amount, name, _workers_amount_chain
    )
  1. Une fonction privée _workers_amount_chain(chain, value) -> ValidationAssertBlock qui définit le quoi.
  2. Une fonction publique validate_workers_amount(*, workers_amount, name) qui fournit l’API et délègue à FrameworkInvariantValidator.create.
  3. Utilisation côté TestSuite.run : validate_workers_amount(...).execute().raise_if_invalid().

Quelle factory pour quel cas#

Holy Book (extrait du chapitre « Extensibilité ») :

02.04.06 — Hiérarchie d'erreurs des invariants

02.04.06 — Hiérarchie d’erreurs des invariants#

Fichier source : src/ocarina/dsl/invariants/errors.py

InvariantViolationError (base — sous-classe d'Exception)
├── DuplicatesError                  (levée par `has_unique_elements`)
└── AggregateInvariantViolationError (levée par `_ValidationResult.raise_if_invalid`)

InvariantViolationError#

class InvariantViolationError(Exception):
    """Base exception raised when an invariant is violated."""

Une simple sous-classe d’Exception sans logique propre. Tous les prédicats lèvent cette classe (ou une sous-classe).

try:
    validate(value).assert_that(predicate).execute().raise_if_invalid()
except InvariantViolationError as e:
    logger.error(f"Validation failed: {e}")

… attrape toutes les violations en une seule clause.

DuplicatesError#

class DuplicatesError(InvariantViolationError):
    def __init__(self, duplicates: Sequence[Any], message: str | None = None) -> None:
        self.duplicates = duplicates
        msg = message or "Duplicate elements detected:\n" + "\n".join(
            f" - {d}" for d in duplicates
        )
        super().__init__(msg)
  • Stocke la liste des doublons dans .duplicates.
  • Génère un message « Duplicate elements detected:\n - apple\n - banana » par défaut.
  • Accepte un message= custom.

Levée par has_unique_elements