Equivalents permutations function
For starter, you shouldn't use abreviations for your variables/functions names: perms for permutations, st for string, let for letter, equiv for equivalence_table… You gain nothing doing so, except obfuscating what is going on which impairs readability.

Then I find that your function has a wrong name, as permutations refers more naturally to swapping the order of the letters. I would rather use something along the line of case_swap or character_equivalence. I find these names more descriptive in constructs of the form for word in &lt;function_name&gt;('I got it.').

Now, what about the behaviour of your function when given a string with characters that are not in string.ascii_lowercase or string.ascii_uppercase, as the spaces or the dot in my previous example? You should handle such cases where let is not a key in equivs. And the simplest to do so is to fallback to using only let in such cases: equivs[let] can become equivs.get(let, let).

An other thing, that I particularly like with Python, is how it is simple to write lazy functions. Instead of building whole lists of results and storing them in memory, you can just yield intermediate results as you compute them. And I have the feeling that such function would benefit from being writen as a generator since I believe it will most likely be called to iterate over its result. And to do that without computing a whole bunch of intermediary string before the first usefull result, we can:


use a recursive function to prepend the equivalents of the first letter to the computed equivalents of the rest of the string; or
ask python to generate the cartesian product by providing ourselves a list of equivalent values for each letter.


The recursive function would look like:

def swap_case(sentence, equivalence_table):
    letter, *rest = sentence
    equivalents = equivalence_table.get(letter, letter)
    if not rest:
        yield from equivalents
    else:
        for end in swap_case(rest, equivalence_table):
            for symbol in equivalents:
                yield symbol + end


but recursion has its limits, especially in the number of function calls you can perform. So I would prefer to use the product generator from itertools to leverage fast iteration optimized in C:

import itertools


def swap_case(sentence, equivalence_table):
    equivalent_letters = (equivalence_table.get(l, l) for l in sentence)
    for characters in itertools.product(*equivalent_letters):
        yield ''.join(characters)


Here the join is needed since itertools.product generates lists of values taken from the provided iterables.

You can also sacrifice a bit of readability to continue iterating in C rather than using a for loop in Python by using map to apply ''.join to each list yielded by itertools.product:

def swap_case(sentence, equivalence_table):
    equivalent_letters = (equivalence_table.get(l, l) for l in sentence)
    return map(''.join, itertools.product(*equivalent_letters))


All in all, both version doesn't change the time complexity of the algorithm since you still have to generate the same amount of values. But the memory usage should be drastically lower.



You may also have noted that I changed the signature to include the equivalence table as a parameter. This is twofold:


easier testing as the function does not relly on a global variable;
parametrizable output as we may want some equivalences in some cases but not in others.


And if we want to easily provide various equivalence tables, we should as well write a function that makes it for us. Looking back at how swap_case will handle it, we should provide the equivalent letters as a value for each of the symbols as a key. Let's also provide an option to include/exclude uppercase letter as well:

def build_equivalence_table(use_uppercase=True, **kwargs):
    values = string.ascii_lowercase
    if use_uppercase:
        values = map(''.join, zip(values, string.ascii_uppercase))
    table = dict(zip(string.ascii_lowercase, values))
    for letter, equivalents in kwargs.items():
        table[letter.lower()] += equivalents
    # At this points, keys of `table` are only lowercase letters
    # let's include all the variants as well
    return {variant: group for group in table.values() for variant in group}


Usage being:

&gt;&gt;&gt; build_equivalence_table(a='@4', t='7', l='!1', e='3')
{'p': 'pP', 'k': 'kK', 'r': 'rR', 'V': 'vV', 'U': 'uU', 'I': 'iI', 'i': 'iI', 'J': 'jJ', 'A': 'aA@4', 'n': 'nN', 's': 'sS', 'K': 'kK', 'D': 'dD', '1': 'lL!1', 'j': 'jJ', 'z': 'zZ', 'B': 'bB', 'w': 'wW', 'C': 'cC', 'P': 'pP', 'N': 'nN', 'Y': 'yY', 'G': 'gG', 'g': 'gG', 'q': 'qQ', 'T': 'tT7', 'h': 'hH', 'M': 'mM', '7': 'tT7', '!': 'lL!1', 'F': 'fF', 'd': 'dD', 'X': 'xX', 'l': 'lL!1', 'f': 'fF', 't': 'tT7', 'y': 'yY', 'e': 'eE3', 'x': 'xX', 'v': 'vV', 'b': 'bB', 'H': 'hH', 'Z': 'zZ', 'L': 'lL!1', 'S': 'sS', 'c': 'cC', 'a': 'aA@4', 'Q': 'qQ', '4': 'aA@4', 'W': 'wW', 'E': 'eE3', 'R': 'rR', 'o': 'oO', '@': 'aA@4', 'm': 'mM', '3': 'eE3', 'u': 'uU', 'O': 'oO'}