Skip to content

Commit

Permalink
Merge pull request #1 from pepkit/dev
Browse files Browse the repository at this point in the history
0.0.4
  • Loading branch information
vreuter committed May 3, 2019
2 parents 9f53ed0 + 0fa86b2 commit 0fbde73
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 19 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ install:
- pip install .
- pip install -r requirements/requirements-dev.txt
- pip install -r requirements/requirements-test.txt
script: pytest --cov=ubiquerg
script: pytest --cov=ubiquerg --cov-report term-missing
after_success:
- coveralls
branches:
Expand Down
6 changes: 6 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [0.0.4] - 2019-05-03
### Added
- `ExpectContext` for uniform test execution, regardless of whether expectation is an ordinary object or an exception
### Changed
- When minimum item count exceeds pool size and/or the "pool" of items is empty, `powerset` returns an empty collection rather than a collection with a single empty element.

## [0.0.3] - 2019-05-02
### Added
- CLI optarg string builder (`build_cli_extra`)
Expand Down
1 change: 1 addition & 0 deletions requirements/requirements-all.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest>=3.0.7
1 change: 0 additions & 1 deletion requirements/requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
pytest>=3.0.7
70 changes: 70 additions & 0 deletions tests/test_ExpectContext.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
""" Tests for ExpectContext """

import pytest
from ubiquerg import ExpectContext

__author__ = "Vince Reuter"
__email__ = "vreuter@virginia.edu"


def atterr(*args, **kwargs):
raise AttributeError()


def ioerr(*args, **kwargs):
raise IOError()


def keyerr(*args, **kwargs):
raise KeyError()


def typerr(*args, **kwargs):
raise TypeError()


FUN_BY_ERR = {IOError: ioerr, TypeError: typerr,
KeyError: keyerr, AttributeError: atterr}


@pytest.mark.parametrize(
["err", "fun", "expect_success"],
[t for err, fun in FUN_BY_ERR.items()
for t in [(err, fun, True)] +
[(err, unfun, False) for unfun in
[f for e, f in FUN_BY_ERR.items() if e != err]]])
@pytest.mark.parametrize("args", [tuple(), ("a", ), ("b", 1)])
@pytest.mark.parametrize("kwargs", [{}, {"a": 0}, {"a": 1, "b": [1, 2]}])
def test_exp_ctx_exceptional_result(err, fun, expect_success, args, kwargs):
""" The expectation context correctly handles exceptional expectation. """
try:
with ExpectContext(expected=err, test_func=fun) as ctx:
ctx(*args, **kwargs)
except AssertionError as e:
if expect_success:
# Wrong failure type
pytest.fail(str(e))
except Exception as e:
if expect_success:
pytest.fail("Expected to catch a {} but hit {}".
format(type(err), type(e)))


@pytest.mark.parametrize(
["exp_res", "fun", "expect_success"],
[(2, lambda *args, **kwargs: 2, True),
(-1, lambda *args, **kwargs: 0, False)])
@pytest.mark.parametrize("args", [tuple(), ("a", ), ("b", 1)])
@pytest.mark.parametrize("kwargs", [{}, {"a": 0}, {"a": 1, "b": [1, 2]}])
def test_exp_ctx_ordinary_result(exp_res, fun, expect_success, args, kwargs):
""" The expectation context correctly handles ordinary expectation. """
try:
with ExpectContext(expected=exp_res, test_func=fun) as ctx:
res = ctx(*args, **kwargs)
except AssertionError as e:
if expect_success:
pytest.fail("Expected success but assertion failed: {}".format(e))
else:
if not expect_success:
pytest.fail("Unexpected function execution success (feigned "
"expectation {} and got {})".format(exp_res, res))
132 changes: 118 additions & 14 deletions tests/test_collection.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
""" Tests for collection utilities """

from collections import OrderedDict
from collections import Counter, OrderedDict
import itertools
import sys
if sys.version_info.major < 3:
from inspect import getargspec as get_fun_sig
Expand All @@ -16,6 +17,22 @@
__email__ = "vreuter@virginia.edu"


MIN_SIZE_KWARG = "min_size"


def combo_space(ks, items):
"""
Create the Cartesian product of values sets, each bound to a key.
:param Iterable[str] ks: subset of keys from the mapping items
:param Mapping[str, Iterable[object]] items: bindings between key and
collection of values
:return itertools.product: Cartesian product of the values sets bound by
the given subset of keys
"""
return itertools.product(*[items[k] for k in ks])


