codekingpro/portable-devtools
114k
1from __future__ import annotations2 3import decimal4from typing import TYPE_CHECKING5 6from babel.core import Locale7from babel.numbers import LC_NUMERIC, format_decimal8 9if TYPE_CHECKING:10 from typing_extensions import Literal11 12 13class UnknownUnitError(ValueError):14 def __init__(self, unit: str, locale: Locale) -> None:15 ValueError.__init__(self, f"{unit} is not a known unit in {locale}")16 17 18def get_unit_name(19 measurement_unit: str,20 length: Literal['short', 'long', 'narrow'] = 'long',21 locale: Locale | str | None = LC_NUMERIC,22) -> str | None:23 """24 Get the display name for a measurement unit in the given locale.25 26 >>> get_unit_name("radian", locale="en")27 'radians'28 29 Unknown units will raise exceptions:30 31 >>> get_unit_name("battery", locale="fi")32 Traceback (most recent call last):33 ...34 UnknownUnitError: battery/long is not a known unit/length in fi35 36 :param measurement_unit: the code of a measurement unit.37 Known units can be found in the CLDR Unit Validity XML file:38 https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml39 40 :param length: "short", "long" or "narrow"41 :param locale: the `Locale` object or locale identifier42 :return: The unit display name, or None.43 """44 locale = Locale.parse(locale)45 unit = _find_unit_pattern(measurement_unit, locale=locale)46 if not unit:47 raise UnknownUnitError(unit=measurement_unit, locale=locale)48 return locale.unit_display_names.get(unit, {}).get(length)49 50 51def _find_unit_pattern(unit_id: str, locale: Locale | str | None = LC_NUMERIC) -> str | None:52 """53 Expand a unit into a qualified form.54 55 Known units can be found in the CLDR Unit Validity XML file:56 https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml57 58 >>> _find_unit_pattern("radian", locale="en")59 'angle-radian'60 61 Unknown values will return None.62 63 >>> _find_unit_pattern("horse", locale="en")64 65 :param unit_id: the code of a measurement unit.66 :return: A key to the `unit_patterns` mapping, or None.67 """68 locale = Locale.parse(locale)69 unit_patterns = locale._data["unit_patterns"]70 if unit_id in unit_patterns:71 return unit_id72 for unit_pattern in sorted(unit_patterns, key=len):73 if unit_pattern.endswith(unit_id):74 return unit_pattern75 return None76 77 78def format_unit(79 value: str | float | decimal.Decimal,80 measurement_unit: str,81 length: Literal['short', 'long', 'narrow'] = 'long',82 format: str | None = None,83 locale: Locale | str | None = LC_NUMERIC,84 *,85 numbering_system: Literal["default"] | str = "latn",86) -> str:87 """Format a value of a given unit.88 89 Values are formatted according to the locale's usual pluralization rules90 and number formats.91 92 >>> format_unit(12, 'length-meter', locale='ro_RO')93 u'12 metri'94 >>> format_unit(15.5, 'length-mile', locale='fi_FI')95 u'15,5 mailia'96 >>> format_unit(1200, 'pressure-millimeter-ofhg', locale='nb')97 u'1\\xa0200 millimeter kvikks\\xf8lv'98 >>> format_unit(270, 'ton', locale='en')99 u'270 tons'100 >>> format_unit(1234.5, 'kilogram', locale='ar_EG', numbering_system='default')101 u'1٬234٫5 كيلوغرام'102 103 Number formats may be overridden with the ``format`` parameter.104 105 >>> import decimal106 >>> format_unit(decimal.Decimal("-42.774"), 'temperature-celsius', 'short', format='#.0', locale='fr')107 u'-42,8\\u202f\\xb0C'108 109 The locale's usual pluralization rules are respected.110 111 >>> format_unit(1, 'length-meter', locale='ro_RO')112 u'1 metru'113 >>> format_unit(0, 'length-mile', locale='cy')114 u'0 mi'115 >>> format_unit(1, 'length-mile', locale='cy')116 u'1 filltir'117 >>> format_unit(3, 'length-mile', locale='cy')118 u'3 milltir'119 120 >>> format_unit(15, 'length-horse', locale='fi')121 Traceback (most recent call last):122 ...123 UnknownUnitError: length-horse is not a known unit in fi124 125 .. versionadded:: 2.2.0126 127 :param value: the value to format. If this is a string, no number formatting will be attempted.128 :param measurement_unit: the code of a measurement unit.129 Known units can be found in the CLDR Unit Validity XML file:130 https://unicode.org/repos/cldr/tags/latest/common/validity/unit.xml131 :param length: "short", "long" or "narrow"132 :param format: An optional format, as accepted by `format_decimal`.133 :param locale: the `Locale` object or locale identifier134 :param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".135 The special value "default" will use the default numbering system of the locale.136 :raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.137 """138 locale = Locale.parse(locale)139 140 q_unit = _find_unit_pattern(measurement_unit, locale=locale)141 if not q_unit:142 raise UnknownUnitError(unit=measurement_unit, locale=locale)143 unit_patterns = locale._data["unit_patterns"][q_unit].get(length, {})144 145 if isinstance(value, str): # Assume the value is a preformatted singular.146 formatted_value = value147 plural_form = "one"148 else:149 formatted_value = format_decimal(value, format, locale, numbering_system=numbering_system)150 plural_form = locale.plural_form(value)151 152 if plural_form in unit_patterns:153 return unit_patterns[plural_form].format(formatted_value)154 155 # Fall back to a somewhat bad representation.156 # nb: This is marked as no-cover, as the current CLDR seemingly has no way for this to happen.157 fallback_name = get_unit_name(measurement_unit, length=length, locale=locale) # pragma: no cover158 return f"{formatted_value} {fallback_name or measurement_unit}" # pragma: no cover159 160 161def _find_compound_unit(162 numerator_unit: str,163 denominator_unit: str,164 locale: Locale | str | None = LC_NUMERIC,165) -> str | None:166 """167 Find a predefined compound unit pattern.168 169 Used internally by format_compound_unit.170 171 >>> _find_compound_unit("kilometer", "hour", locale="en")172 'speed-kilometer-per-hour'173 174 >>> _find_compound_unit("mile", "gallon", locale="en")175 'consumption-mile-per-gallon'176 177 If no predefined compound pattern can be found, `None` is returned.178 179 >>> _find_compound_unit("gallon", "mile", locale="en")180 181 >>> _find_compound_unit("horse", "purple", locale="en")182 183 :param numerator_unit: The numerator unit's identifier184 :param denominator_unit: The denominator unit's identifier185 :param locale: the `Locale` object or locale identifier186 :return: A key to the `unit_patterns` mapping, or None.187 :rtype: str|None188 """189 locale = Locale.parse(locale)190 191 # Qualify the numerator and denominator units. This will turn possibly partial192 # units like "kilometer" or "hour" into actual units like "length-kilometer" and193 # "duration-hour".194 195 resolved_numerator_unit = _find_unit_pattern(numerator_unit, locale=locale)196 resolved_denominator_unit = _find_unit_pattern(denominator_unit, locale=locale)197 198 # If either was not found, we can't possibly build a suitable compound unit either.199 if not (resolved_numerator_unit and resolved_denominator_unit):200 return None201 202 # Since compound units are named "speed-kilometer-per-hour", we'll have to slice off203 # the quantities (i.e. "length", "duration") from both qualified units.204 205 bare_numerator_unit = resolved_numerator_unit.split("-", 1)[-1]206 bare_denominator_unit = resolved_denominator_unit.split("-", 1)[-1]207 208 # Now we can try and rebuild a compound unit specifier, then qualify it:209 210 return _find_unit_pattern(f"{bare_numerator_unit}-per-{bare_denominator_unit}", locale=locale)211 212 213def format_compound_unit(214 numerator_value: str | float | decimal.Decimal,215 numerator_unit: str | None = None,216 denominator_value: str | float | decimal.Decimal = 1,217 denominator_unit: str | None = None,218 length: Literal["short", "long", "narrow"] = "long",219 format: str | None = None,220 locale: Locale | str | None = LC_NUMERIC,221 *,222 numbering_system: Literal["default"] | str = "latn",223) -> str | None:224 """225 Format a compound number value, i.e. "kilometers per hour" or similar.226 227 Both unit specifiers are optional to allow for formatting of arbitrary values still according228 to the locale's general "per" formatting specifier.229 230 >>> format_compound_unit(7, denominator_value=11, length="short", locale="pt")231 '7/11'232 233 >>> format_compound_unit(150, "kilometer", denominator_unit="hour", locale="sv")234 '150 kilometer per timme'235 236 >>> format_compound_unit(150, "kilowatt", denominator_unit="year", locale="fi")237 '150 kilowattia / vuosi'238 239 >>> format_compound_unit(32.5, "ton", 15, denominator_unit="hour", locale="en")240 '32.5 tons per 15 hours'241 242 >>> format_compound_unit(1234.5, "ton", 15, denominator_unit="hour", locale="ar_EG", numbering_system="arab")243 '1٬234٫5 طن لكل 15 ساعة'244 245 >>> format_compound_unit(160, denominator_unit="square-meter", locale="fr")246 '160 par m\\xe8tre carr\\xe9'247 248 >>> format_compound_unit(4, "meter", "ratakisko", length="short", locale="fi")249 '4 m/ratakisko'250 251 >>> format_compound_unit(35, "minute", denominator_unit="nautical-mile", locale="sv")252 '35 minuter per nautisk mil'253 254 >>> from babel.numbers import format_currency255 >>> format_compound_unit(format_currency(35, "JPY", locale="de"), denominator_unit="liter", locale="de")256 '35\\xa0\\xa5 pro Liter'257 258 See https://www.unicode.org/reports/tr35/tr35-general.html#perUnitPatterns259 260 :param numerator_value: The numerator value. This may be a string,261 in which case it is considered preformatted and the unit is ignored.262 :param numerator_unit: The numerator unit. See `format_unit`.263 :param denominator_value: The denominator value. This may be a string,264 in which case it is considered preformatted and the unit is ignored.265 :param denominator_unit: The denominator unit. See `format_unit`.266 :param length: The formatting length. "short", "long" or "narrow"267 :param format: An optional format, as accepted by `format_decimal`.268 :param locale: the `Locale` object or locale identifier269 :param numbering_system: The numbering system used for formatting number symbols. Defaults to "latn".270 The special value "default" will use the default numbering system of the locale.271 :return: A formatted compound value.272 :raise `UnsupportedNumberingSystemError`: If the numbering system is not supported by the locale.273 """274 locale = Locale.parse(locale)275 276 # Look for a specific compound unit first...277 278 if numerator_unit and denominator_unit and denominator_value == 1:279 compound_unit = _find_compound_unit(numerator_unit, denominator_unit, locale=locale)280 if compound_unit:281 return format_unit(282 numerator_value,283 compound_unit,284 length=length,285 format=format,286 locale=locale,287 numbering_system=numbering_system,288 )289 290 # ... failing that, construct one "by hand".291 292 if isinstance(numerator_value, str): # Numerator is preformatted293 formatted_numerator = numerator_value294 elif numerator_unit: # Numerator has unit295 formatted_numerator = format_unit(296 numerator_value,297 numerator_unit,298 length=length,299 format=format,300 locale=locale,301 numbering_system=numbering_system,302 )303 else: # Unitless numerator304 formatted_numerator = format_decimal(305 numerator_value,306 format=format,307 locale=locale,308 numbering_system=numbering_system,309 )310 311 if isinstance(denominator_value, str): # Denominator is preformatted312 formatted_denominator = denominator_value313 elif denominator_unit: # Denominator has unit314 if denominator_value == 1: # support perUnitPatterns when the denominator is 1315 denominator_unit = _find_unit_pattern(denominator_unit, locale=locale)316 per_pattern = locale._data["unit_patterns"].get(denominator_unit, {}).get(length, {}).get("per")317 if per_pattern:318 return per_pattern.format(formatted_numerator)319 # See TR-35's per-unit pattern algorithm, point 3.2.320 # For denominator 1, we replace the value to be formatted with the empty string;321 # this will make `format_unit` return " second" instead of "1 second".322 denominator_value = ""323 324 formatted_denominator = format_unit(325 denominator_value,326 measurement_unit=(denominator_unit or ""),327 length=length,328 format=format,329 locale=locale,330 numbering_system=numbering_system,331 ).strip()332 else: # Bare denominator333 formatted_denominator = format_decimal(334 denominator_value,335 format=format,336 locale=locale,337 numbering_system=numbering_system,338 )339 340 # TODO: this doesn't support "compound_variations" (or "prefix"), and will fall back to the "x/y" representation341 per_pattern = locale._data["compound_unit_patterns"].get("per", {}).get(length, {}).get("compound", "{0}/{1}")342 343 return per_pattern.format(formatted_numerator, formatted_denominator)344 