35
\$\begingroup\$

Unlike Perl, you can't to my knowledge match a regular expression inside an if statement in Python and assign the result to a variable at the same moment. This leads to typical constructs like this:

match = re.search(REGEX, STRING)
if match:
    # do something

So far, so Python. But what if I want to iterate through a file / array of lines, check each line for a few regexes, and fire a catch-all when none has matched? I can't think my way around a rather unwieldy and deeply nested if-else-if-else...-construction:

import re
strings = ["abc zzz", "y", "#comment"]
for s in strings:
    match = re.search("(\S+) (\S+)", s)
    if match:
        print "Multiword: %s+%s" % (match.group(1), match.group(2))
    else:
        match = re.match("y$", s)
        if match:
            print "Positive"
        else:
            match = re.match("n$", s)
            if match:
                print "Negative"
            else:
                # a few more matches possible in real life script,
                # and then the last catch-all:
                print "No match found, line skipped"

Isn't there any way to put this in a much nicer looking elif-construction or something? The following doesn't work in Python, because if-clauses take only expressions, not statements. However, something along these lines would strike me as pythonic, or am I blind to something obvious here?

if match = re.search(" ", s):
    print "Multiword: %s+%s" % (match.group(1), match.group(2))
elif match = re.match("y$", s):
    print "Positive"
else:
    print "No match found, line skipped"
\$\endgroup\$
0

10 Answers 10

32
\$\begingroup\$

You can use the continue keyword to force advancement and move on to evaluating the next string. Code after each if statement will only execute if the test in the previous statement was false.

import re
strings = ["abc zzz", "y", "#comment"]
for s in strings:
    match = re.search("(\S+) (\S+)", s)
    if match:
        print "Multiword: %s+%s" % (match.group(1), match.group(2))
        continue
    match = re.match("y$", s)
    if match:
        print "Positive"
        continue
    match = re.match("n$", s)
    if match:
        print "Negative"
        continue
    
    # a few more matches possible in real life script,
    # and then the last catch-all:
    print "No match found, line skipped"

Revisiting in 2021

Since Python 3.8 we now have assignment expressions! You can do something like this:

import re
strings = ["abc zzz", "y", "#comment"]
for s in strings:
    if match := re.search("(\S+) (\S+)", s):
        print("Multiword: %s+%s" % (match.group(1), match.group(2)))

    elif match := re.match("y$", s):
        print("Positive")

    elif match := re.match("n$", s):
        print("Negative")

    # a few more matches possible in real life script,
    # and then the last catch-all:
    else:
        print("No match found, line skipped")
\$\endgroup\$
4
  • \$\begingroup\$ On the one hand, this doesn't look perfectly "clean" to me, on the other it's easy to understand and I'd say it'd keep easy to maintain. I'll have to check this a bit more in-depth tomorrow, but thanks for the reply! \$\endgroup\$
    – Thor
    Mar 24, 2014 at 15:11
  • \$\begingroup\$ @Thor the only problem with this structure, which I somehow like, is that it's so easy to forget to add a continue in a new clause you add later \$\endgroup\$
    – o0'.
    Mar 24, 2014 at 15:48
  • \$\begingroup\$ I don't get how your revision says "since Python 3.8" and then you forget that print is a function, thus all your prints should be print(...) 🙃 \$\endgroup\$
    – RGS
    Feb 19, 2022 at 16:14
  • 1
    \$\begingroup\$ @RGS lol good catch. Must not have had my coffee that day. Fixed, thanks! \$\endgroup\$
    – flakes
    Feb 19, 2022 at 19:12
15
\$\begingroup\$

Why not using a list of tuple (re, lambda match: action), that is something like

actions = [("(\S+) (\S+)", lambda match: "Multiword: %s+%s" % (match.group(1), match.group(2))),
           ("y$", lambda match: "Positive"),
           ("n$", lambda match: "Negative")]

and then:

for rex, action in actions:
     match = re.match(rex, s)
     if match: 
          print action(match)

If you need to mix search and match then you can use a list of tuple:

(matchmethod, rex, action)

as in

actions = [
    (re.search, "(\S+) (\S+)", lambda match: "Multiword: %s+%s"%(match.group(1), match.group(2)) ),
    (re.match, "y$", lambda match: "Positive"),
    (re.match, "n$", lambda match: "Negative")]

