54

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

dreftymac
  • 31,404
  • 26
  • 119
  • 182
mhlester
  • 22,781
  • 10
  • 52
  • 75
  • What do you say to this? http://stackoverflow.com/questions/9244909/python-conditional-string-formatting – Russia Must Remove Putin Feb 19 '14 at 06: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 Feb 19 '14 at 06:24
  • How does the above fare with {goose:5}? – meawoppl Feb 19 '14 at 06: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 Feb 19 '14 at 06:38
  • For serious, I would wager there is something like 100 special cases you have to handle. See the answer below. – meawoppl Feb 19 '14 at 06:39
  • I'd say 100 is grossly underestimated for the full english language. But the challenge is in formatting the arbitrary strings you *do* want – mhlester Feb 19 '14 at 06:42
  • And you should not restrict yourself to only English, which has a comparatively simple syntax and morphology. Unless you target complex languages like Russian and Finnish right out of the box, your solution is going to be unsatisfactory. If you want "general", make sure it generalizes properly. – tripleee Feb 19 '14 at 07:37
  • See also http://interglacial.com/tpj/13/ -- it's old, and it's about Perl, but it is required reading if you are into this sort of thing. – tripleee Feb 19 '14 at 07:39
  • All I'm trying to do is print a message. Not built a general purpose AI to take over the world :) – mhlester Feb 19 '14 at 07:41
  • A quick one-liner I use for general purposes especially since I don't require pluralization all too often in my applications: `def pluralize(number, word): return "%d %s" % (number, ("%ss" % word) if number != 1 else word)` such that `print pluralize(1, "move")` outputs `1 move` and `print pluralize(0, "move")` outputs `0' moves` – Matt Borja Jun 04 '15 at 16:37
  • This was removed as per this RFC https://rust-lang.github.io/rfcs/0093-remove-format-intl.html – twe4ked Feb 15 '21 at 01:45
  • hi @twe4ked, this question isn't tagged rust, so a similar feature having been removed from rush wouldn't be relevant here – mhlester Dec 14 '22 at 06:08
  • @mhlester whoops! I don't remember writing this comment. But you're right. Definitely not relevant. Sorry! – twe4ked Dec 14 '22 at 23:59

7 Answers7

55

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() }
Neuron
  • 5,141
  • 5
  • 38
  • 59
meawoppl
  • 2,714
  • 1
  • 23
  • 30
  • this is a really interesting approach. it's tough to coerce into a *general purpose* format string though – mhlester Feb 19 '14 at 06:38
  • i've downloaded `inflect`. it does a pretty good job (got caught up on `cacti`), and might be able to be combined with @falsetru's `string.Formatter` approach to provide the perfect combination. thanks so much – mhlester Feb 19 '14 at 06:59
  • 4
    Issue opened, pull-request underway. There will be cacti before long. – meawoppl Feb 19 '14 at 07:36
  • 4
    Hah, turns out cactuses adn cacti is valid:plural:http://en.wikipedia.org/wiki/Cactus, http://grammarist.com/usage/cacti-cactuses/ – meawoppl Feb 23 '14 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). – fearless_fool Sep 04 '14 at 03:33
  • 3
    hahahaha. F-yeah linguistics. Again, let me emphasize that this is a more complicated problem than most people appreciate. – meawoppl Sep 04 '14 at 20:43
54

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).

Aristide
  • 3,606
  • 2
  • 30
  • 50
  • 1
    Really awesome and works perfectly inside of format strings without taking up too much more space. Thanks – Trevor Jex Jan 11 '21 at 03:22
  • 1
    @TrevorJex Thanks. For more golfing awesomeness, now with `^` instead of `!=` ;) – Aristide Jan 12 '21 at 05:58
  • 1
    here is the original solution using `!=1` if you prefer the readability: `bottle{'s'[:i!=1]}` – serg Aug 26 '21 at 23:37
  • This should be the accepted answer. It's short, to the point, and does what was requested – Aethalides Dec 05 '21 at 15:45
  • 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 Jan 31 '22 at 15:32
  • 1
    @gccallie Neat! I took the liberty of adding your idea to the answer. – Aristide Feb 02 '22 at 08:12
  • for n-character plurals, another option is to use `'es ' [:-1*i!=1]`, which will trim the space from the end of `es ` if `i!=1`. not as pretty but generalizes the code snippet to work without alteration for any n-character plural – samiller Aug 01 '22 at 20:31
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.

dsummersl
  • 6,588
  • 50
  • 65
falsetru
  • 357,413
  • 63
  • 732
  • 636
  • 3
    @mhlester, Actually, I read not only the documentation, but also read [the source code `string.py`](http://hg.python.org/cpython/file/2e8a142dbccc/Lib/string.py#l162). – falsetru Feb 19 '14 at 06:37
  • @mhlester, BTW, this does not handle numeric field with plural suffix: e.g. `0(i,ie)` – falsetru Feb 19 '14 at 06: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 Feb 19 '14 at 06:48
  • 1
    @mhlester, Your idea is possible. But only in Python 3.2+. Chec out the update. – falsetru Feb 19 '14 at 06:59
  • oh, that is clever. i'm in 2.7, but that's sure a nice feature – mhlester Feb 19 '14 at 07: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))
Oli
  • 235,628
  • 64
  • 220
  • 299
markus-hinsche
  • 1,372
  • 15
  • 26
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)]}"
TaylorMonacelli
  • 350
  • 2
  • 3
  • 9
Rusca8
  • 533
  • 4
  • 11
  • 1
    Thank you, that's a very approachable solution, and one of the easiest to implement and comprehend! – mhlester Mar 26 '20 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 Mar 28 '20 at 09: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'
Veedrac
  • 58,273
  • 15
  • 112
  • 169
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.'
"""
Eli Chan
  • 43
  • 6