assertpy2 2.0.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.
assertpy2/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .assertpy import (
2
+ WarningLoggingAdapter,
3
+ __version__,
4
+ add_extension,
5
+ assert_that,
6
+ assert_warn,
7
+ fail,
8
+ remove_extension,
9
+ soft_assertions,
10
+ soft_fail,
11
+ )
12
+ from .file import contents_of
13
+
14
+ __all__ = [
15
+ "WarningLoggingAdapter",
16
+ "__version__",
17
+ "add_extension",
18
+ "assert_that",
19
+ "assert_warn",
20
+ "contents_of",
21
+ "fail",
22
+ "remove_extension",
23
+ "soft_assertions",
24
+ "soft_fail",
25
+ ]
assertpy2/assertpy.py ADDED
@@ -0,0 +1,468 @@
1
+ # Copyright (c) 2015-2019, Activision Publishing, Inc.
2
+ # All rights reserved.
3
+ #
4
+ # Redistribution and use in source and binary forms, with or without modification,
5
+ # are permitted provided that the following conditions are met:
6
+ #
7
+ # 1. Redistributions of source code must retain the above copyright notice, this
8
+ # list of conditions and the following disclaimer.
9
+ #
10
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
11
+ # this list of conditions and the following disclaimer in the documentation
12
+ # and/or other materials provided with the distribution.
13
+ #
14
+ # 3. Neither the name of the copyright holder nor the names of its contributors
15
+ # may be used to endorse or promote products derived from this software without
16
+ # specific prior written permission.
17
+ #
18
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
+ # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
+ # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
22
+ # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
+ # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25
+ # ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
+ # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+
29
+ """Assertion library for python unit testing with a fluent API"""
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import inspect
35
+ import logging
36
+ import os
37
+ import sys
38
+ import types
39
+ from collections.abc import Iterator
40
+ from typing import TYPE_CHECKING
41
+
42
+ if TYPE_CHECKING:
43
+ from typing_extensions import Self
44
+
45
+ from .base import BaseMixin
46
+ from .collection import CollectionMixin
47
+ from .contains import ContainsMixin
48
+ from .date import DateMixin
49
+ from .dict import DictMixin
50
+ from .dynamic import DynamicMixin
51
+ from .exception import ExceptionMixin
52
+ from .extracting import ExtractingMixin
53
+ from .file import FileMixin
54
+ from .helpers import HelpersMixin
55
+ from .numeric import NumericMixin
56
+ from .snapshot import SnapshotMixin
57
+ from .string import StringMixin
58
+
59
+ __version__ = "2.0.0"
60
+
61
+ __tracebackhide__ = True # clean tracebacks via py.test integration
62
+ contextlib.__tracebackhide__ = True # monkey patch contextlib with clean py.test tracebacks
63
+
64
+ # assertpy files
65
+ ASSERTPY_FILES = [
66
+ os.path.join("assertpy2", file)
67
+ for file in [
68
+ "assertpy.py",
69
+ "base.py",
70
+ "collection.py",
71
+ "contains.py",
72
+ "date.py",
73
+ "dict.py",
74
+ "dynamic.py",
75
+ "exception.py",
76
+ "extracting.py",
77
+ "file.py",
78
+ "helpers.py",
79
+ "numeric.py",
80
+ "snapshot.py",
81
+ "string.py",
82
+ ]
83
+ ]
84
+
85
+ # soft assertions
86
+ _soft_ctx = 0
87
+ _soft_err = []
88
+
89
+
90
+ @contextlib.contextmanager
91
+ def soft_assertions() -> Iterator[None]:
92
+ """Create a soft assertion context.
93
+
94
+ Normally, any assertion failure will halt test execution immediately by raising an error.
95
+ Soft assertions are way to collect assertion failures (and failure messages) together, to be
96
+ raised all at once at the end, without halting your test.
97
+
98
+ Examples:
99
+ Create a soft assertion context, and some failing tests::
100
+
101
+ from assertpy2 import assert_that, soft_assertions
102
+
103
+ with soft_assertions():
104
+ assert_that('foo').is_length(4)
105
+ assert_that('foo').is_empty()
106
+ assert_that('foo').is_false()
107
+ assert_that('foo').is_digit()
108
+ assert_that('123').is_alpha()
109
+
110
+ When the context ends, any assertion failures are collected together and a single
111
+ ``AssertionError`` is raised::
112
+
113
+ AssertionError: soft assertion failures:
114
+ 1. Expected <foo> to be of length <4>, but was <3>.
115
+ 2. Expected <foo> to be empty string, but was not.
116
+ 3. Expected <False>, but was not.
117
+ 4. Expected <foo> to contain only digits, but did not.
118
+ 5. Expected <123> to contain only alphabetic chars, but did not.
119
+
120
+ Note:
121
+ The soft assertion context only collects *assertion* failures, other errors such as
122
+ ``TypeError`` or ``ValueError`` are always raised immediately. Triggering an explicit test
123
+ failure with :meth:`fail` will similarly halt execution immediately. If you need more
124
+ forgiving behavior, use :meth:`soft_fail` to add a failure message without halting test
125
+ execution.
126
+ """
127
+ global _soft_ctx
128
+ global _soft_err
129
+
130
+ # init ctx
131
+ if _soft_ctx == 0:
132
+ _soft_err = []
133
+ _soft_ctx += 1
134
+
135
+ try:
136
+ yield
137
+ finally:
138
+ # reset ctx
139
+ _soft_ctx -= 1
140
+
141
+ if _soft_err and _soft_ctx == 0:
142
+ out = "soft assertion failures:\n" + "\n".join("%d. %s" % (i + 1, msg) for i, msg in enumerate(_soft_err))
143
+ _soft_err = []
144
+ raise AssertionError(out)
145
+
146
+
147
+ # factory methods
148
+ def assert_that(val, description=""):
149
+ """Set the value to be tested, plus an optional description, and allow assertions to be called.
150
+
151
+ This is a factory method for the :class:`AssertionBuilder`, and the single most important
152
+ method in all of assertpy.
153
+
154
+ Args:
155
+ val: the value to be tested (aka the actual value)
156
+ description (str, optional): the extra error message description. Defaults to ``''``
157
+ (aka empty string)
158
+
159
+ Examples:
160
+ Just import it once at the top of your test file, and away you go...::
161
+
162
+ from assertpy2 import assert_that
163
+
164
+ def test_something():
165
+ assert_that(1 + 2).is_equal_to(3)
166
+ assert_that('foobar').is_length(6).starts_with('foo').ends_with('bar')
167
+ assert_that(['a', 'b', 'c']).contains('a').does_not_contain('x')
168
+ """
169
+ global _soft_ctx
170
+ if _soft_ctx:
171
+ return _builder(val, description, "soft")
172
+ return _builder(val, description)
173
+
174
+
175
+ def assert_warn(val, description="", logger=None):
176
+ """Set the value to be tested, and optional description and logger, and allow assertions to be
177
+ called, but never fail, only log warnings.
178
+
179
+ This is a factory method for the :class:`AssertionBuilder`, but unlike :meth:`assert_that` an
180
+ `AssertionError` is never raised, and execution is never halted. Instead, any assertion failures
181
+ results in a warning message being logged. Uses the given logger, or defaults to a simple logger
182
+ that prints warnings to ``stdout``.
183
+
184
+
185
+ Args:
186
+ val: the value to be tested (aka the actual value)
187
+ description (str, optional): the extra error message description. Defaults to ``''``
188
+ (aka empty string)
189
+ logger (Logger, optional): the logger for warning message on assertion failure. Defaults to ``None``
190
+ (aka use the default simple logger that prints warnings to ``stdout``)
191
+
192
+ Examples:
193
+ Usage::
194
+
195
+ from assertpy2 import assert_warn
196
+
197
+ assert_warn('foo').is_length(4)
198
+ assert_warn('foo').is_empty()
199
+ assert_warn('foo').is_false()
200
+ assert_warn('foo').is_digit()
201
+ assert_warn('123').is_alpha()
202
+
203
+ Even though all of the above assertions fail, ``AssertionError`` is never raised and
204
+ test execution is never halted. Instead, the failed assertions merely log the following
205
+ warning messages to ``stdout``::
206
+
207
+ 2019-10-27 20:00:35 WARNING [test_foo.py:23]: Expected <foo> to be of length <4>, but was <3>.
208
+ 2019-10-27 20:00:35 WARNING [test_foo.py:24]: Expected <foo> to be empty string, but was not.
209
+ 2019-10-27 20:00:35 WARNING [test_foo.py:25]: Expected <False>, but was not.
210
+ 2019-10-27 20:00:35 WARNING [test_foo.py:26]: Expected <foo> to contain only digits, but did not.
211
+ 2019-10-27 20:00:35 WARNING [test_foo.py:27]: Expected <123> to contain only alphabetic chars, but did not.
212
+
213
+ Tip:
214
+ Use :meth:`assert_warn` if and only if you have a *really* good reason to log assertion
215
+ failures instead of failing.
216
+ """
217
+ return _builder(val, description, "warn", logger=logger)
218
+
219
+
220
+ def fail(msg=""):
221
+ """Force immediate test failure with the given message.
222
+
223
+ Args:
224
+ msg (str, optional): the failure message. Defaults to ``''``
225
+
226
+ Examples:
227
+ Fail a test::
228
+
229
+ from assertpy2 import assert_that, fail
230
+
231
+ def test_fail():
232
+ fail('forced fail!')
233
+
234
+ If you wanted to test for a known failure, here is a useful pattern::
235
+
236
+ import operator
237
+
238
+ def test_adder_bad_arg():
239
+ try:
240
+ operator.add(1, 'bad arg')
241
+ fail('should have raised error')
242
+ except TypeError as e:
243
+ assert_that(str(e)).contains('unsupported operand')
244
+ """
245
+ raise AssertionError("Fail: %s!" % msg if msg else "Fail!")
246
+
247
+
248
+ def soft_fail(msg=""):
249
+ """Within a :meth:`soft_assertions` context, append the failure message to the soft error list,
250
+ but do not halt test execution.
251
+
252
+ Otherwise, outside the context, acts identical to :meth:`fail` and forces immediate test
253
+ failure with the given message.
254
+
255
+ Args:
256
+ msg (str, optional): the failure message. Defaults to ``''``
257
+
258
+ Examples:
259
+ Failing soft assertions::
260
+
261
+ from assertpy2 import assert_that, soft_assertions, soft_fail
262
+
263
+ with soft_assertions():
264
+ assert_that(1).is_equal_to(2)
265
+ soft_fail('my message')
266
+ assert_that('foo').is_equal_to('bar')
267
+
268
+ Fails, and outputs the following soft error list::
269
+
270
+ AssertionError: soft assertion failures:
271
+ 1. Expected <1> to be equal to <2>, but was not.
272
+ 2. Fail: my message!
273
+ 3. Expected <foo> to be equal to <bar>, but was not.
274
+
275
+ """
276
+ global _soft_ctx
277
+ if _soft_ctx:
278
+ global _soft_err
279
+ _soft_err.append("Fail: %s!" % msg if msg else "Fail!")
280
+ return
281
+ fail(msg)
282
+
283
+
284
+ # assertion extensions
285
+ _extensions = {}
286
+
287
+
288
+ def add_extension(func):
289
+ """Add a new user-defined custom assertion to assertpy.
290
+
291
+ Once the assertion is registered with assertpy, use it like any other assertion. Pass val to
292
+ :meth:`assert_that`, and then call it.
293
+
294
+ Args:
295
+ func: the assertion function (to be added)
296
+
297
+ Examples:
298
+ Usage::
299
+
300
+ from assertpy2 import add_extension
301
+
302
+ def is_5(self):
303
+ if self.val != 5:
304
+ return self.error(f'{self.val} is NOT 5!')
305
+ return self
306
+
307
+ add_extension(is_5)
308
+
309
+ def test_5():
310
+ assert_that(5).is_5()
311
+
312
+ def test_6():
313
+ assert_that(6).is_5() # fails
314
+ # 6 is NOT 5!
315
+ """
316
+ if not callable(func):
317
+ raise TypeError("func must be callable")
318
+ _extensions[func.__name__] = func
319
+
320
+
321
+ def remove_extension(func):
322
+ """Remove a user-defined custom assertion.
323
+
324
+ Args:
325
+ func: the assertion function (to be removed)
326
+
327
+ Examples:
328
+ Usage::
329
+
330
+ from assertpy2 import remove_extension
331
+
332
+ remove_extension(is_5)
333
+ """
334
+ if not callable(func):
335
+ raise TypeError("func must be callable")
336
+ if func.__name__ in _extensions:
337
+ del _extensions[func.__name__]
338
+
339
+
340
+ def _builder(val, description="", kind=None, expected=None, logger=None):
341
+ """Internal helper to build a new :class:`AssertionBuilder` instance and glue on any extension methods."""
342
+ ab = AssertionBuilder(val, description, kind, expected, logger)
343
+ if _extensions:
344
+ # glue extension method onto new builder instance
345
+ for name, func in _extensions.items():
346
+ meth = types.MethodType(func, ab)
347
+ setattr(ab, name, meth)
348
+ return ab
349
+
350
+
351
+ # warnings
352
+ class WarningLoggingAdapter(logging.LoggerAdapter):
353
+ """Logging adapter to unwind the stack to get the correct callee filename and line number."""
354
+
355
+ def process(self, msg, kwargs):
356
+ def _unwind(frame):
357
+ # walk all the frames
358
+ frames = []
359
+ while frame:
360
+ frames.append((frame.f_code.co_filename, frame.f_lineno))
361
+ frame = frame.f_back
362
+
363
+ # in reverse, find the first assertpy frame (and return the previous one)
364
+ prev = None
365
+ for frame in reversed(frames):
366
+ for f in ASSERTPY_FILES:
367
+ if frame[0].endswith(f):
368
+ return prev
369
+ prev = frame
370
+
371
+ filename, lineno = _unwind(inspect.currentframe())
372
+ return "[%s:%d]: %s" % (os.path.basename(filename), lineno, msg), kwargs
373
+
374
+
375
+ _logger = logging.getLogger("assertpy2")
376
+ _handler = logging.StreamHandler(sys.stdout)
377
+ _handler.setLevel(logging.WARNING)
378
+ _format = logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
379
+ _handler.setFormatter(_format)
380
+ _logger.addHandler(_handler)
381
+ _default_logger = WarningLoggingAdapter(_logger, None)
382
+
383
+
384
+ class AssertionBuilder(
385
+ StringMixin,
386
+ SnapshotMixin,
387
+ NumericMixin,
388
+ HelpersMixin,
389
+ FileMixin,
390
+ ExtractingMixin,
391
+ ExceptionMixin,
392
+ DynamicMixin,
393
+ DictMixin,
394
+ DateMixin,
395
+ ContainsMixin,
396
+ CollectionMixin,
397
+ BaseMixin,
398
+ ):
399
+ """The main assertion class. Never call the constructor directly, always use the
400
+ :meth:`assert_that` helper instead. Or if you just want warning messages, use the
401
+ :meth:`assert_warn` helper.
402
+
403
+ Args:
404
+ val: the value to be tested (aka the actual value)
405
+ description (str, optional): the extra error message description. Defaults to ``''``
406
+ (aka empty string)
407
+ kind (str, optional): the kind of assertions, one of ``None``, ``soft``, or ``warn``.
408
+ Defaults to ``None``
409
+ expected (Error, optional): the expected exception. Defaults to ``None``
410
+ logger (Logger, optional): the logger for warning messages. Defaults to ``None``
411
+ """
412
+
413
+ def __init__(self, val, description="", kind=None, expected=None, logger=None):
414
+ """Never call this constructor directly."""
415
+ self.val = val
416
+ self.description = description
417
+ self.kind = kind
418
+ self.expected = expected
419
+ self.logger = logger if logger else _default_logger
420
+
421
+ def builder(self, val, description="", kind=None, expected=None, logger=None):
422
+ """Helper to build a new :class:`AssertionBuilder` instance. Use this only if not chaining to ``self``.
423
+
424
+ Args:
425
+ val: the value to be tested (aka the actual value)
426
+ description (str, optional): the extra error message description. Defaults to ``''``
427
+ (aka empty string)
428
+ kind (str, optional): the kind of assertions, one of ``None``, ``soft``, or ``warn``.
429
+ Defaults to ``None``
430
+ expected (Error, optional): the expected exception. Defaults to ``None``
431
+ logger (Logger, optional): the logger for warning messages. Defaults to ``None``
432
+ """
433
+ return _builder(val, description, kind, expected, logger)
434
+
435
+ def error(self, msg) -> Self:
436
+ """Helper to raise an ``AssertionError`` with the given message.
437
+
438
+ If an error description is set by :meth:`~assertpy.base.BaseMixin.described_as`, then that
439
+ description is prepended to the error message.
440
+
441
+ Args:
442
+ msg: the error message
443
+
444
+ Examples:
445
+ Used to fail an assertion::
446
+
447
+ if self.val != other:
448
+ return self.error('Expected <%s> to be equal to <%s>, but was not.' % (self.val, other))
449
+
450
+ Raises:
451
+ AssertionError: always raised unless ``kind`` is ``warn`` (as set when using an
452
+ :meth:`assert_warn` assertion) or ``kind`` is ``soft`` (as set when inside a
453
+ :meth:`soft_assertions` context).
454
+
455
+ Returns:
456
+ AssertionBuilder: returns this instance to chain to the next assertion, but only when
457
+ ``AssertionError`` is not raised, as is the case when ``kind`` is ``warn`` or ``soft``.
458
+ """
459
+ out = "%s%s" % ("[%s] " % self.description if len(self.description) > 0 else "", msg)
460
+ if self.kind == "warn":
461
+ self.logger.warning(out)
462
+ return self
463
+ elif self.kind == "soft":
464
+ global _soft_err
465
+ _soft_err.append(out)
466
+ return self
467
+ else:
468
+ raise AssertionError(out)