def get_default_parameters(func, pred=None):
"""
For given function, get mapping from parameter name to default value.
Expand All @@ -35,9 +52,37 @@ def get_default_parameters(func, pred=None):
[(p, a) for p, a in par_arg_pairs if pred(p, a)])


def _generate_too_few_items(minsize, maxsize, pool):
"""
Generate a "pool" of items of random size guaranteed less than given bound.
:param int minsize: minimum pool size
:param int maxsize: maximum pool size
:param Iterable[object] pool: items from which to pull a subset for the
returned pool
:return Iterable[object]: subset of initial pool, constrained to be
sufficiently small in accordance with given bounds
:raise TypeError: if either size bound is not an integer
:raise ValueError: if maxsize < minsize
"""
print_bound = lambda: "[{}, {}]".format(minsize, maxsize)
if not (isinstance(minsize, int) and isinstance(maxsize, int)):
raise TypeError("Size bounds must be integers; got {}".
format(print_bound()))
if maxsize < minsize:
raise ValueError("Nonesense size bounds: {}".format(print_bound()))
n = random.randint(minsize, maxsize)
return n, random.sample(pool, max(n - 1, 0))


POWERSET_BOOL_KWARGSPACE = {
p: [False, True] for p in
get_default_parameters(powerset, lambda _, v: isinstance(v, bool))}
KWARGSPACE_POWERSET = {
ks: combo_space(ks, POWERSET_BOOL_KWARGSPACE)
for n in range(len(POWERSET_BOOL_KWARGSPACE) + 1)
for ks in [tuple(c) for c in
itertools.combinations(POWERSET_BOOL_KWARGSPACE.keys(), n)]}


def pytest_generate_tests(metafunc):
Expand Down Expand Up @@ -79,25 +124,84 @@ def test_is_collection_like(arg, exp):
assert exp == is_collection_like(arg)


@pytest.mark.skip("not implemented")
@pytest.mark.parametrize("kwargs", [])
@pytest.mark.parametrize("kwargs",
[d for ks, vals_list in KWARGSPACE_POWERSET.items()
for d in [dict(zip(ks, vs)) for vs in vals_list]])
def test_powerset_of_empty_pool(arbwrap, kwargs):
""" Empty collection's powerset is always empty. """
assert [] == powerset(arbwrap([]), **kwargs)


@pytest.mark.skip("not implemented")
def test_powerset_fewer_items_than_min(pool, arbwrap, kwargs):
assert [] == powerset(arbwrap(pool), **kwargs)
@pytest.mark.parametrize("include_full_pop", [False, True])
@pytest.mark.parametrize(
["min_items", "pool"],
[_generate_too_few_items(
2, 10, list(string.ascii_letters) + list(range(-10, 11)))
for _ in range(5)])
def test_powerset_fewer_items_than_min(arbwrap, min_items, pool, include_full_pop):
""" Minimum item count in excess of pool size results in empty powerset. """
print("pool (n={}): {}".format(len(pool), pool))
assert [] == powerset(arbwrap(pool), min_items=min_items,
include_full_pop=include_full_pop)


@pytest.mark.parametrize("pool", [[1, 2, 3], ["a", "b", "c"]])
def test_full_powerset(arbwrap, pool):
obs = powerset(arbwrap(pool))
reps = list(filter(lambda kv: kv[1] > 1, Counter(obs).items()))
assert [] == reps, "Repeated items in powerset: {}".format(reps)
assert 2 ** len(pool) == len(obs)

@pytest.mark.skip("not implemented")
@pytest.mark.parametrize(["pool", "kwargs", "expected"], [])

@pytest.mark.parametrize(
["pool", "kwargs", "expected"], [
([-1, 1], {}, [set(), {-1}, {1}, {-1, 1}]),
([-1, 1], {"include_full_pop": False}, [set(), {-1}, {1}]),
([-1, 1], {"nonempty": True}, [{-1}, {1}, {-1, 1}]),
([-1, 1], {"include_full_pop": False, "nonempty": True}, [{-1}, {1}])
])
def test_powerset_legitimate_input(arbwrap, pool, kwargs, expected):
observed = powerset(arbwrap(pool), **kwargs)
assert len(expected) == len(observed)
assert expected == set(observed)
""" Powerset behavior responds to arguments to its parameters """

