Source code for syside.testing

  1"""
  2Helpers for writing pytest tests over SysMLv2/KerML models.
  3
  4This module provides small, dependency-light utilities for asserting
  5properties about models loaded with :mod:`syside`:
  6
  7* :func:`describe` renders a human-readable identifier for a model element,
  8  used in assertion failure messages.
  9* :func:`assert_all`, :func:`assert_any` and :func:`assert_none` assert
 10  quantified predicates over an iterable of elements and raise
 11  ``AssertionError`` with a clear, multi-line message that names the
 12  offending elements.
 13* :func:`model_fixture` builds a pytest fixture that loads a model once per
 14  scope (session by default), so a test suite does not reload it per test.
 15
 16The assertion helpers consume their input iterable exactly once and call the
 17``describe`` callback only for the (capped) set of elements that actually
 18appear in a failure message, so they stay cheap on large models and on the
 19success path.
 20"""
 21
 22from __future__ import annotations
 23
 24import os
 25from collections.abc import Callable, Hashable, Iterable, Iterator
 26from typing import Literal
 27
 28import syside
 29
 30__all__ = [
 31    "describe",
 32    "assert_all",
 33    "assert_any",
 34    "assert_none",
 35    "assert_implies",
 36    "assert_foreach_unique",
 37    "assert_acyclic",
 38    "assert_reachable",
 39    "model_fixture",
 40]
 41
 42# Pytest fixture scopes, narrowed from pytest's own ``_ScopeName``.
 43Scope = Literal["session", "package", "module", "class", "function"]
 44
 45
[docs] 46def describe(item: object) -> str: 47 """ 48 Return a default human-readable identifier for a model element. 49 50 If ``item`` has a non-``None`` ``qualified_name``, return 51 ``f"{type(item).__name__} {item.qualified_name}"`` (the qualified name 52 renders to e.g. ``Pkg::Sub::Name``). Otherwise, or if attribute access 53 raises, fall back to ``repr(item)``. 54 55 This function never raises. 56 """ 57 try: 58 qualified_name = item.qualified_name # type: ignore[attr-defined] 59 if qualified_name is not None: 60 return f"{type(item).__name__} {qualified_name}" 61 except Exception: 62 pass 63 return repr(item)
64 65 66def _label_of[T](predicate: Callable[[T], object], label: str | None) -> str: 67 if label is not None: 68 return label 69 name = getattr(predicate, "__name__", None) 70 return name if isinstance(name, str) else repr(predicate) 71 72 73def _render[T]( 74 offenders: list[T], 75 total_reported: int, 76 describe: Callable[[T], str], 77) -> str: 78 lines = [f" - {describe(item)}" for item in offenders] 79 remaining = total_reported - len(offenders) 80 if remaining > 0: 81 lines.append(f" … and {remaining} more") 82 return "\n".join(lines) 83 84
[docs] 85def assert_all[T]( 86 items: Iterable[T], 87 predicate: Callable[[T], object], 88 *, 89 describe: Callable[[T], str] = describe, 90 label: str | None = None, 91 limit: int = 10, 92) -> None: 93 """ 94 Assert that ``predicate`` is truthy for every item in ``items``. 95 96 Passes vacuously when ``items`` is empty. On failure, raises 97 ``AssertionError`` naming the items for which ``predicate`` was falsy 98 (the counterexamples), listing at most ``limit`` of them. 99 100 ``predicate`` is called exactly once per item. ``describe`` is called 101 only on the items that appear in the failure message. 102 """ 103 total = 0 104 failed_count = 0 105 offenders: list[T] = [] 106 for item in items: 107 total += 1 108 if not predicate(item): 109 failed_count += 1 110 if len(offenders) < limit: 111 offenders.append(item) 112 if failed_count == 0: 113 return 114 name = _label_of(predicate, label) 115 body = _render(offenders, failed_count, describe) 116 raise AssertionError( 117 f"assert_all {name}: {failed_count} of {total} items failed:\n{body}" 118 )
119 120
[docs] 121def assert_any[T]( 122 items: Iterable[T], 123 predicate: Callable[[T], object], 124 *, 125 describe: Callable[[T], str] = describe, 126 label: str | None = None, 127 limit: int = 10, 128) -> None: 129 """ 130 Assert that ``predicate`` is truthy for at least one item in ``items``. 131 132 Fails when ``items`` is empty. On failure, raises ``AssertionError`` 133 stating that none of the examined items satisfied the predicate, listing 134 at most ``limit`` of the examined items. 135 136 ``predicate`` is called exactly once per item, and short-circuits as soon 137 as a truthy result is found. ``describe`` is called only on the items that 138 appear in the failure message. 139 """ 140 total = 0 141 examined: list[T] = [] 142 for item in items: 143 total += 1 144 if predicate(item): 145 return 146 if len(examined) < limit: 147 examined.append(item) 148 name = _label_of(predicate, label) 149 body = _render(examined, total, describe) 150 raise AssertionError( 151 f"assert_any {name}: none of {total} items satisfied it:\n{body}" 152 )
153 154
[docs] 155def assert_none[T]( 156 items: Iterable[T], 157 predicate: Callable[[T], object], 158 *, 159 describe: Callable[[T], str] = describe, 160 label: str | None = None, 161 limit: int = 10, 162) -> None: 163 """ 164 Assert that ``predicate`` is truthy for no item in ``items``. 165 166 Passes vacuously when ``items`` is empty. On failure, raises 167 ``AssertionError`` naming the items for which ``predicate`` was truthy, 168 listing at most ``limit`` of them. 169 170 ``predicate`` is called exactly once per item. ``describe`` is called 171 only on the items that appear in the failure message. 172 """ 173 total = 0 174 matched_count = 0 175 offenders: list[T] = [] 176 for item in items: 177 total += 1 178 if predicate(item): 179 matched_count += 1 180 if len(offenders) < limit: 181 offenders.append(item) 182 if matched_count == 0: 183 return 184 name = _label_of(predicate, label) 185 body = _render(offenders, matched_count, describe) 186 raise AssertionError( 187 f"assert_none {name}: {matched_count} of {total} items matched:\n{body}" 188 )
189 190
[docs] 191def assert_implies[T]( 192 items: Iterable[T], 193 when: Callable[[T], object], 194 then: Callable[[T], object], 195 *, 196 describe: Callable[[T], str] = describe, 197 label: str | None = None, 198 limit: int = 10, 199) -> None: 200 """ 201 Assert that ``when(item)`` truthy implies ``then(item)`` truthy. 202 203 For every item, if ``when`` is truthy then ``then`` must be truthy too. 204 Passes vacuously when ``items`` is empty (or no item satisfies ``when``). 205 On failure, raises ``AssertionError`` naming the items for which the 206 antecedent held but the consequent failed (the counterexamples), listing 207 at most ``limit`` of them. 208 209 ``when`` is called exactly once per item; ``then`` is called only for items 210 where ``when`` is truthy (short-circuited otherwise). ``describe`` is called 211 only on the items that appear in the failure message. 212 """ 213 antecedent_count = 0 214 failed_count = 0 215 offenders: list[T] = [] 216 for item in items: 217 if not when(item): 218 continue 219 antecedent_count += 1 220 if not then(item): 221 failed_count += 1 222 if len(offenders) < limit: 223 offenders.append(item) 224 if failed_count == 0: 225 return 226 if label is not None: 227 name = label 228 else: 229 name = f"{_callable_name(when)} ⇒ {_callable_name(then)}" 230 body = _render(offenders, failed_count, describe) 231 raise AssertionError( 232 f"assert_implies {name}: {failed_count} of {antecedent_count} items " 233 f"satisfying the antecedent failed the consequent:\n{body}" 234 )
235 236
[docs] 237def assert_foreach_unique[T]( 238 items: Iterable[T], 239 target: Callable[[T], Hashable], 240 *, 241 describe: Callable[[T], str] = describe, 242 label: str | None = None, 243 limit: int = 10, 244) -> None: 245 """ 246 Assert that ``target`` yields a distinct value for each item. 247 248 That is, ``target`` is injective over ``items``. Passes when ``items`` is 249 empty or all target values are distinct. On failure, raises 250 ``AssertionError`` naming the target values shared by more than one item, 251 listing at most ``limit`` colliding values; for each, the items that share 252 it are listed (also capped at ``limit``, with a ``…`` suffix). 253 254 ``target`` is called exactly once per item. ``describe`` is called only on 255 the items that appear in the reported collisions. 256 """ 257 groups: dict[Hashable, list[T]] = {} 258 for item in items: 259 groups.setdefault(target(item), []).append(item) 260 collisions = [(value, group) for value, group in groups.items() if len(group) > 1] 261 if not collisions: 262 return 263 name = label if label is not None else _callable_name(target, fallback="target") 264 lines: list[str] = [] 265 for value, group in collisions[:limit]: 266 shown = group[:limit] 267 rendered = ", ".join(describe(item) for item in shown) 268 if len(group) > len(shown): 269 rendered += ", …" 270 lines.append(f" - {value!r}: {rendered}") 271 remaining = len(collisions) - min(len(collisions), limit) 272 if remaining > 0: 273 lines.append(f" … and {remaining} more") 274 body = "\n".join(lines) 275 raise AssertionError( 276 f"assert_foreach_unique {name}: {len(collisions)} value(s) shared by " 277 f"multiple items:\n{body}" 278 )
279 280
[docs] 281def assert_acyclic[T]( 282 nodes: Iterable[T], 283 successors: Callable[[T], Iterable[T]], 284 *, 285 describe: Callable[[T], str] = describe, 286 label: str | None = None, 287) -> None: 288 """ 289 Assert that the directed graph over ``nodes`` has no directed cycle. 290 291 The graph has ``nodes`` as its (initial) vertices; any node returned by 292 ``successors`` is also part of the graph even if it is not in ``nodes``. A 293 node listed among its own ``successors`` is a length-1 cycle. 294 295 Traversal is an iterative depth-first search with an explicit stack (never 296 recursion), so graphs thousands of nodes deep do not hit the Python 297 recursion limit. Passes when ``nodes`` is empty or the graph is acyclic. On 298 failure, raises ``AssertionError`` naming one concrete cycle as a path. 299 300 ``describe`` is called only on the nodes of the reported cycle. 301 """ 302 # Three-state marking by object identity: unvisited (absent), 303 # on-stack (False), done (True). 304 state: dict[int, bool] = {} 305 keep_alive: dict[int, T] = {} 306 307 for root in nodes: 308 root_id = id(root) 309 if state.get(root_id) is True: 310 continue 311 # Each stack frame: (node, node_id, iterator over its successors). 312 stack: list[tuple[T, int, Iterator[T]]] = [ 313 (root, root_id, iter(successors(root))) 314 ] 315 state[root_id] = False 316 keep_alive[root_id] = root 317 path: list[T] = [root] 318 while stack: 319 _node, _node_id, children = stack[-1] 320 advanced = False 321 for child in children: 322 child_id = id(child) 323 marking = state.get(child_id) 324 if marking is False: 325 # Back edge to an on-stack node: cycle found. 326 cycle = _extract_cycle(path, child) 327 rendered = " → ".join(describe(n) for n in cycle) 328 name = label if label is not None else "graph" 329 raise AssertionError( 330 f"assert_acyclic {name}: cycle found:\n {rendered}" 331 ) 332 if marking is True: 333 continue 334 state[child_id] = False 335 keep_alive[child_id] = child 336 stack.append((child, child_id, iter(successors(child)))) 337 path.append(child) 338 advanced = True 339 break 340 if not advanced: 341 state[_node_id] = True 342 stack.pop() 343 path.pop()
344 345
[docs] 346def assert_reachable[T]( 347 sources: Iterable[T], 348 targets: Iterable[T], 349 successors: Callable[[T], Iterable[T]], 350 *, 351 describe: Callable[[T], str] = describe, 352 label: str | None = None, 353 limit: int = 10, 354) -> None: 355 """ 356 Assert that every source can reach at least one target. 357 358 Reachability follows ``successors`` forward. A source that is itself a 359 target reaches trivially (distance 0). Passes when ``sources`` is empty. 360 When ``sources`` is non-empty but ``targets`` is empty, every source fails. 361 362 On failure, raises ``AssertionError`` naming the sources that cannot reach 363 any target (the counterexamples), listing at most ``limit`` of them. 364 365 ``describe`` is called only on the unreachable sources that appear in the 366 failure message. 367 """ 368 # Keep the targets alive for the whole call: an ``id()`` names an object 369 # only while it lives, and a target dropped here (``targets`` may be a 370 # generator) would not be recognised when the walk reaches it again. 371 kept_targets = list(targets) 372 target_ids = {id(target) for target in kept_targets} 373 total = 0 374 failed_count = 0 375 offenders: list[T] = [] 376 for source in sources: 377 total += 1 378 if not _can_reach(source, target_ids, successors): 379 failed_count += 1 380 if len(offenders) < limit: 381 offenders.append(source) 382 if failed_count == 0: 383 return 384 name = label if label is not None else "reachable" 385 body = _render(offenders, failed_count, describe) 386 raise AssertionError( 387 f"assert_reachable {name}: {failed_count} of {total} sources cannot " 388 f"reach any target:\n{body}" 389 )
390 391 392def _can_reach[T]( 393 source: T, 394 target_ids: set[int], 395 successors: Callable[[T], Iterable[T]], 396) -> bool: 397 """Return whether ``source`` reaches any node whose id is in ``target_ids``.""" 398 # ``visited`` pins each node, not just its id: element wrappers are freed 399 # once dropped and CPython reuses a freed object's id, so a bare id set 400 # would skip a later, distinct node as already visited. 401 visited: dict[int, T] = {id(source): source} 402 stack: list[T] = [source] 403 while stack: 404 node = stack.pop() 405 if id(node) in target_ids: 406 return True 407 for child in successors(node): 408 child_id = id(child) 409 if child_id not in visited: 410 visited[child_id] = child 411 stack.append(child) 412 return False 413 414 415def _extract_cycle[T](path: list[T], back_to: T) -> list[T]: 416 """ 417 Return the cycle in ``path`` closing on ``back_to``. 418 419 ``path`` is the current DFS stack of nodes; ``back_to`` is an on-stack node 420 reached by a back edge. The returned list starts and ends at ``back_to``. 421 """ 422 back_id = id(back_to) 423 start = next(i for i, n in enumerate(path) if id(n) == back_id) 424 return [*path[start:], back_to] 425 426 427def _callable_name(fn: Callable[..., object], *, fallback: str | None = None) -> str: 428 """Return ``fn.__name__`` if a string, else ``fallback`` or ``repr(fn)``.""" 429 name = getattr(fn, "__name__", None) 430 if isinstance(name, str): 431 return name 432 return fallback if fallback is not None else repr(fn) 433 434
[docs] 435def model_fixture( 436 *paths: str | os.PathLike[str], 437 sysml_source: str | None = None, 438 kerml_source: str | None = None, 439 scope: Scope = "session", 440 warnings_as_errors: bool = False, 441) -> Callable[[], syside.Model]: 442 """ 443 Build a pytest fixture that loads a model once per scope. 444 445 Provide the model in exactly one way -- there is no guessing between a 446 path and inline text: 447 448 * positional ``paths`` -- one or more file or directory paths, or 449 * ``sysml_source`` -- a single inline SysML v2 source string, or 450 * ``kerml_source`` -- a single inline KerML source string. 451 452 Passing none, or more than one, of these raises :class:`ValueError` 453 immediately (when the fixture is defined, not when a test runs). 454 455 The fixture loads the model once per ``scope`` (``"session"`` by default, 456 so the suite does not reload it per test) and returns the loaded 457 :class:`syside.Model`. If loading fails to compile -- either because 458 :func:`syside.load_model` raises :class:`syside.ModelError`, or because it 459 returns diagnostics for which ``diagnostics.contains_errors()`` is true -- 460 the fixture fails with an ``AssertionError`` describing the diagnostics. 461 With ``warnings_as_errors=True``, diagnostics containing any warnings fail 462 the fixture the same way. 463 464 Intended use in a ``conftest.py``:: 465 466 import syside.testing 467 468 model = syside.testing.model_fixture("path/to/models") 469 470 or with inline source for a focused test:: 471 472 model = syside.testing.model_fixture(sysml_source="package P { part def A; }") 473 474 The returned object is a pytest fixture; reference it by the name you bind 475 it to (``model`` above) as a test argument. 476 """ 477 import pytest 478 479 chosen = [ 480 name 481 for name, given in ( 482 ("paths", bool(paths)), 483 ("sysml_source", sysml_source is not None), 484 ("kerml_source", kerml_source is not None), 485 ) 486 if given 487 ] 488 if len(chosen) != 1: 489 raise ValueError( 490 "model_fixture takes exactly one of: positional path(s), " 491 f"sysml_source=, or kerml_source= (got: {', '.join(chosen) or 'none'})" 492 ) 493 494 @pytest.fixture(scope=scope) 495 def _model() -> syside.Model: 496 try: 497 # Exactly one entrypoint is chosen (validated above). 498 if paths: 499 model = syside.load_model(list(paths)) 500 elif sysml_source is not None: 501 model = syside.load_model( 502 [("memory://sysml_model.sysml", sysml_source)] 503 ) 504 else: 505 assert kerml_source is not None # the only remaining entrypoint 506 model = syside.load_model( 507 [("memory://kerml_model.kerml", kerml_source)] 508 ) 509 except syside.ModelError as error: 510 raise AssertionError( 511 f"model_fixture failed to load model: {error}" 512 ) from error 513 diagnostics = model.diagnostics 514 if diagnostics.contains_errors(): 515 raise AssertionError(f"model_fixture failed to load model: {diagnostics}") 516 if warnings_as_errors and any(True for _ in diagnostics.warnings): 517 raise AssertionError( 518 "model_fixture loaded the model with warnings " 519 f"(warnings_as_errors=True): {diagnostics}" 520 ) 521 return model 522 523 return _model