#
# Copyright (c) 2018-2023 Two Sigma Open Source, LLC
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
'''This module provides custom :mod:`unittest`-style assertions. For
the most part, :mod:`marbles.mixins` assertions trivially wrap
:mod:`unittest` assertions. For example, a call to
:meth:`CategoricalMixins.assertCategoricalLevelIn` will simply pass the
provided arguments to :meth:`~unittest.TestCase.assertIn`.
Custom assertions are provided via mixins so that they can use other
assertions as building blocks. Using mixins, instead of straight
inheritance, means that users can compose multiple mixins to create
a test case with all the assertions that they need.
.. warning::
:mod:`marbles.mixins` can be mixed into a
:class:`unittest.TestCase`, a :class:`marbles.core.TestCase`,
a :class:`marbles.core.AnnotatedTestCase`, or any other class that
implements a :class:`unittest.TestCase` interface. To enforce
this, mixins define `abstract methods <abc>`_. This means that,
when mixing them into your test case, they must come `after` the
class(es) that implement those methods instead of appearing first
in the inheritance list like normal mixins.
.. _abc: https://docs.python.org/3/library/abc.html#abc.abstractmethod
Example:
.. code-block:: python
import unittest
from marbles.core import marbles
from marbles.mixins import mixins
class MyTestCase(unittest.TestCase, mixins.BetweenMixins):
def test_me(self):
self.assertBetween(5, lower=0, upper=10)
class MyMarblesTestCase(marbles.TestCase, mixins.BetweenMixins):
def test_me(self):
self.assertBetween(5, lower=0, upper=10)
'''
import abc
import collections.abc
import operator
import os
from datetime import date, datetime, timedelta, timezone
import pandas as pd
# TODO (jsa): override abc TypeError to inform user that they have to
# inherit from unittest.TestCase (I don't know if this is possible)
[docs]
class BetweenMixins(abc.ABC):
'''Built-in assertions about betweenness.'''
@abc.abstractmethod
def fail(self, msg):
pass # pragma: no cover
@abc.abstractmethod
def _formatMessage(self, msg, standardMsg):
pass # pragma: no cover
[docs]
def assertBetween(self, obj, lower, upper, strict=True, msg=None):
'''Fail if ``obj`` is not between ``lower`` and ``upper``.
If ``strict=True`` (default), fail unless
``lower < obj < upper``. If ``strict=False``, fail unless
``lower <= obj <= upper``.
This is equivalent to ``self.assertTrue(lower < obj < upper)``
or ``self.assertTrue(lower <= obj <= upper)``, but with a nicer
default message.
Parameters
----------
obj
lower
upper
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
'''
if strict:
standardMsg = '%s is not strictly between %s and %s' % (
obj, lower, upper)
op = operator.lt
else:
standardMsg = '%s is not between %s and %s' % (obj, lower, upper)
op = operator.le
if not (op(lower, obj) and op(obj, upper)):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertNotBetween(self, obj, lower, upper, strict=True, msg=None):
'''Fail if ``obj`` is between ``lower`` and ``upper``.
If ``strict=True`` (default), fail if ``lower <= obj <=
upper``. If ``strict=False``, fail if ``lower < obj < upper``.
This is equivalent to ``self.assertFalse(lower < obj < upper)``
or ``self.assertFalse(lower <= obj <= upper)``, but with a
nicer default message.
Raises
------
ValueError
If ``lower`` equals ``upper`` and ``strict=True`` is
specified.
Parameters
----------
obj
lower
upper
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
'''
if strict:
standardMsg = '%s is between %s and %s' % (obj, lower, upper)
op = operator.le
else:
standardMsg = '%s is strictly between %s and %s' % (
obj, lower, upper)
op = operator.lt
# Providing strict=False and a degenerate interval should raise
# ValueError so the test will error instead of fail
if (not strict) and (lower == upper):
raise ValueError('cannot specify strict=False if lower == upper')
if (op(lower, obj) and op(obj, upper)):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
class MonotonicMixins(abc.ABC):
'''Built-in assertions about monotonicity.'''
@abc.abstractmethod
def fail(self):
pass # pragma: no cover
@abc.abstractmethod
def assertIsInstance(self, obj, cls, msg):
pass # pragma: no cover
@abc.abstractmethod
def _formatMessage(self, msg, standardMsg):
pass # pragma: no cover
@staticmethod
def _monotonic(op, sequence):
return all(op(i, j) for i, j in zip(sequence, sequence[1:]))
[docs]
def assertMonotonicIncreasing(self, sequence, strict=True, msg=None):
'''Fail if ``sequence`` is not monotonically increasing.
If ``strict=True`` (default), fail unless each element in
``sequence`` is less than the following element as determined
by the ``<`` operator. If ``strict=False``, fail unless each
element in ``sequence`` is less than or equal to the following
element as determined by the ``<=`` operator.
.. code-block:: python
assert all((i < j) for i, j in zip(sequence, sequence[1:]))
assert all((i <= j) for i, j in zip(sequence, sequence[1:]))
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = ('Elements in %s are not strictly monotonically '
'increasing') % (sequence,)
op = operator.lt
else:
standardMsg = ('Elements in %s are not monotonically '
'increasing') % (sequence,)
op = operator.le
if not self._monotonic(op, sequence):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertNotMonotonicIncreasing(self, sequence, strict=True, msg=None):
'''Fail if ``sequence`` is monotonically increasing.
If ``strict=True`` (default), fail if each element in
``sequence`` is less than the following element as determined
by the ``<`` operator. If ``strict=False``, fail if each
element in ``sequence`` is less than or equal to the following
element as determined by the ``<=`` operator.
.. code-block:: python
assert not all((i < j) for i, j in zip(sequence, sequence[1:]))
assert not all((i <= j) for i, j in zip(sequence, sequence[1:]))
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = ('Elements in %s are strictly monotonically '
'increasing') % (sequence,)
op = operator.lt
else:
standardMsg = ('Elements in %s are monotonically '
'increasing') % (sequence,)
op = operator.le
if self._monotonic(op, sequence):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertMonotonicDecreasing(self, sequence, strict=True, msg=None):
'''Fail if ``sequence`` is not monotonically decreasing.
If ``strict=True`` (default), fail unless each element in
``sequence`` is greater than the following element as
determined by the ``>`` operator. If ``strict=False``, fail
unless each element in ``sequence`` is greater than or equal
to the following element as determined by the ``>=`` operator.
.. code-block:: python
assert all((i > j) for i, j in zip(sequence, sequence[1:]))
assert all((i >= j) for i, j in zip(sequence, sequence[1:]))
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = ('Elements in %s are not strictly monotonically '
'decreasing') % (sequence,)
op = operator.gt
else:
standardMsg = ('Elements in %s are not monotonically '
'decreasing') % (sequence,)
op = operator.ge
if not self._monotonic(op, sequence):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertNotMonotonicDecreasing(self, sequence, strict=True, msg=None):
'''Fail if ``sequence`` is monotonically decreasing.
If ``strict=True`` (default), fail if each element in
``sequence`` is greater than the following element as
determined by the ``>`` operator. If ``strict=False``, fail if
each element in ``sequence`` is greater than or equal to the
following element as determined by the ``>=`` operator.
.. code-block:: python
assert not all((i > j) for i, j in zip(sequence, sequence[1:]))
assert not all((i >= j) for i, j in zip(sequence, sequence[1:]))
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = ('Elements in %s are strictly monotonically '
'decreasing') % (sequence,)
op = operator.gt
else:
standardMsg = ('Elements in %s are monotonically '
'decreasing') % (sequence,)
op = operator.ge
if self._monotonic(op, sequence):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
class UniqueMixins(abc.ABC):
'''Built-in assertions about uniqueness.
These assertions can handle containers that contain unhashable
elements.
'''
@abc.abstractmethod
def fail(self, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIsInstance(self, obj, cls, msg):
pass # pragma: no cover
@abc.abstractmethod
def _formatMessage(self, msg, standardMsg):
pass # pragma: no cover
[docs]
def assertUnique(self, container, msg=None):
'''Fail if elements in ``container`` are not unique.
Parameters
----------
container : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``container`` is not iterable.
'''
if not isinstance(container, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
standardMsg = 'Elements in %s are not unique' % (container,)
# We iterate over each element in the container instead of
# comparing len(container) == len(set(container)) to allow
# for containers that contain unhashable types
for idx, elem in enumerate(container):
# If elem appears at an earlier or later index position
# the elements are not unique
if elem in container[:idx] or elem in container[idx+1:]:
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertNotUnique(self, container, msg=None):
'''Fail if elements in ``container`` are unique.
Parameters
----------
container : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``container`` is not iterable.
'''
if not isinstance(container, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
standardMsg = 'Elements in %s are unique' % (container,)
# We iterate over each element in the container instead of
# comparing len(container) == len(set(container)) to allow
# for containers that contain unhashable types
for idx, elem in enumerate(container):
# If elem appears at an earlier or later index position
# the elements are not unique
if elem in container[:idx] or elem in container[idx+1:]:
return # succeed fast
self.fail(self._formatMessage(msg, standardMsg))
[docs]
class FileMixins(abc.ABC):
'''Built-in assertions for files.
With the exception of :meth:`assertFileExists` and
:meth:`assertFileNotExists`, all custom file assertions take a
``filename`` argument which can accept a file name as a
:class:`str` or :py:class:`bytes` object, or a `file-like object`_.
Accepting a file-like object is useful for testing files that are
not present locally, e.g., files in HDFS.
.. _file-like object: http://docs.python.org/3/glossary.html#term-file-like-object
.. code-block:: python
import unittest
import hdfs3
from marbles.mixins import mixins
class MyFileTest(unittest.TestCase, mixins.FileMixins):
def test_file_encoding(self):
fname = 'myfile.csv'
# You can pass fname directly to the assertion (if the
# file exists locally)
self.assertFileEncodingEqual(fname, 'utf-8')
# Or open the file and pass a file descriptor to the
# assertion
with open(fname) as f:
self.assertFileEncodingEqual(f, 'utf-8')
def test_hdfs_file_encoding(self):
hdfspath = '/path/to/myfile.csv'
client = hdfs3.HDFileSystem(host='host', port='port')
with client.open(hdfspath) as f:
self.assertFileEncodingEqual(f, 'utf-8')
Note that not all file-like objects implement the expected
interface. These custom file assertions expect the following
methods and attributes:
+ :meth:`read`
+ :meth:`write`
+ :meth:`seek`
+ :meth:`tell`
+ :attr:`name`
+ :attr:`encoding`
'''
@abc.abstractmethod
def fail(self, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertEqual(self, first, second, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertNotEqual(self, first, second, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertAlmostEqual(self, first, second, places, msg, delta):
pass # pragma: no cover
@abc.abstractmethod
def assertNotAlmostEqual(self, first, second, places, msg, delta):
pass # pragma: no cover
@abc.abstractmethod
def assertGreater(self, a, b, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertGreaterEqual(self, a, b, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertLess(self, a, b, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertLessEqual(self, a, b, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertRegex(self, text, expected_regex, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertNotRegex(self, text, expected_regex, msg):
pass # pragma: no cover
@abc.abstractmethod
def _formatMessage(self, msg, standardMsg):
pass # pragma: no cover
@staticmethod
def _get_or_open_file(filename):
'''If ``filename`` is a string or bytes object, open the
``filename`` and return the file object. If ``filename`` is
file-like (i.e., it has 'read' and 'write' attributes, return
``filename``.
Parameters
----------
filename : str, bytes, file
Raises
------
TypeError
If ``filename`` is not a string, bytes, or file-like
object.
File-likeness is determined by checking for 'read' and
'write' attributes.
'''
if isinstance(filename, (str, bytes)):
f = open(filename)
elif hasattr(filename, 'read') and hasattr(filename, 'write'):
f = filename
else:
raise TypeError('filename must be str or bytes, or a file')
return f
def _get_file_name(self, filename):
f = self._get_or_open_file(filename)
try:
fname = f.name
except AttributeError as e:
# If f doesn't have an name attribute,
# raise a TypeError
if e.args == ('name',):
raise TypeError('Expected file-like object')
raise e # pragma: no cover
finally:
f.close()
return fname
def _get_file_type(self, filename):
f = self._get_or_open_file(filename)
try:
fname = f.name
except AttributeError as e:
# If f doesn't have an name attribute,
# raise a TypeError
if e.args == ('name',):
raise TypeError('Expected file-like object')
raise e # pragma: no cover
else:
filetype = os.path.splitext(fname)[-1]
finally:
f.close()
return filetype
def _get_file_encoding(self, filename):
f = self._get_or_open_file(filename)
try:
encoding = f.encoding
except AttributeError as e:
# If f doesn't have an encoding attribute,
# raise a TypeError
if e.args == ('encoding',):
raise TypeError('Expected file-like object')
raise e # pragma: no cover
finally:
f.close()
return encoding
def _get_file_size(self, filename):
f = self._get_or_open_file(filename)
try:
f.seek(0, os.SEEK_END)
except AttributeError as e:
# If f doesn't have a seek method,
# raise a TypeError
if e.args == ('seek',):
raise TypeError('Expected file-like object')
raise e # pragma: no cover
else:
length = f.tell()
finally:
f.close()
return length
[docs]
def assertFileExists(self, filename, msg=None):
'''Fail if ``filename`` does not exist as determined by
``os.path.isfile(filename)``.
Parameters
----------
filename : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
'''
standardMsg = '%s does not exist' % filename
if not os.path.isfile(filename):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertFileNotExists(self, filename, msg=None):
'''Fail if ``filename`` exists as determined by
``~os.path.isfile(filename)``.
Parameters
----------
filename : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
'''
standardMsg = '%s exists' % filename
if os.path.isfile(filename):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertFileNameEqual(self, filename, name, msg=None):
'''Fail if ``filename`` does not have the given ``name`` as
determined by the ``==`` operator.
Parameters
----------
filename : str, bytes, file-like
name : str, byes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fname = self._get_file_name(filename)
self.assertEqual(fname, name, msg=msg)
[docs]
def assertFileNameNotEqual(self, filename, name, msg=None):
'''Fail if ``filename`` has the given ``name`` as determined
by the ``!=`` operator.
Parameters
----------
filename : str, bytes, file-like
name : str, byes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fname = self._get_file_name(filename)
self.assertNotEqual(fname, name, msg=msg)
[docs]
def assertFileNameRegex(self, filename, expected_regex, msg=None):
'''Fail unless ``filename`` matches ``expected_regex``.
Parameters
----------
filename : str, bytes, file-like
expected_regex : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fname = self._get_file_name(filename)
self.assertRegex(fname, expected_regex, msg=msg)
[docs]
def assertFileNameNotRegex(self, filename, expected_regex, msg=None):
'''Fail if ``filename`` matches ``expected_regex``.
Parameters
----------
filename : str, bytes, file-like
expected_regex : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fname = self._get_file_name(filename)
self.assertNotRegex(fname, expected_regex, msg=msg)
[docs]
def assertFileTypeEqual(self, filename, extension, msg=None):
'''Fail if ``filename`` does not have the given ``extension``
as determined by the ``==`` operator.
Parameters
----------
filename : str, bytes, file-like
extension : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
ftype = self._get_file_type(filename)
self.assertEqual(ftype, extension, msg=msg)
[docs]
def assertFileTypeNotEqual(self, filename, extension, msg=None):
'''Fail if ``filename`` has the given ``extension`` as
determined by the ``!=`` operator.
Parameters
----------
filename : str, bytes, file-like
extension : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
ftype = self._get_file_type(filename)
self.assertNotEqual(ftype, extension, msg=msg)
[docs]
def assertFileEncodingEqual(self, filename, encoding, msg=None):
'''Fail if ``filename`` is not encoded with the given
``encoding`` as determined by the '==' operator.
Parameters
----------
filename : str, bytes, file-like
encoding : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fencoding = self._get_file_encoding(filename)
fname = self._get_file_name(filename)
standardMsg = '%s is not %s encoded' % (fname, encoding)
self.assertEqual(fencoding.lower(),
encoding.lower(),
self._formatMessage(msg, standardMsg))
[docs]
def assertFileEncodingNotEqual(self, filename, encoding, msg=None):
'''Fail if ``filename`` is encoded with the given ``encoding``
as determined by the '!=' operator.
Parameters
----------
filename : str, bytes, file-like
encoding : str, bytes
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fencoding = self._get_file_encoding(filename)
fname = self._get_file_name(filename)
standardMsg = '%s is %s encoded' % (fname, encoding)
self.assertNotEqual(fencoding.lower(),
encoding.lower(),
self._formatMessage(msg, standardMsg))
[docs]
def assertFileSizeEqual(self, filename, size, msg=None):
'''Fail if ``filename`` does not have the given ``size`` as
determined by the '==' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertEqual(fsize, size, msg=msg)
[docs]
def assertFileSizeNotEqual(self, filename, size, msg=None):
'''Fail if ``filename`` has the given ``size`` as determined
by the '!=' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertNotEqual(fsize, size, msg=msg)
[docs]
def assertFileSizeAlmostEqual(
self, filename, size, places=None, msg=None, delta=None):
'''Fail if ``filename`` does not have the given ``size`` as
determined by their difference rounded to the given number of
decimal ``places`` (default 7) and comparing to zero, or if
their difference is greater than a given ``delta``.
Parameters
----------
filename : str, bytes, file-like
size : int, float
places : int
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
delta : int, float
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertAlmostEqual(
fsize, size, places=places, msg=msg, delta=delta)
[docs]
def assertFileSizeNotAlmostEqual(
self, filename, size, places=None, msg=None, delta=None):
'''Fail unless ``filename`` does not have the given ``size``
as determined by their difference rounded to the given number
ofdecimal ``places`` (default 7) and comparing to zero, or if
their difference is greater than a given ``delta``.
Parameters
----------
filename : str, bytes, file-like
size : int, float
places : int
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
delta : int, float
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertNotAlmostEqual(
fsize, size, places=places, msg=msg, delta=delta)
[docs]
def assertFileSizeGreater(self, filename, size, msg=None):
'''Fail if ``filename``'s size is not greater than ``size`` as
determined by the '>' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertGreater(fsize, size, msg=msg)
[docs]
def assertFileSizeGreaterEqual(self, filename, size, msg=None):
'''Fail if ``filename``'s size is not greater than or equal to
``size`` as determined by the '>=' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertGreaterEqual(fsize, size, msg=msg)
[docs]
def assertFileSizeLess(self, filename, size, msg=None):
'''Fail if ``filename``'s size is not less than ``size`` as
determined by the '<' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertLess(fsize, size, msg=msg)
[docs]
def assertFileSizeLessEqual(self, filename, size, msg=None):
'''Fail if ``filename``'s size is not less than or equal to
``size`` as determined by the '<=' operator.
Parameters
----------
filename : str, bytes, file-like
size : int, float
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``filename`` is not a str or bytes object and is not
file-like.
'''
fsize = self._get_file_size(filename)
self.assertLessEqual(fsize, size, msg=msg)
[docs]
class CategoricalMixins(abc.ABC):
'''Built-in assertions for categorical data.
This mixin includes some common categorical variables (e.g.,
weekdays, months, U.S. states, etc.) that test authors can use
test resources against. For instance, if a dataset is supposed
to contain data for all states in the U.S., test authors can
test the state column in their dataset against the `US_STATES`
attribute.
.. code-block:: python
import unittest
from marbles.mixins import mixins
class MyTestCase(unittest.TestCase, mixins.CategoricalMixins):
def test_that_all_states_are_present(self):
df = ...
self.assertCategoricalLevelsEqual(df['STATE'], self.US_STATES)
These categorical variables are provided as a convenience; test
authors can and should manipulate these variables, or create
their own, as needed. The idea is, for expectations that may
apply across datasets, to ensure that the same expectation
is being tested in the same way across different datasets.
Attributes
----------
WEEKDAYS : list
WEEKDAYS_ABBR : list
Weekdays abbreviated to three characters
MONTHS : list
MONTHS_ABBR : list
Months abbreviated to three characters
US_STATES : list
US_STATES_ABBR : list
U.S. state names abbreviated to two uppercase characters
US_TERRITORIES : list
US_TERRITORIES_ABBR : list
U.S. territory names abbreviated to two uppercase characters
CONTINENTS : list
7-continent model names
'''
# TODO (jsa): providing these as pandas Series objects or numpy
# arrays might make applying transformations (uppercase, lowercase)
# easier
WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday']
WEEKDAYS_ABBR = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December']
MONTHS_ABBR = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
US_STATES = ['Alabama', 'Alaska', 'Arizona', 'Arkansas', 'California',
'Colorado', 'Connecticut', 'Delaware', 'Florida', 'Georgia',
'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas',
'Kentucky', 'Louisiana', 'Maine', 'Maryland', 'Massachusetts',
'Michigan', 'Minnesota', 'Mississippi', 'Missouri', 'Montana',
'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey',
'New Mexico', 'New York', 'North Carolina', 'North Dakota',
'Ohio', 'Oklahoma', 'Oregon', 'Pennsylvania', 'Rhode Island',
'South Carolina', 'South Dakota', 'Tennessee', 'Texas',
'Utah', 'Vermont', 'Virginia', 'Washington', 'West Virginia',
'Wisconsin', 'Wyoming']
US_STATES_ABBR = ['AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL',
'GA', 'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA',
'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE',
'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK',
'OR', 'PA', 'RI', 'SC', 'SD', 'TN', 'TX', 'UT', 'VT',
'VA', 'WA', 'WV', 'WI', 'WY']
US_TERRITORIES = ['American Samoa', 'District of Columbia',
'Federated States of Micronesia', 'Guam',
'Marshall Islands', 'Northern Mariana Islands',
'Palau', 'Puerto Rico', 'Virgin Islands']
US_TERRITORIES_ABBR = ['AS', 'DC', 'FM', 'GU', 'MH', 'MP', 'PW', 'PR', 'VI']
# TODO (jsa): support 4 and/or 6 continent models?
CONTINENTS = ['Africa', 'Antarctica', 'Asia', 'Australia',
'Europe', 'North America', 'South America']
@abc.abstractmethod
def fail(self, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIsInstance(self, obj, cls, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIn(self, member, container, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertNotIn(self, member, container, msg):
pass # pragma: no cover
@abc.abstractmethod
def _formatMessage(self, msg, standardMsg):
pass # pragma: no cover
[docs]
def assertCategoricalLevelsEqual(self, levels1, levels2, msg=None):
'''Fail if ``levels1`` and ``levels2`` do not have the same
domain.
Parameters
----------
levels1 : iterable
levels2 : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If either ``levels1`` or ``levels2`` is not iterable.
'''
if not isinstance(levels1, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(levels2, collections.abc.Iterable):
raise TypeError('Second argument is not iterable')
standardMsg = '%s levels != %s levels' % (levels1, levels2)
if not all(level in levels2 for level in levels1):
self.fail(self._formatMessage(msg, standardMsg))
if not all(level in levels1 for level in levels2):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertCategoricalLevelsNotEqual(self, levels1, levels2, msg=None):
'''Fail if ``levels1`` and ``levels2`` have the same domain.
Parameters
----------
levels1 : iterable
levels2 : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If either ``levels1`` or ``levels2`` is not iterable.
'''
if not isinstance(levels1, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(levels2, collections.abc.Iterable):
raise TypeError('Second argument is not iterable')
standardMsg = '%s levels == %s levels' % (levels1, levels2)
unshared_levels = False
if not all(level in levels2 for level in levels1):
unshared_levels = True
if not all(level in levels1 for level in levels2):
unshared_levels = True
if not unshared_levels:
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertCategoricalLevelIn(self, level, levels, msg=None):
'''Fail if ``level`` is not in ``levels``.
This is equivalent to ``self.assertIn(level, levels)``.
Parameters
----------
level
levels : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``levels`` is not iterable.
'''
if not isinstance(levels, collections.abc.Iterable):
raise TypeError('Second argument is not iterable')
self.assertIn(level, levels, msg=msg)
[docs]
def assertCategoricalLevelNotIn(self, level, levels, msg=None):
'''Fail if ``level`` is in ``levels``.
This is equivalent to ``self.assertNotIn(level, levels)``.
Parameters
----------
level
levels : iterable
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``levels`` is not iterable.
'''
if not isinstance(levels, collections.abc.Iterable):
raise TypeError('Second argument is not iterable')
self.assertNotIn(level, levels, msg=msg)
[docs]
class DateTimeMixins(abc.ABC):
'''Built-in assertions for :class:`date` s, :class:`datetime` s,
and :class:`time` s.
'''
@abc.abstractmethod
def fail(self, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIsInstance(self, obj, cls, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIsNone(self, obj, msg):
pass # pragma: no cover
@abc.abstractmethod
def assertIsNotNone(self, obj, msg):
pass # pragma: no cover
[docs]
def assertDateTimesBefore(self, sequence, target, strict=True, msg=None):
'''Fail if any elements in ``sequence`` are not before
``target``.
If ``target`` is iterable, it must have the same length as
``sequence``
If ``strict=True``, fail unless all elements in ``sequence``
are strictly less than ``target``. If ``strict=False``, fail
unless all elements in ``sequence`` are less than or equal to
``target``.
Parameters
----------
sequence : iterable
target : datetime, date, iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
ValueError
If ``target`` is iterable but does not have the same length
as ``sequence``.
TypeError
If ``target`` is not a datetime or date object and is not
iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = '%s is not strictly less than %s' % (sequence,
target)
op = operator.lt
else:
standardMsg = '%s is not less than %s' % (sequence, target)
op = operator.le
# Null date(time)s will always compare False, but
# we want to know about null date(time)s
if isinstance(target, collections.abc.Iterable):
if len(target) != len(sequence):
raise ValueError(('Length mismatch: '
'first argument contains %s elements, '
'second argument contains %s elements' % (
len(sequence), len(target))))
if not all(op(i, j) for i, j in zip(sequence, target)):
self.fail(self._formatMessage(msg, standardMsg))
elif isinstance(target, (date, datetime)):
if not all(op(element, target) for element in sequence):
self.fail(self._formatMessage(msg, standardMsg))
else:
raise TypeError(
'Second argument is not a datetime or date object or iterable')
[docs]
def assertDateTimesAfter(self, sequence, target, strict=True, msg=None):
'''Fail if any elements in ``sequence`` are not after
``target``.
If ``target`` is iterable, it must have the same length as
``sequence``
If ``strict=True``, fail unless all elements in ``sequence``
are strictly greater than ``target``. If ``strict=False``,
fail unless all elements in ``sequence`` are greater than or
equal to ``target``.
Parameters
----------
sequence : iterable
target : datetime, date, iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
ValueError
If ``target`` is iterable but does not have the same length
as ``sequence``.
TypeError
If ``target`` is not a datetime or date object and is not
iterable.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if strict:
standardMsg = '%s is not strictly greater than %s' % (sequence,
target)
op = operator.gt
else:
standardMsg = '%s is not greater than %s' % (sequence,
target)
op = operator.ge
# Null date(time)s will always compare False, but
# we want to know about null date(time)s
if isinstance(target, collections.abc.Iterable):
if len(target) != len(sequence):
raise ValueError(('Length mismatch: '
'first argument contains %s elements, '
'second argument contains %s elements' % (
len(sequence), len(target))))
if not all(op(i, j) for i, j in zip(sequence, target)):
self.fail(self._formatMessage(msg, standardMsg))
elif isinstance(target, (date, datetime)):
if not all(op(element, target) for element in sequence):
self.fail(self._formatMessage(msg, standardMsg))
else:
raise TypeError(
'Second argument is not a datetime or date object or iterable')
[docs]
def assertDateTimesPast(self, sequence, strict=True, msg=None):
'''Fail if any elements in ``sequence`` are not in the past.
If the max element is a datetime, "past" is defined as anything
prior to ``datetime.now()``; if the max element is a date,
"past" is defined as anything prior to ``date.today()``.
If ``strict=True``, fail unless all elements in ``sequence``
are strictly less than ``date.today()`` (or ``datetime.now()``).
If ``strict=False``, fail unless all elements in ``sequence``
are less than or equal to ``date.today()`` (or
``datetime.now()``).
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If max element in ``sequence`` is not a datetime or date
object.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
# Cannot compare datetime to date, so if dates are provided use
# date.today(), if datetimes are provided use datetime.today()
if isinstance(max(sequence), datetime):
target = datetime.today()
elif isinstance(max(sequence), date):
target = date.today()
else:
raise TypeError('Expected iterable of datetime or date objects')
self.assertDateTimesBefore(sequence, target, strict=strict, msg=msg)
[docs]
def assertDateTimesFuture(self, sequence, strict=True, msg=None):
'''Fail if any elements in ``sequence`` are not in the future.
If the min element is a datetime, "future" is defined as
anything after ``datetime.now()``; if the min element is a date,
"future" is defined as anything after ``date.today()``.
If ``strict=True``, fail unless all elements in ``sequence``
are strictly greater than ``date.today()``
(or ``datetime.now()``). If ``strict=False``, fail all
elements in ``sequence`` are greater than or equal to
``date.today()`` (or ``datetime.now()``).
Parameters
----------
sequence : iterable
strict : bool
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If min element in ``sequence`` is not a datetime or date
object.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
# Cannot compare datetime to date, so if dates are provided use
# date.today(), if datetimes are provided use datetime.today()
if isinstance(min(sequence), datetime):
target = datetime.today()
elif isinstance(min(sequence), date):
target = date.today()
else:
raise TypeError('Expected iterable of datetime or date objects')
self.assertDateTimesAfter(sequence, target, strict=strict, msg=msg)
[docs]
def assertDateTimesFrequencyEqual(self, sequence, frequency, msg=None):
'''Fail if any elements in ``sequence`` aren't separated by
the expected ``fequency``.
Parameters
----------
sequence : iterable
frequency : timedelta
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If ``frequency`` is not a timedelta object.
'''
# TODO (jsa): check that elements in sequence are dates or
# datetimes, keeping in mind that sequence may contain null
# values
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(frequency, timedelta):
raise TypeError('Second argument is not a timedelta object')
standardMsg = 'unexpected frequencies found in %s' % sequence
s1 = pd.Series(sequence)
s2 = s1.shift(-1)
freq = s2 - s1
if not all(f == frequency for f in freq[:-1]):
self.fail(self._formatMessage(msg, standardMsg))
[docs]
def assertDateTimesLagEqual(self, sequence, lag, msg=None):
'''Fail unless max element in ``sequence`` is separated from
the present by ``lag`` as determined by the '==' operator.
If the max element is a datetime, "present" is defined as
``datetime.now()``; if the max element is a date, "present"
is defined as ``date.today()``.
This is equivalent to
``self.assertEqual(present - max(sequence), lag)``.
Parameters
----------
sequence : iterable
lag : timedelta
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If ``lag`` is not a timedelta object.
TypeError
If max element in ``sequence`` is not a datetime or date
object.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(lag, timedelta):
raise TypeError('Second argument is not a timedelta object')
# Cannot compare datetime to date, so if dates are provided use
# date.today(), if datetimes are provided use datetime.today()
if isinstance(max(sequence), datetime):
target = datetime.today()
elif isinstance(max(sequence), date):
target = date.today()
else:
raise TypeError('Expected iterable of datetime or date objects')
self.assertEqual(target - max(sequence), lag, msg=msg)
[docs]
def assertDateTimesLagLess(self, sequence, lag, msg=None):
'''Fail if max element in ``sequence`` is separated from
the present by ``lag`` or more as determined by the '<'
operator.
If the max element is a datetime, "present" is defined as
``datetime.now()``; if the max element is a date, "present"
is defined as ``date.today()``.
This is equivalent to
``self.assertLess(present - max(sequence), lag)``.
Parameters
----------
sequence : iterable
lag : timedelta
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If ``lag`` is not a timedelta object.
TypeError
If max element in ``sequence`` is not a datetime or date
object.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(lag, timedelta):
raise TypeError('Second argument is not a timedelta object')
# Cannot compare datetime to date, so if dates are provided use
# date.today(), if datetimes are provided use datetime.today()
if isinstance(max(sequence), datetime):
target = datetime.today()
elif isinstance(max(sequence), date):
target = date.today()
else:
raise TypeError('Expected iterable of datetime or date objects')
self.assertLess(target - max(sequence), lag, msg=msg)
[docs]
def assertDateTimesLagLessEqual(self, sequence, lag, msg=None):
'''Fail if max element in ``sequence`` is separated from
the present by more than ``lag`` as determined by the '<='
operator.
If the max element is a datetime, "present" is defined as
``datetime.now()``; if the max element is a date, "present"
is defined as ``date.today()``.
This is equivalent to
``self.assertLessEqual(present - max(sequence), lag)``.
Parameters
----------
sequence : iterable
lag : timedelta
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``sequence`` is not iterable.
TypeError
If ``lag`` is not a timedelta object.
TypeError
If max element in ``sequence`` is not a datetime or date
object.
'''
if not isinstance(sequence, collections.abc.Iterable):
raise TypeError('First argument is not iterable')
if not isinstance(lag, timedelta):
raise TypeError('Second argument is not a timedelta object')
# Cannot compare datetime to date, so if dates are provided use
# date.today(), if datetimes are provided use datetime.today()
if isinstance(max(sequence), datetime):
target = datetime.today()
elif isinstance(max(sequence), date):
target = date.today()
else:
raise TypeError('Expected iterable of datetime or date objects')
self.assertLessEqual(target - max(sequence), lag, msg=msg)
[docs]
def assertTimeZoneIsNone(self, dt, msg=None):
'''Fail if ``dt`` has a non-null ``tzinfo`` attribute.
Parameters
----------
dt : datetime
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``dt`` is not a datetime object.
'''
if not isinstance(dt, datetime):
raise TypeError('First argument is not a datetime object')
self.assertIsNone(dt.tzinfo, msg=msg)
[docs]
def assertTimeZoneIsNotNone(self, dt, msg=None):
'''Fail unless ``dt`` has a non-null ``tzinfo`` attribute.
Parameters
----------
dt : datetime
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``dt`` is not a datetime object.
'''
if not isinstance(dt, datetime):
raise TypeError('First argument is not a datetime object')
self.assertIsNotNone(dt.tzinfo, msg=msg)
[docs]
def assertTimeZoneEqual(self, dt, tz, msg=None):
'''Fail unless ``dt``'s ``tzinfo`` attribute equals ``tz`` as
determined by the '==' operator.
Parameters
----------
dt : datetime
tz : timezone
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``dt`` is not a datetime object.
TypeError
If ``tz`` is not a timezone object.
'''
if not isinstance(dt, datetime):
raise TypeError('First argument is not a datetime object')
if not isinstance(tz, timezone):
raise TypeError('Second argument is not a timezone object')
self.assertEqual(dt.tzinfo, tz, msg=msg)
[docs]
def assertTimeZoneNotEqual(self, dt, tz, msg=None):
'''Fail if ``dt``'s ``tzinfo`` attribute equals ``tz`` as
determined by the '!=' operator.
Parameters
----------
dt : datetime
tz : timezone
msg : str
If not provided, the :mod:`marbles.mixins` or
:mod:`unittest` standard message will be used.
Raises
------
TypeError
If ``dt`` is not a datetime object.
TypeError
If ``tz`` is not a timezone object.
'''
if not isinstance(dt, datetime):
raise TypeError('First argument is not a datetime object')
if not isinstance(tz, timezone):
raise TypeError('Second argument is not a timezone object')
self.assertNotEqual(dt.tzinfo, tz, msg=msg)