Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/babel/plural.py: 36%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2babel.numbers
3~~~~~~~~~~~~~
5CLDR Plural support. See UTS #35.
7:copyright: (c) 2013-2026 by the Babel Team.
8:license: BSD, see LICENSE for more details.
9"""
11from __future__ import annotations
13import decimal
14import re
15from collections.abc import Iterable, Mapping
16from typing import Any, Callable, Literal
18_plural_tags = ('zero', 'one', 'two', 'few', 'many', 'other')
19_fallback_tag = 'other'
22def extract_operands(
23 source: float | decimal.Decimal,
24) -> tuple[decimal.Decimal | int, int, int, int, int, int, Literal[0], Literal[0]]:
25 """Extract operands from a decimal, a float or an int, according to `CLDR rules`_.
27 The result is an 8-tuple (n, i, v, w, f, t, c, e), where those symbols are as follows:
29 ====== ===============================================================
30 Symbol Value
31 ------ ---------------------------------------------------------------
32 n absolute value of the source number (integer and decimals).
33 i integer digits of n.
34 v number of visible fraction digits in n, with trailing zeros.
35 w number of visible fraction digits in n, without trailing zeros.
36 f visible fractional digits in n, with trailing zeros.
37 t visible fractional digits in n, without trailing zeros.
38 c compact decimal exponent value: exponent of the power of 10 used in compact decimal formatting.
39 e currently, synonym for ‘c’. however, may be redefined in the future.
40 ====== ===============================================================
42 .. _`CLDR rules`: https://www.unicode.org/reports/tr35/tr35-61/tr35-numbers.html#Operands
44 :param source: A real number
45 :return: A n-i-v-w-f-t-c-e tuple
46 :rtype: tuple[decimal.Decimal, int, int, int, int, int, int, int]
47 """
48 n = abs(source)
49 i = int(n)
50 if isinstance(n, float):
51 if i == n:
52 n = i
53 else:
54 # Cast the `float` to a number via the string representation.
55 # This is required for Python 2.6 anyway (it will straight out fail to
56 # do the conversion otherwise), and it's highly unlikely that the user
57 # actually wants the lossless conversion behavior (quoting the Python
58 # documentation):
59 # > If value is a float, the binary floating point value is losslessly
60 # > converted to its exact decimal equivalent.
61 # > This conversion can often require 53 or more digits of precision.
62 # Should the user want that behavior, they can simply pass in a pre-
63 # converted `Decimal` instance of desired accuracy.
64 n = decimal.Decimal(str(n))
66 if isinstance(n, decimal.Decimal):
67 dec_tuple = n.as_tuple()
68 exp = dec_tuple.exponent
69 fraction_digits = dec_tuple.digits[exp:] if exp < 0 else ()
70 trailing = ''.join(str(d) for d in fraction_digits)
71 no_trailing = trailing.rstrip('0')
72 v = len(trailing)
73 w = len(no_trailing)
74 f = int(trailing or 0)
75 t = int(no_trailing or 0)
76 else:
77 v = w = f = t = 0
78 c = e = 0 # TODO: c and e are not supported
79 return n, i, v, w, f, t, c, e
82class PluralRule:
83 """Represents a set of language pluralization rules. The constructor
84 accepts a list of (tag, expr) tuples or a dict of `CLDR rules`_. The
85 resulting object is callable and accepts one parameter with a positive or
86 negative number (both integer and float) for the number that indicates the
87 plural form for a string and returns the tag for the format:
89 >>> rule = PluralRule({'one': 'n is 1'})
90 >>> rule(1)
91 'one'
92 >>> rule(2)
93 'other'
95 Currently the CLDR defines these tags: zero, one, two, few, many and
96 other where other is an implicit default. Rules should be mutually
97 exclusive; for a given numeric value, only one rule should apply (i.e.
98 the condition should only be true for one of the plural rule elements.
100 .. _`CLDR rules`: https://www.unicode.org/reports/tr35/tr35-33/tr35-numbers.html#Language_Plural_Rules
101 """
103 __slots__ = ('abstract', '_func')
105 def __init__(self, rules: Mapping[str, str] | Iterable[tuple[str, str]]) -> None:
106 """Initialize the rule instance.
108 :param rules: a list of ``(tag, expr)``) tuples with the rules
109 conforming to UTS #35 or a dict with the tags as keys
110 and expressions as values.
111 :raise RuleError: if the expression is malformed
112 """
113 if isinstance(rules, Mapping):
114 rules = rules.items()
115 found = set()
116 self.abstract: list[tuple[str, Any]] = []
117 for key, expr in sorted(rules):
118 if key not in _plural_tags:
119 raise ValueError(f"unknown tag {key!r}")
120 elif key in found:
121 raise ValueError(f"tag {key!r} defined twice")
122 found.add(key)
123 ast = _Parser(expr).ast
124 if ast:
125 self.abstract.append((key, ast))
127 def __repr__(self) -> str:
128 rules = self.rules
129 args = ", ".join(f"{tag}: {rules[tag]}" for tag in _plural_tags if tag in rules)
130 return f"<{type(self).__name__} {args!r}>"
132 @classmethod
133 def parse(
134 cls,
135 rules: Mapping[str, str] | Iterable[tuple[str, str]] | PluralRule,
136 ) -> PluralRule:
137 """Create a `PluralRule` instance for the given rules. If the rules
138 are a `PluralRule` object, that object is returned.
140 :param rules: the rules as list or dict, or a `PluralRule` object
141 :raise RuleError: if the expression is malformed
142 """
143 if isinstance(rules, PluralRule):
144 return rules
145 return cls(rules)
147 @property
148 def rules(self) -> Mapping[str, str]:
149 """The `PluralRule` as a dict of unicode plural rules.
151 >>> rule = PluralRule({'one': 'n is 1'})
152 >>> rule.rules
153 {'one': 'n is 1'}
154 """
155 _compile = _UnicodeCompiler().compile
156 return {tag: _compile(ast) for tag, ast in self.abstract}
158 @property
159 def tags(self) -> frozenset[str]:
160 """A set of explicitly defined tags in this rule. The implicit default
161 ``'other'`` rules is not part of this set unless there is an explicit
162 rule for it.
163 """
164 return frozenset(i[0] for i in self.abstract)
166 def __getstate__(self) -> list[tuple[str, Any]]:
167 return self.abstract
169 def __setstate__(self, abstract: list[tuple[str, Any]]) -> None:
170 self.abstract = abstract
172 def __call__(self, n: float | decimal.Decimal) -> str:
173 if not hasattr(self, '_func'):
174 self._func = to_python(self)
175 return self._func(n)
178def to_javascript(rule: Mapping[str, str] | Iterable[tuple[str, str]] | PluralRule) -> str:
179 """Convert a list/dict of rules or a `PluralRule` object into a JavaScript
180 function. This function depends on no external library:
182 >>> to_javascript({'one': 'n is 1'})
183 "(function(n) { n = Math.abs(n); return (n == 1) ? 'one' : 'other'; })"
185 Negative numbers use their absolute value, as in :class:`PluralRule`.
187 Implementation detail: The function generated will probably evaluate
188 expressions involved into range operations multiple times. This has the
189 advantage that external helper functions are not required and is not a
190 big performance hit for these simple calculations.
192 :param rule: the rules as list or dict, or a `PluralRule` object
193 :raise RuleError: if the expression is malformed
194 """
195 to_js = _JavaScriptCompiler().compile
196 result = ['(function(n) { n = Math.abs(n); return ']
197 for tag, ast in PluralRule.parse(rule).abstract:
198 result.append(f"{to_js(ast)} ? {tag!r} : ")
199 result.append('%r; })' % _fallback_tag)
200 return ''.join(result)
203def to_python(
204 rule: Mapping[str, str] | Iterable[tuple[str, str]] | PluralRule,
205) -> Callable[[float | decimal.Decimal], str]:
206 """Convert a list/dict of rules or a `PluralRule` object into a regular
207 Python function. This is useful in situations where you need a real
208 function and don't are about the actual rule object:
210 >>> func = to_python({'one': 'n is 1', 'few': 'n in 2..4'})
211 >>> func(1)
212 'one'
213 >>> func(3)
214 'few'
215 >>> func = to_python({'one': 'n in 1,11', 'few': 'n in 3..10,13..19'})
216 >>> func(11)
217 'one'
218 >>> func(15)
219 'few'
221 :param rule: the rules as list or dict, or a `PluralRule` object
222 :raise RuleError: if the expression is malformed
223 """
224 namespace = {
225 'IN': in_range_list,
226 'WITHIN': within_range_list,
227 'MOD': cldr_modulo,
228 'extract_operands': extract_operands,
229 }
230 to_python_func = _PythonCompiler().compile
231 result = [
232 'def evaluate(n):',
233 ' n, i, v, w, f, t, c, e = extract_operands(n)',
234 ]
235 for tag, ast in PluralRule.parse(rule).abstract:
236 # the str() call is to coerce the tag to the native string. It's
237 # a limited ascii restricted set of tags anyways so that is fine.
238 result.append(f" if ({to_python_func(ast)}): return {str(tag)!r}")
239 result.append(f" return {_fallback_tag!r}")
240 code = compile('\n'.join(result), '<rule>', 'exec')
241 eval(code, namespace)
242 return namespace['evaluate']
245def to_gettext(rule: Mapping[str, str] | Iterable[tuple[str, str]] | PluralRule) -> str:
246 """The plural rule as gettext expression. The gettext expression is
247 technically limited to integers and returns indices rather than tags.
249 >>> to_gettext({'one': 'n is 1', 'two': 'n is 2'})
250 'nplurals=3; plural=((n == 1) ? 0 : (n == 2) ? 1 : 2);'
252 :param rule: the rules as list or dict, or a `PluralRule` object
253 :raise RuleError: if the expression is malformed
254 """
255 rule = PluralRule.parse(rule)
257 used_tags = rule.tags | {_fallback_tag}
258 _compile = _GettextCompiler().compile
259 _get_index = [tag for tag in _plural_tags if tag in used_tags].index
261 result = [f"nplurals={len(used_tags)}; plural=("]
262 for tag, ast in rule.abstract:
263 result.append(f"{_compile(ast)} ? {_get_index(tag)} : ")
264 result.append(f"{_get_index(_fallback_tag)});")
265 return ''.join(result)
268def in_range_list(
269 num: float | decimal.Decimal,
270 range_list: Iterable[Iterable[float | decimal.Decimal]],
271) -> bool:
272 """Integer range list test. This is the callback for the "in" operator
273 of the UTS #35 pluralization rule language:
275 >>> in_range_list(1, [(1, 3)])
276 True
277 >>> in_range_list(3, [(1, 3)])
278 True
279 >>> in_range_list(3, [(1, 3), (5, 8)])
280 True
281 >>> in_range_list(1.2, [(1, 4)])
282 False
283 >>> in_range_list(10, [(1, 4)])
284 False
285 >>> in_range_list(10, [(1, 4), (6, 8)])
286 False
287 """
288 return num == int(num) and within_range_list(num, range_list)
291def within_range_list(
292 num: float | decimal.Decimal,
293 range_list: Iterable[Iterable[float | decimal.Decimal]],
294) -> bool:
295 """Float range test. This is the callback for the "within" operator
296 of the UTS #35 pluralization rule language:
298 >>> within_range_list(1, [(1, 3)])
299 True
300 >>> within_range_list(1.0, [(1, 3)])
301 True
302 >>> within_range_list(1.2, [(1, 4)])
303 True
304 >>> within_range_list(8.8, [(1, 4), (7, 15)])
305 True
306 >>> within_range_list(10, [(1, 4)])
307 False
308 >>> within_range_list(10.5, [(1, 4), (20, 30)])
309 False
310 """
311 return any(min_ <= num <= max_ for min_, max_ in range_list)
314def cldr_modulo(a: float, b: float) -> float:
315 """Javaish modulo. This modulo operator returns the value with the sign
316 of the dividend rather than the divisor like Python does:
318 >>> cldr_modulo(-3, 5)
319 -3
320 >>> cldr_modulo(-3, -5)
321 -3
322 >>> cldr_modulo(3, 5)
323 3
324 """
325 reverse = 0
326 if a < 0:
327 a *= -1
328 reverse = 1
329 if b < 0:
330 b *= -1
331 rv = a % b
332 if reverse:
333 rv *= -1
334 return rv
337class RuleError(Exception):
338 """Raised if a rule is malformed."""
341_VARS = {
342 'n', # absolute value of the source number.
343 'i', # integer digits of n.
344 'v', # number of visible fraction digits in n, with trailing zeros.*
345 'w', # number of visible fraction digits in n, without trailing zeros.*
346 'f', # visible fraction digits in n, with trailing zeros.*
347 't', # visible fraction digits in n, without trailing zeros.*
348 'c', # compact decimal exponent value: exponent of the power of 10 used in compact decimal formatting.
349 'e', # currently, synonym for `c`. however, may be redefined in the future.
350}
352_RULES: list[tuple[str | None, re.Pattern[str]]] = [
353 (None, re.compile(r'\s+', re.UNICODE)),
354 ('word', re.compile(rf'\b(and|or|is|(?:with)?in|not|mod|[{"".join(_VARS)}])\b')),
355 ('value', re.compile(r'\d+')),
356 ('symbol', re.compile(r'%|,|!=|=')),
357 ('ellipsis', re.compile(r'\.{2,3}|\u2026', re.UNICODE)), # U+2026: ELLIPSIS
358]
361def tokenize_rule(s: str) -> list[tuple[str, str]]:
362 s = s.split('@')[0]
363 result: list[tuple[str, str]] = []
364 pos = 0
365 end = len(s)
366 while pos < end:
367 for tok, rule in _RULES:
368 match = rule.match(s, pos)
369 if match is not None:
370 pos = match.end()
371 if tok:
372 result.append((tok, match.group()))
373 break
374 else:
375 raise RuleError(f"malformed CLDR pluralization rule. Got unexpected {s[pos]!r}")
376 return result[::-1]
379def test_next_token(
380 tokens: list[tuple[str, str]],
381 type_: str,
382 value: str | None = None,
383) -> list[tuple[str, str]] | bool:
384 return tokens and tokens[-1][0] == type_ and (value is None or tokens[-1][1] == value)
387def skip_token(tokens: list[tuple[str, str]], type_: str, value: str | None = None):
388 if test_next_token(tokens, type_, value):
389 return tokens.pop()
392def value_node(value: int) -> tuple[Literal['value'], tuple[int]]:
393 return 'value', (value,)
396def ident_node(name: str) -> tuple[str, tuple[()]]:
397 return name, ()
400def range_list_node(
401 range_list: Iterable[Iterable[float | decimal.Decimal]],
402) -> tuple[Literal['range_list'], Iterable[Iterable[float | decimal.Decimal]]]:
403 return 'range_list', range_list
406def negate(rv: tuple[Any, ...]) -> tuple[Literal['not'], tuple[tuple[Any, ...]]]:
407 return 'not', (rv,)
410class _Parser:
411 """Internal parser. This class can translate a single rule into an abstract
412 tree of tuples. It implements the following grammar::
414 condition = and_condition ('or' and_condition)*
415 ('@integer' samples)?
416 ('@decimal' samples)?
417 and_condition = relation ('and' relation)*
418 relation = is_relation | in_relation | within_relation
419 is_relation = expr 'is' ('not')? value
420 in_relation = expr (('not')? 'in' | '=' | '!=') range_list
421 within_relation = expr ('not')? 'within' range_list
422 expr = operand (('mod' | '%') value)?
423 operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w'
424 range_list = (range | value) (',' range_list)*
425 value = digit+
426 digit = 0|1|2|3|4|5|6|7|8|9
427 range = value'..'value
428 samples = sampleRange (',' sampleRange)* (',' ('…'|'...'))?
429 sampleRange = decimalValue '~' decimalValue
430 decimalValue = value ('.' value)?
432 - Whitespace can occur between or around any of the above tokens.
433 - Rules should be mutually exclusive; for a given numeric value, only one
434 rule should apply (i.e. the condition should only be true for one of
435 the plural rule elements).
436 - The in and within relations can take comma-separated lists, such as:
437 'n in 3,5,7..15'.
438 - Samples are ignored.
440 The translator parses the expression on instantiation into an attribute
441 called `ast`.
442 """
444 def __init__(self, string):
445 self.tokens = tokenize_rule(string)
446 if not self.tokens:
447 # If the pattern is only samples, it's entirely possible
448 # no stream of tokens whatsoever is generated.
449 self.ast = None
450 return
451 self.ast = self.condition()
452 if self.tokens:
453 raise RuleError(f"Expected end of rule, got {self.tokens[-1][1]!r}")
455 def expect(self, type_, value=None, term=None):
456 token = skip_token(self.tokens, type_, value)
457 if token is not None:
458 return token
459 if term is None:
460 term = repr(value is None and type_ or value)
461 if not self.tokens:
462 raise RuleError(f"expected {term} but end of rule reached")
463 raise RuleError(f"expected {term} but got {self.tokens[-1][1]!r}")
465 def condition(self):
466 op = self.and_condition()
467 while skip_token(self.tokens, 'word', 'or'):
468 op = 'or', (op, self.and_condition())
469 return op
471 def and_condition(self):
472 op = self.relation()
473 while skip_token(self.tokens, 'word', 'and'):
474 op = 'and', (op, self.relation())
475 return op
477 def relation(self):
478 left = self.expr()
479 if skip_token(self.tokens, 'word', 'is'):
480 op = 'isnot' if skip_token(self.tokens, 'word', 'not') else 'is'
481 return op, (left, self.value())
482 negated = skip_token(self.tokens, 'word', 'not')
483 method = 'in'
484 if skip_token(self.tokens, 'word', 'within'):
485 method = 'within'
486 else:
487 if not skip_token(self.tokens, 'word', 'in'):
488 if negated:
489 raise RuleError('Cannot negate operator based rules.')
490 return self.newfangled_relation(left)
491 rv = 'relation', (method, left, self.range_list())
492 return negate(rv) if negated else rv
494 def newfangled_relation(self, left):
495 if skip_token(self.tokens, 'symbol', '='):
496 negated = False
497 elif skip_token(self.tokens, 'symbol', '!='):
498 negated = True
499 else:
500 raise RuleError('Expected "=" or "!=" or legacy relation')
501 rv = 'relation', ('in', left, self.range_list())
502 return negate(rv) if negated else rv
504 def range_or_value(self):
505 left = self.value()
506 if skip_token(self.tokens, 'ellipsis'):
507 return left, self.value()
508 else:
509 return left, left
511 def range_list(self):
512 range_list = [self.range_or_value()]
513 while skip_token(self.tokens, 'symbol', ','):
514 range_list.append(self.range_or_value())
515 return range_list_node(range_list)
517 def expr(self):
518 word = skip_token(self.tokens, 'word')
519 if word is None or word[1] not in _VARS:
520 raise RuleError('Expected identifier variable')
521 name = word[1]
522 if skip_token(self.tokens, 'word', 'mod'):
523 return 'mod', ((name, ()), self.value())
524 elif skip_token(self.tokens, 'symbol', '%'):
525 return 'mod', ((name, ()), self.value())
526 return ident_node(name)
528 def value(self):
529 return value_node(int(self.expect('value')[1]))
532def _binary_compiler(tmpl):
533 """Compiler factory for the `_Compiler`."""
534 return lambda self, left, right: tmpl % (self.compile(left), self.compile(right))
537def _unary_compiler(tmpl):
538 """Compiler factory for the `_Compiler`."""
539 return lambda self, x: tmpl % self.compile(x)
542compile_zero = lambda x: '0'
545class _Compiler:
546 """The compilers are able to transform the expressions into multiple
547 output formats.
548 """
550 def compile(self, arg):
551 op, args = arg
552 return getattr(self, f"compile_{op}")(*args)
554 compile_n = lambda x: 'n'
555 compile_i = lambda x: 'i'
556 compile_v = lambda x: 'v'
557 compile_w = lambda x: 'w'
558 compile_f = lambda x: 'f'
559 compile_t = lambda x: 't'
560 compile_c = lambda x: 'c'
561 compile_e = lambda x: 'e'
562 compile_value = lambda x, v: str(v)
563 compile_and = _binary_compiler('(%s && %s)')
564 compile_or = _binary_compiler('(%s || %s)')
565 compile_not = _unary_compiler('(!%s)')
566 compile_mod = _binary_compiler('(%s %% %s)')
567 compile_is = _binary_compiler('(%s == %s)')
568 compile_isnot = _binary_compiler('(%s != %s)')
570 def compile_relation(self, method, expr, range_list):
571 raise NotImplementedError()
574class _PythonCompiler(_Compiler):
575 """Compiles an expression to Python."""
577 compile_and = _binary_compiler('(%s and %s)')
578 compile_or = _binary_compiler('(%s or %s)')
579 compile_not = _unary_compiler('(not %s)')
580 compile_mod = _binary_compiler('MOD(%s, %s)')
582 def compile_relation(self, method, expr, range_list):
583 ranges = ",".join(
584 f"({self.compile(a)}, {self.compile(b)})" for (a, b) in range_list[1]
585 )
586 return f"{method.upper()}({self.compile(expr)}, [{ranges}])"
589class _GettextCompiler(_Compiler):
590 """Compile into a gettext plural expression."""
592 compile_i = _Compiler.compile_n
593 compile_v = compile_zero
594 compile_w = compile_zero
595 compile_f = compile_zero
596 compile_t = compile_zero
598 def compile_relation(self, method, expr, range_list):
599 rv = []
600 expr = self.compile(expr)
601 for item in range_list[1]:
602 if item[0] == item[1]:
603 rv.append(f"({expr} == {self.compile(item[0])})")
604 else:
605 min = self.compile(item[0])
606 max = self.compile(item[1])
607 rv.append(f"({expr} >= {min} && {expr} <= {max})")
608 return f"({' || '.join(rv)})"
611class _JavaScriptCompiler(_GettextCompiler):
612 """Compiles the expression to plain of JavaScript."""
614 # XXX: presently javascript does not support any of the
615 # fraction support and basically only deals with integers.
616 compile_i = lambda x: 'parseInt(n, 10)'
617 compile_v = compile_zero
618 compile_w = compile_zero
619 compile_f = compile_zero
620 compile_t = compile_zero
622 def compile_relation(self, method, expr, range_list):
623 code = _GettextCompiler.compile_relation(self, method, expr, range_list)
624 if method == 'in':
625 expr = self.compile(expr)
626 code = f"(parseInt({expr}, 10) == {expr} && {code})"
627 return code
630class _UnicodeCompiler(_Compiler):
631 """Returns a unicode pluralization rule again."""
633 # XXX: this currently spits out the old syntax instead of the new
634 # one. We can change that, but it will break a whole bunch of stuff
635 # for users I suppose.
637 compile_is = _binary_compiler('%s is %s')
638 compile_isnot = _binary_compiler('%s is not %s')
639 compile_and = _binary_compiler('%s and %s')
640 compile_or = _binary_compiler('%s or %s')
641 compile_mod = _binary_compiler('%s mod %s')
643 def compile_not(self, relation):
644 return self.compile_relation(*relation[1], negated=True)
646 def compile_relation(self, method, expr, range_list, negated=False):
647 ranges = []
648 for item in range_list[1]:
649 if item[0] == item[1]:
650 ranges.append(self.compile(item[0]))
651 else:
652 ranges.append(f"{self.compile(item[0])}..{self.compile(item[1])}")
653 return f"{self.compile(expr)}{' not' if negated else ''} {method} {','.join(ranges)}"