3
\$\begingroup\$

I am dealing with a string draw_result that can be in one of the following formats:

"03-23-27-34-37, Mega Ball: 13"
"01-12 + 08-20"
"04-15-17-25-41"

I always start with draw_result where the value is one from the above values. I want to get to:

[3, 23, 27, 34, 37]
[1, 12, 8, 20]
[4, 15, 17, 25, 41]

I have created this fiddle to show the code working.

CODE

draw_result = "04-15-17-25-41" # change to "01-12 + 08-20" or "04-15-17-25-41" or "03-23-27-34-37, Mega Ball: 13" to test

def convert_to_int_array(draw_result):
    results_as_array = None
    
    if ',' in draw_result:
        target = draw_result.split(',', 1)[0]
        results_as_array = target.split('-')
    elif '+' in draw_result:
        target = draw_result.split('+')
        temp = "-".join(target)
        results_as_array = temp.split('-')
    else:
        results_as_array = draw_result.split('-')
    
    for index in range(0, len(results_as_array)):
        results_as_array[index] = int(results_as_array[index].strip())
    
    return results_as_array
    
result_from_function = convert_to_int_array(draw_result)

print(result_from_function)

The code works, but I want to know if what I've done is good or bad? Can it be done better in terms of readability & fewer lines of code?

I do not want to sacrifice readability/n00b friendliness for fewer lines of code.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Just to understand the setup here: why is the 13 excluded in the first example? \$\endgroup\$ – Mike 'Pomax' Kamermans Jun 11 at 23:28
4
\$\begingroup\$

Nice solution, a few suggestions:

  • The function name convert_to_int_array seems too general. Consider a more specific name, for example, extract_numbers, or something similar.
  • There is no need to initialize results_as_array to None.
  • Mapping a list of strings to integers:
    for index in range(0, len(results_as_array)):
        results_as_array[index] = int(results_as_array[index].strip())
    return results_as_array
    
    looks like the job for map:
    return list(map(int, results_as_array))
    
    Since the result of map is a generator, it needs to be converted to a list with list.
  • Tests: I noticed this line:
    draw_result = "04-15-17-25-41" # change to "01-12 + 08-20" or "04-15-17-25-41" or "03-23-27-34-37, Mega Ball: 13" to test
    
    testing by changing draw_result manually it is time-consuming. A better way would be to keep the tests in a dictionary and to use assert. For example:
    tests = {
          "04-15-17-25-41": [4, 15, 17, 25, 41],
          "01-12 + 08-20": [1, 12, 8, 20],
          "03-23-27-34-37, Mega Ball: 13": [3, 23, 27, 34, 37]
    }
    
    for lottery_string, expected_output in tests.items():
          assert expected_output == convert_to_int_array(lottery_string)
    
    If you want to explore more "unit testing" have a look at unittest.

Alternative approach:

  1. Remove , Mega Ball: 13 if exists
  2. Extract all numbers with a regular expression
import re

def extract_numbers(draw_result):
    draw_result = draw_result.split(',')[0]
    matches = re.findall(r'\d+', draw_result)
    return list(map(int, matches))
\$\endgroup\$
3
  • 1
    \$\begingroup\$ Excellent suggestions, thank you very much. I'll skip the regex approach since it isn't n00b friendly. Well, I've been coding a while (not in python mind you) and regex is still magic to me. \$\endgroup\$ – J86 Jun 11 at 12:30
  • 4
    \$\begingroup\$ I'd offer that the regex here is extremely legible, and if it doesn't meet the criterion of being newbie-friendly, it's a great opportunity for the newbie to level up. This is about as simple as regexes get. \$\endgroup\$ – Reinderien Jun 11 at 16:08
  • \$\begingroup\$ @J86 thanks, I am glad I could help. FYI, I added a section about testing. \$\endgroup\$ – Marc Jun 12 at 6:23
5
\$\begingroup\$

Marc's answer is an excellent option if you don't care about validation.

If you need to be somewhat sure that the given string represents one of the known formats, then you can pre-bind a match method in a list of pre-compiled regular expressions:

import re
from typing import Iterable

lottery_searches = [
    re.compile(pat).match
    for pat in (
        r'^(\d+)-(\d+)-(\d+)-(\d+)-(\d+), Mega Ball.*$',
        r'^(\d+)-(\d+) \+ (\d+)-(\d+)$',
        r'^(\d+)-(\d+)-(\d+)-(\d+)-(\d+)$',
    )
]


