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
|
+
# ------------------------------------------------------------------------------
|