Skip to content

Commit

Permalink
added itertools module with named_product function
Browse files Browse the repository at this point in the history
  • Loading branch information
dangrie158 committed Jul 1, 2019
1 parent be113a2 commit 8038cc4
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.rst
Expand Up @@ -35,6 +35,7 @@ Quickstart
- `Load Jupyter Notebooks as Python Modules <https://py-toolbox.readthedocs.io/en/latest/modules/importlib.html#importing-jupyter-notebooks-as-python-modules>`_
- `Reload modules when importing again (do not cache the result) <https://py-toolbox.readthedocs.io/en/latest/modules/importlib.html#automatically-reload-modules-and-packages-when-importing>`_
- `Mirroring all output of a script to a file <https://py-toolbox.readthedocs.io/en/latest/modules/io.html#redirecting-output-streams>`_
- `Flexibly test a number possible configurations of a function <https://py-toolbox.readthedocs.io/en/latest/modules/itertools.html#flexibly-test-a-number-possible-configurations-of-a-function>`_
- `Automatically configure the framework <https://py-toolbox.readthedocs.io/en/latest/modules/core.html#autoconfigure-toolbox-frameworks>`_
- `Configure defaults <https://py-toolbox.readthedocs.io/en/latest/modules/config.html#configure-the-toolkit>`_

Expand Down
71 changes: 71 additions & 0 deletions docs/modules/itertools.rst
@@ -0,0 +1,71 @@
---------------------
pytb.itertools module
---------------------

************************************************************
Flexibly test a number possible configurations of a function
************************************************************

Assume you have a function that takes a number of parameters:

>>> def my_func(a, b, c=2, **kwargs):
... print(' '.join((a, b, c)), kwargs)

And you want to call it with multiple parameter combinations

>>> my_params = {
... 'a': 'a1',
... 'b': ('b1','b2'),
... 'c': ('c1', 'c2'),
... 'additional_arg': 'val'
... }

You can use the :meth:`named_tuple` function of this module to create any
possible combination of the provided parameters

>>> for params in named_product(my_params):
... my_func(**params)
a1 b1 c1 {'additional_arg': 'val'}
a1 b1 c2 {'additional_arg': 'val'}
a1 b2 c1 {'additional_arg': 'val'}
a1 b2 c2 {'additional_arg': 'val'}

Excluding some combinations
---------------------------

If some parameter combinations are not allowed, you can use the
functions ability to work with nested dicts to overwrite values defined in
an outer dict

>>> my_params = {
... 'a': 'a1',
... 'b': ('b1','b2'),
... 'c': {
... 'c1': {'b': 'b1'},
... 'c2': {},
... 'c3': {
... 'additional_arg': 'other val',
... 'another arg': 'yet another val'}
... },
... 'additional_arg': 'val'
... }

>>> for params in named_product(my_params):
... my_func(**params)
a1 b1 c1 {'additional_arg': 'val'}
a1 b1 c2 {'additional_arg': 'val'}
a1 b2 c2 {'additional_arg': 'val'}
a1 b1 c3 {'additional_arg': 'other val', 'another arg': 'yet another val'}
a1 b2 c3 {'additional_arg': 'other val', 'another arg': 'yet another val'}

Note that for ``c='c1'`` only ``b='b1'`` was used. You can also define
new variables inside each dict that only get used for combinations in
this branch.

*****************
API Documentation
*****************

.. automodule:: pytb.itertools
:members:
:show-inheritance:
94 changes: 94 additions & 0 deletions pytb/itertools.py
@@ -0,0 +1,94 @@
"""
Methods to work with iterables conveniently.
(methods that could be in the python stdlib itertools package)
"""

import itertools
from collections.abc import Iterable
from typing import Optional, Any, Mapping, Generator


def named_product(
values: Optional[Mapping[Any, Any]] = None,
repeat: int = 1,
**kwargs: Mapping[Any, Any]
) -> Generator[Any, None, None]:
"""
.. testsetup:: *
from pytb.itertools import named_product
Return each possible combination of the input parameters (cartesian product),
thus this provides the same basic functionality of :meth:``itertools.product``.
However this method provides more flexibility as it:
1. returns dicts instead of tuples
.. doctest::
>>> list(named_product(a=('X', 'Y'), b=(1, 2)))
[{'a': 'X', 'b': 1}, {'a': 'X', 'b': 2}, {'a': 'Y', 'b': 1}, {'a': 'Y', 'b': 2}]
2. accepts either a dict or kwargs
.. doctest::
>>> list(named_product({ 'a':('X', 'Y') }, b=(1, 2)))
[{'a': 'X', 'b': 1}, {'a': 'X', 'b': 2}, {'a': 'Y', 'b': 1}, {'a': 'Y', 'b': 2}]
3. accepts nested dicts
.. doctest::
>>> list(named_product(
... a=(
... {'X': {'b':(1,2)}},
... {'Y': {
... 'b': (3, 4),
... 'c': (5, )
... }
... }
... )
... ))
[{'a': {'X': {'b': (1, 2)}}}, {'a': {'Y': {'b': (3, 4), 'c': (5,)}}}]
4. accepts scalar values
.. doctest::
>>> list(named_product(b='Xy', c=('a', 'b')))
[{'b': 'Xy', 'c': 'a'}, {'b': 'Xy', 'c': 'b'}]
:param values: a dict of iterables used to create the cartesian product
:param repeat: repeat iteration of the product N-times
:param \**kwargs: optional keyword arguments. The dict of
keyword arguments is merged with the values dict,
with ``kwargs`` overwriting values in ``values``
"""
# merge the values dict with the kwargs, giving
# precedence to the kwargs
if values is not None:
kwargs = {**values, **kwargs}

# convert scalar values to 1-tuples
for name, entry in kwargs.copy().items():
# always pack strings as they are iterable,
# but most likely used as scalars
if isinstance(entry, str):
kwargs.update({name: (entry,)})
elif not isinstance(entry, Iterable):
# pack the value into a tuple
kwargs.update({name: (entry,)})

if any(isinstance(v, dict) for v in kwargs.values()):
# recursivley expand all dict elements to the set of values
for key_outer, val_outer in kwargs.items():
if isinstance(val_outer, dict):
for key_inner, val_inner in val_outer.items():
subproduct = {key_outer: key_inner, **val_inner}
# yield from to exhaust the recursive call to the iterator
yield from named_product(repeat=repeat, **{**kwargs, **subproduct})
else:
# non-recursive exit point yields the product of all values
for combination in itertools.product(*kwargs.values(), repeat=repeat):
yield dict(zip(list(kwargs.keys()), combination))
10 changes: 10 additions & 0 deletions pytb/test/test_itertools.py
@@ -0,0 +1,10 @@
import doctest
import unittest

import pytb.itertools

suite = unittest.TestSuite()
suite.addTest(doctest.DocTestSuite(pytb.itertools))

runner = unittest.TextTestRunner(verbosity=2)
runner.run(suite)

0 comments on commit 8038cc4

Please sign in to comment.