def lottery_string_to_ints(lottery: str) -> Iterable[int]:
    for search in lottery_searches:
        match = search(lottery)
        if match:
            return (int(g) for g in match.groups())

    raise ValueError(f'"{lottery}" is not a valid lottery string')

with output

In[2]: tuple(lottery_string_to_ints('03-23-27-34-37, Mega Ball: 13'))
Out[2]: (3, 23, 27, 34, 37)

In[3]: tuple(lottery_string_to_ints('01-12 + 08-20'))
Out[3]: (1, 12, 8, 20)

In[4]: tuple(lottery_string_to_ints('04-15-17-25-41'))
Out[4]: (4, 15, 17, 25, 41)

In[5]: tuple(lottery_string_to_ints('04-15'))
Traceback (most recent call last):
  File "262933.py", line 20, in lottery_string_to_ints
    raise ValueError(f'"{lottery}" is not a valid lottery string')
ValueError: "04-15" is not a valid lottery string
\$\endgroup\$
1
  • 1
    \$\begingroup\$ Wow! Such a great answer I did not know you could group re expressions like that. Would it be better to write \d\d instead of \d+ as it seems OP assumes the input will always be a two digit number? It makes sense if these are lottery numbers. PEP572 introduced the walrus operator : (3.8+) so you could have done if match := search(lottery) ^^ \$\endgroup\$ – N3buchadnezzar Jun 11 at 18:18
4
\$\begingroup\$

inclusive vs exclusive

In your opening paragraph you write:

I am dealing with a string draw_result that can be in one of the following formats:

"03-23-27-34-37, Mega Ball: 13" 
"01-12 + 08-20" 
"04-15-17-25-41"

How sure are you that the input will match exactly one of those types? The answer will shape the type of code you need to write.

Assume you are 100% sure that the code is of one of those three types. I would then write some kind of parser that checks if the input is exactly of one of those types.

On the other hand, are we just interested in getting some strings and parsing out the first 4-5 numbers, regardless of whether the separator uses -, , or something else?


expandability

def extract_numbers(draw_result, sep=",", replacements=["-", "+"], extra_ball="mega"):
    temp_draw = draw_result
    for old_sep in replacements:
        temp_draw = temp_draw.replace(old_sep, sep)
    numbers = temp_draw.split(sep)
    last = numbers[-1].strip().lower()
    if last.startswith(extra_ball):
        numbers.remove(last)  # More efficient to use del numbers[-1]
    return list(map(int, numbers))

I would argue that the following code is better than yours for a few reasons

  • The desired separator , is explicitly stated in the input arguments
  • It is easy to add additional symbols to iterate over in the replacement lists
  • Notice how in your code you assumed there was only one , in your code and the mega_ball text would follow it. Here we simply replace every symbol in replacements with sep (,) and split the list on sep.
  • Using startswith makes it clear that we looking at the last element of the list.

falling between two chairs

A bigger problem with your code as aforementioned in the intro is that it falls between two chairs. Either you want to explicitly check that the input is of the correct form, or you want to allow everything and extract the first 5 digits.

Your current code falls somewhere between both of these cases.


regex

Just because something is magic is not an excuse not to use it if it is the best tool for the job. Would Harry Potter avoid using magic against Voldemort just because it is hard to learn? ;-)

My main guideline is to use regular expressions for throwaway code, and for user-input validation. Or when I'm trying to find a specific pattern within a big glob of text. For most other purposes, I'll write a grammar and implement a simple parser.

