Home Assistant Unofficial Reference 2024.12.1
language.py
Go to the documentation of this file.
1 """Helper methods for language selection in Home Assistant."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Iterable
6 from dataclasses import dataclass
7 import math
8 import operator
9 import re
10 
11 from homeassistant.const import MATCH_ALL
12 
13 SEPARATOR_RE = re.compile(r"[-_]")
14 SAME_LANGUAGES = (
15  # no = spoken Norwegian
16  # nb = written Norwegian (BokmÃ¥l)
17  ("nb", "no"),
18  # he = Hebrew new code
19  # iw = Hebrew old code
20  ("he", "iw"),
21 )
22 
23 
25  language: str,
26  country: str | None = None,
27  code: str | None = None,
28 ) -> Iterable[str]:
29  """Yield an ordered list of regions for a language based on country/code hints.
30 
31  Regions should be checked for support in the returned order if no other
32  information is available.
33  """
34  if country is not None:
35  yield country.upper()
36 
37  if language == "en":
38  # Prefer U.S. English if no country
39  if country is None:
40  yield "US"
41  elif language == "zh":
42  if code == "Hant":
43  yield "HK"
44  yield "TW"
45  else:
46  yield "CN"
47 
48  # fr -> fr-FR
49  yield language.upper()
50 
51 
52 def is_region(language: str, region: str | None) -> bool:
53  """Return true if region is not known to be a script/code instead."""
54  if language == "es":
55  return region != "419"
56 
57  if language == "sr":
58  return region != "Latn"
59 
60  if language == "zh":
61  return region not in ("Hans", "Hant")
62 
63  return True
64 
65 
66 def is_language_match(lang_1: str, lang_2: str) -> bool:
67  """Return true if two languages are considered the same."""
68  if lang_1 == lang_2:
69  # Exact match
70  return True
71 
72  if tuple(sorted([lang_1, lang_2])) in SAME_LANGUAGES:
73  return True
74 
75  return False
76 
77 
78 @dataclass
79 class Dialect:
80  """Language with optional region and script/code."""
81 
82  language: str
83  region: str | None
84  code: str | None = None
85 
86  def __post_init__(self) -> None:
87  """Fix casing of language/region."""
88  # Languages are lower-cased
89  self.languagelanguage = self.languagelanguage.casefold()
90 
91  if self.regionregion is not None:
92  # Regions are upper-cased
93  self.regionregion = self.regionregion.upper()
94 
95  def score(
96  self, dialect: Dialect, country: str | None = None
97  ) -> tuple[float, float]:
98  """Return score for match with another dialect where higher is better.
99 
100  Score < 0 indicates a failure to match.
101  """
102  if not is_language_match(self.languagelanguage, dialect.language):
103  # Not a match
104  return (-1, 0)
105 
106  is_exact_language = self.languagelanguage == dialect.language
107 
108  if (self.regionregion is None) and (dialect.region is None):
109  # Weak match with no region constraint
110  # Prefer exact language match
111  return (2 if is_exact_language else 1, 0)
112 
113  if (self.regionregion is not None) and (dialect.region is not None):
114  if self.regionregion == dialect.region:
115  # Same language + region match
116  # Prefer exact language match
117  return (
118  math.inf,
119  1 if is_exact_language else 0,
120  )
121 
122  # Regions are both set, but don't match
123  return (0, 0)
124 
125  # Generate ordered list of preferred regions
126  pref_regions = list(
128  self.languagelanguage,
129  country=country,
130  code=self.code,
131  )
132  )
133 
134  try:
135  # Determine score based on position in the preferred regions list.
136  if self.regionregion is not None:
137  region_idx = pref_regions.index(self.regionregion)
138  elif dialect.region is not None:
139  region_idx = pref_regions.index(dialect.region)
140 
141  # More preferred regions are at the front.
142  # Add 1 to boost above a weak match where no regions are set.
143  return (1 + (len(pref_regions) - region_idx), 0)
144  except ValueError:
145  # Region was not in preferred list
146  pass
147 
148  # Not a preferred region
149  return (0, 0)
150 
151  @staticmethod
152  def parse(tag: str) -> Dialect:
153  """Parse language tag into language/region/code."""
154  parts = SEPARATOR_RE.split(tag, maxsplit=1)
155  language = parts[0]
156  region: str | None = None
157  code: str | None = None
158 
159  if len(parts) > 1:
160  region_or_code = parts[1]
161  if is_region(language, region_or_code):
162  # US, GB, etc.
163  region = region_or_code
164  else:
165  # Hant, 419, etc.
166  code = region_or_code
167 
168  return Dialect(
169  language=language,
170  region=region,
171  code=code,
172  )
173 
174 
176  target: str, supported: Iterable[str], country: str | None = None
177 ) -> list[str]:
178  """Return a sorted list of matching language tags based on a target tag and country hint."""
179  if target == MATCH_ALL:
180  return list(supported)
181 
182  target_dialect = Dialect.parse(target)
183 
184  # Higher score is better
185  scored = sorted(
186  (
187  (
188  dialect := Dialect.parse(tag),
189  target_dialect.score(dialect, country=country),
190  tag,
191  )
192  for tag in supported
193  ),
194  key=operator.itemgetter(1),
195  reverse=True,
196  )
197 
198  # Score < 0 is not a match
199  return [tag for _dialect, score, tag in scored if score[0] >= 0]
200 
201 
202 def intersect(languages_1: set[str], languages_2: set[str]) -> set[str]:
203  """Intersect two sets of languages using is_match for aliases."""
204  languages = set()
205  for lang_1 in languages_1:
206  for lang_2 in languages_2:
207  if is_language_match(lang_1, lang_2):
208  languages.add(lang_1)
209 
210  return languages
tuple[float, float] score(self, Dialect dialect, str|None country=None)
Definition: language.py:97
bool is_region(str language, str|None region)
Definition: language.py:52
set[str] intersect(set[str] languages_1, set[str] languages_2)
Definition: language.py:202
Iterable[str] preferred_regions(str language, str|None country=None, str|None code=None)
Definition: language.py:28
list[str] matches(str target, Iterable[str] supported, str|None country=None)
Definition: language.py:177
bool is_language_match(str lang_1, str lang_2)
Definition: language.py:66