57

Given a dictionary of ints, I'm trying to format a string with each number, and a pluralization of the item.

Sample input dict:

data = {'tree': 1, 'bush': 2, 'flower': 3, 'cactus': 0}

Sample output str:

'My garden has 1 tree, 2 bushes, 3 flowers, and 0 cacti'

It needs to work with an arbitrary format string.

The best solution I've come up with is a PluralItem class to store two attributes, n (the original value), and s (the string 's' if plural, empty string '' if not). Subclassed for different pluralization methods

class PluralItem(object):
    def __init__(self, num):
        self.n = num
        self._get_s()
    def _get_s(self):
        self.s = '' if self.n == 1 else 's'

class PluralES(PluralItem):
    def _get_s(self):
        self.s = 's' if self.n == 1 else 'es'

class PluralI(PluralItem):
    def _get_s(self):
        self.s = 'us' if self.n == 1 else 'i'

Then make a new dict through comprehension and a classes mapping:

classes = {'bush': PluralES, 'cactus': PluralI, None: PluralItem}
plural_data = {key: classes.get(key, classes[None])(value) for key, value in data.items()}

Lastly, the format string, and implementation:

formatter = 'My garden has {tree.n} tree{tree.s}, {bush.n} bush{bush.s}, {flower.n} flower{flower.s}, and {cactus.n} cact{cactus.s}'
print(formatter.format(**plural_data))

Outputs the following:

My garden has 1 tree, 2 bushes, 3 flowers, and 0 cacti

For such an undoubtedly common need, I'm hesitant to throw in the towel with such a convoluted solution.

Is there a way to format a string like this using the built-in format method, and minimal additional code? Pseudocode might be something like:

"{tree} tree{tree(s)}, {bush} bush{bush(es)}, {flower} flower{flower(s)}, {cactus} cact{cactus(i,us)}".format(data)

where parentheses return the contents if value is plural, or if contents has comma, means plural/singular

13
  • What do you say to this? stackoverflow.com/questions/9244909/…
    – Aaron Hall
    Commented Feb 19, 2014 at 6:20
  • That's essentially what my class is doing, but I can't figure out how to put something like that in the string formatting. Especially with multiple keys.
    – mhlester
    Commented Feb 19, 2014 at 6:24
  • How does the above fare with {goose:5}?
    – meawoppl
    Commented Feb 19, 2014 at 6:36
  • yeah, for my code you'd have to make yet another subclass to replace the whole word. hence the search for a better way
    – mhlester
    Commented Feb 19, 2014 at 6:38
  • For serious, I would wager there is something like 100 special cases you have to handle. See the answer below.
    – meawoppl
    Commented Feb 19, 2014 at 6:39

7 Answers 7

63

Basic trick

When you have only two forms, and just need a quick and dirty fix, try 's'[:i^1]:

for i in range(5):
    print(f"{i} bottle{'s'[:i^1]} of beer.")

Output:

0 bottles of beer.
1 bottle of beer.
2 bottles of beer.
3 bottles of beer.
4 bottles of beer.

Explanation:

^ is the bitwise operator XOR (exclusive disjunction).

  • When i is zero, i ^ 1 evaluates to 1. 's'[:1] gives 's'.
  • When i is one, i ^ 1 evaluates to 0. 's'[:0] gives the empty string.
  • When i is more than one, i ^ 1 evaluates to an integer greater than 1 (starting with 3, 2, 5, 4, 7, 6, 9, 8..., see https://oeis.org/A004442 for more information). Python doesn't mind and happily returns as many characters of 's' as it can, which is 's'.

My 1 cent ;)

Edit. A previous, one-character longer version of the original trick used != instead of ^.

Extensions

n-character plural forms

For 2-character plural forms (e.g., bush/bushes), use 'es'[:2*i^2]. More generally, for an n-character plural form, replace 2 by n in the previous expression.

Opposite

In the comments, user @gccallie suggests 's'[i^1:] to add an 's' to verbs in the third person singular:

