diff --git a/docs/changelog.md b/docs/changelog.md index 1c7514d..6fa7f7f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,7 @@ ## [0.0.3] - 2019-05-02 ### Added - CLI optarg string builder (`build_cli_extra`) +- `powerset` (all subsets of a collection) ## [0.0.2] - 2019-05-01 ## Changed diff --git a/tests/test_build_cli_extra.py b/tests/test_build_cli_extra.py index 0939510..51f33b4 100644 --- a/tests/test_build_cli_extra.py +++ b/tests/test_build_cli_extra.py @@ -2,20 +2,34 @@ from collections import OrderedDict import pytest -from ubiquerg import build_cli_extra +from ubiquerg import build_cli_extra, powerset __author__ = "Vince Reuter" __email__ = "vreuter@virginia.edu" +def pytest_generate_tests(metafunc): + """ Test case generation and parameterization for this module """ + if "ordwrap" in metafunc.fixturenames: + metafunc.parametrize("ordwrap", [tuple, OrderedDict]) + + @pytest.mark.parametrize(["optargs", "expected"], [ ([("-X", None), ("--revert", 1), ("-O", "outfile"), ("--execute-locally", None), ("-I", ["file1", "file2"])], "-X --revert 1 -O outfile --execute-locally -I file1 file2") ]) -def test_build_cli_extra(optargs, expected): +def test_build_cli_extra(optargs, expected, ordwrap): """ Check that CLI optargs are rendered as expected. """ - observed = build_cli_extra(**OrderedDict(optargs)) + observed = build_cli_extra(ordwrap(optargs)) print("expected: {}".format(expected)) print("observed: {}".format(observed)) assert expected == observed + + +@pytest.mark.parametrize( + "optargs", powerset([(None, "a"), (1, "one")], nonempty=True)) +def test_illegal_cli_extra_input_is_exceptional(optargs, ordwrap): + """ Non-string keys are illegal and cause a TypeError. """ + with pytest.raises(TypeError): + build_cli_extra(ordwrap(optargs)) diff --git a/tests/test_collection.py b/tests/test_collection.py index 1730371..5e30148 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,5 +1,7 @@ """ Tests for collection utilities """ +from collections import OrderedDict +import inspect import random import string import sys @@ -10,6 +12,36 @@ __email__ = "vreuter@virginia.edu" +def get_default_parameters(func, pred=None): + """ + For given function, get mapping from parameter name to default value. + + :param callable func: the function to inspect + :param func(object, object) -> bool pred: how to determine whether the + parameter should be included, based on name and default value + :return OrderedDict[str, object]]: mapping from parameter name to default value + """ + if not callable(func): + raise TypeError("Not a callable: {} ({})".format(func.__name__, type(func))) + spec = inspect.getfullargspec(func) + par_arg_pairs = zip(spec.args[(len(spec.args) - len(spec.defaults)):], + spec.defaults) + return OrderedDict( + par_arg_pairs if pred is None else + [(p, a) for p, a in par_arg_pairs if pred(p, a)]) + + +POWERSET_BOOL_KWARGSPACE = { + p: [False, True] for p in + get_default_parameters(powerset, lambda _, v: isinstance(v, bool))} + + +def pytest_generate_tests(metafunc): + """ Test case generation and parameterization for this module """ + if "arbwrap" in metafunc.fixturenames: + metafunc.parametrize("arbwrap", [list, tuple, set, iter]) + + def randcoll(pool, dt): """ Generate random collection of 1-10 elements. @@ -38,6 +70,30 @@ def randcoll(pool, dt): (randcoll([int(d) for d in string.digits], set), True), ("", False), ("abc", False)] ) -def test_coll_like(arg, exp): +def test_is_collection_like(arg, exp): """ Test arbiter of whether an object is collection-like. """ assert exp == is_collection_like(arg) + + +@pytest.mark.skip("not implemented") +@pytest.mark.parametrize("kwargs", []) +def test_powerset_of_empty_pool(arbwrap, kwargs): + 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.skip("not implemented") +@pytest.mark.parametrize(["pool", "kwargs", "expected"], []) +def test_powerset_legitimate_input(arbwrap, pool, kwargs, expected): + observed = powerset(arbwrap(pool), **kwargs) + assert len(expected) == len(observed) + assert expected == set(observed) + + +@pytest.mark.skip("not implemented") +def test_powerset_illegal_input(arbwrap): + pass diff --git a/tests/test_this_package.py b/tests/test_this_package.py new file mode 100644 index 0000000..1e3091c --- /dev/null +++ b/tests/test_this_package.py @@ -0,0 +1,21 @@ +""" Validate what's available directly on the top-level import. """ + +import pytest + +__author__ = "Vince Reuter" +__email__ = "vreuter@virginia.edu" + + +@pytest.mark.parametrize( + ["obj_name", "typecheck"], + [("build_cli_extra", callable), ("is_collection_like", callable), + ("powerset", callable)]) +def test_top_level_exports(obj_name, typecheck): + """ At package level, validate object availability and type. """ + import ubiquerg + try: + obj = getattr(ubiquerg, obj_name) + except AttributeError: + pytest.fail("Unavailable on {}: {}".format(ubiquerg.__name__, obj_name)) + else: + assert typecheck(obj) diff --git a/ubiquerg/cli_tools.py b/ubiquerg/cli_tools.py index 62d205d..adcfbc9 100644 --- a/ubiquerg/cli_tools.py +++ b/ubiquerg/cli_tools.py @@ -8,7 +8,7 @@ __all__ = ["build_cli_extra"] -def build_cli_extra(**kwargs): +def build_cli_extra(optargs): """ Render CLI options/args as text to add to base command. @@ -17,14 +17,25 @@ def build_cli_extra(**kwargs): with single space between each. All non-string values are converted to string. + :param Mapping | Iterable[(str, object)] optargs: values used as + options/arguments :return str: text to add to base command, based on given opts/args + :raise TypeError: if an option name isn't a string """ def render(k, v): + if not isinstance(k, str): + raise TypeError( + "Option name isn't a string: {} ({})".format(k, type(k))) if v is None: return k if is_collection_like(v): v = " ".join(map(str, v)) return "{} {}".format(k, v) - return " ".join(render(*kv) for kv in kwargs.items()) + try: + data_iter = optargs.items() + except AttributeError: + data_iter = optargs + + return " ".join(render(*kv) for kv in data_iter) diff --git a/ubiquerg/collection.py b/ubiquerg/collection.py index 3b158b2..1b26587 100644 --- a/ubiquerg/collection.py +++ b/ubiquerg/collection.py @@ -1,5 +1,6 @@ """ Tools for working with collections """ +import itertools import sys if sys.version_info < (3, 3): from collections import Iterable @@ -10,7 +11,7 @@ __email__ = "vreuter@virginia.edu" -__all__ = ["is_collection_like"] +__all__ = ["is_collection_like", "powerset"] def is_collection_like(c): @@ -21,3 +22,36 @@ def is_collection_like(c): :return bool: Whether the argument is a (non-string) collection """ return isinstance(c, Iterable) and not isinstance(c, str) + + +def powerset(items, min_items=None, include_full_pop=True, nonempty=False): + """ + Build the powerset of a collection of items. + + :param Iterable[object] items: "Pool" of all items, the population for + which to build the power set. + :param int min_items: Minimum number of individuals from the population + to allow in any given subset. + :param bool include_full_pop: Whether to include the full population in + the powerset (default True to accord with genuine definition) + :param bool nonempty: force each subset returned to be nonempty + :return list[object]: Sequence of subsets of the population, in + nondecreasing size order + :raise TypeError: if minimum item count is specified but is not an integer + :raise ValueError: if minimum item count is insufficient to guarantee + nonempty subsets + """ + if min_items is None: + min_items = 1 if nonempty else 0 + else: + if not isinstance(min_items, int): + raise TypeError("Min items count for each subset isn't an integer: " + "{} ({})".format(min_items, type(min_items))) + 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. + max_items = len(items) + 1 if include_full_pop else len(items) + return list(itertools.chain.from_iterable( + itertools.combinations(items, k) + for k in range(min_items, max_items)))