Plural String Formatting

Question:

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

Asked By: mhlester

||

Answers:

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.

Answered By: falsetru

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() }
Answered By: meawoppl

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'
Answered By: Veedrac

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))
Answered By: markus-hinsche

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.'
"""
Answered By: Eli Chan

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)]}"
Answered By: Rusca8

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

Answered By: Aristide