Coverage for src / ocarina / dsl / invariants / internals / validation_chain.py: 25.40%

112 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-06-04 16:22 +0200

1"""Validation chain core. 

2 

3Architecture: 

4 ValidationStartBlock: Entry point, awaits first assertion. 

5 ValidationAssertBlock: Accepts more assertions, alternatives, or execution. 

6 _ValidationChain: Internal accumulator of validation steps. 

7 _ValidationResult: Result container with error aggregation. 

8 

9Type flow: 

10 validate(value: T) ──> ValidationStartBlock[T] 

11 | 

12 └─ assert_that(predicate: Predicate[T]) 

13 | 

14 v 

15 ValidationAssertBlock[T] 

16 | 

17 ┌─────┴──────────────┐ 

18 | | 

19 assert_that(P[T]) otherwise(P[T]) 

20 | 

21 v 

22 ValidationAssertBlock[T] (T preserved as long as value doesn't change) 

23 | 

24 └─ then(new_value: U) 

25 | 

26 v 

27 ValidationStartBlock[U] (new type for the related value) 

28 | 

29 └─ assert_that(predicate: Predicate[U]) 

30 | 

31 v 

32 ValidationAssertBlock[U] (chain continues, type adapted to U) 

33 

34Key design decisions: 

35 - Error aggregation: all steps run, errors collected rather than fail-fast. 

36 - .otherwise() implements logical OR by combining predicates with _any_of(). 

37 - .then() threads the same _ValidationChain across value changes, 

38 so chain_validations() and .execute() always see the full picture. 

39 - _ValidationChain is mutable and shared across blocks in the same chain; 

40 blocks hold a reference to it, not a copy. 

41""" 

42 

43from collections.abc import Callable 

44from typing import TYPE_CHECKING, Any, final 

45 

46from ocarina.dsl.invariants.errors import ( 

47 AggregateInvariantViolationError, 

48 InvariantViolationError, 

49) 

50 

51if TYPE_CHECKING: 

52 from collections.abc import Sequence 

53 

54type Predicate[T] = Callable[[T], None] 

55 

56 

57def _with_msg[T]( 

58 predicate: Predicate[T], msg: str | None, name: str | None = None 

59) -> _PredicateWithMsg[T]: 

60 """Wrap a predicate with an optional custom error message. 

61 

62 Args: 

63 predicate: The validation function to wrap. 

64 msg: Optional custom error message to use instead of predicate's default. 

65 name: Optional name prefix for the error message (e.g., "email:"). 

66 

67 Returns: 

68 A wrapped predicate that uses the custom message on failure. 

69 

70 """ 

71 prefix = f"{name}:" if name else "" 

72 full_msg = f"{prefix} {msg}" if msg else None 

73 return _PredicateWithMsg(predicate, full_msg) 

74 

75 

76@final 

77class _PredicateWithMsg[T]: 

78 """Internal wrapper that associates a predicate with a custom error message. 

79 

80 This allows predicates to have context-specific error messages while keeping 

81 the predicate functions themselves reusable. 

82 

83 Attributes: 

84 predicate: The validation function. 

85 msg: Optional custom error message. 

86 

87 """ 

88 

89 def __init__(self, predicate: Predicate[T], msg: str | None = None) -> None: 

90 """Initialize the predicate wrapper. 

91 

92 Args: 

93 predicate: The validation function to wrap. 

94 msg: Optional custom error message to override predicate's default. 

95 

96 """ 

97 self.predicate = predicate 

98 self.msg = msg 

99 

100 def __call__(self, value: T) -> None: 

101 """Execute the predicate with custom error message handling. 

102 

103 Args: 

104 value: The value to validate. 

105 

106 Raises: 

107 InvariantViolationError: If validation fails. 

108 

109 """ 

110 try: 

111 self.predicate(value) 

112 except InvariantViolationError as exc: 

113 if self.msg: 

114 raise InvariantViolationError(self.msg) from exc 

115 raise 

116 

117 

118def _any_of[T](*predicates: _PredicateWithMsg[T]) -> _PredicateWithMsg[T]: 

119 """Create a combined predicate that succeeds if ANY of the predicates succeed. 

120 

121 This implements logical OR for predicates, used by the .otherwise() method. 

122 If all predicates fail, an error listing all failures is raised. 

123 

124 Args: 

125 *predicates: Variable number of predicates to combine with OR logic. 

126 

127 Returns: 

128 A single predicate that passes if at least one input predicate passes. 

129 

130 Example: 

131 >>> # age must be exactly 18 OR less than or equal to 65 

132 >>> combined = _any_of(is_equal_to(18), is_less_than_or_equal_to(65)) 

133 >>> combined(20) # Passes (satisfies second predicate) 

134 >>> combined(70) # Raises (fails both predicates) 

135 

136 """ 

