Skip to content

Commit fcb73a0

Browse files
committed
Import aliases from CLDR data and use them better
We had some manual fallbacks for missing field/unit lengths, and ignored many `<alias>`es in CLDR data entirely. This PR imports those aliases and uses them to resolve missing lengths, improving output in corner cases and fixes some rare KeyError crashes too. There's a quirk related to person-variant unit aliases, which are only expressed in CLDR for the short length. If one follows the spec to the letter, a request for a long/narrow person-variant unit will route through the short length and lose the requested length. We use the same "workaround" as ICU to preserve the length, by importing these as whole-unit aliases. Not totally spec- compliant, but the end result is simply better. Fixes #1076
1 parent c874ed6 commit fcb73a0

6 files changed

Lines changed: 165 additions & 27 deletions

File tree

babel/dates.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -992,20 +992,13 @@ def _iter_patterns(a_unit):
992992
if add_direction:
993993
# Try to find the length variant version first ("year-narrow")
994994
# before falling back to the default.
995-
unit_rel_patterns = date_fields.get(f"{a_unit}-{format}") or date_fields[a_unit]
995+
unit_rel_patterns = date_fields.get(f"{a_unit}-{format}") or date_fields.get(a_unit) or {}
996996
if seconds >= 0:
997997
yield unit_rel_patterns['future']
998998
else:
999999
yield unit_rel_patterns['past']
10001000
a_unit = f"duration-{a_unit}"
1001-
unit_pats = unit_patterns.get(a_unit, {})
1002-
yield unit_pats.get(format)
1003-
# We do not support `<alias>` tags at all while ingesting CLDR data,
1004-
# so these aliases specified in `root.xml` are hard-coded here:
1005-
# <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
1006-
# <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
1007-
if format in ("long", "narrow"):
1008-
yield unit_pats.get("short")
1001+
yield unit_patterns.get(a_unit, {}).get(format) # resolves aliases
10091002

10101003
for unit, secs_per_unit in TIMEDELTA_UNITS:
10111004
value = abs(seconds) / secs_per_unit

babel/units.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,12 @@ def format_unit(
174174
)
175175
plural_form = locale.plural_form(value)
176176

177-
unit_patterns = locale._data["unit_patterns"][q_unit]
178-
179-
# We do not support `<alias>` tags at all while ingesting CLDR data,
180-
# so these aliases specified in `root.xml` are hard-coded here:
181-
# <unitLength type="long"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
182-
# <unitLength type="narrow"><alias source="locale" path="../unitLength[@type='short']"/></unitLength>
183-
lengths_to_check = [length, "short"] if length in ("long", "narrow") else [length]
184-
185-
for real_length in lengths_to_check:
186-
length_patterns = unit_patterns.get(real_length, {})
187-
# Fall back from the correct plural form to "other"
188-
# (this is specified in LDML "Lateral Inheritance")
189-
pat = length_patterns.get(plural_form) or length_patterns.get("other")
190-
if pat:
191-
return pat.format(formatted_value)
177+
length_patterns = locale._data["unit_patterns"][q_unit].get(length, {}) # resolves aliases
178+
# Fall back from the correct plural form to "other"
179+
# (this is specified in LDML "Lateral Inheritance")
180+
pat = length_patterns.get(plural_form) or length_patterns.get("other")
181+
if pat:
182+
return pat.format(formatted_value)
192183

193184
# Fall back to a somewhat bad representation.
194185
# nb: This is marked as no-cover, as the current CLDR seemingly has no way for this to happen.

scripts/import_cldr.py

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ def _translate_alias(ctxt, path):
7878
return keys
7979

8080

