Skip to content

Instantly share code, notes, and snippets.

@outofmbufs
Last active March 8, 2021 15:02
Show Gist options
  • Save outofmbufs/f976925e21a2ea35bdfc6e33a8072db1 to your computer and use it in GitHub Desktop.
Save outofmbufs/f976925e21a2ea35bdfc6e33a8072db1 to your computer and use it in GitHub Desktop.
Python code to force something that *might* be a tuple (or a single thing) into a tuple. Useful(?) for processing arguments that can be a "thing" or multiple "things". Also defines a decorator (which seems like a horrible idea, but was fun!)
import functools
from collections import ChainMap
def tuplify(arg, *, keep=str):
"""Return tuple from an iterable or a naked single object.
If arg is a non-iterable object, returns: (arg,)
If arg is iterable, returns: tuple(arg)
Keyword argument 'keep' allows some iterables (most notably: strings)
to be treated as non-iterable, so that, for example:
tuplify('bozo', keep=str)
will return ('bozo',) not ('b', 'o', 'z', 'o')
This behavior for strings is actually the default, so:
tuplify('bozo') --> ('bozo',)
tuplify('bozo', keep=None) --> ('b', 'o', 'z', 'o')
Callers can use all the forms for 'keep' that isinstance supports; thus
'keep' itself can be a single type or a multi-level recursive tuple (!!)
"""
if keep is not None and isinstance(arg, keep):
return (arg,)
try:
return tuple(arg)
except TypeError:
return (arg,)
# this is fun, but probably a terrible idea ... a decorator to add tuplify()
# functionality to ONE specific argument of a function. The explicit design
# choice here is to only handle doing it for one particular argument; stack
# the decorator if multiple arguments must be tuplified.
#
def tuplifyarg(a):
"""decorator to tuplify a function argument.
a - kwarg name (e.g., 'foo') or positional arg index (e.g., 0)
CAVEAT CODER: the wrapper, invoked at call time, can only process the
arguments it gets; it cannot process omitted args that will take
on default values. Make sure to code any default arg values
accordingly (i.e., as tuples if that's what they need to be).
"""
# enforce some sanity at DECORATOR invocation time, rather than
# get very confusing errors later at function call time:
# argument 'a' must either be string-like or int-like
try:
bad = str(a) != a and int(a) != a
except TypeError:
bad = True
if bad:
raise TypeError(f"decorator argument 'a' (={a}) must be string or int")
# _deco is the outer/callable decorator object that will be called
# to decorate the function, and will (at that time) return the inner
# _wrapped() function which represents the wrapped/decorated function.
def _deco(f):
@functools.wraps(f)
def _wrapped(*args, **kwargs):
if str(a) == a:
if a in kwargs:
kwargs = ChainMap({a: tuplify(kwargs[a])}, kwargs)
else:
args = list(args)
args[a] = tuplify(args[a])
return f(*args, **kwargs)
return _wrapped
return _deco
# if tuplifyarg is a terrible idea, so is this! tuplify ALL arguments
def tuplifyfunc(f):
@functools.wraps(f)
def _wrapped(*args, **kwargs):
tupled_args = [tuplify(a) for a in args]
tupled_kwargs = {k: tuplify(v) for k, v in kwargs.items()}
return f(*tupled_args, **tupled_kwargs)
return _wrapped
if __name__ == "__main__":
import unittest
class TestMethods(unittest.TestCase):
def test_basics(self):
a_tuple = (1, 2, 3)
a_string = "bozo"
same = object() # sentinel; means result same as v
as_a_tuple = object() # sentinel; means (v,) is result for v
for v, result, *keep in (
(None, as_a_tuple),
(None, as_a_tuple, None),
(a_string, tuple(a_string), None), # string decomposed
(a_string, tuple(a_string), list), # string decomposed
(a_string, as_a_tuple), # string preserved...
(a_string, as_a_tuple, str),
(a_string, as_a_tuple, (str,)),
(a_string, as_a_tuple, (list, str)),
(a_string, as_a_tuple, (str, list)),
(a_string, as_a_tuple, (float, (int, (str,)))),
(tuple(), same),
(a_tuple, same),
(a_tuple, as_a_tuple, tuple),
(a_tuple, same, (str, int, float)),
(a_tuple, as_a_tuple, (str, int, float, tuple))):
if result is same:
result = v
elif result is as_a_tuple:
result = (v,)
with self.subTest(v=v, result=result, keep=keep):
t = tuplify(v, keep=keep[0]) if keep else tuplify(v)
self.assertEqual(t, result)
def test_deco_kwarg_1(self):
@tuplifyarg('bozo')
def clowns(*, krusty, bozo):
return len(bozo)
self.assertEqual(clowns(krusty=None, bozo=None), 1)
def test_deco_kwarg_2(self):
@tuplifyarg('krusty')
@tuplifyarg('bozo')
def clowns(*, krusty, bozo):
return len(krusty) + len(bozo)
self.assertEqual(clowns(krusty=None, bozo=(1, 2, 3)), 4)
def test_deco_arg_1(self):
@tuplifyarg(0)
def clowns(krusty, bozo):
return len(krusty)
self.assertEqual(clowns(None, None), 1)
def test_deco_arg_2(self):
with self.assertRaises(TypeError):
@tuplifyarg(1.2)
def foo(a, b):
pass
def test_deco_multiarg_1(self):
@tuplifyarg('b')
@tuplifyarg(0)
def lengths(a, b):
return len(a)+len(b)
self.assertEqual(lengths(None, b='abc'), 2)
def test_deco_multiarg_2(self):
@tuplifyarg(0)
@tuplifyarg('b')
def lengths(a, b='xyzzy'):
return len(a)+len(b)
self.assertEqual(lengths(None, b='abc'), 2)
# DEFAULT values of function arguments ARE NOT tuplified!!
self.assertEqual(lengths(None), 6)
def test_deco_func(self):
@tuplifyfunc
def foo(a, b, *, c='xyzzy', d=[]):
return len(a) + len(b) + len(c) + len(d)
self.assertEqual(foo(1, "bozo", d=[1, 2, 3]), 10)
# this test actually demonstrates/illustrates a limitation of
# the decorator/wrapper ... it cannot tuplify an omitted argument
# (because it doesn't even get to 'see' it). This could be used as
# a demented "is the argument defaulted" sentinel! (ick/haha) and
# that's what this test tests/demonstrates.
def test_defaulted(self):
@tuplifyarg('a')
def a_was_defaulted(*, a=None):
return a is None
# note/understand the difference being demonstrated here!
self.assertTrue(a_was_defaulted())
self.assertFalse(a_was_defaulted(a=None))
unittest.main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment