Repository: python-babel/babel

Issue:
format_compact_decimal incorrectly works when the pattern has no 0 characters
## Overview Description

`format_compact_decimal` incorrectly works when the pattern has no 0 characters. Also, it doesn't handle `count="1"` rule.

## Steps to Reproduce

```pycon
>>> from babel import numbers
>>> numbers.format_compact_decimal(1000, format_type='long', locale='it')
'mille1'  # should be 'mille'
>>> numbers.format_compact_decimal(1234, format_type='long', locale='it')
'mille1'  # should be '1 mila'
>>> numbers.format_compact_decimal(1000, format_type='long', locale='fr')
'1 millier'  # should be 'mille'
>>> numbers.format_compact_decimal(1234, format_type='long', locale='fr')
'1 millier'  # correct
```

## Additional Information

* https://github.com/unicode-org/cldr/blob/release-41/common/main/it.xml#L5210
* https://github.com/unicode-org/cldr/blob/release-41/common/main/fr.xml#L7743
* [Explicit 0 and 1 rules](https://unicode.org/reports/tr35/tr35-numbers.html#Explicit_0_1_rules)

Comments:

Patch:
diff --git a/babel/messages/extract.py b/babel/messages/extract.py
index 4f0f649..c19dd5a 100644
--- a/babel/messages/extract.py
+++ b/babel/messages/extract.py
@@ -16,9 +16,10 @@
     :license: BSD, see LICENSE for more details.
 """
 import ast
+import io
 import os
-from os.path import relpath
 import sys
+from os.path import relpath
 from tokenize import generate_tokens, COMMENT, NAME, OP, STRING
 
 from babel.util import parse_encoding, parse_future_flags, pathmatch
@@ -532,7 +533,7 @@ def _parse_python_string(value, encoding, future_flags):
     return None
 
 
-def extract_javascript(fileobj, keywords, comment_tags, options):
+def extract_javascript(fileobj, keywords, comment_tags, options, lineno=1):
     """Extract messages from JavaScript source code.
 
     :param fileobj: the seekable, file-like object the messages should be
@@ -544,7 +545,11 @@ def extract_javascript(fileobj, keywords, comment_tags, options):
     :param options: a dictionary of additional options (optional)
                     Supported options are:
                     * `jsx` -- set to false to disable JSX/E4X support.
-                    * `template_string` -- set to false to disable ES6 template string support.
+                    * `template_string` -- if `True`, supports gettext(`key`)
+                    * `parse_template_string` -- if `True` will parse the
+                                                 contents of javascript
+                                                 template strings.
+    :param lineno: line number offset (for parsing embedded fragments)
     """
     from babel.messages.jslexer import Token, tokenize, unquote_string
     funcname = message_lineno = None
@@ -556,12 +561,12 @@ def extract_javascript(fileobj, keywords, comment_tags, options):
     last_token = None
     call_stack = -1
     dotted = any('.' in kw for kw in keywords)
-
     for token in tokenize(
         fileobj.read().decode(encoding),
         jsx=options.get("jsx", True),
         template_string=options.get("template_string", True),
-        dotted=dotted
+        dotted=dotted,
+        lineno=lineno
     ):
         if (  # Turn keyword`foo` expressions into keyword("foo") calls:
             funcname and  # have a keyword...
@@ -573,7 +578,11 @@ def extract_javascript(fileobj, keywords, comment_tags, options):
             call_stack = 0
             token = Token('operator', ')', token.lineno)
 
-        if token.type == 'operator' and token.value == '(':
+        if options.get('parse_template_string') and not funcname and token.type == 'template_string':
+            for item in parse_template_string(token.value, keywords, comment_tags, options, token.lineno):
+                yield item
+
+        elif token.type == 'operator' and token.value == '(':
             if funcname:
                 message_lineno = token.lineno
                 call_stack += 1
@@ -665,3 +674,41 @@ def extract_javascript(fileobj, keywords, comment_tags, options):
             funcname = token.value
 
         last_token = token
+
+
+def parse_template_string(template_string, keywords, comment_tags, options, lineno=1):
+    """Parse JavaScript template string.
+
+    :param template_string: the template string to be parsed
+    :param keywords: a list of keywords (i.e. function names) that should be
+                     recognized as translation functions
+    :param comment_tags: a list of translator tags to search for and include
+                         in the results
+    :param options: a dictionary of additional options (optional)
+    :param lineno: starting line number (optional)
+    """
+    from babel.messages.jslexer import line_re
+    prev_character = None
+    level = 0
+    inside_str = False
+    expression_contents = ''
+    for character in template_string[1:-1]:
+        if not inside_str and character in ('"', "'", '`'):
+            inside_str = character
+        elif inside_str == character and prev_character != r'\\':
+            inside_str = False
+        if level:
+            expression_contents += character
+        if not inside_str:
+            if character == '{' and prev_character == '$':
+                level += 1
+            elif level and character == '}':
+                level -= 1
+                if level == 0 and expression_contents:
+                    expression_contents = expression_contents[0:-1]
+                    fake_file_obj = io.BytesIO(expression_contents.encode())
+                    for item in extract_javascript(fake_file_obj, keywords, comment_tags, options, lineno):
+                        yield item
+                    lineno += len(line_re.findall(expression_contents))
+                    expression_contents = ''
+        prev_character = character
diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py
index 1264b2d..886f69d 100644
--- a/babel/messages/jslexer.py
+++ b/babel/messages/jslexer.py
@@ -151,17 +151,17 @@ def unquote_string(string):
     return u''.join(result)
 
 
-def tokenize(source, jsx=True, dotted=True, template_string=True):
+def tokenize(source, jsx=True, dotted=True, template_string=True, lineno=1):
     """
     Tokenize JavaScript/JSX source.  Returns a generator of tokens.
 
     :param jsx: Enable (limited) JSX parsing.
     :param dotted: Read dotted names as single name token.
     :param template_string: Support ES6 template strings
+    :param lineno: starting line number (optional)
     """
     may_divide = False
     pos = 0
-    lineno = 1
     end = len(source)
     rules = get_rules(jsx=jsx, dotted=dotted, template_string=template_string)
 
diff --git a/babel/numbers.py b/babel/numbers.py
index 2221e95..da5936d 100644
--- a/babel/numbers.py
+++ b/babel/numbers.py
@@ -17,6 +17,7 @@
 # TODO:
 #  Padding and rounding increments in pattern:
 #  - https://www.unicode.org/reports/tr35/ (Appendix G.6)
+from __future__ import annotations
 import decimal
 import re
 from datetime import date as date_, datetime as datetime_
@@ -431,7 +432,7 @@ def format_compact_decimal(number, *, format_type="short", locale=LC_NUMERIC, fr
     u'123万'
     >>> format_compact_decimal(2345678, format_type="long", locale="mk")
     u'2 милиони'
-    >>> format_compact_decimal(21098765, format_type="long", locale="mk")
+    >>> format_compact_decimal(21000000, format_type="long", locale="mk")
     u'21 милион'
 
     :param number: the number to format
@@ -469,11 +470,15 @@ def _get_compact_format(number, compact_format, locale, fraction_digits=0):
             # equal to the number of 0's in the pattern minus 1
             number = number / (magnitude // (10 ** (pattern.count("0") - 1)))
             # round to the number of fraction digits requested
-            number = round(number, fraction_digits)
+            rounded = round(number, fraction_digits)
             # if the remaining number is singular, use the singular format
             plural_form = locale.plural_form(abs(number))
-            plural_form = plural_form if plural_form in compact_format else "other"
+            if plural_form not in compact_format:
+                plural_form = "other"
+            if number == 1 and "1" in compact_format:
+                plural_form = "1"
             format = compact_format[plural_form][str(magnitude)]
+            number = rounded
             break
     return number, format
 
@@ -960,17 +965,19 @@ def parse_pattern(pattern):
     return NumberPattern(pattern, (pos_prefix, neg_prefix),
                          (pos_suffix, neg_suffix), grouping,
                          int_prec, frac_prec,
-                         exp_prec, exp_plus)
+                         exp_prec, exp_plus, number)
 
 
 class NumberPattern:
 
     def __init__(self, pattern, prefix, suffix, grouping,
-                 int_prec, frac_prec, exp_prec, exp_plus):
+                 int_prec, frac_prec, exp_prec, exp_plus,
+                 number_pattern: str | None = None):
         # Metadata of the decomposed parsed pattern.
         self.pattern = pattern
         self.prefix = prefix
         self.suffix = suffix
+        self.number_pattern = number_pattern
         self.grouping = grouping
         self.int_prec = int_prec
         self.frac_prec = frac_prec
@@ -1115,7 +1122,7 @@ class NumberPattern:
 
         retval = ''.join([
             self.prefix[is_negative],
-            number,
+            number if self.number_pattern != '' else '',
             self.suffix[is_negative]])
 
         if u'¤' in retval:


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4915816..f9a7bee 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -33,7 +33,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip setuptools wheel
-        python -m pip install tox tox-gh-actions==2.1.0
+        python -m pip install 'tox<4.0.0' 'tox-gh-actions==2.12.0'
     - name: Run test via Tox
       run: tox --skip-missing-interpreters
       env:
diff --git a/tests/messages/test_js_extract.py b/tests/messages/test_js_extract.py
index 72c5211..95985c0 100644
--- a/tests/messages/test_js_extract.py
+++ b/tests/messages/test_js_extract.py
@@ -150,3 +150,42 @@ def test_template_string_tag_usage():
     )
 
     assert messages == [(1, 'Tag template, wow', [], None)]
+
+
+def test_inside_template_string():
+    buf = BytesIO(b"const msg = `${gettext('Hello')} ${user.name}`")
+    messages = list(
+        extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True})
+    )
+
+    assert messages == [(1, 'Hello', [], None)]
+
+
+def test_inside_template_string_with_linebreaks():
+    buf = BytesIO(b"""\
+const userName = gettext('Username')
+const msg = `${
+gettext('Hello')
+} ${userName} ${
+gettext('Are you having a nice day?')
+}`
+const msg2 = `${
+gettext('Howdy')
+} ${userName} ${
+gettext('Are you doing ok?')
+}`
+""")
+    messages = list(
+        extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True})
+    )
+
+    assert messages == [(1, 'Username', [], None), (3, 'Hello', [], None), (5, 'Are you having a nice day?', [], None), (8, 'Howdy', [], None), (10, 'Are you doing ok?', [], None)]
+
+
+def test_inside_nested_template_string():
+    buf = BytesIO(b"const msg = `${gettext('Greetings!')} ${ evening ? `${user.name}: ${gettext('This is a lovely evening.')}` : `${gettext('The day is really nice!')} ${user.name}`}`")
+    messages = list(
+        extract.extract('javascript', buf, {"gettext": None}, [], {'parse_template_string': True})
+    )
+
+    assert messages == [(1, 'Greetings!', [], None), (1, 'This is a lovely evening.', [], None), (1, 'The day is really nice!', [], None)]
diff --git a/tests/test_numbers.py b/tests/test_numbers.py
index bb6c4e8..37d2f9e 100644
--- a/tests/test_numbers.py
+++ b/tests/test_numbers.py
@@ -153,7 +153,12 @@ class FormatDecimalTestCase(unittest.TestCase):
         assert numbers.format_compact_decimal(-123456789, format_type='short', locale='en_US') == u'-123M'
         assert numbers.format_compact_decimal(-123456789, format_type='long', locale='en_US') == u'-123 million'
         assert numbers.format_compact_decimal(2345678, locale='mk', format_type='long') == u'2 милиони'
-        assert numbers.format_compact_decimal(21098765, locale='mk', format_type='long') == u'21 милион'
+        assert numbers.format_compact_decimal(21000000, locale='mk', format_type='long') == u'21 милион'
+        assert numbers.format_compact_decimal(21345, locale="gv", format_type="short") == u'21K'
+        assert numbers.format_compact_decimal(1000, locale='it', format_type='long') == u'mille'
+        assert numbers.format_compact_decimal(1234, locale='it', format_type='long') == u'1 mila'
+        assert numbers.format_compact_decimal(1000, locale='fr', format_type='long') == u'mille'
+        assert numbers.format_compact_decimal(1234, locale='fr', format_type='long') == u'1 millier'
 
 class NumberParsingTestCase(unittest.TestCase):