81+
def _sibling_alias_type(alias_elem) -> str:
82+
"""
83+
Extract target type from an alias path pointing at a sibling.
84+
85+
E.g. ``../unit[@type='duration-year']`` -> ``duration-year``.
86+
"""
87+
path_attr = alias_elem.attrib['path']
88+
parent, _, leaf = path_attr.rpartition('/')
89+
if parent != "..":
90+
raise ValueError(f"not a sibling alias: {path_attr}")
91+
return TYPE_ATTR_RE.match(leaf).group(1)
92+
93+
8194
def _parse_currency_date(s):
8295
if not s:
8396
return None
@@ -805,8 +818,20 @@ def parse_decimal_formats(data, tree):
805818
length_type = elem.attrib.get('type')
806819
if _should_skip_elem(elem, length_type, decimal_formats):
807820
continue
808-
if elem.findall('./alias'):
809-
# TODO map the alias to its target
821+
alias_elem = elem.find('./alias')
822+
if alias_elem is not None:
823+
if alias_elem.attrib['path'] != "../decimalFormatLength[@type='short']":
824+
raise ValueError("Unexpected decimal format alias in new CLDR data; this may require hand-holding.")
825+
# In particular: if you're reading this, the tribal knowledge below
826+
# (that this is compact_decimal_format) needs to be double-checked.
827+
828+
# `root.xml` aliases `long` to `short`.
829+
# The typed lengths only carry compact patterns, so the alias lands in `compact_decimal_formats`.
830+
# Only root has this.
831+
compact_decimal_formats = data.setdefault('compact_decimal_formats', {})
832+
compact_decimal_formats[length_type] = Alias(
833+
('compact_decimal_formats', _sibling_alias_type(alias_elem)),
834+
)
810835
continue
811836
for pattern_el in elem.findall('./decimalFormat/pattern'):
812837
pattern_type = pattern_el.attrib.get('type')
@@ -879,11 +904,58 @@ def parse_unit_patterns(data, tree):
879904
unit_patterns = data.setdefault('unit_patterns', {})
880905
compound_patterns = data.setdefault('compound_unit_patterns', {})
881906
unit_display_names = data.setdefault('unit_display_names', {})
907+
# `root.xml` aliases the `long` and `narrow` unit lengths to `short`,
908+
# and some units to others (e.g. `duration-day-person` to `duration-day`).
909+
# This is keyed unit-first instead of length-first (XML),
910+
# so length aliases are expanded into per-unit aliases below.
911+
# Only root has aliases.
912+
length_aliases = {}
882913

883914
for elem in tree.findall('.//units/unitLength'):
884915
unit_length_type = elem.attrib['type']
916+
alias_elem = elem.find('./alias')
917+
if alias_elem is not None:
918+
length_aliases[unit_length_type] = _sibling_alias_type(alias_elem)
919+
continue
885920
for unit in elem.findall('unit'):
886921
unit_type = unit.attrib['type']
922+
alias_elem = unit.find('./alias')
923+
if alias_elem is not None:
924+
target_unit = _sibling_alias_type(alias_elem)
925+
if unit_type.endswith('-person'):
926+
# HACK (and this is a mouthful):
927+
# The `duration-*-person` units (used for formatting ages) are
928+
# aliased to their base units. The alias element only exists
929+
# inside `unitLength[short]`, so literal resolution would route
930+
# a long/narrow request through the short chain and lose the
931+
# requested length ("3 y" where "3 years" was wanted).
932+
# ICU considers this a deficiency of the alias data structure
933+
# (https://unicode-org.atlassian.net/browse/ICU-20400) and
934+
# compensates by stripping the `-person` suffix before lookup
935+
# This seems to make sense, so we do that too:
936+
# alias the whole unit, preserving the requested length.
937+
#
938+
# NB: this deliberately bends literal TR35 alias resolution to
939+
# match ICU's output. Should CLDR ever gain a length-preserving
940+
# alias representation, spec compliance is restored by deleting
941+
# this special-case `-person` branch and translating those
942+
# aliases as written, as the branch below does..
943+
unit_patterns[unit_type] = Alias(('unit_patterns', target_unit))
944+
unit_display_names[unit_type] = Alias(('unit_display_names', target_unit))
945+
else:
946+
# The other aliased units (`graphics-dot*` -> `graphics-pixel*`,
947+
# `energy-foodcalorie` -> `energy-kilocalorie`) get no such
948+
# compensation in ICU and follow the literal chain: the alias
949+
# applies within this length only, and a locale's own data for
950+
# the unit at other lengths (plus the length aliases below)
951+
# takes precedence over the target unit's.
952+
unit_patterns.setdefault(unit_type, {})[unit_length_type] = Alias(
953+
('unit_patterns', target_unit, unit_length_type),
954+
)
955+
unit_display_names.setdefault(unit_type, {})[unit_length_type] = Alias(
956+
('unit_display_names', target_unit, unit_length_type),
957+
)
958+
continue
887959
unit_and_length_patterns = unit_patterns.setdefault(unit_type, {}).setdefault(unit_length_type, {})
888960
for pattern in unit.findall('unitPattern'):
889961
if pattern.attrib.get('case', 'nominative') != 'nominative':
@@ -922,11 +994,32 @@ def parse_unit_patterns(data, tree):
922994
compound_unit_info['compound_variations'] = compound_variations
923995
compound_patterns.setdefault(unit_type, {})[unit_length_type] = compound_unit_info
924996

