25
\$\begingroup\$

This is one of the puzzles I was trying to solve, where the question is to validate the credit card number with following characteristics:

  • It must contain exactly 16 digits.
  • It must start with a 4,5 or 6
  • It must only consist of digits (0-9).
  • It may have digits in groups of 4 , separated by one hyphen "-".
  • It must NOT use any other separator like ' ' , '_', etc.
  • It must NOT have 4 or more consecutive repeated digits.

I have go through some of the solutions here and here as well, but the characteristics for validating are different from what I was working on, hence posting this.

total_count = int(raw_input())
numbers_list = []
#refernece list to check the starting series
start_list = [4,5,6]

for count in range(total_count):
    numbers_list.append(raw_input())

#condition 1, validates the starting series
def val_start(num):
    if int(num[0]) in start_list:
        return True
    else:
        return False

#to check if individial elements of the list are of length=4
#4321-5555-67899-9991, splitted to ["4321","5555","67899","991"] which is invalid
def val_group(num):
    for val in num.split("-"):
        if len(val) != 4:
            return False

    return True

#condition 2, validates the length of the number
def val_len(num):
    check = num
    check2 = True
    if "-" in num:
        check = "".join(num.split("-"))
        check2 = val_group(num)

    if ((len(check) == 16) and check2):
        return True
    else:
        return False


#condition 3, validates if input consists only number
def val_isdigit(num):
    if not num.isdigit():
        for ch in num:
            if not (ch.isdigit() | (ch == "-")):
                return False
    return True

#condition 4, validates the repetition of any number for 4 consective times
def val_rep(num):
    res = "".join(num.split("-"))
    for i in range(len(res)):
        try:
            if (res[i] == res[i+1]):
                if (res[i+1] == res[i+2]):
                    if (res[i+2] == res[i+3]):
                        return False
        except IndexError:
           pass
    return True

for num in numbers_list:
    #returns all the values into a list
    result = [val_start(num), val_len(num),val_isdigit(num), val_rep(num)]
    if False in result:
        print("Invalid")
    else:
        print("Valid")

I am looking for an optimised way for the function val_rep(), which validates the repetition of any number for 4 consecutive times.

\$\endgroup\$
5
  • 5
    \$\begingroup\$ I realise this is just an artificial challenge, but in case anyone's curious, MasterCard will soon be issuing card numbers beginning with a 2 rather than a 5, which would fail this check: mastercard.us/en-us/issuers/get-support/… \$\endgroup\$
    – IMSoP
    Jul 18, 2017 at 11:49
  • 6
    \$\begingroup\$ Additionally to those requirements, I'd recommend doing a luhn check en.wikipedia.org/wiki/Luhn_algorithm \$\endgroup\$
    – JeffS
    Jul 18, 2017 at 18:24
  • 1
    \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$
    – Peilonrayz
    Jul 19, 2017 at 10:32
  • \$\begingroup\$ Ok..Sure..I am unaware of that.. \$\endgroup\$ Jul 19, 2017 at 10:33
  • \$\begingroup\$ The last digit is mod 10 checksum that JeffS mentioned en.wikipedia.org/wiki/ISO/IEC_7812#Check_digit \$\endgroup\$ Jul 19, 2017 at 15:38

4 Answers 4

26
\$\begingroup\$

First of all, you should encapsulate your functionality in a function, unless you really want to use your code only once. At the moment you would have to duplicate your code somewhat just to check a second credit card. So instead let us write a function with a doc string:

def is_valid_card_number(sequence):
    """Returns `True' if the sequence is a valid credit card number.

    A valid credit card number
    - must contain exactly 16 digits,
    - must start with a 4, 5 or 6 
    - must only consist of digits (0-9) or hyphens '-',
    - may have digits in groups of 4, separated by one hyphen "-". 
    - must NOT use any other separator like ' ' , '_',
    - must NOT have 4 or more consecutive repeated digits.
    """

    #  do your stuff here

However, credit card numbers follow a fixed pattern, and therefore are a perfect candidate for regular expressions:

import re

PATTERN='^([456][0-9]{3})-?([0-9]{4})-?([0-9]{4})-?([0-9]{4})$'

This might seem arcane, so let us have a look at that pattern. Anything inside parentheses is considered a group. So we have four groups: ([456][0-9]{3}) and three times ([0-9]{4}). The hyphens in between have ? and are therefore optional. If they exist, they must occur once, so -- is not allowed.

The […] indicates are character class. Only characters inside that class are allowed at that point. The ^ indicates that we're describing the start of our string, not some point in the middle. So we have to start with 456. That requriement is now met. We're followed by 3 ({3}) digits. Our first group therefore consists of four digits, and so do the others. The $ makes sure that there is nothing left: our string has to stop there.

The re documentation provides more information.

We now have met most of our conditions:

  • we have 16 digits
  • we have groups of four
  • we only have digits or hyphens
  • we start with a 4, 5 or 6

We're missing the four consecutive digits. This is now tricky, since it's not clear whether the hyphens are part of the run. If they are only invalid four 4 character groups, we can simply check whether the current character * 4 is the complete group:

import re

PATTERN='^([456][0-9]{3})-?([0-9]{4})-?([0-9]{4})-?([0-9]{4})$'

def is_valid_card_number(sequence):
    """Returns `True' if the sequence is a valid credit card number.

    A valid credit card number
    - must contain exactly 16 digits,
    - must start with a 4, 5 or 6 
    - must only consist of digits (0-9) or hyphens '-',
    - may have digits in groups of 4, separated by one hyphen "-". 
    - must NOT use any other separator like ' ' , '_',
    - must NOT have 4 or more consecutive repeated digits.
    """

    match = re.match(PATTERN,sequence)

    if match == None:
        return False

    for group in match.groups:
        if group[0] * 4 == group:
            return False
    return True

If the repeated digits may also not span hyphens/groups it gets a little bit more complicated, but not too much. This is left as an exercise, though.

That being said: make sure to handle multiple numbers, not a single one. That way you will be able to check your code easier. But that depends. If it's a single use script, you probably don't have a need for a function.

\$\endgroup\$
10
  • \$\begingroup\$ Just to note, your code says 1122-2233-4455-6677 or 1122223344556677 is valid, however I don't think that passes the "must NOT have 4 or more consecutive repeated digits" requirement \$\endgroup\$
    – Peilonrayz
    Jul 18, 2017 at 9:01
  • 2
    \$\begingroup\$ @Peilonrayz noted right below the code: "If the repeated digits may also not span hyphens/groups it gets a little bit more complicated, but not too much. This is left as an exercise, though." \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 9:02
  • \$\begingroup\$ Ah yes, I missed that. :) \$\endgroup\$
    – Peilonrayz
    Jul 18, 2017 at 9:05
  • 1
    \$\begingroup\$ Can't you just do PATTERN='^([456][0-9]{3})(-?([0-9]{4}){3})$', or am I missing something? \$\endgroup\$
    – Stephen S
    Jul 18, 2017 at 13:00
  • 1
    \$\begingroup\$ @StephenS Now we have one match.groups too many. You would have to ignore the second one. (?:…) might work, but I'm not sure about the latter capturing in Python at the moment. \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 13:02
12
\$\begingroup\$

You can do most of this with regex. Going through your requirements:

  • It must only consist of digits (0-9).

    And so we can use [0-9] for each digit.

  • It must contain exactly 16 digits.

    And so we can use something like [0-9]{16}. However, that only checks that it contains sixteen digits, so 1234567890123456abc would be valid. So we need something like ^[0-9]{16}$

  • It must start with a 4,5 or 6

    And so we can change the first match. ^[456][0-9]{15}$

  • It may have digits in groups of 4 , separated by one hyphen "-".

    This makes the regex a bit longer, but it still stays quite simple. The only complex thing is it's all or nothing it seems, so you can either separate or not. This check would be best in Python. However that means splitting up the groups with -?

    ^[456][0-9]{3}-?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$
    
  • It must NOT use any other separator like ' ' , '_', etc.

    This works the same as the above. If it however wanted us to allow those, we'd have to change the separators to groups such as [\- _].

  • It must NOT have 4 or more consecutive repeated digits.

    We can't do this easily in pure regex, so it's best to do this in Python. And so we'd want to use capture groups to get the numbers.

    ^([456][0-9]{3})-?([0-9]{4})-?([0-9]{4})-?([0-9]{4})$
    

And so if it matches the above regex, you just have to check that no four consecutive digits are the same.

To improve val_rep I'd use a modified version on the itertools pairwise recipe.

def quadwise(iterable):
    "s -> (s0,s1,s2,s3), (s1,s2,s3,s4), (s2,s3,s4,s5), ..."
    a, b, c, d = tee(iterable, 4)
    next(b, None)
    next(c, None)
    next(c, None)
    next(d, None)
    next(d, None)
    next(d, None)
    return zip(a, b, c, d)

Which can allow for a simple check, such as:

def val_rep(num):
    for head, *tail in quadwise(num):
        if all(head == item for item in tail):
            return False
    return True

Which could be 'simplified' to:

def val_rep(num):
    return not any(
        all(head == item for item in tail)
        for head, *tail in quadwise(num)
    )

Which in Python 2 would have to be something like:

def val_rep(num):
    return not any(
        all(head == item for item in (a, b, c))
        for head, a, b, c in quadwise(num)
    )

And so I'd recommend something like:

REGEX = r'^([456][0-9]{3})-?([0-9]{4})-?([0-9]{4})-?([0-9]{4})$'

def quadwise(iterable):
    "s -> (s0,s1,s2,s3), (s1,s2,s3,s4), (s2,s3,s4,s5), ..."
    a, b, c, d = itertools.tee(iterable, 4)
    next(b, None)
    next(c, None)
    next(c, None)
    next(d, None)
    next(d, None)
    next(d, None)
    return zip(a, b, c, d)

def val_rep(num):
    return not any(
        all(head == item for item in (a, b, c))
        for head, a, b, c in quadwise(num)
    )

def valid_creditcard(card):
    groups = re.match(REGEX, card)
    if not groups:
        return False
    if card.count('-') not in (0, 3):
        return False
    return val_rep(''.join(groups.groups()))

