30

I have a simple task I need to perform in Python, which is to convert a string to all lowercase and strip out all non-ascii non-alpha characters.

For example:

"This is a Test" -> "thisisatest"
"A235th@#$&( er Ra{}|?>ndom" -> "atherrandom"

I have a simple function to do this:

import string
import sys

def strip_string_to_lowercase(s):
    tmpStr = s.lower().strip()
    retStrList = []
    for x in tmpStr:
        if x in string.ascii_lowercase:
            retStrList.append(x)

    return ''.join(retStrList)

But I cannot help thinking there is a more efficient, or more elegant, way.

Thanks!


Edit:

Thanks to all those that answered. I learned, and in some cases re-learned, a good deal of python.

grieve
  • 13,220
  • 10
  • 49
  • 61
  • your code doesn't automatically strip < and you don't have o before t – SilentGhost Mar 12 '09 at 14:43
  • @SilentGhost I think the < was probably cut and paste and his example string begins with capital O, not zero. – Dana Mar 12 '09 at 14:47
  • @SilentGhost: I had a capital 'o' in there that does look a lot like a zero. Probably a bad example to use. I will edit it. – grieve Mar 12 '09 at 14:48
  • @Dana: yep the < was a cut and paste error, that previewed correctly, but didn't actually display correctly. Oops! – grieve Mar 12 '09 at 14:54

11 Answers11

27

Another solution (not that pythonic, but very fast) is to use string.translate - though note that this will not work for unicode. It's also worth noting that you can speed up Dana's code by moving the characters into a set (which looks up by hash, rather than performing a linear search each time). Here are the timings I get for various of the solutions given:

import string, re, timeit

# Precomputed values (for str_join_set and translate)

letter_set = frozenset(string.ascii_lowercase + string.ascii_uppercase)
tab = string.maketrans(string.ascii_lowercase + string.ascii_uppercase,
                       string.ascii_lowercase * 2)
deletions = ''.join(ch for ch in map(chr,range(256)) if ch not in letter_set)

s="A235th@#$&( er Ra{}|?>ndom"

# From unwind's filter approach
def test_filter(s):
    return filter(lambda x: x in string.ascii_lowercase, s.lower())

# using set instead (and contains)
def test_filter_set(s):
    return filter(letter_set.__contains__, s).lower()

# Tomalak's solution
def test_regex(s):
    return re.sub('[^a-z]', '', s.lower())

# Dana's
def test_str_join(s):
    return ''.join(c for c in s.lower() if c in string.ascii_lowercase)

# Modified to use a set.
def test_str_join_set(s):
    return ''.join(c for c in s.lower() if c in letter_set)

# Translate approach.
def test_translate(s):
    return string.translate(s, tab, deletions)


for test in sorted(globals()):
    if test.startswith("test_"):
        assert globals()[test](s)=='atherrandom'
        print "%30s : %s" % (test, timeit.Timer("f(s)", 
              "from __main__ import %s as f, s" % test).timeit(200000))

This gives me:

               test_filter : 2.57138351271
           test_filter_set : 0.981806765698
                test_regex : 3.10069885233
             test_str_join : 2.87172979743
         test_str_join_set : 2.43197956381
            test_translate : 0.335367566218

