units.py 13 KB

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