997+
for aliased_length, target_length in length_aliases.items():
998+
for dict_name, dst in (
999+
('unit_patterns', unit_patterns),
1000+
('compound_unit_patterns', compound_patterns),
1001+
('unit_display_names', unit_display_names),
1002+
):
1003+
for unit_type, lengths in dst.items():
1004+
if isinstance(lengths, Alias):
1005+
# A length-preserving whole-unit alias installed above.
1006+
continue
1007+
lengths.setdefault(aliased_length, Alias((dict_name, unit_type, target_length)))
1008+
9251009

9261010
def parse_date_fields(data, tree):
9271011
date_fields = data.setdefault('date_fields', {})
9281012
for elem in tree.findall('.//dates/fields/field'):
9291013
field_type = elem.attrib['type']
1014+
# `root` aliases `x-narrow` to `x-short` and `x-short` to `x`.
1015+
# Locales defining only some length variants inherit these.
1016+
# Only root has these.
1017+
alias_elem = elem.find('alias')
1018+
if alias_elem is not None:
1019+
date_fields[field_type] = Alias(
1020+
_translate_alias(['date_fields', field_type], alias_elem.attrib['path']),
1021+
)
1022+
continue
9301023
date_fields.setdefault(field_type, {})
9311024
for rel_time in elem.findall('relativeTime'):
9321025
rel_time_type = rel_time.attrib['type']

tests/test_dates.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,22 @@ def test_issue_1162(locale, format, negative, expected):
11901190
assert dates.format_timedelta(delta, add_direction=True, format=format, locale=locale) == expected
11911191

11921192

1193+
@pytest.mark.parametrize(('locale', 'format', 'expected'), [
1194+
# `am` defines `year-short` with <relative> names but no <relativeTime> patterns; this used to raise KeyError.
1195+
('am', 'short', 'በ2 ዓመታት ውስጥ'),
1196+
('am', 'narrow', 'በ2 ዓመታት ውስጥ'),
1197+
# `af` has no `year-narrow`; root aliases narrow to short,
1198+
# so the narrow form must use `year-short` ('oor 2 j.')
1199+
# rather than skipping straight to `year`.
1200+
('af', 'narrow', 'oor 2 j.'),
1201+
('af', 'short', 'oor 2 j.'),
1202+
('af', 'long', 'oor 2 jaar'),
1203+
])
1204+
def test_issue_1076_date_field_length_aliases(locale, format, expected):
1205+
delta = timedelta(days=800)
1206+
assert dates.format_timedelta(delta, add_direction=True, format=format, locale=locale) == expected
1207+
1208+
11931209
def test_issue_1192():
11941210
# The actual returned value here is not actually strictly specified ("get_timezone_name"
11951211
# is not an operation specified as such). Issue #1192 concerned this invocation returning