# Verification logic type dependencies
assert isinstance(expected, list)
assert all(isinstance(ss, set) for ss in expected)

@pytest.mark.skip("not implemented")
def test_powerset_illegal_input(arbwrap):
pass
observed = powerset(arbwrap(pool), **kwargs) # Find the powerset.

# Check for repeats.
reps = list(filter(lambda kv: kv[1] > 1, Counter(observed).items()))
assert [] == reps, "Repeated items in powerset: {}".format(reps)
assert len(expected) == len(observed) # Dumbest check is on raw size.

# Better, harder check is on mutual inclusion.
# With no repeats in the empirical powerset and expected size validated
# as equal to observe size, to verify bijection we check for all "hits."
exp_hits = [False] * len(expected)
for sub in observed:
sought = set(sub)
for i, exp in enumerate(expected):
if sought == exp:
exp_hits[i] = True
break
missed = [ss for ss, hit in zip(expected, exp_hits) if not hit]
assert [] == missed, "{} missed subsets: {}".format(len(missed), missed)


@pytest.mark.parametrize(
["kwargs", "exp_err"],
[({"min_items": 0, "nonempty": True}, ValueError),
({"min_items": -1, "nonempty": True}, ValueError),
({"min_items": -sys.maxsize, "nonempty": True}, ValueError),
({"min_items": "1"}, TypeError),
({"min_items": "None"}, TypeError),
({"min_items": [2]}, TypeError)])
@pytest.mark.parametrize("pool", [string.ascii_letters, range(-10, 11)])
def test_powerset_illegal_input(arbwrap, kwargs, exp_err, pool):
""" Invalid argument combination to powerset parameters is exceptional. """
invalid_kwargs = set(kwargs.keys()) - set(get_fun_sig(powerset).args)
assert not invalid_kwargs, \
"Attempting to use keyword arguments not in the signature of {}: {}".\
format(powerset.__name__, ", ".join(invalid_kwargs))
with pytest.raises(exp_err):
powerset(arbwrap(pool), **kwargs)
3 changes: 2 additions & 1 deletion tests/test_this_package.py → tests/test_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
@pytest.mark.parametrize(
["obj_name", "typecheck"],
[("build_cli_extra", callable), ("is_collection_like", callable),
("powerset", callable)])
("powerset", callable),
("ExpectContext", lambda obj: isinstance(obj, type))])
def test_top_level_exports(obj_name, typecheck):
""" At package level, validate object availability and type. """
import ubiquerg
Expand Down
1 change: 1 addition & 0 deletions ubiquerg/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
""" Package exports """

from .assistest import *
from .cli_tools import *
from .collection import *
from ._version import __version__
2 changes: 1 addition & 1 deletion ubiquerg/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.0.3"
__version__ = "0.0.4dev"
35 changes: 35 additions & 0 deletions ubiquerg/assistest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
""" Assistance functions for writing and running tests """

import pytest

__author__ = "Vince Reuter"
__email__ = "vreuter@virginia.edu"


class ExpectContext(object):
""" Pytest validation context, a framework for varied kinds of expectations. """

def __init__(self, expected, test_func):
"""
Create the test context by specifying expectation and function.
:param object | type expected: expected result or exception
:param callable test_func: the callable object to test
"""
self._f = test_func
self._exp = expected

def __enter__(self):
""" Return the instance for use as a callable. """
return self

def __exit__(self, exc_type, exc_val, exc_tb):
pass

def __call__(self, *args, **kwargs):
""" Execute the instance's function, passing given args/kwargs. """
if isinstance(self._exp, type) and issubclass(self._exp, Exception):
with pytest.raises(self._exp):
self._f(*args, **kwargs)
else:
assert self._exp == self._f(*args, **kwargs)
7 changes: 6 additions & 1 deletion ubiquerg/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ def powerset(items, min_items=None, include_full_pop=True, nonempty=False):
if nonempty and min_items < 1:
raise ValueError("When minimum item count is {}, nonempty subsets "
"cannot be guaranteed.".format(min_items))
items = list(items) # Account for iterable burn possibility.
# Account for iterable burn possibility; besides, collection should be
# relatively small if building the powerset.
items = list(items)
n = len(items)
if n == 0 or n < min_items:
return []
max_items = len(items) + 1 if include_full_pop else len(items)
return list(itertools.chain.from_iterable(
itertools.combinations(items, k)
Expand Down

0 comments on commit 0fbde73

Please sign in to comment.