Coverage for src / ocarina / dsl / invariants / assertions.py: 12.95%
135 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-04 16:22 +0200
« prev ^ index » next coverage.py v7.13.5, created at 2026-06-04 16:22 +0200
1"""Invariants assertions.
3This module provides composable validation predicates for building validation chains.
4Each invariant is a predicate function that raises an InvariantViolationError
5when the condition is not met.
7Predicates can be composed using the validation chain DSL:
8 validate(email, name="email")
9 .assert_that(is_str)
10 .assert_that(is_email)
11 .execute()
12 .raise_if_invalid()
14Higher-order predicates (those that return predicates) allow for parameterized
15validation:
16 validate(age, name="age")
17 .assert_that(is_equal_to(18))
18 .otherwise(is_less_than_or_equal_to(65))
19 .execute()
20 .raise_if_invalid()
21"""
23import re
24from datetime import UTC, datetime
25from pathlib import Path
26from typing import TYPE_CHECKING, Any
28from .errors import (
29 DuplicatesError,
30 InvariantViolationError,
31)
33if TYPE_CHECKING:
34 from collections.abc import Callable, Iterable, Sized
36 from .validate import Predicate
39def is_str(value: Any) -> None: # noqa: ANN401
40 """Assert that value is a string.
42 Args:
43 value: The value to check.
45 Raises:
46 InvariantViolationError: If value is not a string.
48 Example:
49 >>> is_str("hello") # OK
50 >>> is_str(42) # Raises InvariantViolationError
52 """
53 if not isinstance(value, str):
54 msg = "Expected value to be string."
55 raise InvariantViolationError(msg)
58def is_none(value: Any) -> None: # noqa: ANN401
59 """Assert that value is None.
61 Args:
62 value: The value to check.
64 Raises:
65 InvariantViolationError: If value is not None.
67 Example:
68 >>> is_none(None) # OK
69 >>> is_none(42) # Raises InvariantViolationError
71 """
72 if value is not None:
73 msg = f"Expected None, got {value!r}."
74 raise InvariantViolationError(msg)
77def is_not_none(value: Any) -> None: # noqa: ANN401
78 """Assert that value is not None.
80 Args:
81 value: The value to check.
83 Raises:
84 InvariantViolationError: If value is None.
86 Example:
87 >>> is_not_none(42) # OK
88 >>> is_not_none(None) # Raises InvariantViolationError
90 """
91 if value is None:
92 msg = "Expected value not to be None."
93 raise InvariantViolationError(msg)
96def is_equal_to(cmp: Any) -> Predicate[Any]: # noqa: ANN401
97 """Create a predicate that asserts value equals the comparison value.
99 This is a higher-order function that returns a predicate configured
100 with a specific comparison value.
102 Args:
103 cmp: The value to compare against.
105 Returns:
106 A predicate function that checks equality.
108 Raises:
109 InvariantViolationError: If value != cmp (raised by returned predicate).
111 Example:
112 >>> check_is_five = is_equal_to(5)
113 >>> check_is_five(5) # OK
114 >>> check_is_five(3) # Raises InvariantViolationError
116 """
118 def unwrapped(value: Any) -> None: # noqa: ANN401
119 if value != cmp:
120 msg = f"{value} is not equal to {cmp}."
121 raise InvariantViolationError(msg)
123 return unwrapped
126def is_less_than_or_equal_to(cmp: float) -> Predicate[float]:
127 """Create a predicate that asserts value <= comparison value.
129 Args:
130 cmp: The upper bound (inclusive).
132 Returns:
133 A predicate function that checks the bound.
135 Raises:
136 InvariantViolationError: If value > cmp (raised by returned predicate).
138 Example:
139 >>> check_max_100 = is_less_than_or_equal_to(100)
140 >>> check_max_100(50) # OK
141 >>> check_max_100(150) # Raises InvariantViolationError
143 """
145 def unwrapped(value: float) -> None:
146 if value > cmp:
147 msg = f"{value} is not less than or equal to {cmp}."
148 raise InvariantViolationError(msg)
150 return unwrapped
153def is_positive(value: float) -> None:
154 """Assert that value is positive (>= 0).
156 Args:
157 value: The numeric value to check.
159 Raises:
160 InvariantViolationError: If value < 0.
162 Example:
163 >>> is_positive(42) # OK
164 >>> is_positive(0) # OK
165 >>> is_positive(-1) # Raises InvariantViolationError
167 """
168 if value < 0:
169 msg = f"Expected positive number, got {value}."
170 raise InvariantViolationError(msg)
173def is_not_zero(value: float) -> None:
174 """Assert that value is not zero.
176 Args:
177 value: The numeric value to check.
179 Raises:
180 InvariantViolationError: If value == 0.
182 Example:
183 >>> is_not_zero(42) # OK
184 >>> is_not_zero(-1) # OK
185 >>> is_not_zero(0) # Raises InvariantViolationError
187 """
188 if value == 0:
189 msg = "Value must not be zero."
190 raise InvariantViolationError(msg)
193def is_in(elements: Iterable[Any]) -> Predicate[Any]:
194 """Create a predicate that asserts value is in the given collection.
196 Args:
197 elements: The collection of allowed values.
199 Returns:
200 A predicate function that checks membership.
202 Raises:
203 InvariantViolationError: If value not in elements.
205 Example:
206 >>> check_color = is_in(["red", "green", "blue"])
207 >>> check_color("red") # OK
208 >>> check_color("yellow") # Raises InvariantViolationError
210 """
211 elms = tuple(elements)
213 def unwrapped(value: Any) -> None: # noqa: ANN401
214 if value not in elms:
215 pretty_elms = ", ".join(map(str, elms))
216 msg = f"Value {value!r} must be in: {pretty_elms}."
217 raise InvariantViolationError(msg)
219 return unwrapped
222def is_file(value: str | Path) -> None:
223 """Assert that the path points to an existing file.
225 Args:
226 value: A file path (string or Path object).
228 Raises:
229 InvariantViolationError: If the path doesn't point to a file.
231 Example:
232 >>> is_file("/path/to/existing/file.txt") # OK
233 >>> is_file("/path/to/directory") # Raises InvariantViolationError
235 """
236 if not Path(value).is_file():
237 msg = f"'{value}' does not point to a file."
238 raise InvariantViolationError(msg)
241def is_dir(value: str | Path) -> None:
242 """Assert that the path points to an existing directory.
244 Args:
245 value: A directory path (string or Path object).
247 Raises:
248 InvariantViolationError: If the path doesn't point to a directory.
250 Example:
251 >>> is_dir("/path/to/directory") # OK
252 >>> is_dir("/path/to/file.txt") # Raises InvariantViolationError
254 """
255 if not Path(value).is_dir():
256 msg = f"'{value}' does not point to a directory."
257 raise InvariantViolationError(msg)
260def is_iso_date_string(value: str) -> None:
261 """Assert that the string is a valid ISO 8601 date string.
263 Args:
264 value: The string to validate.
266 Raises:
267 InvariantViolationError: If the string is not a valid ISO date.
269 Example:
270 >>> is_iso_date_string("2025-12-18") # OK
271 >>> is_iso_date_string("2025-12-18T10:30:00Z") # OK
272 >>> is_iso_date_string("invalid") # Raises InvariantViolationError
274 """
275 try:
276 datetime.fromisoformat(value)
277 except Exception as exc:
278 msg = f"'{value}' is not a valid ISO date string."
279 raise InvariantViolationError(msg) from exc
282def is_iso_utc_date_string(value: str) -> None:
283 """Assert that the string is a valid ISO 8601 UTC date string.
285 The date must be in UTC timezone (not just a valid ISO date).
287 Args:
288 value: The string to validate.
290 Raises:
291 InvariantViolationError: If the string is not a valid UTC ISO date.
293 Example:
294 >>> is_iso_utc_date_string("2025-12-18T10:30:00+00:00") # OK
295 >>> is_iso_utc_date_string("2025-12-18T10:30:00Z") # OK
296 >>> is_iso_utc_date_string("2025-12-18T10:30:00+02:00") # Not UTC, raises error
297 >>> is_iso_utc_date_string("2025-12-18") # No timezone, raises error
299 """
300 try:
301 dt = datetime.fromisoformat(value)
302 except Exception as exc:
303 msg = f"'{value}' is not a valid ISO date string."
304 raise InvariantViolationError(msg) from exc
306 if dt.tzinfo != UTC:
307 msg = f"'{value}' is not in UTC (tz={dt.tzinfo})."
308 raise InvariantViolationError(msg)
311def is_email(value: str) -> None:
312 """Assert that the string is a valid email address (fast check)."""
313 if " " in value:
314 msg = f"'{value}' must not contain whitespace."
315 raise InvariantViolationError(msg)
317 if value.count("@") != 1:
318 msg = f"'{value}' must contain exactly one '@' character."
319 raise InvariantViolationError(msg)
321 local_part, domain_part = value.split("@")
322 if not local_part or not domain_part:
323 msg = f"'{value}' must have non-empty local and domain parts."
324 raise InvariantViolationError(msg)
326 if "." not in domain_part:
327 msg = f"Domain part of '{value}' must contain at least one '.'."
328 raise InvariantViolationError(msg)
331def has_unique_elements(
332 *,
333 key: Callable[[Any], Any] | None = None,
334):
335 """Create a predicate that asserts all elements in a collection are unique.
337 Uses strict type checking: 1, 1.0, and True are considered different values.
338 Supports unhashable types (lists, dicts, sets) by comparing them directly.
340 Args:
341 key: Optional function to extract comparison key from each element.
342 If None, elements are compared directly.
344 Returns:
345 A predicate function that checks uniqueness.
347 Raises:
348 DuplicatesError: If duplicates are found (raised by returned predicate).
350 Example:
351 >>> check_unique = has_unique_elements()
352 >>> check_unique([1, 2, 3]) # OK
353 >>> check_unique([1, 2, 2, 3]) # Raises DuplicatesError
354 >>> check_unique([[1, 2], [3, 4]]) # OK (unhashable types supported)
356 """
358 def unwrapped(value: Iterable[Any]) -> None:
359 items = list(value)
360 key_fn = key or (lambda x: x)
362 seen: list[tuple[type, Any]] = []
363 duplicates = []
365 for item in items:
366 keyed = key_fn(item)
367 typed_key = (type(keyed), keyed)
369 found = False
370 for seen_key in seen:
371 if typed_key[0] == seen_key[0] and typed_key[1] == seen_key[1]:
372 found = True
373 if keyed not in duplicates:
374 duplicates.append(keyed)
375 break
377 if not found:
378 seen.append(typed_key)
380 if duplicates:
381 raise DuplicatesError(duplicates)
383 return unwrapped
386def is_empty(value: Sized) -> None:
387 """Assert that the collection is empty.
389 Args:
390 value: Any collection with a length (list, dict, string, etc.).
392 Raises:
393 InvariantViolationError: If the collection is not empty.
395 Example:
396 >>> is_empty([]) # OK
397 >>> is_empty("") # OK
398 >>> is_empty([1, 2, 3]) # Raises InvariantViolationError
400 """
401 if len(value) != 0:
402 msg = "Value must be empty."
403 raise InvariantViolationError(msg)
406def is_truthy(value: Any) -> None: # noqa: ANN401 -> This is intentional.
407 """Assert that value is truthy in Python's boolean context.
409 Args:
410 value: The value to check.
412 Raises:
413 InvariantViolationError: If value is falsy (False, None, 0, "", [], etc.).
415 Example:
416 >>> is_truthy(42) # OK
417 >>> is_truthy("hello") # OK
418 >>> is_truthy([1]) # OK
419 >>> is_truthy(0) # Raises InvariantViolationError
420 >>> is_truthy("") # Raises InvariantViolationError
421 >>> is_truthy([]) # Raises InvariantViolationError
423 """
424 if not value:
425 msg = "Value must be truthy."
426 raise InvariantViolationError(msg)
429_forbidden_chars = re.compile(r'[\x00-\x1f\\/:*?"<>|]')
430_windows_reserved = re.compile(
431 r"^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$", re.IGNORECASE
432)
435def is_valid_filename(value: str) -> None:
436 r"""Assert that the string is a valid filename on all major OSes.
438 Applies the union of restrictions from Windows, Linux, and macOS:
439 - No forbidden characters: \\ / : * ? " < > | and control chars (U+0000; U+001F)
440 - No leading or trailing dot or space
441 - Not a Windows reserved name (CON, NUL, COM1…COM9, LPT1…LPT9, etc.)
442 - Length between 1 and 255 characters
444 Args:
445 value: The filename string to validate.
447 Raises:
448 InvariantViolationError: If the string is not a valid cross-platform filename.
450 Example:
451 >>> is_valid_filename("my_test_runner") # OK
452 >>> is_valid_filename("test/run") # Raises InvariantViolationError
453 >>> is_valid_filename("CON") # Raises InvariantViolationError
454 >>> is_valid_filename(".hidden") # Raises InvariantViolationError
456 """
457 if not value:
458 msg = "Filename must not be empty."
459 raise InvariantViolationError(msg)
461 if len(value) > 255: # noqa: PLR2004
462 msg = f"Filename '{value}' exceeds 255 characters."
463 raise InvariantViolationError(msg)
465 if _forbidden_chars.search(value):
466 msg = (
467 f"Filename '{value}' contains forbidden characters"
468 " "
469 '(control chars or one of: \\ / : * ? " < > |).'
470 )
471 raise InvariantViolationError(msg)
473 if value[0] in (".", " ") or value[-1] in (".", " "):
474 msg = f"Filename '{value}' must not start or end with a dot or a space."
475 raise InvariantViolationError(msg)
477 stem = value.split(".", 1)[0]
478 if _windows_reserved.match(stem):
479 msg = f"Filename '{value}' uses a reserved Windows device name."
480 raise InvariantViolationError(msg)
483def each(predicate: Predicate[Any]) -> Predicate[Iterable[Any]]:
484 """Create a predicate that applies a predicate to each element of a collection.
486 Args:
487 predicate: The predicate to apply to each element.
489 Returns:
490 A predicate function that checks each element.
492 Raises:
493 InvariantViolationError: If any element fails the predicate.
495 Example:
496 >>> check_all_valid = each(is_valid_filename)
497 >>> check_all_valid(["my_test", "other_test"]) # OK
498 >>> check_all_valid(["my_test", "CON"]) # Raises InvariantViolationError
500 """
502 def unwrapped(value: Iterable[Any]) -> None:
503 for item in value:
504 predicate(item)
506 return unwrapped