for i in range(5):
    print(f"{i} bottle{'s'[:i^1]} of beer lie{'s'[i^1:]} on the wall.")

Output:

0 bottles of beer lie on the wall.
1 bottle of beer lies on the wall.
2 bottles of beer lie on the wall.
3 bottles of beer lie on the wall.
4 bottles of beer lie on the wall.

Python interprets the first form as [:stop], and the second one as [start:].

Replication

Starting with Python 3.8, you can (ab)use the walrus operator to avoid multiple calculations of the same suffix. This is especially useful in French, where adjectives get the plural marks:

for i in range(5):
    print(f"{i} grande{(s:='s'[:i^1])}, belle{s} et solide{s} bouteille{s}.")

Output:

0 grandes, belles et solides bouteilles.
1 grande, belle et solide bouteille.
2 grandes, belles et solides bouteilles.
3 grandes, belles et solides bouteilles.
4 grandes, belles et solides bouteilles.

Note the mandatory parenthesis, and be aware that the new variable is not local to the f-string.

Of course, in "normal" style, you should write this in two lines (assignment + f-string).

7
  • 1
    Really awesome and works perfectly inside of format strings without taking up too much more space. Thanks
    – Trevor Jex
    Commented Jan 11, 2021 at 3:22
  • 1
    @TrevorJex Thanks. For more golfing awesomeness, now with ^ instead of != ;)
    – Aristide
    Commented Jan 12, 2021 at 5:58
  • 2
    here is the original solution using !=1 if you prefer the readability: bottle{'s'[:i!=1]}
    – serg
    Commented Aug 26, 2021 at 23:37
  • 1
    In case someone needs it: I was trying to obtain the opposite result to conjugate a verb - 's' for singular subject and no 's' for plural subject - and came up with this solution: 's'[i^1:]
    – gccallie
    Commented Jan 31, 2022 at 15:32
  • 1
    @gccallie Neat! I took the liberty of adding your idea to the answer.
    – Aristide
    Commented Feb 2, 2022 at 8:12
56

Check out the inflect package. It will pluralize things, as well as do a whole host of other linguistic trickery. There are too many situations to special-case these yourself!

From the docs at the link above:

import inflect
p = inflect.engine()

# UNCONDITIONALLY FORM THE PLURAL
print("The plural of ", word, " is ", p.plural(word))

# CONDITIONALLY FORM THE PLURAL
print("I saw", cat_count, p.plural("cat", cat_count))

For your specific example:

{print(str(count) + " " + p.pluralize(string, count)) for string, count in data.items() }
6
  • this is a really interesting approach. it's tough to coerce into a general purpose format string though
    – mhlester
    Commented Feb 19, 2014 at 6:38
  • 4
    Issue opened, pull-request underway. There will be cacti before long.
    – meawoppl
    Commented Feb 19, 2014 at 7:36
  • 4
    Hah, turns out cactuses adn cacti is valid:plural:en.wikipedia.org/wiki/Cactus, grammarist.com/usage/cacti-cactuses
    – meawoppl
    Commented Feb 23, 2014 at 18:35
  • 4
    @meawoppl: Just don't do what Ruby on Rails did: some smart aleck thought it would be cool to inflect the plural of "cow" as "kine" (which is correct but pedantic), but created the side effect that "scow" pluralized as "skine" (clearly wrong). Commented Sep 4, 2014 at 3:33
  • 4
    hahahaha. F-yeah linguistics. Again, let me emphasize that this is a more complicated problem than most people appreciate.
    – meawoppl
    Commented Sep 4, 2014 at 20:43
22

Using custom formatter:

import string

class PluralFormatter(string.Formatter):
    def get_value(self, key, args, kwargs):
        if isinstance(key, int):
            return args[key]
        if key in kwargs:
            return kwargs[key]
        if '(' in key and key.endswith(')'):
            key, rest = key.split('(', 1)
            value = kwargs[key]
            suffix = rest.rstrip(')').split(',')
            if len(suffix) == 1:
                suffix.insert(0, '')
            return suffix[0] if value <= 1 else suffix[1]
        else:
            raise KeyError(key)

