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.

Avantage : un seul .execute(), un seul message d’erreur, une seule propagation. Pas besoin d’écrire trois validate(...).execute().raise_if_invalid().

Subtilité#

Le _chain est partagé par référence entre tous les blocks. C’est volontaire :

The validation chain accumulator is mutable and shared across blocks in the same chain ; blocks hold a reference to it, not a copy.

(Extrait du docstring d’introduction de validation_chain.py.)

C’est ce qui permet d’utiliser .then() proprement : on n’a pas à recombiner après coup.

chain_validations(*)#

def chain_validations(
    first: ValidationAssertBlock[Any],
    *rest: ValidationAssertBlock[Any],
) -> ValidationAssertBlock[Any]:
    merged_chain = _ValidationChain()
    merged_chain._merge_chain(first._chain)
    for block in rest:
        merged_chain._merge_chain(block._chain)
    return ValidationAssertBlock(first._value, merged_chain, first._name)
  1. Crée une nouvelle chaîne. Pas de mutation des chaînes en argument.
  2. Merge dans l’ordre : tous les steps de la première chaîne, puis tous les steps de la seconde, puis de la troisième, etc.
  3. Retourne un ValidationAssertBlock : on peut appeler .execute() dessus.

Cas d’usage#

# Exemple libre : validations indépendantes pré-construites
user_validation = validate(user, name="user").assert_that(is_not_none)
email_validation = validate(email, name="email").assert_that(is_email)
age_validation = validate(age, name="age").assert_that(is_positive)

chain_validations(user_validation, email_validation, age_validation).execute().raise_if_invalid()
# Dans TestCycle.__init__
chain_validations(
    validate_test_cycle_name(cycle_name=name, name="test_cycle"),
    validate_campaigns_names(campaigns=all_campaigns, name="all campaigns (smoke + deep tests)"),
    validate_test_suites_names(suites=campaign._suites, name="suites"),
    validate_test_runners_names(tests=suite._tests, name="tests"),
).execute().raise_if_invalid()

.then() vs chain_validations(*)#

Critère.then()chain_validations()
Quand l’utiliserPlusieurs valeurs liées (user, user.email, user.age)Plusieurs validations indépendantes déjà construites
StyleLinéaire dans une chaîneComposition après-coup
MutationMute la chaîne sous-jacenteCrée une nouvelle chaîne (pas de mutation)
Type de retourValidationStartBlock[U]ValidationAssertBlock[Any]
Cas d’usageValidation d’une dataclass et de ses champsValidation des invariants framework au constructeur d’un TestCycle, ou validation d’un invariant métier complexe

Tests de type#

tests/dsl/invariants/test_types.yml contient :

- case: then_allows_type_change
  description: .then() should allow switching to a different type in the validation chain
  main: |
    validate(42).assert_that(is_positive).then("email@test.com").assert_that(is_email)

Le checker accepte de passer d’int à str via .then(), mais refuse une assertion incompatible avec le type courant :

validate(42).assert_that(is_email)
#                        ^^^^^^^^
# error: Argument 1 to "assert_that" has incompatible type
#   "Callable[[str], None]"; expected "Callable[[int], None]"