One important guideline (that's really hard to sidestep, though I see people try all the time) is to always use a parser in cases where the target language's grammar is recursive.

For example, consider a tiny "expression language" for evaluating parenthesized arithmetic expressions. Examples of "programs" in this language would look like this:

1 + 2
5 * (10 - 6)
((1 + 1) / (2 + 2)) / 3

A grammar is easy to write, and looks something like this:

DIGIT := ["0"-"9"]
NUMBER := (DIGIT)+
OPERATOR := ("+" | "-" | "*" | "/" )
EXPRESSION := (NUMBER | GROUP) (OPERATOR EXPRESSION)?
GROUP := "(" EXPRESSION ")"

With that grammar, you can build a recursive descent parser in a jiffy.


inclusive regex

Lets look at how inclusive regex would look. Here we allow everything, and removes the extra ball.

def extract_numbers_broad(draw_result, extra_ball="mega ball", replacement_text=""):
    """Input is a string of numbers:
    "03-23-27-34-37, Mega Ball: 13"
    "01-12 + 08-20"
    "04-15-17-25-41"

    Output:
    [3, 23, 27, 34, 37]
    [1, 12, 8, 20]
    [4, 15, 17, 25, 41]
    """
    # Remove , Mega Ball: 13 if exists (or any other text)
    # \d = any digit, * = find 0 or more of those
    regex_to_find = extra_ball.lower() + r": (\d*)"
    draw_result = re.sub(extra_ball, replacement_text, draw_result.lower())
    # Extract all numbers with a regular expression and map them to integers
    return list(map(int, re.findall(r"\d+", draw_result)))

Notice how short this is, even with the included docstring. In addition I would argue that even for a beginner it is fairly easy to figure out what is going on. We simply find some text and replaces it. The only regex we use is \d* which finds any number of digits "","1","23" uzw.

Note that by changing replacement_text="" to replacement_text=r"\2" it would extract the mega ball number. But more on those capture groups later


exclusive regex

Assume on the other hand that we absolutely have to make sure that the input is in one of the following formats

 "03-23-27-34-37, Mega Ball: 13" 
 "01-12 + 08-20" 
 "04-15-17-25-41"

So how do we start building a regex expression to match this? We want to capture two digit numbers: \d\d, we want to catch -, the tricky part is the + thingy right? Here we want to catch either + or -. In regex this would be ( + |-). Now we either have 4 or 5 numbers to match, and to match 0 or 1 of something in regex we use ?. So (-\d\d)? would match if we have nothing (4 digits) or if we had -41 (a fifth digit). Putting everything together we get

\d\d-\d\d( + |-)\d\d(-\d\d)?

One way to test out such expressions is using an online regex tool

online regex tool

This can help tremendously explaining every bit of the code and how it works. In addition my favorite feature is the code generator which in our case returns

# coding=utf8
# the above tag defines encoding for this document and is for Python 2.x compatibility

import re

regex = r"(?P<digit1>(\d\d))(?P<sep>.)(?P<digit2>\d\d)(\s*\+\s*|(?P=sep))(?P<digit3>\d\d)(?P=sep)(?P<digit4>\d\d)((?P=sep)(?P<digit5>\d\d))?"

test_str = ("\"03-23-27-34-37, Mega Ball: 13\"\n"
    "\"01-12 + 08-20\"\n"
    "\"04-15-17-25-41\"\n")

matches = re.finditer(regex, test_str, re.MULTILINE)

for matchNum, match in enumerate(matches, start=1):
    
    print ("Match {matchNum} was found at {start}-{end}: {match}".format(matchNum = matchNum, start = match.start(), end = match.end(), match = match.group()))
    
    for groupNum in range(0, len(match.groups())):
        groupNum = groupNum + 1
        
        print ("Group {groupNum} found at {start}-{end}: {group}".format(groupNum = groupNum, start = match.start(groupNum), end = match.end(groupNum), group = match.group(groupNum)))

# Note: for Python 2.7 compatibility, use ur"" to prefix the regex and u"" to prefix the test string and substitution.

Which makes it quite easy exporting the code, and how to get started with regex.


named groups

Note that regex also allows us to use named groups. E.g instead of \d\d we may write (?P<digit0>\d\d) so that later we can extract that double digit by referring to the name digit0.

In addition we can use verbose mode to write more readable regular expressions. In this mode:

  • Whitespace within the pattern is ignored, except when in a character class or preceded by an unescaped backslash.
  • When a line contains a '#' neither in a character class or preceded by an unescaped backslash, all characters from the leftmost such '#' through the end of the line are ignored.

Putting it all together our pattern matching expression might look like this

PATTERN = re.compile(
    r"""
         (?P<digit0>\d\d)                   # Matches a double digit [00..99] and names it digit0
         (?P<sep>-)                         # Matches any one digit character - saves it as sep
         (?P<digit1>\d\d)                   # Matches a double digit [00..99] and names it digit1
         (\s+\+\s+|(?P=sep))                # Matches SPACE + SPACE OR the seperator saved in sep (-)
         (?P<digit2>\d\d)                   # Matches a double digit [00..99] and names it digit2
         (?P=sep)                           # Matches any one digit character - saves it as sep
         (?P<digit3>\d\d)                   # Matches a double digit [00..99] and names it digit3
         ((?P=sep)(?P<digit4>\d\d))?        # Checks if there is a final fifth digit (-01), saves to digit5
        """,
    re.VERBOSE,
)

I find this very readable especially with the comments and extracting our results are now very easy

def extract_numbers_narrow(draw_result, digits=5):
    numbers = []
    if match := re.match(PATTERN2, draw_result):
        for i in range(digits):
            ith_digit = f"digit{i}"
            try:
                number = int(match.group(ith_digit))
            except IndexError:  # Catches if the group does not exists
                continue
            except TypeError:  # Catches if the group is None
                continue
            numbers.append(number)
    return numbers

f-strings

We could have stopped there, but using pythons f-string formatter can allow us to build regex expressions even easier. Let us start by defining a few handy functions

def named_capture_group(name, capture):
    return f"(?P<{name}>{capture})"


def match_named_capture_group(name):
    return f"(?P={name})"


def regex_or(x, y):
    return f"({x}|{y})"


def regex_zero_or_one_match(match):
    return f"({match})?"

Similarly we can define a few useful global variables

DOUBLE_DIGIT = "\d\d"
ANY_CHARACTHER = "."
WHITESPACE = "\s"
AT_LEAST_ONE_MATCH = "+"

From which we can build our digits, and separators

SEP = named_capture_group("sep", ANY_CHARACTHER)
SEP_MATCH = match_named_capture_group("sep")
DIGIT0 = named_capture_group("digit0", DOUBLE_DIGIT)
DIGIT1 = named_capture_group("digit1", DOUBLE_DIGIT)
DIGIT2 = named_capture_group("digit2", DOUBLE_DIGIT)
DIGIT3 = named_capture_group("digit3", DOUBLE_DIGIT)
DIGIT4 = named_capture_group("digit4", DOUBLE_DIGIT)
IF_DIGIT4 = regex_zero_or_one_match(SEP_MATCH + DIGIT4)
PLUS = WHITESPACE + AT_LEAST_ONE_MATCH + "\+" + WHITESPACE + AT_LEAST_ONE_MATCH
PLUS_OR_SEP_MATCH = regex_or(PLUS, SEP_MATCH)

Note how I allow any separator, as long as they all match. Our long winded regex expression now turns into.

PATTERN2 = re.compile(
    fr"""
    {DIGIT0}
    {SEP}
    {DIGIT1}
    {PLUS_OR_SEP_MATCH}
    {DIGIT2}
    {SEP_MATCH}
    {DIGIT3}
    {IF_DIGIT4}
        """,
    re.VERBOSE,
)

Which is manageable for even the noobiest of noobs. Personally I would not have gone this far, and just have stuck with the PATTERN1 regex as with comments it takes just a few seconds to decode its meaning, but I guess the last method is useful for learning the syntax.


code

import re


def extract_numbers(draw_result, sep=",", replacements=["-", "+"], extra_ball="mega"):
    temp_draw = draw_result
    for old_sep in replacements:
        temp_draw = temp_draw.replace(old_sep, sep)
    numbers = temp_draw.split(sep)
    last = numbers[-1].strip().lower()
    if last.startswith(extra_ball):
        numbers.remove(last)  # More efficient to use del numbers[-1]
    return list(map(int, numbers))


def extract_numbers_broad(draw_result, text_to_replace="mega ball", extra_ball=""):
    """Input is a string of numbers:
    "03-23-27-34-37, Mega Ball: 13"
    "01-12 + 08-20"
    "04-15-17-25-41"

    Output:
    [3, 23, 27, 34, 37]
    [1, 12, 8, 20]
    [4, 15, 17, 25, 41]
    """
    # Remove , Mega Ball: 13 if exists (or any other text)
    # \d = any digit, * = find 0 or more of those
    regex_to_find = extra_ball.lower() + r": \d*"
    draw_result = re.sub(regex_to_find, replacement, draw_result.lower())
    # Extract all numbers with a regular expression
    return list(map(int, re.findall(r"\d+", draw_result)))


def named_capture_group(name, capture):
    return f"(?P<{name}>{capture})"


def match_named_capture_group(name):
    return f"(?P={name})"


def regex_or(x, y):
    return f"({x}|{y})"


def regex_zero_or_one_match(match):
    return f"(match)?"


DOUBLE_DIGIT = "\d\d"
# ZERO_OR_ONE_MATCH = "?"
ANY_CHARACTHER = "."
WHITESPACE = "\s"
AT_LEAST_ONE_MATCH = "+"
# OR = "|"


SEP = named_capture_group("sep", ANY_CHARACTHER)
SEP_MATCH = match_named_capture_group("sep")
DIGIT0 = named_capture_group("digit0", DOUBLE_DIGIT)
DIGIT1 = named_capture_group("digit1", DOUBLE_DIGIT)
DIGIT2 = named_capture_group("digit2", DOUBLE_DIGIT)
DIGIT3 = named_capture_group("digit3", DOUBLE_DIGIT)
DIGIT4 = named_capture_group("digit4", DOUBLE_DIGIT)
IF_DIGIT4 = regex_zero_or_one_match(SEP_MATCH + DIGIT4)

PLUS = WHITESPACE + AT_LEAST_ONE_MATCH + "\+" + WHITESPACE + AT_LEAST_ONE_MATCH
PLUS_OR_SEP_MATCH = regex_or(PLUS, SEP_MATCH)

PATTERN2 = re.compile(
    fr"""
    {DIGIT0}
    {SEP}
    {DIGIT1}
    {PLUS_OR_SEP_MATCH}
    {DIGIT2}
    {SEP_MATCH}
    {DIGIT3}
    {IF_DIGIT4}
        """,
    re.VERBOSE,
)

PATTERN = re.compile(
    r"""
         (?P<digit0>\d\d)                   # Matches a double digit [00..99] and names it digit0
         (?P<sep>.)                         # Matches any one digit character - saves it as sep
         (?P<digit1>\d\d)                   # Matches a double digit [00..99] and names it digit1
         (\s+\+\s+|(?P=sep))                # Matches SPACE + SPACE OR the seperator saved in sep (-)
         (?P<digit2>\d\d)                   # Matches a double digit [00..99] and names it digit2
         (?P=sep)                           # Matches any one digit character - saves it as sep
         (?P<digit3>\d\d)                   # Matches a double digit [00..99] and names it digit3
         ((?P=sep)(?P<digit4>\d\d))?        # Checks if there is a final fifth digit (-01), saves to digit5
        """,
    re.VERBOSE,
)


def extract_numbers_narrow(draw_result, digits=5):
    numbers = []
    if match := re.match(PATTERN2, draw_result):
        for i in range(digits):
            ith_digit = f"digit{i}"
            try:
                number = int(match.group(ith_digit))
            except IndexError:  # Catches if the group does not exists
                continue
            except TypeError:  # Catches if the group is None
                continue
            numbers.append(number)
    return numbers


if __name__ == "__main__":

    draw_result = ["03-23-27-34-37, Mega Ball: 13", "01-12 + 08-20", "04-15-17-25-41"]
    for draw in draw_result:
        print(extract_numbers_narrow(draw))
\$\endgroup\$
1
\$\begingroup\$

Note that this is also something that regular expressions were explicitly designed for:

import re

def extract_leading_numbers(input_string):
    return [
        int(s) for s in
        re.findall(
          r"\d+",
          re.sub("[a-zA-Z].*", "", input_string)
        )
    ]

Which gives us:

input_strings = [
  "03-23-27-34-37, Mega Ball: 13",
  "01-12 + 08-20",
  "04-15-17-25-41",
] 

[extract_leading_numbers(s) for s in input_strings]

# [
#  [3, 23, 27, 34, 37],
#  [1, 12, 8, 20],
#  [4, 15, 17, 25, 41]
# ]

To make this work, we first throw everything away that's text rather than numbers (including numbers following any text) using re.sub("[a-zA-Z].*", "", input_string) which substitutes the empty string for "some letter followed by anything-at-all", after which we look for all stretches of numbers in the result of that replacement, using re.findall(r"\d+", ...). Finally, as this will have extracted strings rather than numbers, we then make sure to explicitly convert those strings to numbers, using the int() casting function and list comprehension.

\$\endgroup\$
2
  • 1
    \$\begingroup\$ You should refrain from using Python builtins names for your variables str, list and so forth. \$\endgroup\$ – N3buchadnezzar Jun 12 at 12:19
  • \$\begingroup\$ gah, yes this is very true. \$\endgroup\$ – Mike 'Pomax' Kamermans Jun 12 at 14:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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