[Edit] Updated with filter solutions as well. (Note that using set.__contains__ makes a big difference here, as it avoids making an extra function call for the lambda.

Community
  • 1
  • 1
Brian
  • 116,865
  • 28
  • 107
  • 112
  • 1
    The timing code was a nice addition. See my answer below where I added in the filter solutions as well. – grieve Mar 12 '09 at 16:17
  • Oops - missed those. I've added a filter solution as well now. – Brian Mar 12 '09 at 16:26
  • Accepting this one, because it is comprehensive. It also contains the filter with a set solution which is the optimal combination of speed and elegance for me. – grieve Mar 13 '09 at 00:50
  • Very nice, translation tables are still my favourite. – Christian Witts Mar 13 '09 at 07:35
  • test_filter_set = lambda s: filter(letter_set.__contains__, s).lower() is slightly faster – jfs Mar 14 '09 at 10:03
  • Good point - no need to call lower() on characters we're just going to throw away. Updated. – Brian Mar 17 '09 at 19:10
  • Great answer - I like the tomalak's regex method since it's cleanest and most pythonic. Anything fewer than 10k runs against it will be sufficiently fast to justify code cleanliness/extensibility over speed. – Adam Nelson Dec 02 '09 at 15:21
17
>>> filter(str.isalpha, "This is a Test").lower()
'thisisatest'
>>> filter(str.isalpha, "A235th@#$&( er Ra{}|?>ndom").lower()
'atherrandom'
A. Coady
  • 54,452
  • 8
  • 34
  • 40
11

Not especially runtime efficient, but certainly nicer on poor, tired coder eyes:

def strip_string_and_lowercase(s):
    return ''.join(c for c in s.lower() if c in string.ascii_lowercase)
SilentGhost
  • 307,395
  • 66
  • 306
  • 293
Dana
  • 32,083
  • 17
  • 62
  • 73
8

I would:

  • lowercase the string
  • replace all [^a-z] with ""

Like that:

def strip_string_to_lowercase():
  nonascii = re.compile('[^a-z]')
  return lambda s: nonascii.sub('', s.lower().strip())

EDIT: It turns out that the original version (below) is really slow, though some performance can be gained by converting it into a closure (above).

def strip_string_to_lowercase(s):
  return re.sub('[^a-z]', '', s.lower().strip())

My performance measurements with 100,000 iterations against the string

"A235th@#$&( er Ra{}|?>ndom"

revealed that:

  • f_re_0 took 2672.000 ms (this is the original version of this answer)
  • f_re_1 took 2109.000 ms (this is the closure version shown above)
  • f_re_2 took 2031.000 ms (the closure version, without the redundant strip())
  • f_fl_1 took 1953.000 ms (unwind's filter/lambda version)
  • f_fl_2 took 1485.000 ms (Coady's filter version)
  • f_jn_1 took 1860.000 ms (Dana's join version)

For the sake of the test, I did not print the results.

Community
  • 1
  • 1
Tomalak
  • 332,285
  • 67
  • 532
  • 628
  • the strip() isn't particularly needed as anything that strip() would remove is removed by the '[^a-z]' :o) – George Shore Mar 12 '09 at 14:52
  • Two loops or not, it's faster than any other way I've tried, including string.translate. – bobince Mar 12 '09 at 14:55
  • @George Shore: You are right. I don't expect it to make much of a difference (performance-wise) though. And I left it in so when you look at the code it's clear instantly that the result will be stripped - it would be a not-so-obvious "side effect" otherwise. – Tomalak Mar 12 '09 at 15:01
  • @bobince: it's the slowest solution posted – SilentGhost Mar 12 '09 at 15:05
  • @Tomalok: You've the arguments to re.sub reversed - you're operating on the empty string. – Brian Mar 12 '09 at 16:17
  • @Brian: I'm not. It's sub(replacement, string [, count = 0]). – Tomalak Mar 12 '09 at 16:28
  • You might want to mention how your version is used: replacer = strip_string_to_lowercase() print replacer(s) What a pain. – timkay Mar 12 '09 at 16:49
  • @Tomalok: I was still seeing your unfixed code, where you were putting the string first. ie: "nonascii.sub(subject.lower().strip(),'')" You've fixed that in the previous change. – Brian Mar 12 '09 at 16:50
  • @timkay - you only need to call it once (and store the value off) - probably immediately after you define it. Alternatively, stick an @apply decorator before the definition (though that is maybe less clear.) – Brian Mar 12 '09 at 17:02
  • @timkay: The regex is not the most efficient way to do it anyway (using a closure buys a little, but not enough), so this won't end up as the accepted answer anyway. – Tomalak Mar 12 '09 at 17:03
  • @Brian: Hopefully I fixed all copy/paste glitches and other stupid mistakes in my code by now. I'm not intending to squeeze any more milliseconds out of the regex approach, it's no use. ;-) – Tomalak Mar 12 '09 at 17:05
7

Python 2.x translate method

Convert to lowercase and filter non-ascii non-alpha characters:

from string import ascii_letters, ascii_lowercase, maketrans

table = maketrans(ascii_letters, ascii_lowercase*2)
deletechars = ''.join(set(maketrans('','')) - set(ascii_letters))

print "A235th@#$&( er Ra{}|?>ndom".translate(table, deletechars)
# -> 'atherrandom'

Python 3 translate method

Filter non-ascii:

ascii_bytes = "A235th@#$&(٠٫٢٥ er Ra{}|?>ndom".encode('ascii', 'ignore')

Use bytes.translate() to convert to lowercase and delete non-alpha bytes:

from string import ascii_letters, ascii_lowercase

alpha, lower = [s.encode('ascii') for s in [ascii_letters, ascii_lowercase]]
table = bytes.maketrans(alpha, lower*2)           # convert to lowercase
deletebytes = bytes(set(range(256)) - set(alpha)) # delete nonalpha

print(ascii_bytes.translate(table, deletebytes))
# -> b'atherrandom'
jfs
  • 399,953
  • 195
  • 994
  • 1,670
4

Similar to @Dana's, but I think this sounds like a filtering job, and that should be visible in the code. Also without the need to explicitly call join():

def strip_string_to_lowercase(s):
  return filter(lambda x: x in string.ascii_lowercase, s.lower())
unwind
  • 391,730
  • 64
  • 469
  • 606
3

I added the filter solutions to Brian's code:

import string, re, timeit

# Precomputed values (for str_join_set and translate)

letter_set = frozenset(string.ascii_lowercase + string.ascii_uppercase)
tab = string.maketrans(string.ascii_lowercase + string.ascii_uppercase,
                       string.ascii_lowercase * 2)
deletions = ''.join(ch for ch in map(chr,range(256)) if ch not in letter_set)

s="A235th@#$&( er Ra{}|?>ndom"

def test_original(s):
    tmpStr = s.lower().strip()
    retStrList = []
    for x in tmpStr:
        if x in string.ascii_lowercase:
            retStrList.append(x)

    return ''.join(retStrList)


def test_regex(s):
    return re.sub('[^a-z]', '', s.lower())

def test_regex_closure(s):
  nonascii = re.compile('[^a-z]')
  def replacer(s):
    return nonascii.sub('', s.lower().strip())
  return replacer(s)


def test_str_join(s):
    return ''.join(c for c in s.lower() if c in string.ascii_lowercase)

def test_str_join_set(s):
    return ''.join(c for c in s.lower() if c in letter_set)

def test_filter_set(s):
    return filter(letter_set.__contains__, s.lower())

def test_filter_isalpha(s):
    return filter(str.isalpha, s).lower()

def test_filter_lambda(s):
    return filter(lambda x: x in string.ascii_lowercase, s.lower())

def test_translate(s):
    return string.translate(s, tab, deletions)

for test in sorted(globals()):
    if test.startswith("test_"):
        print "%30s : %s" % (test, timeit.Timer("f(s)", 
              "from __main__ import %s as f, s" % test).timeit(200000))

This gives me:

       test_filter_isalpha : 1.31981746283
        test_filter_lambda : 2.23935583992
           test_filter_set : 0.76511679557
             test_original : 2.13079176264
                test_regex : 2.44295629752
        test_regex_closure : 2.65205913042
             test_str_join : 2.25571266739
         test_str_join_set : 1.75565888961
            test_translate : 0.269259640541

It appears that isalpha is using a similar algorithm, at least in terms of O(), to the set algorithm.


Edit: Added the filter set, and renamed the filter functions to be a little more clear.

grieve
  • 13,220
  • 10
  • 49
  • 61
2
>>> import string
>>> a = "O235th@#$&( er Ra{}|?&lt;ndom"
>>> ''.join(i for i in a.lower() if i in string.ascii_lowercase)
'otheraltndom'

doing essentially the same as you.

SilentGhost
  • 307,395
  • 66
  • 306
  • 293
  • Yours skips the capital O and R, sg, because you're testing for membership in ascii_lowercase before you call lower() – Dana Mar 12 '09 at 14:46
2

This is a typical application of list compehension:

import string
s = "O235th@#$&( er Ra{}|?<ndom"
print ''.join(c for c in s.lower() if c in string.ascii_lowercase)

It won't filter out "<" (html entity), as in your example, but I assume that was accidental cut and past problem.

Ber
  • 40,356
  • 16
  • 72
  • 88
1

Python 2.x:

import string
valid_chars= string.ascii_lowercase + string.ascii_uppercase

def only_lower_ascii_alpha(text):
    return filter(valid_chars.__contains__, text).lower()

Works with either str or unicode arguments.

>>> only_lower_ascii_alpha("Hello there 123456!")
'hellothere'
>>> only_lower_ascii_alpha(u"435 café")
u'caf'
Community
  • 1
  • 1
tzot
  • 92,761
  • 29
  • 141
  • 204
0

Personally I would use a regular expression and then convert the final string to lower case. I have no idea how to write it in Python, but the basic idea is to:

  1. Remove characters in string that don't match case-insensitive regex "\w"
  2. Convert string to lower-case

or vice versa.

Asclepius
  • 57,944
  • 17
  • 167
  • 143
Dan Roberts
  • 4,664
  • 3
  • 34
  • 43