tests/test_numbers.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,3 +673,11 @@ def test_format_decimal_with_none_locale(monkeypatch):
673673
monkeypatch.setattr(numbers, "LC_NUMERIC", None) # Pretend we couldn't find any locale when importing the module
674674
with pytest.raises(TypeError, match="Empty"):
675675
numbers.format_decimal(0, locale=None)
676+
677+
678+
def test_issue_1076_fixes():
679+
# Japanese only defines short compact decimal formats; root aliases long to short. This used to raise a KeyError.
680+
assert numbers.format_compact_decimal(12345, format_type="long", locale="ja") == "1万"
681+
assert numbers.format_compact_decimal(2345678, format_type="long", locale="ja") == "235万"
682+
# Swahili does not define an accounting currency format; root aliases it to the standard format.
683+
assert numbers.format_currency(-2, "USD", format_type="accounting", locale="sw") == "-US$\xa02.00"

tests/test_units.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import pytest
22

3-
from babel.units import format_unit
3+
from babel.units import format_unit, get_unit_name
44

55

66
# New units in CLDR 46
@@ -35,3 +35,40 @@ def test_deprecated_unit_ids():
3535
for id in ("concentr-permillion", "concentr-portion", "concentr-portion-per-1e9"):
3636
with pytest.warns(DeprecationWarning, match=id):
3737
format_unit(1, id, locale='en')
38+
39+
40+
@pytest.mark.parametrize('count, unit, locale, length, expected', [
41+
# Root aliases `duration-*-person` to `duration-*`;
42+
# no locale defines the person variants at all.
43+
# These resolve length-preservingly (person long -> base long),
44+
# matching ICU's `-person`-stripping behavior
45+
# (see `getMeasureData` in https://github.com/unicode-org/icu/blob/main/icu4c/source/i18n/number_longnames.cpp
46+
# and https://unicode-org.atlassian.net/browse/ICU-20400).
47+
# This deliberately bends literal TR35 alias resolution.
48+
# See `parse_unit_patterns` in `scripts/import_cldr.py` for the full story.
49+
(2, 'duration-day-person', 'af', 'long', '2 dae'),
50+
(3, 'duration-day-person', 'fi', 'long', '3 päivää'),
51+
(3, 'duration-day-person', 'fi', 'short', '3 pv'),
52+
(3, 'duration-day-person', 'fi', 'narrow', '3pv'),
53+
(3, 'duration-year-person', 'fi', 'long', '3 vuotta'),
54+
# `fi` defines `energy-foodcalorie` at long and narrow but not short;
55+
# the root alias fills short in from `energy-kilocalorie`.
56+
(3, 'energy-foodcalorie', 'fi', 'short', '3 kcal'),
57+
# `fi` defines `graphics-dot` at short and narrow but not long;
58+
# the root alias fills long in from short.
59+
(3, 'graphics-dot', 'fi', 'long', '3 pistettä'),
60+
# `cs` defines `graphics-dot` itself but not its short forms;
61+
# the root aliases short to `graphics-pixel` short rather than the display name.
62+
(3, 'graphics-dot', 'cs', 'short', '3 px'),
63+
])
64+
def test_issue_1076_unit_aliases(count, unit, locale, length, expected):
65+
assert format_unit(count, unit, length, locale=locale) == expected
66+
67+
68+
def test_issue_1076_unit_name_length_aliases():
69+
# Finnish defines no narrow display name for days; root aliases narrow to
70+
# short. This used to return None.
71+
assert get_unit_name('duration-day', length='narrow', locale='fi') == 'pv'
72+
# The person variant resolves length-preservingly to `duration-day`.
73+
assert get_unit_name('duration-day-person', length='long', locale='fi') == 'päivät'
74+
assert get_unit_name('duration-day-person', length='short', locale='fi') == 'pv'

0 commit comments

Comments
 (0)