And of course:

for matchtype, rex, action in actions:
     match = matchtype(rex, s)
     if match: 
          print action(match)
\$\endgroup\$
5
  • 1
    \$\begingroup\$ Misses the fact that one of the calls was to re.search, another to re.match. \$\endgroup\$ Mar 24, 2014 at 15:04
  • \$\begingroup\$ @RemcoGerlich : The solution can be easily adapted. See my edit. \$\endgroup\$
    – hivert
    Mar 24, 2014 at 15:08
  • \$\begingroup\$ I know, and it's a valid method. I think it'll depend on the number of tests and how much their code has in common, for deciding if this is a good idea. In general I think it makes the code less readable, but it has the potential to make it a lot shorter, and that's good. \$\endgroup\$ Mar 24, 2014 at 15:10
  • 2
    \$\begingroup\$ This is much more complex in understanding then I'm used to, but it seems pretty pythonic. I'll experiment with this, thank you! \$\endgroup\$
    – Thor
    Mar 24, 2014 at 15:15
  • \$\begingroup\$ @thor: this is just pushing further Python's switch statement \$\endgroup\$
    – hivert
    Mar 24, 2014 at 15:20
13
\$\begingroup\$

I'd put it in a function and return from it when a match was found, that way you don't have all the indents for the else: cases, just a list of tests and returns for them:

import re
strings = ["abc zzz", "y", "#comment"]

def run_tests(s)
    match = re.search("(\S+) (\S+)", s)
    if match:
        print "Multiword: %s+%s" % (match.group(1), match.group(2))
        return

    if re.match("y$", s):
        print "Positive"
        return

    if re.match("n$", s):
        print "Negative"
        return

    # a few more matches possible in real life script,
    # and then the last catch-all:
    print "No match found, line skipped"

for s in strings:
    run_tests(s)

I'd try to put the list of tests into some data structure to loop over (like the messages and the patterns to test for), but because the code is slightly different (search vs match, simple print vs doing something with match) it's clearer just to keep it like this.

\$\endgroup\$
2
  • \$\begingroup\$ This works fine until I want to fill different variables under the different matches - then I'd always have to pass the full list of possible variables back and forth between this function and the caller, right? \$\endgroup\$
    – Thor
    Mar 24, 2014 at 15:14
  • 2
    \$\begingroup\$ You can always make your code arbitrarily complex, yes :-). In such cases maybe all of your variables should be part of some sort of State class, and a state variable could be passed to a set of test_ functions, or they could be methods of that class, or et cetera. \$\endgroup\$ Mar 24, 2014 at 17:30
6
\$\begingroup\$

I like @hivert's approach, but would formalize it a bit more:

import re

tests = [
    ("(\S+) (\S+)", "Multiword: {0}+{1}"),
    ("^y$",         "Positive"),
    ("^n$",         "Negative")
]

def get_first_match(s, tests=tests, none_match="No match found, line skipped"):
    for reg,fmt in tests:
        match = re.search(reg, s)
        if match:
            return fmt.format(*match.groups())
    return none_match

then

strings = ["abc zzz", "y", "#comment"]
for s in strings:
    print(get_first_match(s))
\$\endgroup\$
2
  • \$\begingroup\$ The first one was re.search, the others are re.match \$\endgroup\$
    – Izkata
    Mar 25, 2014 at 19:52
  • 1
    \$\begingroup\$ @Izkata: that is why I prepended a "^" to them - it forces .search to behave like .match (only match at start of line). \$\endgroup\$ Mar 25, 2014 at 20:01
5
\$\begingroup\$

This is a known issue with regexps. But you can just put the options in a container, such as a list, and then use a for loop:

import re
strings = ["abc zzz", "y", "#comment"]
regexps_and_messages = [
    ("(\S+) (\S+)", "Multiword: %s+%s"),
    ("y$", "Positive"),
    ("n$", "Negative"),
]

for s in strings:
    for regexp, message in regexps_and_messages:
        m = re.match(regexp, s)
        if m is not None:
            print message % m.groups()
            break
    else: # if no break in above loop
        print "No match found, line skipped"