if __name__ == '__main__':
    total_count = int(raw_input())
    cards = [
        raw_input()
        for count in range(total_count)
    ]

    for card in cards:
        if valid_creditcard(card):
            print('Valid')
        else:
            print('Invalid')
\$\endgroup\$
14
  • 2
    \$\begingroup\$ You've talked about -?, but you're missing it in your code. Typo? Oh, and we can do the 4-digit-consecutive check in regular expressions, but the expression will be… ugly. \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 8:55
  • \$\begingroup\$ (Your last non-python code snippet is still missing -?) \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 8:57
  • \$\begingroup\$ @Zeta Oops, thank you for that. :) I noticed I was reading it wrong half way through writing the answer. \$\endgroup\$
    – Peilonrayz
    Jul 18, 2017 at 8:57
  • \$\begingroup\$ If you use ^$ in your REGEX, you don't need the card.count('-'). \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 9:05
  • 2
    \$\begingroup\$ I think the quadwise approach is overkill compared to something like any(len(group) >= 4 for _, group in itertools.groupby(num)). \$\endgroup\$ Jul 18, 2017 at 16:51
7
\$\begingroup\$

Your val_rep function can also be simplified with regular expressions by using backreferences (see the \number syntax). Here \1 will be replaced by the first matched number.

import re

REP_PATTERN=r"([0-9])(?:-?\1){3}"

def val_rep(sequence):
    return not re.search(REP_PATTERN, sequence)

You can even combine all the tests into a single regular expression using a negative lookahead (?!...):

import re

PATTERN=r"^(?!.*([0-9])(?:-?\1){3})[456][0-9]{3}(-?)[0-9]{4}\2[0-9]{4}\2[0-9]{4}$"

def is_valid_card(sequence):
    return re.search(PATTERN, sequence)

Note that in the second pattern we are using a backreference to reject card numbers such as 4123-45678910-1234 where hyphens are inconsistent (\2 will match either a hyphen or an empty sequence)

\$\endgroup\$
5
\$\begingroup\$

You can massively simplify this by using regular expressions. Python includes an efficient module for that, named re (short for regular expressions).

In this case, check if a given string is a valid credit card number (CC.py):

import re

PATTERN = "([4-6]{1})([0-9]{3}-?)([0-9]{4}-?){2}([0-9]{4})"


def is_valid_creditcard(sequence):
    """Check if a sequence is a valid credit card number.
    Rules for sequences to qualify as credit card numbers:

    Sequences must:

    -Contain exactly 16 digits;
    -Start with a 4,5 or 6;
    -Only consist of digits (0-9).

    Sequences may:
    -Have digits in groups of 4, separated by one hyphen.

    Sequence must not:
    -Use any other separator;
    -Have 4 or more consecutive repeated digits.
    """
    for i, n in enumerate(sequence):
        try:
            if (sequence[i], 
                sequence[i+1], 
                sequence[i+2],
                sequence[i+3]
            ) == (n, n, n, n):
                return False
        except IndexError:
            pass
    return bool(re.match(PATTERN, sequence))

Examples:

>>> import CC
>>> CC.is_valid_creditcard("0000-1111-2222-3333")
False
>>> # Starts with "0", not "4"/ "5" / "6"
>>> CC.is_valid_creditcard("4444-5555-6666-777")
False
>>> # Incorrectly grouped
>>> CC.is_valid_creditcard("4444-3333-2222-XXXX")
False
>>> # Contains non-numerical characters
>>> CC.is_valid_creditcard("444-55555-6666-7777")
False
>>> # Incorrectly grouped
>>> CC.is_valid_creditcard("4567:8901:2345:6789")
False
>>> # Illegal seperators
>>> CC.is_valid_creditcard("4444-5555-6666-7777")
False
>>> # Contains 4 or more consecutive repeated digits 
>>> CC.is_valid_creditcard("4011-7505-1047-1848")
True
>>> CC.is_valid_creditcard("6015399610667820")
True

If you're new to regular expressions, you may want to check out this page.

\$\endgroup\$
4
  • 3
    \$\begingroup\$ Your function fails both the hyphens (due to your any check) as well as the "4 consecutive repeated digits" requirement. Also, the length check is wrong. According to the OP, a sequence may contain hypens, therefore "4567-1234-1234-1234" is a valid sequence, but certainly does not have length 16. You should probably review (heh) your code and OP's requirements. \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 8:09
  • 2
    \$\begingroup\$ Your new regular expression misses the "no 4 consecutive digits part". \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 8:27
  • 1
    \$\begingroup\$ Still wrong. Now any credit card that contains four times the same digit is wrong, e.g. 4567-1234-4564-4534 has 6 4s, but is valid according to the spec. \$\endgroup\$
    – Zeta
    Jul 18, 2017 at 8:42
  • \$\begingroup\$ I noticed that just now myself- I really need to get more creative. \$\endgroup\$
    – Daniel
    Jul 18, 2017 at 8:44

Not the answer you're looking for? Browse other questions tagged or ask your own question.