137 

138 def _try_predicate( 

139 p: _PredicateWithMsg[T], value: T 

140 ) -> InvariantViolationError | None: 

141 try: 

142 p(value) 

143 except InvariantViolationError as exc: 

144 return exc 

145 else: 

146 return None 

147 

148 def combined(value: T) -> None: 

149 errors = [] 

150 for p in predicates: 

151 error = _try_predicate(p, value) 

152 if error is None: 

153 return 

154 errors.append(error) 

155 

156 formatted_error_messages = " | " + "\n | ".join(str(e) for e in errors) 

157 msg = ( 

158 f"All predicates failed for value {value!r}.\n" 

159 "» At least one of the following conditions must be satisfied:\n" 

160 f"{formatted_error_messages}" 

161 ) 

162 raise InvariantViolationError(msg) 

163 

164 return _PredicateWithMsg(combined) 

165 

166 

167@final 

168class _ValidationResult: 

169 """Container for validation results with error aggregation. 

170 

171 Attributes: 

172 is_valid: True if all validations passed, False otherwise. 

173 errors: List of all InvariantViolationErrors that occurred. 

174 validated_values: List of values that passed validation. 

175 

176 """ 

177 

178 def __init__( 

179 self, 

180 *, 

181 is_valid: bool, 

182 errors: Sequence[InvariantViolationError], 

183 validated_values: Sequence[Any], 

184 ) -> None: 

185 """Initialize validation result. 

186 

187 Args: 

188 is_valid: Whether all validations passed. 

189 errors: Sequence of validation errors (empty if valid). 

190 validated_values: Sequence of successfully validated values. 

191 

192 """ 

193 self.is_valid = is_valid 

194 self.errors = errors 

195 self.validated_values = validated_values 

196 

197 def raise_if_invalid(self) -> None: 

198 """Raise an exception if validation failed. 

199 

200 Returns: 

201 None. 

202 

203 Side Effects: 

204 Raises an exception if validation failed. 

205 

206 Raises: 

207 AggregateInvariantViolationError: If any validations failed, 

208 containing all accumulated errors. 

209 

210 Example: 

211 >>> result = validate(value).assert_that(predicate).execute() 

212 >>> result.raise_if_invalid() # Raises if validation failed 

213 

214 """ 

215 if not self.is_valid: 

216 raise AggregateInvariantViolationError(self.errors) 

217 

218 

219@final 

220class _ValidationChain: 

221 """Internal accumulator for validation steps. 

222 

223 This class maintains the list of validation steps and executes them 

224 sequentially, collecting errors rather than failing fast. 

225 

226 Attributes: 

227 _steps: List of (value, name, predicate) tuples to execute. 

228 

229 """ 

230 

231 def __init__(self) -> None: 

232 """Initialize an empty validation chain.""" 

233 self._steps: list[tuple[Any, str | None, _PredicateWithMsg[Any]]] = [] 

234 

235 def _merge_chain(self, other: _ValidationChain) -> None: 

236 """Merge assertions from another chain into this one. 

237 

238 Args: 

239 other: Another validation chain whose steps will be appended. 

240 

241 See Also: 

242 - chain_validations 

243 

244 """ 

245 self._steps.extend(other._steps) 

246 

247 def add_assertion( 

248 self, 

249 value: Any, # noqa: ANN401 

250 predicate: _PredicateWithMsg[Any], 

251 name: str | None = None, 

252 ) -> None: 

253 """Add a validation step to the chain. 

254 

255 Args: 

256 value: The value to validate. 

257 predicate: The wrapped predicate to apply. 

258 name: Optional name for better error messages. 

259 

260 """ 

261 self._steps.append((value, name, predicate)) 

262 

263 def execute(self) -> _ValidationResult: 

264 """Execute all validation steps and aggregate results. 

265 

266 Returns: 

267 A ValidationResult containing success status, errors, and valid values. 

268 

269 Note: 

270 This method does NOT raise exceptions. All errors are collected 

271 and returned in the result. Use result.raise_if_invalid() to raise. 

272 

273 """ 

274 