\$\endgroup\$
2
  • \$\begingroup\$ Not that I particularly need the re.match vs re.search distinction, but this makes it impossible to retain it. Also, the for ... else construction is, to my experience, so rarely used that most maintainers wouldn't know right away what this does - or is that just my own ignorance speaking? \$\endgroup\$
    – Thor
    Mar 24, 2014 at 15:13
  • \$\begingroup\$ @Thor not really, if you do need the behavior of match just prepend ^ to the applicable regular expressions, e.g. "^y$" and "^n$". The behavior will differ slightly from that of match when using the multiline regex flag (it will also match after every newline instead of only at the start of the string being searched), but as you're checking line-by-line anyway that shouldn't make a difference (and in those cases $ will match before any newlines as well so you'd have to worry about that one too, unless such behavior is desirable). \$\endgroup\$
    – JAB
    Mar 24, 2014 at 17:07
5
\$\begingroup\$

To go even further on the approaches suggested that put the regexes in a list you could join the regexes together with | and then match the line against all possible patterns in one go.

import re

class LineMatcher(object):
    def __init__(self, patterns):
        # In order to match each string, we build a regex combining which can match
        # all the parts. 
        # Something like: ((\S+) (\S+))|(y$)|(n$)|(.*))
        # When we match against it, we can figure out which pattern was matched
        self._groups = {}
        regexes = []

        # because groups could contain groups, we need to keep track of the current
        # group index so that we know which index each pattern will end up with.
        current_group_index = 1
        for pattern, handler in patterns:
            group_count = re.compile(pattern).groups
            self._groups[current_group_index] = (group_count, handler)
            regexes.append("(%s)" % pattern)
            current_group_index += group_count + 1

        self._regex = re.compile("|".join(regexes))

    def match(self, string):
        match = self._regex.match(string)
        group_count, handler = self._groups[match.lastindex]
        captures = match.groups()[match.lastindex:match.lastindex + group_count]
        return handler(*captures)


matcher = LineMatcher([
    ("(\S+) (\S+)", lambda first, second: "Multiword: %s+%s"),
    ("y$", lambda: "Positive"),
    ("n$", lambda: "Negative"),
    (".*", lambda: "No match found, line skipped")
])


strings = ["abc zzz", "y", "#comment"]
for s in strings:
    print matcher.match(s)
\$\endgroup\$
4
  • \$\begingroup\$ I like this solution because it avoids having to re-execute the regular expression searches for each possibility; you will still have a similar number of conditionals, essentially, but short-circuited checks \$\endgroup\$
    – JAB
    Mar 24, 2014 at 17:14
  • \$\begingroup\$ I like this solution, it seems very pythonic, extensible, and "cleaner" than the continue-approach of @Calpratt. On the other hand, I can grasp that approach within the first glance of the code, whereas here I have to work my way through it. I guess my question now is: If you stumbled across something like this in code you have to maintain, which would make life easier for you? With this in mind, I'll choose the cruder but easier continue-approach. \$\endgroup\$
    – Thor
    Mar 25, 2014 at 10:54
  • \$\begingroup\$ It should be noted that this approach only works because the regexes are all mutually exclusive. If you needed it to be multi-word and end in a 'y', for example, this can't be used. \$\endgroup\$
    – Gabe
    Mar 25, 2014 at 14:21
  • \$\begingroup\$ @Gabe, actually in my code above its not mutually exclusive. Not that .* matches anything. I think that in the case of ambiguity it always returns the first match. \$\endgroup\$ Mar 25, 2014 at 20:37
5
\$\begingroup\$

Calpratt's answer without continue:

import re
strings = ["abc zzz", "y", "#comment"]
for s in strings:
    match = re.search("(\S+) (\S+)", s)
    if match:
        print "Multiword: %s+%s" % (match.group(1), match.group(2))
    elif re.match("y$", s):
        print "Positive"
    elif re.match("n$", s):
        print "Negative"
    else:
        print "No match found, line skipped"
\$\endgroup\$
1
  • 2
    \$\begingroup\$ Yes, but this works only for exactly this situation. From the first elif onwards you have no chance to access the match result. There's even the danger of trying to access the match object in the elif branches, which would lead to working on wrong results. No downvote as this is not wrong per se, but it's not as flexible as the other approaches. \$\endgroup\$
    – Thor
    Mar 25, 2014 at 10:49
3
\$\begingroup\$

Once you recognized the pattern in your code, you can wrap it as a base class to avoid boilerplates. This also makes the code more maintainable.

import re

class Handler:
    PATTERN = ''
    def __init__(self):
        self._pattern = re.compile(self.PATTERN)

    def match(self, *args, **kwargs):
        return self._pattern.match(*args, **kwargs)

    def handle(self, matched):
        pass

class MultiwordHandler(Handler):
    PATTERN = '(\S+) (\S+)'

    def match(self, *args, **kwargs):
        return self._pattern.search(*args, **kwargs)

    def handle(self, matched):
        print 'Multiword: %s+%s' % (matched.group(1), matched.group(2))

class PositiveHandler(Handler):
    PATTERN = 'y$'

    def handle(self, matched):
        print 'Positive'

class NegativeHandler(Handler):
    PATTERN = 'n$'

    def handle(self, matched):
        print 'Negative'

And use them like this:

handlers = [MultiwordHandler(), PositiveHandler(), NegativeHandler()]

strings = ["abc zzz", "y", "#comment"]

for s in strings:
    for handler in handlers:
        matched = handler.match(s)
        if matched:
            handler.handle(matched)
            break
    else:
        print "No match found, line skipped"
\$\endgroup\$
0
\$\begingroup\$

A hackish idea, defining a helper function match which at success overwrites itself with the result:

import re
strings = ["abc zzz", "y", "#comment"]
for s in strings:
    def match(result):
        if result:
            global match
            match = result
        return result
    if match(re.search("(\S+) (\S+)", s)):
        print "Multiword: %s+%s" % (match.group(1), match.group(2))
    elif match(re.match("y$", s)):
        print "Positive"
    elif match(re.match("n$", s)):
        print "Negative"
    else:
        print "No match found, line skipped"

Try it online!

\$\endgroup\$
5
  • 1
    \$\begingroup\$ You have presented an alternative solution, but haven't reviewed the code. Please edit to show what aspects of the question code prompted you to write this version, and in what ways it's an improvement over the original. It may be worth (re-)reading How to Answer. \$\endgroup\$ Sep 21, 2021 at 8:30
  • \$\begingroup\$ @TobySpeight Just like all other answers, including the accepted one with its 30 upvotes, no? How is mine any different? How did they "review" any more than I did? \$\endgroup\$
    – no comment
    Sep 21, 2021 at 10:12
  • \$\begingroup\$ Many of those answers are very old, and wouldn't be acceptable if posted today. Don't take low-quality old posts as your benchmark. (OTOH, if you find new posts that don't review the code, please do flag them for attention). \$\endgroup\$ Sep 21, 2021 at 10:20
  • \$\begingroup\$ @TobySpeight Ah, ok. So I need to fake a review just because I'm late to the party. I'll try. \$\endgroup\$
    – no comment
    Sep 21, 2021 at 10:21
  • \$\begingroup\$ Or answer some newer questions (this question is arguably off-topic, as it seems to be example code rather than the real thing). \$\endgroup\$ Sep 21, 2021 at 10:24
-2
\$\begingroup\$

You can make flow of your application more linear by either return-ing from the method when failing or do it Python way using Exceptions, e.g.:

try:
    if not do_action_1:
        raise Exception('Action 1 failed')
    if not do_action_2:
        raise Exception('Action 2 failed')
    if not do_action_3:
        raise Exception('Action 3 failed')
    if not do_action_4:
        raise Exception('Action 4 failed')
    if not do_action_5:
        raise Exception('Action 5 failed')
except Exception as e:
    print 'Whoops: %s' % e
\$\endgroup\$
2
  • 7
    \$\begingroup\$ I'd rather call this an abuse of exceptions. \$\endgroup\$ Mar 24, 2014 at 14:58
  • \$\begingroup\$ Apart from feeling that I have to agree with @RemcoGerlich on this, I also have to admit I don't quite see how to apply this to my situation. Are the do_action_x-parts all supposed to be individual functions, one for each match I'm trying to check? \$\endgroup\$
    – Thor
    Mar 24, 2014 at 15:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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