data = {'tree': 1, 'bush': 2, 'flower': 3, 'cactus': 0}
formatter = PluralFormatter()
fmt = "{tree} tree{tree(s)}, {bush} bush{bush(es)}, {flower} flower{flower(s)}, {cactus} cact{cactus(i,us)}"
print(formatter.format(fmt, **data))

Output:

1 tree, 2 bushes, 3 flowers, 0 cacti

UPDATE

If you're using Python 3.2+ (str.format_map was added), you can use the idea of OP (see comment) that use customized dict.

class PluralDict(dict):
    def __missing__(self, key):
        if '(' in key and key.endswith(')'):
            key, rest = key.split('(', 1)
            value = super().__getitem__(key)
            suffix = rest.rstrip(')').split(',')
            if len(suffix) == 1:
                suffix.insert(0, '')
            return suffix[0] if value <= 1 else suffix[1]
        raise KeyError(key)

data = PluralDict({'tree': 1, 'bush': 2, 'flower': 3, 'cactus': 0})
fmt = "{tree} tree{tree(s)}, {bush} bush{bush(es)}, {flower} flower{flower(s)}, {cactus} cact{cactus(i,us)}"
print(fmt.format_map(data))

Output: same as above.

5
  • 3
    @mhlester, Actually, I read not only the documentation, but also read the source code string.py.
    – falsetru
    Commented Feb 19, 2014 at 6:37
  • @mhlester, BTW, this does not handle numeric field with plural suffix: e.g. 0(i,ie)
    – falsetru
    Commented Feb 19, 2014 at 6:44
  • without reading the source code or documentation, i'd wager that's a simple enough matter of extending the args[key] line with similar code. don't bother diluting this
    – mhlester
    Commented Feb 19, 2014 at 6:48
  • 1
    @mhlester, Your idea is possible. But only in Python 3.2+. Chec out the update.
    – falsetru
    Commented Feb 19, 2014 at 6:59
  • oh, that is clever. i'm in 2.7, but that's sure a nice feature
    – mhlester
    Commented Feb 19, 2014 at 7:01
16

Django users have pluralize, a function used in templates:

You have {{ num_messages }} message{{ num_messages|pluralize }}.

But you can import this into your code and call it directly:

from django.template.defaultfilters import pluralize

f'You have {num_messages} message{pluralize(num_messages)}.'
'You have {} message{}.'.format(num_messages, pluralize(num_messages))
'You have %d message%s' % (num_messages, pluralize(num_messages))
4

If there's a limited number of words you're gonna pluralize, I found it easier to have them as lists [singular, plural], and then make a small function that returns the index given the amount:

def sp(num):
    if num == 1:
        return 0
    else:
        return 1

Then it works like this:

lemon = ["lemon", "lemons"]
str = f"Hi I have bought 2 {lemon[sp(2)]}"

And actually you can get a lot of them at once if you split the word:

s = ["","s"]
str = f"Hi I have 1 cow{s[sp(1)]}"
2
  • 1
    Thank you, that's a very approachable solution, and one of the easiest to implement and comprehend!
    – mhlester
    Commented Mar 26, 2020 at 23:43
  • Thanks! I'm quite self-taught at coding so all those packages and obscure methods make it quite harder for me. I try to go for solutions that solve stuff with as minimal change and as less new info as possible :P
    – Rusca8
    Commented Mar 28, 2020 at 9:09
3

I would go with something like

class Pluralizer:
    def __init__(self, value):
        self.value = value

    def __format__(self, formatter):
        formatter = formatter.replace("N", str(self.value))
        start, _, suffixes = formatter.partition("/")
        singular, _, plural = suffixes.rpartition("/")

        return "{}{}".format(start, singular if self.value == 1 else plural)