275 def run_step( 

276 value: Any, # noqa: ANN401 

277 predicate_with_msg: _PredicateWithMsg[Any], 

278 ) -> tuple[bool, Any | InvariantViolationError]: 

279 try: 

280 predicate_with_msg(value) 

281 except InvariantViolationError as exc: 

282 return False, exc 

283 else: 

284 return True, value 

285 

286 errors = [] 

287 validated = [] 

288 for value, _, predicate_with_msg in self._steps: 

289 success, outcome = run_step(value, predicate_with_msg) 

290 if success: 

291 validated.append(outcome) 

292 else: 

293 errors.append(outcome) 

294 return _ValidationResult( 

295 is_valid=(len(errors) == 0), errors=errors, validated_values=validated 

296 ) 

297 

298 

299@final 

300class ValidationStartBlock[T]: 

301 """Entry point for a validation chain, awaiting the first assertion. 

302 

303 This is the initial state returned by validate(). 

304 It provides only the assert_that() method to begin validation. 

305 

306 Type Parameters: 

307 T: The type of value being validated. 

308 

309 Example: 

310 >>> start = validate(42, name="age") 

311 >>> start.assert_that(is_positive) # Returns ValidationAssertBlock 

312 

313 """ 

314 

315 def __init__( 

316 self, value: T, chain: _ValidationChain | None = None, name: str | None = None 

317 ) -> None: 

318 """Initialize a validation start block. 

319 

320 Args: 

321 value: The value to validate. 

322 chain: Optional existing chain to continue (used internally by .then()). 

323 name: Optional name for this value in error messages. 

324 

325 """ 

326 self._value = value 

327 self._chain = chain or _ValidationChain() 

328 self._name = name 

329 

330 def assert_that( 

331 self, predicate: Predicate[T], *, msg: str | None = None 

332 ) -> ValidationAssertBlock[T]: 

333 """Add the first validation predicate to the chain. 

334 

335 Args: 

336 predicate: A validation function that raises on failure. 

337 msg: Optional custom error message to use instead of predicate's default. 

338 

339 Returns: 

340 A ValidationAssertBlock for further chaining. 

341 

342 Example: 

343 >>> validate(email, name="email") 

344 ... .assert_that(is_str) 

345 ... .assert_that(is_email) 

346 

347 """ 

348 predicate = _with_msg(predicate, msg, self._name) 

349 self._chain.add_assertion(self._value, predicate, self._name) 

350 return ValidationAssertBlock( 

351 self._value, self._chain, self._name, last_predicate=predicate 

352 ) 

353 

354 

355@final 

356class ValidationAssertBlock[T]: 

357 """Validation block after at least one assertion has been added. 

358 

359 This block provides multiple options: 

360 - .assert_that(): Add another validation 

361 - .otherwise(): Add an alternative predicate (logical OR) 

362 - .then(): Switch to validating a related value 

363 - .execute(): Run all validations and get results 

364 

365 Type Parameters: 

366 T: The type of value being validated. 

367 

368 Example: 

369 >>> validate(age) 

370 ... .assert_that(is_positive) 

371 ... .assert_that(is_less_than_or_equal_to(120)) 

372 ... .execute() 

373 ... .raise_if_invalid() 

374 

375 """ 

376 

377 def __init__( 

378 self, 

379 value: T, 

380 chain: _ValidationChain, 

381 name: str | None = None, 

382 last_predicate: _PredicateWithMsg[T] | None = None, 

383 ) -> None: 

384 """Initialize a validation assert block. 

385 

386 Args: 

387 value: The value being validated. 

388 chain: The validation chain accumulating steps. 

389 name: Optional name for this value in error messages. 

390 last_predicate: The most recently added predicate (for .otherwise()). 

391 

392 """ 

393 self._value = value 

394 self._chain = chain 

395 self._name = name 

396 self._last_predicate = last_predicate 

397 self._otherwise_predicates: list[_PredicateWithMsg[T]] = [] 

398 

399 def assert_that( 

400 self, predicate: Predicate[T], msg: str | None = None 

401 ) -> ValidationAssertBlock[T]: 

402 """Add another validation predicate (logical AND). 

403 

404 Args: 

405 predicate: A validation function that raises on failure. 

406 msg: Optional custom error message. 

407 

408 Returns: 

409 Self for chaining. 

410 

411 Example: 

412 >>> validate(password) 

413 ... .assert_that(has_min_length(8)) 

414 ... .assert_that(contains_uppercase) 

415 ... .assert_that(contains_digit) 

416 

417 """ 

