Decorator

Decorators add new functionality to a function or class at runtime. This module shows how a simple "encryption" function for one string can be decorated to work with a collection of strings. Note that the decorator handles nested collections with the use of recursion.

from functools import wraps

# Module-level constants
_MASKING = "*"


def run_with_stringy(fn):
    """Run a string function with a string or a collection of strings.

    We define a custom decorator that allows us to convert a function whose
    input is a single string into a function whose input can be a string
    or a collection of strings.

    A function decorator consists of the following:

    - An input function to run with
    - A wrapper function that uses the input function

    The `wrapper` does not need to accept the input function as a parameter
    because it can get that from its parent `run_with_any`. Also, the
    parameters that `wrapper` receives do NOT have to be the same as the
    ones that the input function `fn` needs to receive. However, it is highly
    recommended to have the parameter lists for `wrapper` and `fn` line up so
    that developers are less likely to get confused.

    The formal specification for function decorators is here:

    https://www.python.org/dev/peps/pep-0318/

    The formal specification for class decorators is here:

    https://www.python.org/dev/peps/pep-3129/
    """

    @wraps(fn)
    def wrapper(obj):
        """Apply wrapped function to a string or a collection.

        This looks like a policy-based engine which runs a `return` statement
        if a particular set of rules is true. Otherwise it aborts. This is
        an example of the Strategy design pattern.

        https://en.wikipedia.org/wiki/Strategy_pattern

        But instead of writing the logic using classes, we write the logic
        using a single function that encapsulates all possible rules.
        """
        if isinstance(obj, str):
            return fn(obj)
        elif isinstance(obj, dict):
            return {key: wrapper(value) for key, value in obj.items()}
        elif isinstance(obj, (list, set, tuple)):
            sequence_kls = type(obj)
            return sequence_kls(wrapper(value) for value in obj)
        raise ValueError(f"Found an invalid item: {obj}")

    return wrapper


@run_with_stringy
def hide_content(content):
    """Hide half of the string content."""
    start_point = len(content) // 2
    num_of_asterisks = len(content) // 2 + len(content) % 2
    return content[:start_point] + _MASKING * num_of_asterisks


def _is_hidden(obj):
    """Check whether string or collection is hidden."""
    if isinstance(obj, str):
        return _MASKING in obj
    elif isinstance(obj, dict):
        return all(_is_hidden(value) for value in obj.values())
    return all(_is_hidden(value) for value in obj)


def main():
    # There is so much plain-text data out in the open
    insecure_data = [
        {"username": "johndoe", "country": "USA"},  # User information
        ["123-456-7890", "123-456-7891"],  # Social security numbers
        [("johndoe", "janedoe"), ("bobdoe", "marydoe")],  # Couple names
        "secretLaunchCode123",  # Secret launch code
    ]

    # Time to encrypt it all so that it can't be snatched away. This kind
    # of work is the stuff that might be done by a company for GDPR. For more
    # on that policy, check out the following Wikipedia page:
    # https://en.wikipedia.org/wiki/General_Data_Protection_Regulation
    secure_data = hide_content(insecure_data)

    # See what changed between the insecure data and the secure data
    for insecure_item, secure_item in zip(insecure_data, secure_data):
        assert insecure_item != secure_item
        assert not _is_hidden(insecure_item)
        assert _is_hidden(secure_item)

    # Throw an error on a collection with non-string objects
    input_failed = False
    try:
        hide_content([1])
    except ValueError:
        input_failed = True
    assert input_failed is True


if __name__ == "__main__":
    main()