bean-test 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bean/test.py ADDED
@@ -0,0 +1,1573 @@
1
+ # ============================================================================ #
2
+ # #
3
+ # ,---. ,---. #
4
+ # / `-<>-' :D \ #
5
+ # | | #
6
+ # . . . #
7
+ # .`-~~~~~~~~~~-' #
8
+ # #
9
+ # Bean there, done that. #
10
+ # #
11
+ # ============================================================================ #
12
+
13
+ __version__ = "0.1.0"
14
+ __doc__ = "Tiny test framework"
15
+ __author__ = "numen-0"
16
+ __license__ = "MIT"
17
+
18
+ # ------------------------------------------------------------------------------
19
+ # api
20
+ # ------------------------------------------------------------------------------
21
+
22
+ __all__ = [
23
+ "Lab", "register", "inline",
24
+ "expect",
25
+ "rand",
26
+ ]
27
+
28
+ # Hack: Hide imported stuff for `dir(module)`
29
+ __dir__ = lambda: __all__
30
+
31
+ # ------------------------------------------------------------------------------
32
+ # imports
33
+ # ------------------------------------------------------------------------------
34
+
35
+ import dataclasses as _dataclasses
36
+ import datetime as _datetime
37
+ import random as _random
38
+ import time as _time
39
+ import typing as _
40
+
41
+ # ------------------------------------------------------------------------------
42
+ # lab
43
+ # ------------------------------------------------------------------------------
44
+
45
+ class Lab:
46
+
47
+ #* vars
48
+
49
+ EMPTY_CASE: tuple[tuple[()]] = ((),)
50
+ tests: dict[str, list[TestCase]] = {}
51
+
52
+ #* types
53
+
54
+ type TestFn = _.Callable[[], None]
55
+ type CaseTestFn[*Ts] = _.Callable[[*Ts], None]
56
+ type InternalTestFn[*Ts] = _.Callable[[*Ts], None]
57
+
58
+ @_dataclasses.dataclass(frozen=True, slots=True)
59
+ class Report:
60
+ total: int
61
+ passed: int
62
+ failed: int
63
+ skipped: int
64
+ results: dict[str, list[tuple[Lab.TestCase, Lab.TestResult]]]
65
+
66
+ def __bool__(self) -> bool:
67
+ return self.ok
68
+
69
+ @property
70
+ def ok(self) -> bool:
71
+ return self.failed == 0
72
+
73
+ @property
74
+ def namespaces(self) -> set[str]:
75
+ return set(self.results.keys())
76
+
77
+ def failures(
78
+ self,
79
+ ) -> list[tuple[Lab.TestCase, Lab.TestResult]]:
80
+ return [
81
+ (test, result)
82
+ for results in self.results.values()
83
+ for test, result in results
84
+ if not result.ok and not test.failable
85
+ ]
86
+
87
+ #* sugar
88
+
89
+ def print(
90
+ self,
91
+ *,
92
+ verbosity: _.Literal["minimal", "simple", "full"] = "simple",
93
+ color: bool = True,
94
+ ) -> bool:
95
+ import sys
96
+ import traceback
97
+
98
+ errors: list[Exception] = []
99
+
100
+ class Color:
101
+ def __init__(self, code: str):
102
+ self.code = code
103
+
104
+ def __call__(self, text: str) -> str:
105
+ if not color: return text
106
+ return f"{self.code}{text}\033[0m"
107
+
108
+ GREEN = Color("\033[0;32m")
109
+ RED = Color("\033[0;31m")
110
+ YELLOW = Color("\033[0;33m")
111
+ DIM = Color("\033[0;2m")
112
+ RESET = Color("\033[0m")
113
+
114
+ res_map = {
115
+ "ok": (
116
+ GREEN("+"),
117
+ GREEN("O"),
118
+ GREEN("[passed]") + " ",
119
+ ),
120
+ "skip": (
121
+ DIM("s"),
122
+ DIM("S"),
123
+ DIM("[disabled]"),
124
+ ),
125
+ "warn": (
126
+ YELLOW("f"),
127
+ YELLOW("/"),
128
+ YELLOW("[ignored]") + " ",
129
+ ),
130
+ "error": (
131
+ RED("F"),
132
+ RED("X"),
133
+ RED("[error]") + " ",
134
+ ),
135
+ }
136
+
137
+ def p_res(test: Lab.TestCase, result: Lab.TestResult):
138
+ duration = result.duration * 1000
139
+
140
+ if result.skipped:
141
+ status = "skip"
142
+ elif result.ok:
143
+ status = "ok"
144
+ elif result.failable:
145
+ status = "warn"
146
+ else:
147
+ status = "error"
148
+
149
+ if result.exception is not None:
150
+ errors.append(result.exception)
151
+
152
+ symbol, mark, label = res_map[status]
153
+
154
+ match verbosity:
155
+ case "minimal":
156
+ print(symbol, end="")
157
+ case "simple":
158
+ print(f" - {test.name:{name_width}} : {mark}")
159
+ case "full":
160
+ if not test.is_cased:
161
+ print(f" - {test.name:{name_width}} : {label} "
162
+ f"[{duration:.2f} ms]")
163
+ else:
164
+ print(f" - {test.name:{name_width}} : {label} "
165
+ f"([{result.indx}]={result.case!r}) "
166
+ f"[{duration:.2f} ms]")
167
+
168
+ all_results = sorted(
169
+ [
170
+ (namespace, sorted(results, key=lambda t: t[0].name))
171
+ for namespace, results in self.results.items()
172
+ ],
173
+ key=lambda t: t[0], # namespace
174
+ )
175
+
176
+ name_width = min(32, max(
177
+ (
178
+ len(test.name)
179
+ for _, results in all_results
180
+ for test, _ in results
181
+ ),
182
+ default=0,
183
+ ))
184
+
185
+ for namespace, results in all_results:
186
+ match verbosity:
187
+ case "minimal": pass
188
+ case "simple": print(f"{namespace}:")
189
+ case "full": print(f"{namespace} [{len(results)}]:")
190
+
191
+ for test, result in results:
192
+ p_res(test, result)
193
+
194
+ if verbosity == "minimal":
195
+ print()
196
+
197
+ print("=" * 80)
198
+
199
+ if errors:
200
+ eg = ExceptionGroup("Some test failed", errors)
201
+
202
+ if color: print(RED.code, end="")
203
+ traceback.print_exception(eg, file=sys.stdout)
204
+ if color: print(RESET.code, end="")
205
+
206
+ print("=" * 80)
207
+
208
+ print(
209
+ f"{self.total} tests: "
210
+ f"{self.passed} passed, "
211
+ f"{self.failed} failed, "
212
+ f"{self.skipped} skipped"
213
+ )
214
+
215
+ return self.ok
216
+
217
+ @_dataclasses.dataclass(frozen=True, slots=True)
218
+ class TestResult[*Ts]:
219
+ indx: int
220
+ case: tuple[*Ts]
221
+ duration: float
222
+ exception: Exception | None = None
223
+
224
+ failable: bool = False
225
+ skipped: bool = False
226
+
227
+ @property
228
+ def ok(self) -> bool:
229
+ return self.skipped or self.exception is None
230
+
231
+ @_dataclasses.dataclass(frozen=True, slots=True)
232
+ class TestCase[*Ts]:
233
+ fn: Lab.InternalTestFn[*Ts]
234
+ name: str
235
+ cases: _.Sequence[tuple[*Ts]]
236
+
237
+ enabled: bool
238
+ failable: bool
239
+
240
+ @property
241
+ def is_cased(self) -> bool:
242
+ return self.cases != Lab.EMPTY_CASE
243
+
244
+ def run(self) -> list[Lab.TestResult[*Ts]]:
245
+ results = []
246
+
247
+ for i, case in enumerate(self.cases):
248
+ ex = None
249
+ start = _time.perf_counter()
250
+
251
+ try:
252
+ self.fn(*case)
253
+
254
+ except Exception as e:
255
+ ex = e
256
+
257
+ results.append(Lab.TestResult(
258
+ indx=i,
259
+ case=case,
260
+ duration=_time.perf_counter() - start,
261
+ exception=ex,
262
+ failable=self.failable,
263
+ ))
264
+
265
+ return results
266
+
267
+ #* methods
268
+
269
+ @classmethod
270
+ def report(
271
+ cls,
272
+ namespaces: str | _.Collection[str] | None = None,
273
+ failable: _.Literal["ignore", "fail"] = "ignore",
274
+ ) -> Report:
275
+ if namespaces is None:
276
+ selected = cls.tests.keys()
277
+ elif isinstance(namespaces, str):
278
+ selected = (namespaces,)
279
+ else:
280
+ selected = namespaces
281
+
282
+ total, passed, failed, skipped = 0, 0, 0, 0
283
+ results: dict[str, list[tuple[Lab.TestCase, Lab.TestResult]]] = {}
284
+
285
+ for namespace in selected:
286
+ namespace_results: list[tuple[Lab.TestCase, Lab.TestResult]] = []
287
+ tests: list[Lab.TestCase] = cls.tests.get(namespace, [])
288
+
289
+ for test in tests:
290
+ if not test.enabled:
291
+ skipped += len(test.cases)
292
+
293
+ namespace_results.extend([
294
+ (test, Lab.TestResult(
295
+ indx=i,
296
+ case=case,
297
+ duration=-1,
298
+ exception=None,
299
+ failable=test.failable,
300
+ skipped=True,
301
+ )) for i, case in enumerate(test.cases)
302
+ ])
303
+ continue
304
+
305
+ for result in test.run():
306
+ if result.ok or (test.failable and failable == "ignore"):
307
+ passed += 1
308
+
309
+ else:
310
+ failed += 1
311
+
312
+ namespace_results.append((test, result))
313
+
314
+ results[namespace] = namespace_results
315
+ total += len(namespace_results)
316
+
317
+ return Lab.Report(
318
+ total=total,
319
+ passed=passed,
320
+ failed=failed,
321
+ skipped=skipped,
322
+ results=results,
323
+ )
324
+
325
+ #* sugar
326
+
327
+ @classmethod
328
+ def print(
329
+ cls,
330
+ namespaces: str | _.Collection[str] | None = None,
331
+ *,
332
+ failable: _.Literal["ignore", "fail"] = "ignore",
333
+ verbosity: _.Literal["minimal", "simple", "full"] = "minimal",
334
+ color: bool = True,
335
+ ) -> bool:
336
+ return Lab.report(
337
+ namespaces=namespaces,
338
+ failable=failable,
339
+ ).print(
340
+ verbosity=verbosity,
341
+ color=color,
342
+ )
343
+
344
+ # ------------------------------------------------------------------------------
345
+ # test
346
+ # ------------------------------------------------------------------------------
347
+
348
+ @_.overload
349
+ def register[*Ts](
350
+ namespace: str = "default",
351
+ *,
352
+ cases: _.Sequence[tuple[*Ts]],
353
+ enabled: bool = True,
354
+ failable: bool = False,
355
+ ) -> _.Callable[[Lab.CaseTestFn[*Ts]], Lab.CaseTestFn[*Ts]]: ...
356
+
357
+ @_.overload
358
+ def register[*Ts](
359
+ namespace: str = "default",
360
+ *,
361
+ cases: None = None,
362
+ enabled: bool = True,
363
+ failable: bool = False,
364
+ ) -> _.Callable[[Lab.TestFn], Lab.TestFn]: ...
365
+
366
+ def register[*Ts](
367
+ namespace: str = "default",
368
+ *,
369
+ cases: _.Sequence[tuple[*Ts]]|None = None,
370
+ enabled: bool = True,
371
+ failable: bool = False,
372
+ ) -> _.Union[
373
+ _.Callable[[Lab.TestFn], Lab.TestFn],
374
+ _.Callable[[Lab.CaseTestFn[*Ts]], Lab.CaseTestFn[*Ts]],
375
+ ]:
376
+ """ Register a test, optionally parameterized by cases. """
377
+
378
+ ns = Lab.tests.setdefault(namespace, [])
379
+
380
+ if cases is None:
381
+ def d1(fn: Lab.TestFn):
382
+
383
+ ns.append(
384
+ Lab.TestCase[()](
385
+ fn=lambda *case: fn(),
386
+ name=fn.__name__,
387
+ cases=Lab.EMPTY_CASE,
388
+ enabled=enabled,
389
+ failable=failable,
390
+ )
391
+ )
392
+
393
+ return fn
394
+ return d1
395
+
396
+ def d2(fn: Lab.CaseTestFn[*Ts]):
397
+
398
+ ns.append(
399
+ Lab.TestCase[*Ts](
400
+ fn=fn,
401
+ name=fn.__name__,
402
+ cases=cases,
403
+ enabled=enabled,
404
+ failable=failable,
405
+ )
406
+ )
407
+
408
+ return fn
409
+
410
+ return d2
411
+
412
+ #* sugar
413
+
414
+ @_.overload
415
+ def inline[*Ts](
416
+ *,
417
+ cases: _.Sequence[tuple[*Ts]],
418
+ enabled: bool = True,
419
+ failable: bool = False,
420
+ ) -> _.Callable[[Lab.CaseTestFn[*Ts]], Lab.CaseTestFn[*Ts]]: ...
421
+
422
+ @_.overload
423
+ def inline[*Ts](
424
+ *,
425
+ cases: None = None,
426
+ enabled: bool = True,
427
+ failable: bool = False,
428
+ ) -> _.Callable[[Lab.TestFn], Lab.TestFn]: ...
429
+
430
+ def inline[*Ts](
431
+ *,
432
+ cases: _.Sequence[tuple[*Ts]]|None = None,
433
+ enabled: bool = True,
434
+ failable: bool = False,
435
+ ) -> _.Union[
436
+ _.Callable[[Lab.TestFn], Lab.TestFn],
437
+ _.Callable[[Lab.CaseTestFn[*Ts]], Lab.CaseTestFn[*Ts]],
438
+ ]: return register(
439
+ namespace="inline",
440
+ cases=cases,
441
+ enabled=enabled,
442
+ failable=failable,
443
+ )
444
+
445
+ # ------------------------------------------------------------------------------
446
+ # rand
447
+ # ------------------------------------------------------------------------------
448
+
449
+ def _instantiate[T](*args, **kwargs) -> _.Callable[[type[T]], T]:
450
+
451
+ def decorator(cls: type[T]) -> T:
452
+ return cls(*args, **kwargs)
453
+
454
+ return decorator
455
+
456
+ @_instantiate()
457
+ class rand:
458
+
459
+ type Factory[T] = _.Callable[[_random.Random], T]
460
+
461
+ CHARS: str = (
462
+ "abcdefghijklmnopqrstuvwxyz"
463
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
464
+ "0123456789"
465
+ )
466
+
467
+ @staticmethod
468
+ def cases(
469
+ *factories: Factory,
470
+ count: int = 1,
471
+ seed: int|float|str|bytes|bytearray|None = None
472
+ ) -> _.Generator[tuple[_.Any, ...], None, None]:
473
+ """ Generate test cases from random factories. """
474
+ rng = _random.Random(seed)
475
+
476
+ return (
477
+ tuple(factory(rng) for factory in factories)
478
+ for _ in range(count)
479
+ )
480
+
481
+ @staticmethod
482
+ def product[T](
483
+ *values: _.Iterable[T],
484
+ ) -> _.Iterable[tuple[T, ...]]:
485
+ """ Return the cartesian product of the given iterables. """
486
+ import itertools
487
+ return itertools.product(*values)
488
+
489
+ @staticmethod
490
+ def choice[T](*values: T) -> Factory[T]:
491
+ """ Return factory that randomly chooses one of the values. """
492
+ if not values:
493
+ raise ValueError("choice() requires at least one value")
494
+
495
+ return lambda rng: rng.choice(values)
496
+
497
+ @staticmethod
498
+ def bind[T](
499
+ factory: _.Callable[..., T],
500
+ **kwargs: _.Any,
501
+ ) -> Factory[T]:
502
+ """ Bind keyword arguments to factory. """
503
+ return lambda rng: factory(rng, **kwargs)
504
+
505
+ @_.overload
506
+ def __call__[T](
507
+ self,
508
+ typ: type[T],
509
+ *,
510
+ min: int|float|None = None,
511
+ max: int|float|None = None,
512
+ ) -> Factory[T]: ...
513
+
514
+ @_.overload
515
+ def __call__(
516
+ self,
517
+ typ: type[bytes],
518
+ *,
519
+ n: int|None = None,
520
+ ) -> Factory[bytes]: ...
521
+
522
+ @_.overload
523
+ def __call__(
524
+ self,
525
+ typ: type[str],
526
+ *,
527
+ min: int|None = None,
528
+ max: int|None = None,
529
+ chars: str = CHARS,
530
+ ) -> Factory[str]: ...
531
+
532
+ @_.overload
533
+ def __call__(
534
+ self,
535
+ typ: type[_datetime.date],
536
+ *,
537
+ min: _datetime.date|None = None,
538
+ max: _datetime.date|None = None,
539
+ ) -> Factory[_datetime.date]: ...
540
+
541
+ @_.overload
542
+ def __call__(
543
+ self,
544
+ typ: _.Any,
545
+ /,
546
+ **kwargs,
547
+ ) -> Factory[_.Any]: ...
548
+
549
+ def __call__[T](
550
+ self,
551
+ typ: type[T],
552
+ *,
553
+ min: int|float|_datetime.date|None = None,
554
+ max: int|float|_datetime.date|None = None,
555
+ n: int|None = None,
556
+ chars: str = CHARS,
557
+ **kwargs,
558
+ ) -> Factory[T]:
559
+ origin = _.get_origin(typ)
560
+ if origin is _.Literal:
561
+ values = _.get_args(typ)
562
+ return rand.choice(*values)
563
+
564
+ import enum, inspect, uuid
565
+
566
+ r: rand.Factory
567
+
568
+ if typ is bool:
569
+ r = lambda rng: bool(rng.getrandbits(1))
570
+
571
+ elif typ is int:
572
+ assert not isinstance(min, _datetime.date)
573
+ assert not isinstance(max, _datetime.date)
574
+ a = int(min) if min is not None else 0
575
+ b = int(max) if max is not None else 20
576
+ r = lambda rng: rng.randint(a, b)
577
+
578
+ elif typ is float:
579
+ assert not isinstance(min, _datetime.date)
580
+ assert not isinstance(max, _datetime.date)
581
+ a = float(min) if min is not None else 0.0
582
+ b = float(max) if max is not None else 1.0
583
+ r = lambda rng: rng.uniform(a, b)
584
+
585
+ elif typ is str:
586
+ assert not isinstance(min, _datetime.date)
587
+ assert not isinstance(max, _datetime.date)
588
+ a = int(min) if min is not None else 0
589
+ b = int(max) if max is not None else 20
590
+ r = lambda rng: "".join(rng.choices(chars, k=rng.randint(a, b)))
591
+
592
+ elif inspect.isclass(typ) and issubclass(typ, enum.Enum):
593
+ r = rand.choice(*tuple(typ))
594
+
595
+ elif typ is bytes:
596
+ n = int(n) if n is not None else 32
597
+ r = lambda rng: rng.randbytes(n=n)
598
+
599
+ elif typ is _datetime.date:
600
+ lo = min or _datetime.date.today()
601
+ hi = max or lo
602
+
603
+ if not isinstance(lo, _datetime.date):
604
+ raise TypeError("date min must be a date")
605
+
606
+ if not isinstance(hi, _datetime.date):
607
+ raise TypeError("date max must be a date")
608
+
609
+ lo, hi = lo.toordinal(), hi.toordinal()
610
+ r = lambda rng: _datetime.date.fromordinal(rng.randint(lo, hi))
611
+
612
+ elif typ is uuid.UUID:
613
+ r = lambda rng: uuid.UUID(int=rng.getrandbits(128))
614
+
615
+ else:
616
+ raise TypeError(f"Unsupported random factory: {typ!r}")
617
+
618
+ return _.cast(rand.Factory[T], r)
619
+
620
+ # ------------------------------------------------------------------------------
621
+ # expect
622
+ # ------------------------------------------------------------------------------
623
+
624
+ class expect:
625
+ __slots__ = ("value",)
626
+
627
+ def __init__(self, value: _.Any):
628
+ self.value = value
629
+
630
+ #* misc
631
+
632
+ def Check(
633
+ self,
634
+ predicate: _.Callable[[_.Any], bool],
635
+ msg: str | None = None,
636
+ ) -> _.Self:
637
+ if not predicate(self.value):
638
+ raise AssertionError(
639
+ msg or f"predicate failed for {self.value!r}"
640
+ )
641
+
642
+ return self
643
+
644
+ def Set(
645
+ self,
646
+ value: _.Any,
647
+ ) -> _.Self:
648
+ self.value = value
649
+ return self
650
+
651
+ def Map(
652
+ self,
653
+ fn: _.Callable[[_.Any], _.Any],
654
+ ) -> _.Self:
655
+ self.value = fn(self.value)
656
+ return self
657
+
658
+ def MapEach(
659
+ self,
660
+ fn: _.Callable[[_.Any], _.Any],
661
+ factory: _.Callable[[_.Iterable], _.Iterable] = list,
662
+ ) -> _.Self:
663
+ self.IsIterable()
664
+ self.value = factory(map(fn, self.value))
665
+ return self
666
+
667
+ #* context
668
+
669
+ class Raises:
670
+ """
671
+ Context manager asserting that one of the given exceptions is raised.
672
+ """
673
+
674
+ def __init__(self, *exc_types: type[BaseException]):
675
+ self.exc_types = exc_types
676
+ self.exception: BaseException | None = None
677
+
678
+ def __enter__(self) -> expect.Raises:
679
+ return self
680
+
681
+ def __exit__(self, exc_type, exc, tb) -> bool:
682
+ names = ", ".join(t.__name__ for t in self.exc_types)
683
+
684
+ if exc is None:
685
+ raise AssertionError(
686
+ f"expected {names} to be raised, but nothing was raised"
687
+ )
688
+
689
+ if not isinstance(exc, self.exc_types):
690
+ raise AssertionError(
691
+ f"expected {names} to be raised, "
692
+ f"got {type(exc).__name__}: {exc}"
693
+ ) from exc
694
+
695
+ self.exception = exc
696
+ return True
697
+
698
+ #* truthiness
699
+
700
+ def Truthy(
701
+ self,
702
+ msg: str | None = None,
703
+ ) -> _.Self:
704
+ if not self.value:
705
+ raise AssertionError(
706
+ msg or f"expected truthy expression, got {self.value!r}"
707
+ )
708
+ return self
709
+
710
+ def Falsy(
711
+ self,
712
+ msg: str | None = None,
713
+ ) -> _.Self:
714
+ if self.value:
715
+ raise AssertionError(
716
+ msg or f"expected falsy expression, got {self.value!r}"
717
+ )
718
+
719
+ return self
720
+
721
+ def Empty(self) -> _.Self:
722
+ try:
723
+ length = len(self.value) # type: ignore
724
+ except TypeError:
725
+ raise TypeError(
726
+ f"expected a sized value, got {type(self.value).__name__}"
727
+ )
728
+
729
+ if length != 0:
730
+ raise AssertionError(
731
+ f"expected {self.value!r} to be empty, got length {length}"
732
+ )
733
+
734
+ return self
735
+
736
+
737
+ def NotEmpty(self) -> _.Self:
738
+ try:
739
+ length = len(self.value) # type: ignore
740
+ except TypeError:
741
+ raise TypeError(
742
+ f"expected a sized value, got {type(self.value).__name__}"
743
+ )
744
+
745
+ if length == 0:
746
+ raise AssertionError(
747
+ f"expected {self.value!r} to not be empty"
748
+ )
749
+
750
+ return self
751
+
752
+ #* membership
753
+
754
+ def In(
755
+ self,
756
+ container: _.Container[_.Any],
757
+ msg: str | None = None,
758
+ ) -> _.Self:
759
+ if self.value not in container:
760
+ raise AssertionError(
761
+ msg or f"expected {self.value!r} to be in {container!r}"
762
+ )
763
+
764
+ return self
765
+
766
+ def NotIn(
767
+ self,
768
+ expected: _.Any,
769
+ msg: str | None = None,
770
+ ) -> _.Self:
771
+ if self.value in expected:
772
+ raise AssertionError(
773
+ msg or f"expected {self.value!r} to not be in {expected!r}"
774
+ )
775
+
776
+ return self
777
+
778
+ def HasAttr(
779
+ self,
780
+ attr: str,
781
+ msg: str | None = None,
782
+ ) -> _.Self:
783
+ if not hasattr(self.value, attr):
784
+ raise AssertionError(
785
+ msg or f"expected {self.value!r} to have attribute '{attr!r}'"
786
+ )
787
+
788
+ return self
789
+
790
+ def NotHasAttr(
791
+ self,
792
+ attr: str,
793
+ msg: str | None = None,
794
+ ) -> _.Self:
795
+ if hasattr(self.value, attr):
796
+ raise AssertionError(
797
+ msg
798
+ or f"expected {self.value!r} not to have attribute '{attr!r}'"
799
+ )
800
+
801
+ return self
802
+
803
+ #* equality
804
+
805
+ def Equal(
806
+ self,
807
+ expected: _.Any,
808
+ msg: str | None = None,
809
+ ) -> _.Self:
810
+ if self.value != expected:
811
+ raise AssertionError(
812
+ msg or f"expected {self.value!r} == {expected!r}"
813
+ )
814
+
815
+ return self
816
+
817
+ def NotEqual(
818
+ self,
819
+ expected: _.Any,
820
+ msg: str | None = None,
821
+ ) -> _.Self:
822
+ if self.value == expected:
823
+ raise AssertionError(
824
+ msg or f"expected {self.value!r} != {expected!r}"
825
+ )
826
+
827
+ return self
828
+
829
+ def Greater(
830
+ self,
831
+ expected: _.Any,
832
+ msg: str | None = None,
833
+ ) -> _.Self:
834
+ if self.value <= expected:
835
+ raise AssertionError(
836
+ msg or f"expected {self.value!r} > {expected!r}"
837
+ )
838
+
839
+ return self
840
+
841
+ def GreaterEqual(
842
+ self,
843
+ expected: _.Any,
844
+ msg: str | None = None,
845
+ ) -> _.Self:
846
+ if self.value < expected:
847
+ raise AssertionError(
848
+ msg or f"expected {self.value!r} >= {expected!r}"
849
+ )
850
+
851
+ return self
852
+
853
+ def Less(
854
+ self,
855
+ expected: _.Any,
856
+ msg: str | None = None,
857
+ ) -> _.Self:
858
+ if self.value >= expected:
859
+ raise AssertionError(
860
+ msg or f"expected {self.value!r} < {expected!r}"
861
+ )
862
+
863
+ return self
864
+
865
+ def LessEqual(
866
+ self,
867
+ expected: _.Any,
868
+ msg: str | None = None,
869
+ ) -> _.Self:
870
+ if self.value > expected:
871
+ raise AssertionError(
872
+ msg or f"expected {self.value!r} <= {expected!r}"
873
+ )
874
+
875
+ return self
876
+
877
+ def AlmostEqual(
878
+ self,
879
+ expected: float,
880
+ msg: str | None = None,
881
+ *,
882
+ rel_tol: float = 1e-9,
883
+ abs_tol: float = 0.0,
884
+ ) -> _.Self:
885
+ import math
886
+ self.IsNumeric()
887
+
888
+ if not math.isclose(
889
+ self.value, # type:ignore
890
+ expected,
891
+ rel_tol=rel_tol,
892
+ abs_tol=abs_tol,
893
+ ):
894
+ raise AssertionError(
895
+ msg or f"expected {self.value!r} == {expected!r} (almost)"
896
+ )
897
+
898
+ return self
899
+
900
+ def NotAlmostEqual(
901
+ self,
902
+ expected: float,
903
+ msg: str | None = None,
904
+ *,
905
+ rel_tol: float = 1e-9,
906
+ abs_tol: float = 0.0,
907
+ ) -> _.Self:
908
+ import math
909
+ self.IsNumeric()
910
+
911
+ if math.isclose(
912
+ self.value, # type:ignore
913
+ expected,
914
+ rel_tol=rel_tol,
915
+ abs_tol=abs_tol,
916
+ ):
917
+ raise AssertionError(
918
+ msg or f"expected {self.value!r} != {expected!r} (almost)"
919
+ )
920
+
921
+ return self
922
+
923
+ #* strings
924
+
925
+ def StartsWith(
926
+ self,
927
+ expected: str,
928
+ msg: str | None = None,
929
+ ) -> _.Self:
930
+ self.IsString()
931
+
932
+ if not self.value.startswith(expected): # type:ignore
933
+ raise AssertionError(
934
+ msg or f"expected {self.value!r} to start with {expected!r}"
935
+ )
936
+
937
+ return self
938
+
939
+ def NotStartsWith(
940
+ self,
941
+ expected: str,
942
+ msg: str | None = None,
943
+ ) -> _.Self:
944
+ self.IsString()
945
+
946
+ if self.value.startswith(expected): # type:ignore
947
+ raise AssertionError(
948
+ msg or f"expected {self.value!r} to not start with {expected!r}"
949
+ )
950
+
951
+ return self
952
+
953
+ def EndsWith(
954
+ self,
955
+ expected: str,
956
+ msg: str | None = None,
957
+ ) -> _.Self:
958
+ self.IsString()
959
+
960
+ if not self.value.endswith(expected): # type:ignore
961
+ raise AssertionError(
962
+ msg or f"expected {self.value!r} to end with {expected!r}"
963
+ )
964
+
965
+ return self
966
+
967
+ def NotEndsWith(
968
+ self,
969
+ expected: str,
970
+ msg: str | None = None,
971
+ ) -> _.Self:
972
+ self.IsString()
973
+
974
+ if self.value.endswith(expected): # type:ignore
975
+ raise AssertionError(
976
+ msg or f"expected {self.value!r} to not end with {expected!r}"
977
+ )
978
+
979
+ return self
980
+
981
+ def Regex(
982
+ self,
983
+ expected: str,
984
+ msg: str | None = None,
985
+ ) -> _.Self:
986
+ import re
987
+ self.IsString()
988
+
989
+ if re.search(expected, self.value) is None: # type:ignore
990
+ raise AssertionError(
991
+ msg or f"expected {self.value!r} to match regex {expected!r}"
992
+ )
993
+
994
+ return self
995
+
996
+ def NotRegex(
997
+ self,
998
+ expected: str,
999
+ msg: str | None = None,
1000
+ ) -> _.Self:
1001
+ import re
1002
+ self.IsString()
1003
+
1004
+ if re.search(expected, self.value) is not None: # type:ignore
1005
+ raise AssertionError(
1006
+ msg or f"expected {self.value!r} not to match regex {expected!r}"
1007
+ )
1008
+
1009
+ return self
1010
+
1011
+ #* quantifiers
1012
+
1013
+ def All(
1014
+ self,
1015
+ predicate: _.Callable[[_.Any], bool],
1016
+ msg: str | None = None,
1017
+ ) -> _.Self:
1018
+ self.IsIterable()
1019
+
1020
+ for index, value in enumerate(self.value):
1021
+ if not predicate(value):
1022
+ raise AssertionError(
1023
+ msg
1024
+ or "expected all items to satisfy predicate, "
1025
+ f"failed at index {index}: {value!r}"
1026
+ )
1027
+
1028
+ return self
1029
+
1030
+ def Any(
1031
+ self,
1032
+ predicate: _.Callable[[_.Any], bool],
1033
+ msg: str | None = None,
1034
+ ) -> _.Self:
1035
+ self.IsIterable()
1036
+
1037
+ for index, value in enumerate(self.value):
1038
+ if predicate(value):
1039
+ return self
1040
+
1041
+ raise AssertionError(
1042
+ msg or "expected any item to satisfy predicate, but none did"
1043
+ )
1044
+
1045
+ def NotAny(
1046
+ self,
1047
+ predicate: _.Callable[[_.Any], bool],
1048
+ msg: str | None = None,
1049
+ ) -> _.Self:
1050
+ self.IsIterable()
1051
+
1052
+ for index, value in enumerate(self.value):
1053
+ if predicate(value):
1054
+ raise AssertionError(
1055
+ msg
1056
+ or "expected no items to satisfy predicate, "
1057
+ f"first match at index {index}: {value!r}"
1058
+ )
1059
+
1060
+ return self
1061
+
1062
+ def IsSorted(
1063
+ self,
1064
+ *,
1065
+ key: _.Callable[[_.Any], _.Any] | None = None,
1066
+ reverse: bool = False,
1067
+ ) -> _.Self:
1068
+ """ Assert that an iterable is sorted. """
1069
+ self.IsIterable()
1070
+
1071
+ values = list(self.value)
1072
+
1073
+ if key is None:
1074
+ key = lambda value: value
1075
+
1076
+ for index in range(1, len(values)):
1077
+ previous = key(values[index - 1])
1078
+ current = key(values[index])
1079
+
1080
+ if reverse:
1081
+ if previous < current:
1082
+ raise AssertionError(
1083
+ f"expected value to be sorted in reverse at index "
1084
+ f"{index-1}: {values[index-1]!r} < {values[index]!r}"
1085
+ )
1086
+ else:
1087
+ if previous > current:
1088
+ raise AssertionError(
1089
+ f"expected value to be sorted at index {index - 1}: "
1090
+ f"{values[index-1]!r} > {values[index]!r}"
1091
+ )
1092
+
1093
+ return self
1094
+
1095
+ #* collections
1096
+
1097
+ def ContainerEqual(
1098
+ self,
1099
+ expected: _.Iterable[_.Any],
1100
+ msg: str | None = None,
1101
+ ) -> _.Self:
1102
+ """ Equal containers, ignoring order but preserving multiplicity. """
1103
+ self.IsIterable()
1104
+
1105
+ actual, expected = list(self.value), list(expected)
1106
+
1107
+ if len(actual) != len(expected):
1108
+ raise AssertionError(
1109
+ msg or (
1110
+ f"expected containers of equal size, "
1111
+ f"got {len(expected)} and {len(actual)}"
1112
+ )
1113
+ )
1114
+
1115
+ for index, value in enumerate(actual):
1116
+ for match_index, candidate in enumerate(expected):
1117
+ if value == candidate:
1118
+ del expected[match_index]
1119
+ break
1120
+ else:
1121
+ raise AssertionError(
1122
+ msg or (
1123
+ f"self.value item at index {index} "
1124
+ f"({value!r}) was not found in expected"
1125
+ )
1126
+ )
1127
+
1128
+ return self
1129
+
1130
+ def SequenceEqual(
1131
+ self,
1132
+ expected: _.Iterable[_.Any],
1133
+ msg: str | None = None,
1134
+ ) -> _.Self:
1135
+ """ Equal sequences. """
1136
+ self.IsIterable()
1137
+
1138
+ actual, expected = list(self.value), list(expected)
1139
+
1140
+ if len(actual) != len(expected):
1141
+ raise AssertionError(
1142
+ msg
1143
+ or "expected sequences of equal length, "
1144
+ f"got {len(expected)} and {len(actual)}"
1145
+ )
1146
+
1147
+ for index, (got, want) in enumerate(zip(actual, expected)):
1148
+ if got != want:
1149
+ raise AssertionError(
1150
+ msg
1151
+ or f"sequences differ at index {index}: "
1152
+ f"expected {want!r}, got {got!r}"
1153
+ )
1154
+ return self
1155
+
1156
+ def SetEqual(
1157
+ self,
1158
+ expected: _.Iterable[_.Any],
1159
+ msg: str | None = None,
1160
+ ) -> _.Self:
1161
+ """ Equality using set semantics. """
1162
+
1163
+ self.Superset(expected, msg)
1164
+ self.Superset(expected, msg)
1165
+
1166
+ return self
1167
+
1168
+ def NotSetEqual(
1169
+ self,
1170
+ expected: _.Iterable[_.Any],
1171
+ msg: str | None = None,
1172
+ ) -> _.Self:
1173
+ """ Disequality using set semantics. """
1174
+ self.IsIterable()
1175
+
1176
+ bucket_value, bucket_expected = list(self.value), list(expected)
1177
+
1178
+ for index, value in enumerate(bucket_expected):
1179
+ if value not in bucket_value:
1180
+ return self
1181
+
1182
+ for index, value in enumerate(bucket_expected):
1183
+ if value not in bucket_value:
1184
+ return self
1185
+
1186
+ raise AssertionError(
1187
+ msg or f"expected {self.value!r} != {expected!r}"
1188
+ )
1189
+
1190
+ def Subset(
1191
+ self,
1192
+ expected: _.Iterable[_.Any],
1193
+ msg: str | None = None,
1194
+ ) -> _.Self:
1195
+ """ Every item in self.value exists in expected. """
1196
+ self.IsIterable()
1197
+
1198
+ bucket = list(expected)
1199
+ for index, value in enumerate(self.value):
1200
+ if value not in bucket:
1201
+ raise AssertionError(
1202
+ msg
1203
+ or f"item at index {index} ({value!r}) "
1204
+ "was not found in expected, "
1205
+ f"expected {self.value!r} c {expected}"
1206
+ )
1207
+
1208
+ return self
1209
+
1210
+ def NotSubset(
1211
+ self,
1212
+ expected: _.Iterable[_.Any],
1213
+ msg: str | None = None,
1214
+ ) -> _.Self:
1215
+ """ Not every item in self.value exists in expected. """
1216
+ self.IsIterable()
1217
+
1218
+ bucket = list(expected)
1219
+ for index, value in enumerate(self.value):
1220
+ if value not in bucket:
1221
+ return self
1222
+
1223
+ raise AssertionError(
1224
+ msg or
1225
+ "expected to not be a subset of expected, "
1226
+ "but every item was found, "
1227
+ f"expected {self.value!r} !c {expected}"
1228
+ )
1229
+
1230
+ def Superset(
1231
+ self,
1232
+ expected: _.Iterable[_.Any],
1233
+ msg: str | None = None,
1234
+ ) -> _.Self:
1235
+ """ Every item in expected exists in self.value. """
1236
+ self.IsIterable()
1237
+
1238
+ bucket = list(self.value)
1239
+ for index, value in enumerate(expected):
1240
+ if value not in bucket:
1241
+ raise AssertionError(
1242
+ msg or (
1243
+ f"expected item at index {index} ({value!r}) "
1244
+ f"was not found, "
1245
+ f"expected {expected!r} c {self.value}"
1246
+ )
1247
+ )
1248
+
1249
+ return self
1250
+
1251
+ def NotSuperset(
1252
+ self,
1253
+ expected: _.Iterable[_.Any],
1254
+ msg: str | None = None,
1255
+ ) -> _.Self:
1256
+ """ Not every item in expected exists in self.value. """
1257
+ self.IsIterable()
1258
+
1259
+ bucket = list(self.value)
1260
+ for index, value in enumerate(expected):
1261
+ if value not in bucket:
1262
+ return self
1263
+
1264
+ raise AssertionError(
1265
+ msg or
1266
+ f"expected to not be a superset of expected, "
1267
+ "but every item was found, "
1268
+ f"expected {expected!r} !c {self.value}"
1269
+ )
1270
+
1271
+ def Disjoint(
1272
+ self,
1273
+ expected: _.Iterable[_.Any],
1274
+ msg: str | None = None,
1275
+ ) -> _.Self:
1276
+ """ No item exists in both containers. """
1277
+ self.IsIterable()
1278
+
1279
+ bucket = list(expected)
1280
+ for index, value in enumerate(self.value):
1281
+ if value in bucket:
1282
+ raise AssertionError(
1283
+ msg or (
1284
+ f"expected containers to disjoint, "
1285
+ f"item at index {index} ({value!r}) exists in both, "
1286
+ f"expected {expected!r} n {self.value} == 0"
1287
+ )
1288
+ )
1289
+
1290
+ return self
1291
+
1292
+ def Intersects(
1293
+ self,
1294
+ expected: _.Iterable[_.Any],
1295
+ msg: str | None = None,
1296
+ ) -> _.Self:
1297
+ """ At least one item exists in both containers. """
1298
+ self.IsIterable()
1299
+
1300
+ expected = list(expected)
1301
+
1302
+ for index, value in enumerate(self.value):
1303
+ if value in expected:
1304
+ return self
1305
+
1306
+ raise AssertionError(
1307
+ msg
1308
+ or "expected containers to intersect, "
1309
+ "but they have no items in common"
1310
+ f"expected {expected!r} n {self.value} != 0"
1311
+ )
1312
+
1313
+ #* type / identifiers
1314
+
1315
+ def Is(
1316
+ self,
1317
+ expected: _.Any,
1318
+ msg: str | None = None,
1319
+ ) -> _.Self:
1320
+ if self.value is not expected:
1321
+ raise AssertionError(
1322
+ msg or f"expected {expected!r} (is), got {self.value!r}"
1323
+ )
1324
+
1325
+ return self
1326
+
1327
+ def IsNot(
1328
+ self,
1329
+ expected: _.Any,
1330
+ msg: str | None = None,
1331
+ ) -> _.Self:
1332
+ if self.value is expected:
1333
+ raise AssertionError(
1334
+ msg or f"expected {expected!r} (is not), got {self.value!r}"
1335
+ )
1336
+
1337
+ return self
1338
+
1339
+
1340
+ def IsNone(
1341
+ self,
1342
+ msg: str | None = None,
1343
+ ) -> _.Self:
1344
+ if self.value is not None:
1345
+ raise AssertionError(
1346
+ msg or f"expected None, got {self.value!r}"
1347
+ )
1348
+
1349
+ return self
1350
+
1351
+ def IsNotNone(
1352
+ self,
1353
+ msg: str | None = None,
1354
+ ) -> _.Self:
1355
+ if self.value is None:
1356
+ raise AssertionError(
1357
+ msg or f"expected not None, got {self.value!r}"
1358
+ )
1359
+
1360
+ return self
1361
+
1362
+ def IsInstance(
1363
+ self,
1364
+ cls: _.Any,
1365
+ msg: str | None = None,
1366
+ ) -> _.Self:
1367
+ if not isinstance(self.value, cls):
1368
+ raise AssertionError(
1369
+ msg or f"expected {cls!r} (is), got instace {self.value!r}"
1370
+ )
1371
+
1372
+ return self
1373
+
1374
+ def IsNotInstance(
1375
+ self,
1376
+ cls: _.Any,
1377
+ msg: str | None = None,
1378
+ ) -> _.Self:
1379
+ if isinstance(self.value, cls):
1380
+ raise AssertionError(
1381
+ msg or f"expected {cls!r} (is not), got instace {self.value!r}"
1382
+ )
1383
+
1384
+ return self
1385
+
1386
+ def IsNumeric(
1387
+ self
1388
+ ) -> _.Self:
1389
+ if (
1390
+ isinstance(self.value, bool)
1391
+ or not isinstance(self.value, (int, float))
1392
+ ):
1393
+ raise TypeError(
1394
+ f"expected an int or float, got {type(self.value).__name__}"
1395
+ )
1396
+
1397
+ return self
1398
+
1399
+ def IsString(
1400
+ self
1401
+ ) -> _.Self:
1402
+ if not isinstance(self.value, str):
1403
+ raise TypeError(
1404
+ f"expected an str, got {type(self.value).__name__}"
1405
+ )
1406
+
1407
+ return self
1408
+
1409
+ def IsEnum(
1410
+ self
1411
+ ) -> _.Self:
1412
+ import enum, inspect
1413
+
1414
+ if inspect.isclass(self.value) and issubclass(self.value, enum.Enum):
1415
+ return self
1416
+
1417
+ raise TypeError(
1418
+ f"expected an enum, got {type(self.value).__name__}"
1419
+ )
1420
+
1421
+ def IsIterable(
1422
+ self
1423
+ ) -> _.Self:
1424
+ from collections.abc import Iterable
1425
+
1426
+ if not isinstance(self.value, Iterable):
1427
+ raise TypeError(
1428
+ f"expected an iterable, got {type(self.value).__name__}"
1429
+ )
1430
+
1431
+ return self
1432
+
1433
+ # ------------------------------------------------------------------------------
1434
+ # main
1435
+ # ------------------------------------------------------------------------------
1436
+
1437
+ if __name__ == "__main__":
1438
+
1439
+ import argparse as _argparse
1440
+ import importlib.util as _importlib_util
1441
+ import pathlib as _pathlib
1442
+ import sys as _sys
1443
+
1444
+ # Hack: running as __main__ can load the module twice, creating two Lab
1445
+ # class definitions. Alias it to its real module name.
1446
+ _sys.modules[__spec__.name] = _sys.modules[__name__]
1447
+
1448
+ parser = _argparse.ArgumentParser(
1449
+ prog="python -m bean.test",
1450
+ description="Run bean tests.",
1451
+ )
1452
+
1453
+ parser.add_argument(
1454
+ "roots",
1455
+ nargs="+",
1456
+ metavar="DIR",
1457
+ help="Root directories to search for tests.",
1458
+ )
1459
+
1460
+ parser.add_argument(
1461
+ "-p",
1462
+ "--pattern",
1463
+ default="*.py",
1464
+ help="Pattern used to discover test files.",
1465
+ )
1466
+
1467
+ parser.add_argument(
1468
+ "-r",
1469
+ "--recursive",
1470
+ action="store_true",
1471
+ help="Recursively search root directories for test files.",
1472
+ )
1473
+
1474
+ parser.add_argument(
1475
+ "-i",
1476
+ "--ignore",
1477
+ action="append",
1478
+ nargs="+",
1479
+ default=[[
1480
+ "__pycache__",
1481
+ ".git",
1482
+ ".venv",
1483
+ ]],
1484
+ metavar="DIR",
1485
+ help="Directory name to ignore while recursively searching.",
1486
+ )
1487
+
1488
+ parser.add_argument(
1489
+ "-n",
1490
+ "--namespace",
1491
+ nargs="+",
1492
+ help="Namespaces to run. Use 'inline' for inline tests.",
1493
+ )
1494
+
1495
+ parser.add_argument(
1496
+ "-f",
1497
+ "--failable",
1498
+ action="store_true",
1499
+ help="How failable tests affect the result.",
1500
+ )
1501
+
1502
+ parser.add_argument(
1503
+ "-v",
1504
+ "--verbosity",
1505
+ choices=("minimal", "simple", "full"),
1506
+ default="minimal",
1507
+ help="Report verbosity.",
1508
+ )
1509
+
1510
+ parser.add_argument(
1511
+ "-c",
1512
+ "--color",
1513
+ action="store_true",
1514
+ help="Enable colored output.",
1515
+ )
1516
+
1517
+ args = parser.parse_args()
1518
+ ignored = sorted(
1519
+ item
1520
+ for items in args.ignore
1521
+ for item in items
1522
+ )
1523
+
1524
+ files: list[_pathlib.Path] = []
1525
+
1526
+ for root in sorted(args.roots):
1527
+ path = _pathlib.Path(root)
1528
+
1529
+ if not path.exists():
1530
+ parser.error(f"root does not exist: {root}")
1531
+
1532
+ if not path.is_dir():
1533
+ parser.error(f"root is not a directory: {root}")
1534
+
1535
+ if args.recursive:
1536
+ paths = path.rglob(args.pattern)
1537
+ else:
1538
+ paths = path.glob(args.pattern)
1539
+
1540
+ for file in paths:
1541
+ if not file.is_file():
1542
+ continue
1543
+
1544
+ if any(
1545
+ ignore in file.parts
1546
+ for ignore in ignored
1547
+ ): continue
1548
+
1549
+ files.append(file)
1550
+
1551
+ for file in sorted(files):
1552
+ spec = _importlib_util.spec_from_file_location(
1553
+ file.stem,
1554
+ file,
1555
+ )
1556
+
1557
+ if spec is None or spec.loader is None:
1558
+ raise ImportError(f"Cannot load test module: {file}")
1559
+
1560
+ module = _importlib_util.module_from_spec(spec)
1561
+ spec.loader.exec_module(module)
1562
+
1563
+ if Lab.print(
1564
+ namespaces=args.namespace or None,
1565
+ failable="ignore" if args.failable else "fail",
1566
+ verbosity=args.verbosity,
1567
+ color=args.color,
1568
+ ): _sys.exit(0)
1569
+
1570
+ _sys.exit(1)
1571
+
1572
+ # ------------------------------------------------------------------------------
1573
+ # ------------------------------------------------------------------------------