Number uncovering with given equations I am making a number uncovering app. The program takes in an equation that has numbers hidden as letters, like:
abc + d = efg - hi

and it will return all the strings like
290 + 6 = 347 - 51
290 + 7 = 348 - 51
250 + 6 = 347 - 91
250 + 7 = 348 - 91
290 + 4 = 365 - 71
270 + 4 = 365 - 91
290 + 4 = 375 - 81
280 + 4 = 375 - 91
290 + 7 = 358 - 61
260 + 7 = 358 - 91
290 + 5 = 376 - 81
280 + 5 = 376 - 91
490 + 6 = 527 - 31
...

But there can will be duplicated strings. For example, when I input
a + b = c

the program returns
...
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 2 = 3
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
1 + 3 = 4
...

I know that I can put all the strings into set and then iterate through the set again to print out the strings, but I want to know if that is necessary.
Perhaps in my program there is an unnecessary loop or such that is causing the duplicated strings, which in turns makes its performance slower? If so, I would like to know the proper way of doing it.
Here is my code:
from itertools import permutations
from re import findall

ope = '+-*/( ).='
var = 'abcdefghijklmnopqrstuvwxyz'
num = '1234567890'

while True:
    while True:
        whole = input(&quot;&gt;&gt;&gt; &quot;) # User inputs equation here
        if any(c not in ope + var + num for c in whole): # Makes sure that all the characters are valid
            print(f'You may only use: {ope}{var}{num}')
            continue
        chars = list({char for char in whole if char in var}) # Extract all the mystery numbers

        if len(chars) &gt; 10: # There are only 10 different digits, so the user may only input 10 different mystery numbers
            print('Enter at most 9 different letters.')
            continue
        while len(chars) &lt; 10: # If there are less that 10 different mystery numbers
            chars.append(None) # add None until the length of the list is 10

        # From here to the break is to check if the syntax works, so a bit processing time is used on the first permutation of chars
        exp = whole
        for i, v in enumerate(chars):
            if v:
                exp = exp.replace(v, str(i))

        if all(int(e[0]) for e in findall('\d+', exp)):
            try:
                eval(exp.replace('=', '=='))
            except SyntaxError:
                print('Invalid Syntax')
                continue
        break # If not continue was hit, that means that the equation is valid

    for p in permutations(chars):
        exp = whole
        for i, v in enumerate(p):
            if v:
                exp = exp.replace(v, str(i))
        if all(int(e[0]) for e in findall('[\d\w]+', exp)): # If all the individual numbers don't begin with 0
            if eval(exp.replace('=', '==')): # If the equation is correct
                print(exp) # Print the equation
Functions
Split your code into individual smaller functions, doing singular tasks. A few examples would be validating the expression, extracting variables and so on.
if __name__ block
For scripts, it is a good practice to put your executable feature inside the if __name__ == &quot;__main__&quot; clause.
Comments
The comments like in:
while len(chars) &lt; 10: # If there are less that 10 different mystery numbers

are pointless. The condition already explains what you write in 10 words. A more important comment here could've been what the mystery numbers are?
This applies to almost all of the comments you have added in your program. Once you split to functions, add docstrings to those functions explaining what and maybe why the function does. The code should be self-explanatory on the how.
Execution logic
From what I understand of the program, you are:

asking user for expression
validating if there are any invalid characters
extracting variables (and creating a forced list of 10 elements)
Not sure about the findall('\d+', exp) validation, and later the same thing is being achieved by a different regex pattern
check if the expression is mathematically consistent
permute all elements from variables list (created above)
iterate on all permutations and replace into the expression
evaluate expression and print if successful

A lot of the code seems duplicated, for eg. the exp.replace calls, the findall validation etc. You have to work with 2 nested loops just because there are no helper functions to assist you.
For the permutations themselves, you can obviously permute over a forced set of length 10, or you can just permute over the integers range(10) selecting as many elements as there are variables. Since you anyway need those integers (using enumerate), I'd go for this.
As a side note, instead of printing all valid results in the main loop, use a generator and yield valid results.

Example rewrite
from typing import Tuple, Set
from itertools import count, permutations

OPERATORS = '+-*/( ).='
VARIABLES = 'abcdefghijklmnopqrstuvwxyz'
NUMERALS = '1234567890'
VALID_CHARACTERS = OPERATORS + VARIABLES + NUMERALS


def is_expression_valid(expression: str) -&gt; bool:
    return all(c in VALID_CHARACTERS for c in expression)


def is_expression_consistent(expression: str, variables: Set[str]) -&gt; bool:
    for index, value in zip(count(1), variables):
        expression = expression.replace(value, str(index))
    try:
        eval(expression.replace(&quot;=&quot;, &quot;==&quot;))
    except SyntaxError:
        return False
    return True


def extract_variables(expression: str) -&gt; Set[str]:
    variables = {char for char in expression if char in VARIABLES}
    if len(variables) &gt; 10:
        raise ValueError()
    return variables


def get_expression() -&gt; Tuple[str, Set[str]]:
    while True:
        expression = input(&quot;&gt;&gt;&gt; &quot;)
        if not is_expression_valid(expression):
            print(f&quot;Invalid characters in the expression. Use only from: {VALID_CHARACTERS}&quot;)
            continue
        try:
            variables = extract_variables(expression)
        except ValueError:
            print(&quot;Expected at most 9 variable/letters!&quot;)
            continue
        if not is_expression_consistent(expression, variables):
            print(&quot;Invalid syntax!&quot;)
            continue
        break
    return expression, variables


def substitute_expression_format(expression: str) -&gt; str:
    return &quot;&quot;.join(r&quot;{}&quot; if c in VARIABLES else c for c in expression)


def valid_results(expression: str, variables: Set[str]) -&gt; str:
    formatted_expression = substitute_expression_format(expression)
    for permute in permutations(range(10), len(variables)):
        variation = formatted_expression.format(*permute)
        if eval(variation.replace(&quot;=&quot;, &quot;==&quot;)):
            yield variation


def main():
    expression, variables = get_expression()
    for result in valid_results(expression, variables):
        print(result)


if __name__ == &quot;__main__&quot;:
    main()

Notice in is_expression_consistent, I've use zip with itertools.count, so that the dependency on regex is removed.
Now, the above fails for invalid token check, when the first substitution is \$ 0 \$; which can be resolved by having a try-except in the generator:
def valid_results(expression: str, variables: Set[str]) -&gt; str:
    formatted_expression = substitute_expression_format(expression)
    for permute in permutations(range(10), len(variables)):
        variation = formatted_expression.format(*permute)
        try:
            if eval(variation.replace(&quot;=&quot;, &quot;==&quot;)):
                yield variation
        except SyntaxError:
            continue

You can check the code on tio.run.

EDIT
For fixing the issue mentioned in comments (about repeated letters):
def substitute_expression_format(expression: str) -&gt; str:
    return &quot;&quot;.join(f&quot;{{{c}}}&quot; if c in VARIABLES else c for c in expression)


def valid_results(expression: str, variables: Set[str]) -&gt; str:
    formatted_expression = substitute_expression_format(expression)
    for permute in permutations(range(10), len(variables)):
        variation = formatted_expression.format(**dict(zip(variables, permute)))
        try:
            if eval(variation.replace(&quot;=&quot;, &quot;==&quot;)):
                yield variation
        except SyntaxError:
            continue

Updated example on tio.run.