"There are {:N thing/s} which are made of {:/a cactus/N cacti}".format(Pluralizer(10), Pluralizer(1))
#>>> 'There are 10 things which are made of a cactus'

The format is always/singular/plural, which singular (then plural) optional.

So

"xyz/foo/bar".format(Pluralizer(1)) == "xyzfoo"
"xyz/foo/bar".format(Pluralizer(2)) == "xyzbar"

"xyz/bar".format(Pluralizer(1)) == "xyz"
"xyz/bar".format(Pluralizer(2)) == "xyzbar"

"xyz".format(Pluralizer(1)) == "xyz"
"xyz".format(Pluralizer(2)) == "xyz"

Then for your example one just does:

data = {'tree': 1, 'bush': 2, 'flower': 3, 'cactus': 0}
string = 'My garden has {tree:N tree/s}, {bush:N bush/es}, {flower:N flower/s}, and {cactus:N cact/us/i}'

string.format_map({k: Pluralizer(v) for k, v in data.items()})
#>>> 'My garden has 1 tree, 2 bushes, 3 flowers, and 0 cacti'
0
2

I was inspired by the answers above, particularly @Veedrac's, to create a Plurality utility:

https://gist.github.com/elidchan/40baea13bb91193a326e3a8c4cbcaeb9

Features:

  • Customizable number-indexed templates (e.g. see 'vague' below)
  • Numbers and support for $n template tokens
  • Singular/plural forms (e.g. 'cact/us/i') and support for $thing/$things template tokens
  • Indefinite article capability (inspired by https://stackoverflow.com/a/20337527/4182210) and support for $a template token
  • Left/right string concatenation
  • Partials with any subset of number, forms, and templates
  • Partial completion via call() or format string

From the docstring:

"""
Usage:

>>> from utils.verbiage import Plurality

>>> f"We have {Plurality(0, 'g/oose/eese')}."
'We have 0 geese.'
>>> f"We have {Plurality(1, 'g/oose/eese')}."
'We have 1 goose.'
>>> f"We have {Plurality(2, 'g/oose/eese')}."
'We have 2 geese.'

>>> oxen = Plurality('ox/en')
>>> oxen.template_formatter
'1=$n $thing;n=$n $things'
>>> f"We have {oxen(0)}."
'We have 0 oxen.'
>>> f"We have {oxen(1)}."
'We have 1 ox.'
>>> f"We have {oxen(2)}."
'We have 2 oxen.'

>>> cows = Plurality('/cow/kine', '0=no $things', '1=$a $thing')
>>> cows.template_formatter
'0=no $things;1=a $thing;n=$n $things'
>>> f"We have {cows(0)}."
'We have no kine.'
>>> f"We have {cows(1)}."
'We have a cow.'
>>> f"We have {cows(2)}."
'We have 2 kine.'

>>> 'We have {:0=no $things;0.5=half $a $thing}.'.format(Plurality(0, 'octop/us/odes'))
'We have no octopodes.'
>>> 'We have {:octop/us/odes;0=no $things;0.5=half $a $thing}.'.format(Plurality(0.5))
'We have half an octopus.'
>>> 'We have {:4;octop/us/odes;0=no $things;0.5=half $a $thing}.'.format(Plurality())
'We have 4 octopodes.'

>>> data = {'herb': 1, 'bush': 2, 'flower': 3, 'cactus': 0}
>>> s = "We have {herb:herb/s}, {bush:bush/es}, {flower:flower/s}, and {cactus:cact/us/i}."
>>> s.format_map({k: Plurality(v) for k, v in data.items()})
'We have 1 herb, 2 bushes, 3 flowers, and 0 cacti.'
>>> vague = Plurality('0=no $things;1=$a $thing;2=a couple $things;n=some $things')
>>> s.format_map({k: vague(v) for k, v in data.items()})
'We have an herb, a couple bushes, some flowers, and no cacti.'
"""

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