418 predicate = _with_msg(predicate, msg, self._name) 

419 self._chain.add_assertion(self._value, predicate, self._name) 

420 self._last_predicate = predicate 

421 self._otherwise_predicates = [] 

422 return self 

423 

424 def otherwise( 

425 self, fallback: Predicate[T], *, msg: str | None = None 

426 ) -> ValidationAssertBlock[T]: 

427 """Add an alternative predicate. 

428 

429 Logical OR with the last assertion and any previously added alternatives. 

430 

431 The validation passes if EITHER the last assert_that() OR this otherwise() 

432 succeeds. Multiple otherwise() calls create a multi-way OR. 

433 

434 Args: 

435 fallback: Alternative validation predicate. 

436 msg: Optional custom error message for this alternative. 

437 

438 Returns: 

439 Self for chaining. 

440 

441 Example: 

442 >>> validate(age) 

443 ... .assert_that(is_equal_to(18), msg="Must be 18") 

444 ... .otherwise(is_equal_to(21), msg="Or must be 21") 

445 ... .otherwise(is_equal_to(65), msg="Or must be 65") 

446 

447 """ 

448 if self._last_predicate is None: # pragma: no cover 

449 """Note: Guard to make the type-system happy: 

450 this case isn't allowed by the fluent API.""" 

451 

452 msg = "otherwise() must follow assert_that()." 

453 raise RuntimeError(msg) 

454 

455 fallback_with_msg = _with_msg(fallback, msg, self._name) 

456 combined = _any_of( 

457 self._last_predicate, *([*self._otherwise_predicates, fallback_with_msg]) 

458 ) 

459 self._chain._steps.pop() # noqa: SLF001 

460 self._chain.add_assertion(self._value, combined, self._name) 

461 self._last_predicate = combined 

462 self._otherwise_predicates.append(fallback_with_msg) 

463 return self 

464 

465 def then[U]( 

466 self, new_value: U, *, name: str | None = None 

467 ) -> ValidationStartBlock[U]: 

468 """Switch to validating a related value, continuing the same chain. 

469 

470 This allows validating multiple related values in sequence, with all 

471 errors aggregated together. 

472 

473 Args: 

474 new_value: The next value to validate. 

475 name: Optional name for the new value in error messages. 

476 

477 Returns: 

478 A new ValidationStartBlock for the new value. 

479 

480 Example: 

481 >>> validate(user, name="user") 

482 ... .assert_that(is_not_none) 

483 ... .then(user.email, name="email") 

484 ... .assert_that(is_email) 

485 ... .then(user.age, name="age") 

486 ... .assert_that(is_positive) 

487 ... .execute() 

488 

489 """ 

490 return ValidationStartBlock(new_value, self._chain, name) 

491 

492 def execute(self) -> _ValidationResult: 

493 """Execute all validation steps and return aggregated results. 

494 

495 Returns: 

496 A ValidationResult containing all errors and validated values. 

497 

498 Note: 

499 This does NOT raise exceptions. Use .raise_if_invalid() on the 

500 result to raise an exception if validation failed. 

501 

502 Example: 

503 >>> result = validate(x).assert_that(predicate).execute() 

504 >>> if not result.is_valid: 

505 ... logger.error(f"Validation failed: {result.errors}") 

506 

507 """ 

508 return self._chain.execute() 

509 

510 

511def chain_validations( 

512 first: ValidationAssertBlock[Any], 

513 *rest: ValidationAssertBlock[Any], 

514) -> ValidationAssertBlock[Any]: 

515 """Merge multiple independent validation chains into one. 

516 

517 This allows combining pre-built validation chains, useful for composing 

518 complex validations from reusable pieces. 

519 

520 Args: 

521 first: The first validation chain. 

522 *rest: Additional validation chains to merge. 

523 

524 Returns: 

525 A single ValidationAssertBlock containing all merged validations. 

526 

527 Example: 

528 >>> user_validation = validate(user).assert_that(is_not_none) 

529 >>> email_validation = validate(email).assert_that(is_email) 

530 >>> combined = chain_validations(user_validation, email_validation) 

531 >>> combined.execute().raise_if_invalid() 

532 

533 """ 

534 merged_chain = _ValidationChain() 

535 merged_chain._merge_chain(first._chain) # noqa: SLF001 

536 

537 for block in rest: 

538 merged_chain._merge_chain(block._chain) # noqa: SLF001 

539 

540 return ValidationAssertBlock(first._value, merged_chain, first._name) # noqa: SLF001