65

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/… 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. Commented Feb 19, 2014 at 6:24
  • How does the above fare with {goose:5}? Commented Feb 19, 2014 at 6:36
  • 1
    yeah, for my code you'd have to make yet another subclass to replace the whole word. hence the search for a better way 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. Commented Feb 19, 2014 at 6:39

7 Answers 7

70

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

Sign up to request clarification or add additional context in comments.

7 Comments

Really awesome and works perfectly inside of format strings without taking up too much more space. Thanks
@TrevorJex Thanks. For more golfing awesomeness, now with ^ instead of != ;)
here is the original solution using !=1 if you prefer the readability: bottle{'s'[:i!=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 Neat! I took the liberty of adding your idea to the answer.
|
58

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 Comments

this is a really interesting approach. it's tough to coerce into a general purpose format string though
Issue opened, pull-request underway. There will be cacti before long.
Hah, turns out cactuses adn cacti is valid:plural:en.wikipedia.org/wiki/Cactus, grammarist.com/usage/cacti-cactuses
@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).
hahahaha. F-yeah linguistics. Again, let me emphasize that this is a more complicated problem than most people appreciate.
|
23

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 Comments

@mhlester, Actually, I read not only the documentation, but also read the source code string.py.
@mhlester, BTW, this does not handle numeric field with plural suffix: e.g. 0(i,ie)
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, Your idea is possible. But only in Python 3.2+. Chec out the update.
oh, that is clever. i'm in 2.7, but that's sure a nice feature
17

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

Comments

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 Comments

Thank you, that's a very approachable solution, and one of the easiest to implement and comprehend!
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
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' 

Comments

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.' """ 

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.