Python Bulls and Cows
@Josay's and @Reinderien's answers both give useful advice, with @Josay's being particularly comprehensive. However, there is one point that I've noticed neither of them have covered

Generation of the secret number

# Generate secret number with SECRET_NUMBER_SIZE digits and no duplicates
secret_number = ""
while len(secret_number) &lt; SECRET_NUMBER_SIZE:
    new_digit = str(random.randint(0, 9))
    if new_digit not in secret_number:
        secret_number += new_digit


This is an inelegant way of generating the secret number for two reasons:


You concatenate to a string, an immutable type. This is much more expensive than necessary, which does not a notable impact on a program of this size, but could become problematic as you scale up; it's better to practice good techniques, even at small scales. Additionally, you should never need to iteratively concatenate to a string in situations where the string's contents are generated all at once, because there are two successively more efficient ways:


Since Python uses dynamic lists, you can create a list with the string's contents, and then use "".join(str(i) for i in src_lst)
To improve the efficiency even further, you can utilize the fact that you know the list's size already, and "pre-allocate" the list using lst = [None] * SECRET_NUMBER_SIZE and then iteratively fill in its contents using a numerical index variable. This is a bit unpythonic, as @Josay goes over in their answer, and is probably a bit of premature optimization, but it's a good trick to have up your sleave if you want to eek out a bit more performance, and may be extremely useful depending on the situation.
But there's an even better way, which we'll get to in a second...

Every number is checked to ensure it hasn't already occurred in the result list. The reason this is not ideal is because at larger scales, one could end up generating many numbers before they stumble upon one that hasn't already been chosen. To mitigate this, one could lower the generation range each time, and offset the numbers accordingly, but it's probably not worth the effort because this isn't much of a practical concern, even at larger scales; generating random numbers using the Mersenne Twister is very cheap. But this concern helps lead to an elegant solution...


random.sample() is your friend here. It allows you to generate multiple distinct random numbers quite easily; all you have to provide is a population sequence and the size of the sample. For this situation, a population sequence is quite simple: range(10) supplies all the numbers between 0 and 9 inclusive. You can now simplify your 5 lines above into ~1 line:

secret_number = "".join((str(i) for i in
    random.sample(range(10), SECRET_NUMBER_SIZE)))