mirror of
https://github.com/SickGear/SickGear.git
synced 2024-11-25 14:25:05 +00:00
1932 lines
68 KiB
Python
1932 lines
68 KiB
Python
|
"""
|
|||
|
langcodes knows what languages are. It knows the standardized codes that
|
|||
|
refer to them, such as `en` for English, `es` for Spanish and `hi` for Hindi.
|
|||
|
Often, it knows what these languages are called *in* a language, and that
|
|||
|
language doesn't have to be English.
|
|||
|
|
|||
|
See README.md for the main documentation, or read it on GitHub at
|
|||
|
https://github.com/LuminosoInsight/langcodes/ . For more specific documentation
|
|||
|
on the functions in langcodes, scroll down and read the docstrings.
|
|||
|
|
|||
|
Some of these functions, particularly those that work with the names of
|
|||
|
languages, require the `language_data` module to be installed.
|
|||
|
"""
|
|||
|
from operator import itemgetter
|
|||
|
from typing import Any, List, Tuple, Dict, Sequence, Iterable, Optional, Mapping, Union
|
|||
|
import warnings
|
|||
|
import sys
|
|||
|
|
|||
|
from langcodes.tag_parser import LanguageTagError, parse_tag, normalize_characters
|
|||
|
from langcodes.language_distance import tuple_distance_cached
|
|||
|
from langcodes.data_dicts import (
|
|||
|
ALL_SCRIPTS,
|
|||
|
DEFAULT_SCRIPTS,
|
|||
|
LANGUAGE_REPLACEMENTS,
|
|||
|
LANGUAGE_ALPHA3,
|
|||
|
LANGUAGE_ALPHA3_BIBLIOGRAPHIC,
|
|||
|
TERRITORY_REPLACEMENTS,
|
|||
|
NORMALIZED_MACROLANGUAGES,
|
|||
|
LIKELY_SUBTAGS,
|
|||
|
VALIDITY,
|
|||
|
)
|
|||
|
|
|||
|
# When we're getting natural language information *about* languages, it's in
|
|||
|
# English if you don't specify the language.
|
|||
|
DEFAULT_LANGUAGE = 'en'
|
|||
|
|
|||
|
|
|||
|
LANGUAGE_NAME_IMPORT_MESSAGE = """
|
|||
|
Looking up language names now requires the `language_data` package.
|
|||
|
|
|||
|
Install it with:
|
|||
|
pip install language_data
|
|||
|
Or as an optional feature of langcodes:
|
|||
|
pip install langcodes[data]
|
|||
|
"""
|
|||
|
|
|||
|
|
|||
|
class Language:
|
|||
|
"""
|
|||
|
The Language class defines the results of parsing a language tag.
|
|||
|
Language objects have the following attributes, any of which may be
|
|||
|
unspecified (in which case their value is None):
|
|||
|
|
|||
|
- *language*: the code for the language itself.
|
|||
|
- *script*: the 4-letter code for the writing system being used.
|
|||
|
- *territory*: the 2-letter or 3-digit code for the country or similar territory
|
|||
|
of the world whose usage of the language appears in this text.
|
|||
|
- *extlangs*: a list of more specific language codes that follow the language
|
|||
|
code. (This is allowed by the language code syntax, but deprecated.)
|
|||
|
- *variants*: codes for specific variations of language usage that aren't
|
|||
|
covered by the *script* or *territory* codes.
|
|||
|
- *extensions*: information that's attached to the language code for use in
|
|||
|
some specific system, such as Unicode collation orders.
|
|||
|
- *private*: a code starting with `x-` that has no defined meaning.
|
|||
|
|
|||
|
The `Language.get` method converts a string to a Language instance.
|
|||
|
It's also available at the top level of this module as the `get` function.
|
|||
|
"""
|
|||
|
|
|||
|
ATTRIBUTES = [
|
|||
|
'language',
|
|||
|
'extlangs',
|
|||
|
'script',
|
|||
|
'territory',
|
|||
|
'variants',
|
|||
|
'extensions',
|
|||
|
'private',
|
|||
|
]
|
|||
|
|
|||
|
# When looking up "likely subtags" data, we try looking up the data for
|
|||
|
# increasingly less specific versions of the language code.
|
|||
|
BROADER_KEYSETS = [
|
|||
|
{'language', 'script', 'territory'},
|
|||
|
{'language', 'territory'},
|
|||
|
{'language', 'script'},
|
|||
|
{'language'},
|
|||
|
{'script'},
|
|||
|
{},
|
|||
|
]
|
|||
|
|
|||
|
MATCHABLE_KEYSETS = [
|
|||
|
{'language', 'script', 'territory'},
|
|||
|
{'language', 'script'},
|
|||
|
{'language'},
|
|||
|
]
|
|||
|
|
|||
|
# Values cached at the class level
|
|||
|
_INSTANCES: Dict[tuple, 'Language'] = {}
|
|||
|
_PARSE_CACHE: Dict[Tuple[str, bool], 'Language'] = {}
|
|||
|
|
|||
|
def __init__(
|
|||
|
self,
|
|||
|
language: Optional[str] = None,
|
|||
|
extlangs: Optional[Sequence[str]] = None,
|
|||
|
script: Optional[str] = None,
|
|||
|
territory: Optional[str] = None,
|
|||
|
variants: Optional[Sequence[str]] = None,
|
|||
|
extensions: Optional[Sequence[str]] = None,
|
|||
|
private: Optional[str] = None,
|
|||
|
):
|
|||
|
"""
|
|||
|
The constructor for Language objects.
|
|||
|
|
|||
|
It's inefficient to call this directly, because it can't return
|
|||
|
an existing instance. Instead, call Language.make(), which
|
|||
|
has the same signature.
|
|||
|
"""
|
|||
|
self.language = language
|
|||
|
self.extlangs = extlangs
|
|||
|
self.script = script
|
|||
|
self.territory = territory
|
|||
|
self.variants = variants
|
|||
|
self.extensions = extensions
|
|||
|
self.private = private
|
|||
|
|
|||
|
# Cached values
|
|||
|
self._simplified: 'Language' = None
|
|||
|
self._searchable: 'Language' = None
|
|||
|
self._broader: List[str] = None
|
|||
|
self._assumed: 'Language' = None
|
|||
|
self._filled: 'Language' = None
|
|||
|
self._macrolanguage: Optional['Language'] = None
|
|||
|
self._str_tag: str = None
|
|||
|
self._dict: dict = None
|
|||
|
self._disp_separator: str = None
|
|||
|
self._disp_pattern: str = None
|
|||
|
|
|||
|
# Make sure the str_tag value is cached
|
|||
|
self.to_tag()
|
|||
|
|
|||
|
@classmethod
|
|||
|
def make(
|
|||
|
cls,
|
|||
|
language: Optional[str] = None,
|
|||
|
extlangs: Optional[Sequence[str]] = None,
|
|||
|
script: Optional[str] = None,
|
|||
|
territory: Optional[str] = None,
|
|||
|
variants: Optional[Sequence[str]] = None,
|
|||
|
extensions: Optional[Sequence[str]] = None,
|
|||
|
private: Optional[str] = None,
|
|||
|
) -> 'Language':
|
|||
|
"""
|
|||
|
Create a Language object by giving any subset of its attributes.
|
|||
|
|
|||
|
If this value has been created before, return the existing value.
|
|||
|
"""
|
|||
|
values = (
|
|||
|
language,
|
|||
|
tuple(extlangs or ()),
|
|||
|
script,
|
|||
|
territory,
|
|||
|
tuple(variants or ()),
|
|||
|
tuple(extensions or ()),
|
|||
|
private,
|
|||
|
)
|
|||
|
if values in cls._INSTANCES:
|
|||
|
return cls._INSTANCES[values]
|
|||
|
|
|||
|
instance = cls(
|
|||
|
language=language,
|
|||
|
extlangs=extlangs,
|
|||
|
script=script,
|
|||
|
territory=territory,
|
|||
|
variants=variants,
|
|||
|
extensions=extensions,
|
|||
|
private=private,
|
|||
|
)
|
|||
|
cls._INSTANCES[values] = instance
|
|||
|
return instance
|
|||
|
|
|||
|
@staticmethod
|
|||
|
def get(tag: Union[str, 'Language'], normalize=True) -> 'Language':
|
|||
|
"""
|
|||
|
Create a Language object from a language tag string.
|
|||
|
|
|||
|
If normalize=True, non-standard or overlong tags will be replaced as
|
|||
|
they're interpreted. This is recommended.
|
|||
|
|
|||
|
Here are several examples of language codes, which are also test cases.
|
|||
|
Most language codes are straightforward, but these examples will get
|
|||
|
pretty obscure toward the end.
|
|||
|
|
|||
|
>>> Language.get('en-US')
|
|||
|
Language.make(language='en', territory='US')
|
|||
|
|
|||
|
>>> Language.get('zh-Hant')
|
|||
|
Language.make(language='zh', script='Hant')
|
|||
|
|
|||
|
>>> Language.get('und')
|
|||
|
Language.make()
|
|||
|
|
|||
|
This function is idempotent, in case you already have a Language object:
|
|||
|
|
|||
|
>>> Language.get(Language.get('en-us'))
|
|||
|
Language.make(language='en', territory='US')
|
|||
|
|
|||
|
The non-code 'root' is sometimes used to represent the lack of any
|
|||
|
language information, similar to 'und'.
|
|||
|
|
|||
|
>>> Language.get('root')
|
|||
|
Language.make()
|
|||
|
|
|||
|
By default, getting a Language object will automatically convert
|
|||
|
deprecated tags:
|
|||
|
|
|||
|
>>> Language.get('iw')
|
|||
|
Language.make(language='he')
|
|||
|
|
|||
|
>>> Language.get('in')
|
|||
|
Language.make(language='id')
|
|||
|
|
|||
|
One type of deprecated tag that should be replaced is for sign
|
|||
|
languages, which used to all be coded as regional variants of a
|
|||
|
fictitious global sign language called 'sgn'. Of course, there is no
|
|||
|
global sign language, so sign languages now have their own language
|
|||
|
codes.
|
|||
|
|
|||
|
>>> Language.get('sgn-US')
|
|||
|
Language.make(language='ase')
|
|||
|
|
|||
|
>>> Language.get('sgn-US', normalize=False)
|
|||
|
Language.make(language='sgn', territory='US')
|
|||
|
|
|||
|
'en-gb-oed' is a tag that's grandfathered into the standard because it
|
|||
|
has been used to mean "spell-check this with Oxford English Dictionary
|
|||
|
spelling", but that tag has the wrong shape. We interpret this as the
|
|||
|
new standardized tag 'en-gb-oxendict', unless asked not to normalize.
|
|||
|
|
|||
|
>>> Language.get('en-gb-oed')
|
|||
|
Language.make(language='en', territory='GB', variants=['oxendict'])
|
|||
|
|
|||
|
>>> Language.get('en-gb-oed', normalize=False)
|
|||
|
Language.make(language='en-gb-oed')
|
|||
|
|
|||
|
'zh-min-nan' is another oddly-formed tag, used to represent the
|
|||
|
Southern Min language, which includes Taiwanese as a regional form. It
|
|||
|
now has its own language code.
|
|||
|
|
|||
|
>>> Language.get('zh-min-nan')
|
|||
|
Language.make(language='nan')
|
|||
|
|
|||
|
The vague tag 'zh-min' is now also interpreted as 'nan', with a private
|
|||
|
extension indicating that it had a different form:
|
|||
|
|
|||
|
>>> Language.get('zh-min')
|
|||
|
Language.make(language='nan', private='x-zh-min')
|
|||
|
|
|||
|
Occasionally Wiktionary will use 'extlang' tags in strange ways, such
|
|||
|
as using the tag 'und-ibe' for some unspecified Iberian language.
|
|||
|
|
|||
|
>>> Language.get('und-ibe')
|
|||
|
Language.make(extlangs=['ibe'])
|
|||
|
|
|||
|
Here's an example of replacing multiple deprecated tags.
|
|||
|
|
|||
|
The language tag 'sh' (Serbo-Croatian) ended up being politically
|
|||
|
problematic, and different standards took different steps to address
|
|||
|
this. The IANA made it into a macrolanguage that contains 'sr', 'hr',
|
|||
|
and 'bs'. Unicode further decided that it's a legacy tag that should
|
|||
|
be interpreted as 'sr-Latn', which the language matching rules say
|
|||
|
is mutually intelligible with all those languages.
|
|||
|
|
|||
|
We complicate the example by adding on the territory tag 'QU', an old
|
|||
|
provisional tag for the European Union, which is now standardized as
|
|||
|
'EU'.
|
|||
|
|
|||
|
>>> Language.get('sh-QU')
|
|||
|
Language.make(language='sr', script='Latn', territory='EU')
|
|||
|
"""
|
|||
|
if isinstance(tag, Language):
|
|||
|
if not normalize:
|
|||
|
# shortcut: we have the tag already
|
|||
|
return tag
|
|||
|
|
|||
|
# We might need to normalize this tag. Convert it back into a
|
|||
|
# string tag, to cover all the edge cases of normalization in a
|
|||
|
# way that we've already solved.
|
|||
|
tag = tag.to_tag()
|
|||
|
|
|||
|
if (tag, normalize) in Language._PARSE_CACHE:
|
|||
|
return Language._PARSE_CACHE[tag, normalize]
|
|||
|
|
|||
|
data: Dict[str, Any] = {}
|
|||
|
|
|||
|
# If the complete tag appears as something to normalize, do the
|
|||
|
# normalization right away. Smash case and convert underscores to
|
|||
|
# hyphens when checking, because the case normalization that comes from
|
|||
|
# parse_tag() hasn't been applied yet.
|
|||
|
|
|||
|
tag_lower = normalize_characters(tag)
|
|||
|
if normalize and tag_lower in LANGUAGE_REPLACEMENTS:
|
|||
|
tag = LANGUAGE_REPLACEMENTS[tag_lower]
|
|||
|
|
|||
|
components = parse_tag(tag)
|
|||
|
|
|||
|
for typ, value in components:
|
|||
|
if typ == 'extlang' and normalize and 'language' in data:
|
|||
|
# smash extlangs when possible
|
|||
|
minitag = f"{data['language']}-{value}"
|
|||
|
norm = LANGUAGE_REPLACEMENTS.get(normalize_characters(minitag))
|
|||
|
if norm is not None:
|
|||
|
data.update(Language.get(norm, normalize).to_dict())
|
|||
|
else:
|
|||
|
data.setdefault('extlangs', []).append(value)
|
|||
|
elif typ in {'extlang', 'variant', 'extension'}:
|
|||
|
data.setdefault(typ + 's', []).append(value)
|
|||
|
elif typ == 'language':
|
|||
|
if value == 'und':
|
|||
|
pass
|
|||
|
elif normalize:
|
|||
|
replacement = LANGUAGE_REPLACEMENTS.get(value.lower())
|
|||
|
if replacement is not None:
|
|||
|
# parse the replacement if necessary -- this helps with
|
|||
|
# Serbian and Moldovan
|
|||
|
data.update(Language.get(replacement, normalize).to_dict())
|
|||
|
else:
|
|||
|
data['language'] = value
|
|||
|
else:
|
|||
|
data['language'] = value
|
|||
|
elif typ == 'territory':
|
|||
|
if normalize:
|
|||
|
data['territory'] = TERRITORY_REPLACEMENTS.get(value.lower(), value)
|
|||
|
else:
|
|||
|
data['territory'] = value
|
|||
|
elif typ == 'grandfathered':
|
|||
|
# If we got here, we got a grandfathered tag but we were asked
|
|||
|
# not to normalize it, or the CLDR data doesn't know how to
|
|||
|
# normalize it. The best we can do is set the entire tag as the
|
|||
|
# language.
|
|||
|
data['language'] = value
|
|||
|
else:
|
|||
|
data[typ] = value
|
|||
|
|
|||
|
result = Language.make(**data)
|
|||
|
Language._PARSE_CACHE[tag, normalize] = result
|
|||
|
return result
|
|||
|
|
|||
|
def to_tag(self) -> str:
|
|||
|
"""
|
|||
|
Convert a Language back to a standard language tag, as a string.
|
|||
|
This is also the str() representation of a Language object.
|
|||
|
|
|||
|
>>> Language.make(language='en', territory='GB').to_tag()
|
|||
|
'en-GB'
|
|||
|
|
|||
|
>>> Language.make(language='yue', script='Hant', territory='HK').to_tag()
|
|||
|
'yue-Hant-HK'
|
|||
|
|
|||
|
>>> Language.make(script='Arab').to_tag()
|
|||
|
'und-Arab'
|
|||
|
|
|||
|
>>> str(Language.make(territory='IN'))
|
|||
|
'und-IN'
|
|||
|
"""
|
|||
|
if self._str_tag is not None:
|
|||
|
return self._str_tag
|
|||
|
subtags = ['und']
|
|||
|
if self.language:
|
|||
|
subtags[0] = self.language
|
|||
|
if self.extlangs:
|
|||
|
for extlang in sorted(self.extlangs):
|
|||
|
subtags.append(extlang)
|
|||
|
if self.script:
|
|||
|
subtags.append(self.script)
|
|||
|
if self.territory:
|
|||
|
subtags.append(self.territory)
|
|||
|
if self.variants:
|
|||
|
for variant in sorted(self.variants):
|
|||
|
subtags.append(variant)
|
|||
|
if self.extensions:
|
|||
|
for ext in self.extensions:
|
|||
|
subtags.append(ext)
|
|||
|
if self.private:
|
|||
|
subtags.append(self.private)
|
|||
|
self._str_tag = '-'.join(subtags)
|
|||
|
return self._str_tag
|
|||
|
|
|||
|
def simplify_script(self) -> 'Language':
|
|||
|
"""
|
|||
|
Remove the script from some parsed language data, if the script is
|
|||
|
redundant with the language.
|
|||
|
|
|||
|
>>> Language.make(language='en', script='Latn').simplify_script()
|
|||
|
Language.make(language='en')
|
|||
|
|
|||
|
>>> Language.make(language='yi', script='Latn').simplify_script()
|
|||
|
Language.make(language='yi', script='Latn')
|
|||
|
|
|||
|
>>> Language.make(language='yi', script='Hebr').simplify_script()
|
|||
|
Language.make(language='yi')
|
|||
|
"""
|
|||
|
if self._simplified is not None:
|
|||
|
return self._simplified
|
|||
|
|
|||
|
if self.language and self.script:
|
|||
|
if DEFAULT_SCRIPTS.get(self.language) == self.script:
|
|||
|
result = self.update_dict({'script': None})
|
|||
|
self._simplified = result
|
|||
|
return self._simplified
|
|||
|
|
|||
|
self._simplified = self
|
|||
|
return self._simplified
|
|||
|
|
|||
|
def assume_script(self) -> 'Language':
|
|||
|
"""
|
|||
|
Fill in the script if it's missing, and if it can be assumed from the
|
|||
|
language subtag. This is the opposite of `simplify_script`.
|
|||
|
|
|||
|
>>> Language.make(language='en').assume_script()
|
|||
|
Language.make(language='en', script='Latn')
|
|||
|
|
|||
|
>>> Language.make(language='yi').assume_script()
|
|||
|
Language.make(language='yi', script='Hebr')
|
|||
|
|
|||
|
>>> Language.make(language='yi', script='Latn').assume_script()
|
|||
|
Language.make(language='yi', script='Latn')
|
|||
|
|
|||
|
This fills in nothing when the script cannot be assumed -- such as when
|
|||
|
the language has multiple scripts, or it has no standard orthography:
|
|||
|
|
|||
|
>>> Language.make(language='sr').assume_script()
|
|||
|
Language.make(language='sr')
|
|||
|
|
|||
|
>>> Language.make(language='eee').assume_script()
|
|||
|
Language.make(language='eee')
|
|||
|
|
|||
|
It also dosn't fill anything in when the language is unspecified.
|
|||
|
|
|||
|
>>> Language.make(territory='US').assume_script()
|
|||
|
Language.make(territory='US')
|
|||
|
"""
|
|||
|
if self._assumed is not None:
|
|||
|
return self._assumed
|
|||
|
if self.language and not self.script:
|
|||
|
try:
|
|||
|
self._assumed = self.update_dict(
|
|||
|
{'script': DEFAULT_SCRIPTS[self.language]}
|
|||
|
)
|
|||
|
except KeyError:
|
|||
|
self._assumed = self
|
|||
|
else:
|
|||
|
self._assumed = self
|
|||
|
return self._assumed
|
|||
|
|
|||
|
def prefer_macrolanguage(self) -> 'Language':
|
|||
|
"""
|
|||
|
BCP 47 doesn't specify what to do with macrolanguages and the languages
|
|||
|
they contain. The Unicode CLDR, on the other hand, says that when a
|
|||
|
macrolanguage has a dominant standardized language, the macrolanguage
|
|||
|
code should be used for that language. For example, Mandarin Chinese
|
|||
|
is 'zh', not 'cmn', according to Unicode, and Malay is 'ms', not 'zsm'.
|
|||
|
|
|||
|
This isn't a rule you'd want to follow in all cases -- for example, you may
|
|||
|
want to be able to specifically say that 'ms' (the Malay macrolanguage)
|
|||
|
contains both 'zsm' (Standard Malay) and 'id' (Indonesian). But applying
|
|||
|
this rule helps when interoperating with the Unicode CLDR.
|
|||
|
|
|||
|
So, applying `prefer_macrolanguage` to a Language object will
|
|||
|
return a new object, replacing the language with the macrolanguage if
|
|||
|
it is the dominant language within that macrolanguage. It will leave
|
|||
|
non-dominant languages that have macrolanguages alone.
|
|||
|
|
|||
|
>>> Language.get('arb').prefer_macrolanguage()
|
|||
|
Language.make(language='ar')
|
|||
|
|
|||
|
>>> Language.get('cmn-Hant').prefer_macrolanguage()
|
|||
|
Language.make(language='zh', script='Hant')
|
|||
|
|
|||
|
>>> Language.get('yue-Hant').prefer_macrolanguage()
|
|||
|
Language.make(language='yue', script='Hant')
|
|||
|
"""
|
|||
|
if self._macrolanguage is not None:
|
|||
|
return self._macrolanguage
|
|||
|
language = self.language or 'und'
|
|||
|
if language in NORMALIZED_MACROLANGUAGES:
|
|||
|
self._macrolanguage = self.update_dict(
|
|||
|
{'language': NORMALIZED_MACROLANGUAGES[language]}
|
|||
|
)
|
|||
|
else:
|
|||
|
self._macrolanguage = self
|
|||
|
return self._macrolanguage
|
|||
|
|
|||
|
def to_alpha3(self, variant: str = 'T') -> str:
|
|||
|
"""
|
|||
|
Get the three-letter language code for this language, even if it's
|
|||
|
canonically written with a two-letter code.
|
|||
|
|
|||
|
These codes are the 'alpha3' codes defined by ISO 639-2.
|
|||
|
|
|||
|
When this function returns, it always returns a 3-letter string. If
|
|||
|
there is no known alpha3 code for the language, it raises a LookupError.
|
|||
|
|
|||
|
In cases where the distinction matters, we default to the 'terminology'
|
|||
|
code. You can pass `variant='B'` to get the 'bibliographic' code instead.
|
|||
|
For example, the terminology code for German is 'deu', while the
|
|||
|
bibliographic code is 'ger'.
|
|||
|
|
|||
|
(The confusion between these two sets of codes is a good reason to avoid
|
|||
|
using alpha3 codes. Every language that has two different alpha3 codes
|
|||
|
also has an alpha2 code that's preferred, such as 'de' for German.)
|
|||
|
|
|||
|
>>> Language.get('fr').to_alpha3()
|
|||
|
'fra'
|
|||
|
>>> Language.get('fr-CA').to_alpha3()
|
|||
|
'fra'
|
|||
|
>>> Language.get('fr').to_alpha3(variant='B')
|
|||
|
'fre'
|
|||
|
>>> Language.get('de').to_alpha3(variant='T')
|
|||
|
'deu'
|
|||
|
>>> Language.get('ja').to_alpha3()
|
|||
|
'jpn'
|
|||
|
>>> Language.get('un').to_alpha3()
|
|||
|
Traceback (most recent call last):
|
|||
|
...
|
|||
|
LookupError: 'un' is not a known language code, and has no alpha3 code.
|
|||
|
|
|||
|
|
|||
|
All valid two-letter language codes have corresponding alpha3 codes,
|
|||
|
even the un-normalized ones. If they were assigned an alpha3 code by ISO
|
|||
|
before they were assigned a normalized code by CLDR, these codes may be
|
|||
|
different:
|
|||
|
|
|||
|
>>> Language.get('tl', normalize=False).to_alpha3()
|
|||
|
'tgl'
|
|||
|
>>> Language.get('tl').to_alpha3()
|
|||
|
'fil'
|
|||
|
>>> Language.get('sh', normalize=False).to_alpha3()
|
|||
|
'hbs'
|
|||
|
|
|||
|
|
|||
|
Three-letter codes are preserved, even if they're unknown:
|
|||
|
|
|||
|
>>> Language.get('qqq').to_alpha3()
|
|||
|
'qqq'
|
|||
|
>>> Language.get('und').to_alpha3()
|
|||
|
'und'
|
|||
|
"""
|
|||
|
variant = variant.upper()
|
|||
|
if variant not in 'BT':
|
|||
|
raise ValueError("Variant must be 'B' or 'T'")
|
|||
|
|
|||
|
language = self.language
|
|||
|
if language is None:
|
|||
|
return 'und'
|
|||
|
elif len(language) == 3:
|
|||
|
return language
|
|||
|
else:
|
|||
|
if variant == 'B' and language in LANGUAGE_ALPHA3_BIBLIOGRAPHIC:
|
|||
|
return LANGUAGE_ALPHA3_BIBLIOGRAPHIC[language]
|
|||
|
elif language in LANGUAGE_ALPHA3:
|
|||
|
return LANGUAGE_ALPHA3[language]
|
|||
|
else:
|
|||
|
raise LookupError(
|
|||
|
f"{language!r} is not a known language code, "
|
|||
|
"and has no alpha3 code."
|
|||
|
)
|
|||
|
|
|||
|
def broader_tags(self) -> List[str]:
|
|||
|
"""
|
|||
|
Iterate through increasingly general tags for this language.
|
|||
|
|
|||
|
This isn't actually that useful for matching two arbitrary language tags
|
|||
|
against each other, but it is useful for matching them against a known
|
|||
|
standardized form, such as in the CLDR data.
|
|||
|
|
|||
|
The list of broader versions to try appears in UTR 35, section 4.3,
|
|||
|
"Likely Subtags".
|
|||
|
|
|||
|
>>> Language.get('nn-Latn-NO-x-thingy').broader_tags()
|
|||
|
['nn-Latn-NO-x-thingy', 'nn-Latn-NO', 'nn-NO', 'nn-Latn', 'nn', 'und-Latn', 'und']
|
|||
|
|
|||
|
>>> Language.get('arb-Arab').broader_tags()
|
|||
|
['arb-Arab', 'ar-Arab', 'arb', 'ar', 'und-Arab', 'und']
|
|||
|
"""
|
|||
|
if self._broader is not None:
|
|||
|
return self._broader
|
|||
|
self._broader = [self.to_tag()]
|
|||
|
seen = set([self.to_tag()])
|
|||
|
for keyset in self.BROADER_KEYSETS:
|
|||
|
for start_language in (self, self.prefer_macrolanguage()):
|
|||
|
filtered = start_language._filter_attributes(keyset)
|
|||
|
tag = filtered.to_tag()
|
|||
|
if tag not in seen:
|
|||
|
self._broader.append(tag)
|
|||
|
seen.add(tag)
|
|||
|
return self._broader
|
|||
|
|
|||
|
def broaden(self) -> 'List[Language]':
|
|||
|
"""
|
|||
|
Like `broader_tags`, but returrns Language objects instead of strings.
|
|||
|
"""
|
|||
|
return [Language.get(tag) for tag in self.broader_tags()]
|
|||
|
|
|||
|
def maximize(self) -> 'Language':
|
|||
|
"""
|
|||
|
The Unicode CLDR contains a "likelySubtags" data file, which can guess
|
|||
|
reasonable values for fields that are missing from a language tag.
|
|||
|
|
|||
|
This is particularly useful for comparing, for example, "zh-Hant" and
|
|||
|
"zh-TW", two common language tags that say approximately the same thing
|
|||
|
via rather different information. (Using traditional Han characters is
|
|||
|
not the same as being in Taiwan, but each implies that the other is
|
|||
|
likely.)
|
|||
|
|
|||
|
These implications are provided in the CLDR supplemental data, and are
|
|||
|
based on the likelihood of people using the language to transmit text
|
|||
|
on the Internet. (This is why the overall default is English, not
|
|||
|
Chinese.)
|
|||
|
|
|||
|
It's important to recognize that these tags amplify majorities, and
|
|||
|
that not all language support fits into a "likely" language tag.
|
|||
|
|
|||
|
>>> str(Language.get('zh-Hant').maximize())
|
|||
|
'zh-Hant-TW'
|
|||
|
>>> str(Language.get('zh-TW').maximize())
|
|||
|
'zh-Hant-TW'
|
|||
|
>>> str(Language.get('ja').maximize())
|
|||
|
'ja-Jpan-JP'
|
|||
|
>>> str(Language.get('pt').maximize())
|
|||
|
'pt-Latn-BR'
|
|||
|
>>> str(Language.get('und-Arab').maximize())
|
|||
|
'ar-Arab-EG'
|
|||
|
>>> str(Language.get('und-CH').maximize())
|
|||
|
'de-Latn-CH'
|
|||
|
|
|||
|
As many standards are, this is US-centric:
|
|||
|
|
|||
|
>>> str(Language.make().maximize())
|
|||
|
'en-Latn-US'
|
|||
|
|
|||
|
"Extlangs" have no likely-subtags information, so they will give
|
|||
|
maximized results that make no sense:
|
|||
|
|
|||
|
>>> str(Language.get('und-ibe').maximize())
|
|||
|
'en-ibe-Latn-US'
|
|||
|
"""
|
|||
|
if self._filled is not None:
|
|||
|
return self._filled
|
|||
|
|
|||
|
for tag in self.broader_tags():
|
|||
|
if tag in LIKELY_SUBTAGS:
|
|||
|
result = Language.get(LIKELY_SUBTAGS[tag], normalize=False)
|
|||
|
result = result.update(self)
|
|||
|
self._filled = result
|
|||
|
return result
|
|||
|
|
|||
|
raise RuntimeError(
|
|||
|
"Couldn't fill in likely values. This represents a problem with "
|
|||
|
"the LIKELY_SUBTAGS data."
|
|||
|
)
|
|||
|
|
|||
|
# Support an old, wordier name for the method
|
|||
|
fill_likely_values = maximize
|
|||
|
|
|||
|
def match_score(self, supported: 'Language') -> int:
|
|||
|
"""
|
|||
|
DEPRECATED: use .distance() instead, which uses newer data and is _lower_
|
|||
|
for better matching languages.
|
|||
|
"""
|
|||
|
warnings.warn(
|
|||
|
"`match_score` is deprecated because it's based on deprecated CLDR info. "
|
|||
|
"Use `distance` instead, which is _lower_ for better matching languages. ",
|
|||
|
DeprecationWarning,
|
|||
|
)
|
|||
|
return 100 - min(self.distance(supported), 100)
|
|||
|
|
|||
|
def distance(self, supported: 'Language') -> int:
|
|||
|
"""
|
|||
|
Suppose that `self` is the language that the user desires, and
|
|||
|
`supported` is a language that is actually supported.
|
|||
|
|
|||
|
This method returns a number from 0 to 134 measuring the 'distance'
|
|||
|
between the languages (lower numbers are better). This is not a
|
|||
|
symmetric relation.
|
|||
|
|
|||
|
The language distance is not really about the linguistic similarity or
|
|||
|
history of the languages; instead, it's based largely on sociopolitical
|
|||
|
factors, indicating which language speakers are likely to know which
|
|||
|
other languages in the present world. Much of the heuristic is about
|
|||
|
finding a widespread 'world language' like English, Chinese, French, or
|
|||
|
Russian that speakers of a more localized language will accept.
|
|||
|
|
|||
|
A version that works on language tags, as strings, is in the function
|
|||
|
`tag_distance`. See that function for copious examples.
|
|||
|
"""
|
|||
|
if supported == self:
|
|||
|
return 0
|
|||
|
|
|||
|
# CLDR has realized that these matching rules are undermined when the
|
|||
|
# unspecified language 'und' gets maximized to 'en-Latn-US', so this case
|
|||
|
# is specifically not maximized:
|
|||
|
if self.language is None and self.script is None and self.territory is None:
|
|||
|
desired_triple = ('und', 'Zzzz', 'ZZ')
|
|||
|
else:
|
|||
|
desired_complete = self.prefer_macrolanguage().maximize()
|
|||
|
desired_triple = (
|
|||
|
desired_complete.language,
|
|||
|
desired_complete.script,
|
|||
|
desired_complete.territory,
|
|||
|
)
|
|||
|
|
|||
|
if (
|
|||
|
supported.language is None
|
|||
|
and supported.script is None
|
|||
|
and supported.territory is None
|
|||
|
):
|
|||
|
supported_triple = ('und', 'Zzzz', 'ZZ')
|
|||
|
else:
|
|||
|
supported_complete = supported.prefer_macrolanguage().maximize()
|
|||
|
supported_triple = (
|
|||
|
supported_complete.language,
|
|||
|
supported_complete.script,
|
|||
|
supported_complete.territory,
|
|||
|
)
|
|||
|
|
|||
|
return tuple_distance_cached(desired_triple, supported_triple)
|
|||
|
|
|||
|
def is_valid(self) -> bool:
|
|||
|
"""
|
|||
|
Checks whether the language, script, territory, and variants
|
|||
|
(if present) are all tags that have meanings assigned by IANA.
|
|||
|
For example, 'ja' (Japanese) is a valid tag, and 'jp' is not.
|
|||
|
|
|||
|
The data is current as of CLDR 40.
|
|||
|
|
|||
|
>>> Language.get('ja').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('jp').is_valid()
|
|||
|
False
|
|||
|
>>> Language.get('en-001').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('en-000').is_valid()
|
|||
|
False
|
|||
|
>>> Language.get('en-Latn').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('en-Latnx').is_valid()
|
|||
|
False
|
|||
|
>>> Language.get('und').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('en-GB-oxendict').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('en-GB-oxenfree').is_valid()
|
|||
|
False
|
|||
|
>>> Language.get('x-heptapod').is_valid()
|
|||
|
True
|
|||
|
|
|||
|
Some scripts are, confusingly, not included in CLDR's 'validity' pattern.
|
|||
|
If a script appears in the IANA registry, we consider it valid.
|
|||
|
|
|||
|
>>> Language.get('ur-Aran').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('cu-Cyrs').is_valid()
|
|||
|
True
|
|||
|
|
|||
|
A language tag with multiple extlangs will parse, but is not valid.
|
|||
|
The only allowed example is 'zh-min-nan', which normalizes to the
|
|||
|
language 'nan'.
|
|||
|
|
|||
|
>>> Language.get('zh-min-nan').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('sgn-ase-bfi').is_valid()
|
|||
|
False
|
|||
|
|
|||
|
These examples check that duplicate tags are not valid:
|
|||
|
|
|||
|
>>> Language.get('de-1901').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('de-1901-1901').is_valid()
|
|||
|
False
|
|||
|
>>> Language.get('en-a-bbb-c-ddd').is_valid()
|
|||
|
True
|
|||
|
>>> Language.get('en-a-bbb-a-ddd').is_valid()
|
|||
|
False
|
|||
|
|
|||
|
Of course, you should be prepared to catch a failure to parse the
|
|||
|
language code at all:
|
|||
|
|
|||
|
>>> Language.get('C').is_valid()
|
|||
|
Traceback (most recent call last):
|
|||
|
...
|
|||
|
langcodes.tag_parser.LanguageTagError: Expected a language code, got 'c'
|
|||
|
"""
|
|||
|
if self.extlangs is not None:
|
|||
|
# An erratum to BCP 47 says that tags with more than one extlang are
|
|||
|
# invalid.
|
|||
|
if len(self.extlangs) > 1:
|
|||
|
return False
|
|||
|
|
|||
|
subtags = [self.language, self.script, self.territory]
|
|||
|
checked_subtags = []
|
|||
|
if self.variants is not None:
|
|||
|
subtags.extend(self.variants)
|
|||
|
for subtag in subtags:
|
|||
|
if subtag is not None:
|
|||
|
checked_subtags.append(subtag)
|
|||
|
if not subtag.startswith('x-') and not VALIDITY.match(subtag):
|
|||
|
if subtag not in ALL_SCRIPTS:
|
|||
|
return False
|
|||
|
|
|||
|
# We check extensions for validity by ensuring that there aren't
|
|||
|
# two extensions introduced by the same letter. For example, you can't
|
|||
|
# have two 'u-' extensions.
|
|||
|
if self.extensions:
|
|||
|
checked_subtags.extend([extension[:2] for extension in self.extensions])
|
|||
|
if len(set(checked_subtags)) != len(checked_subtags):
|
|||
|
return False
|
|||
|
return True
|
|||
|
|
|||
|
def has_name_data(self) -> bool:
|
|||
|
"""
|
|||
|
Return True when we can name languages in this language. Requires
|
|||
|
`language_data` to be installed.
|
|||
|
|
|||
|
This is true when the language, or one of its 'broader' versions, is in
|
|||
|
the list of CLDR target languages.
|
|||
|
|
|||
|
>>> Language.get('fr').has_name_data()
|
|||
|
True
|
|||
|
>>> Language.get('so').has_name_data()
|
|||
|
True
|
|||
|
>>> Language.get('enc').has_name_data()
|
|||
|
False
|
|||
|
>>> Language.get('und').has_name_data()
|
|||
|
False
|
|||
|
"""
|
|||
|
try:
|
|||
|
from language_data.name_data import LANGUAGES_WITH_NAME_DATA
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
matches = set(self.broader_tags()) & LANGUAGES_WITH_NAME_DATA
|
|||
|
return bool(matches)
|
|||
|
|
|||
|
# These methods help to show what the language tag means in natural
|
|||
|
# language. They actually apply the language-matching algorithm to find
|
|||
|
# the right language to name things in.
|
|||
|
|
|||
|
def _get_name(
|
|||
|
self, attribute: str, language: Union[str, 'Language'], max_distance: int
|
|||
|
) -> str:
|
|||
|
try:
|
|||
|
from language_data.names import code_to_names
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
assert attribute in self.ATTRIBUTES
|
|||
|
if isinstance(language, str):
|
|||
|
language = Language.get(language)
|
|||
|
|
|||
|
attr_value = getattr(self, attribute)
|
|||
|
if attr_value is None:
|
|||
|
if attribute == 'language':
|
|||
|
attr_value = 'und'
|
|||
|
else:
|
|||
|
return None
|
|||
|
names = code_to_names(attr_value)
|
|||
|
|
|||
|
result = self._best_name(names, language, max_distance)
|
|||
|
if result is not None:
|
|||
|
return result
|
|||
|
else:
|
|||
|
# Construct a string like "Unknown language [zzz]"
|
|||
|
placeholder = None
|
|||
|
if attribute == 'language':
|
|||
|
placeholder = 'und'
|
|||
|
elif attribute == 'script':
|
|||
|
placeholder = 'Zzzz'
|
|||
|
elif attribute == 'territory':
|
|||
|
placeholder = 'ZZ'
|
|||
|
|
|||
|
unknown_name = None
|
|||
|
if placeholder is not None:
|
|||
|
names = code_to_names(placeholder)
|
|||
|
unknown_name = self._best_name(names, language, max_distance)
|
|||
|
if unknown_name is None:
|
|||
|
unknown_name = 'Unknown language subtag'
|
|||
|
return f'{unknown_name} [{attr_value}]'
|
|||
|
|
|||
|
def _best_name(
|
|||
|
self, names: Mapping[str, str], language: 'Language', max_distance: int
|
|||
|
):
|
|||
|
matchable_languages = set(language.broader_tags())
|
|||
|
possible_languages = [
|
|||
|
key for key in sorted(names.keys()) if key in matchable_languages
|
|||
|
]
|
|||
|
|
|||
|
target_language, score = closest_match(
|
|||
|
language, possible_languages, max_distance
|
|||
|
)
|
|||
|
if target_language in names:
|
|||
|
return names[target_language]
|
|||
|
else:
|
|||
|
return names.get(DEFAULT_LANGUAGE)
|
|||
|
|
|||
|
def language_name(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> str:
|
|||
|
"""
|
|||
|
Give the name of the language (not the entire tag, just the language part)
|
|||
|
in a natural language. The target language can be given as a string or
|
|||
|
another Language object.
|
|||
|
|
|||
|
By default, things are named in English:
|
|||
|
|
|||
|
>>> Language.get('fr').language_name()
|
|||
|
'French'
|
|||
|
>>> Language.get('el').language_name()
|
|||
|
'Greek'
|
|||
|
|
|||
|
But you can ask for language names in numerous other languages:
|
|||
|
|
|||
|
>>> Language.get('fr').language_name('fr')
|
|||
|
'français'
|
|||
|
>>> Language.get('el').language_name('fr')
|
|||
|
'grec'
|
|||
|
|
|||
|
Why does everyone get Slovak and Slovenian confused? Let's ask them.
|
|||
|
|
|||
|
>>> Language.get('sl').language_name('sl')
|
|||
|
'slovenščina'
|
|||
|
>>> Language.get('sk').language_name('sk')
|
|||
|
'slovenčina'
|
|||
|
>>> Language.get('sl').language_name('sk')
|
|||
|
'slovinčina'
|
|||
|
>>> Language.get('sk').language_name('sl')
|
|||
|
'slovaščina'
|
|||
|
"""
|
|||
|
return self._get_name('language', language, max_distance)
|
|||
|
|
|||
|
def display_name(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> str:
|
|||
|
"""
|
|||
|
It's often helpful to be able to describe a language code in a way that a user
|
|||
|
(or you) can understand, instead of in inscrutable short codes. The
|
|||
|
`display_name` method lets you describe a Language object *in a language*.
|
|||
|
|
|||
|
The `.display_name(language, min_score)` method will look up the name of the
|
|||
|
language. The names come from the IANA language tag registry, which is only in
|
|||
|
English, plus CLDR, which names languages in many commonly-used languages.
|
|||
|
|
|||
|
The default language for naming things is English:
|
|||
|
|
|||
|
>>> Language.make(language='fr').display_name()
|
|||
|
'French'
|
|||
|
|
|||
|
>>> Language.make().display_name()
|
|||
|
'Unknown language'
|
|||
|
|
|||
|
>>> Language.get('zh-Hans').display_name()
|
|||
|
'Chinese (Simplified)'
|
|||
|
|
|||
|
>>> Language.get('en-US').display_name()
|
|||
|
'English (United States)'
|
|||
|
|
|||
|
But you can ask for language names in numerous other languages:
|
|||
|
|
|||
|
>>> Language.get('fr').display_name('fr')
|
|||
|
'français'
|
|||
|
|
|||
|
>>> Language.get('fr').display_name('es')
|
|||
|
'francés'
|
|||
|
|
|||
|
>>> Language.make().display_name('es')
|
|||
|
'lengua desconocida'
|
|||
|
|
|||
|
>>> Language.get('zh-Hans').display_name('de')
|
|||
|
'Chinesisch (Vereinfacht)'
|
|||
|
|
|||
|
>>> Language.get('en-US').display_name('zh-Hans')
|
|||
|
'英语(美国)'
|
|||
|
"""
|
|||
|
reduced = self.simplify_script()
|
|||
|
language = Language.get(language)
|
|||
|
language_name = reduced.language_name(language, max_distance)
|
|||
|
extra_parts = []
|
|||
|
|
|||
|
if reduced.script is not None:
|
|||
|
extra_parts.append(reduced.script_name(language, max_distance))
|
|||
|
if reduced.territory is not None:
|
|||
|
extra_parts.append(reduced.territory_name(language, max_distance))
|
|||
|
|
|||
|
if extra_parts:
|
|||
|
clarification = language._display_separator().join(extra_parts)
|
|||
|
pattern = language._display_pattern()
|
|||
|
return pattern.format(language_name, clarification)
|
|||
|
else:
|
|||
|
return language_name
|
|||
|
|
|||
|
def _display_pattern(self) -> str:
|
|||
|
"""
|
|||
|
Get the pattern, according to CLDR, that should be used for clarifying
|
|||
|
details of a language code.
|
|||
|
"""
|
|||
|
# Technically we are supposed to look up this pattern in each language.
|
|||
|
# Practically, it's the same in every language except Chinese, where the
|
|||
|
# parentheses are full-width.
|
|||
|
if self._disp_pattern is not None:
|
|||
|
return self._disp_pattern
|
|||
|
if self.distance(Language.get('zh')) <= 25:
|
|||
|
self._disp_pattern = "{0}({1})"
|
|||
|
else:
|
|||
|
self._disp_pattern = "{0} ({1})"
|
|||
|
return self._disp_pattern
|
|||
|
|
|||
|
def _display_separator(self) -> str:
|
|||
|
"""
|
|||
|
Get the symbol that should be used to separate multiple clarifying
|
|||
|
details -- such as a comma in English, or an ideographic comma in
|
|||
|
Japanese.
|
|||
|
|
|||
|
Requires that `language_data` is installed.
|
|||
|
"""
|
|||
|
try:
|
|||
|
from language_data.names import DISPLAY_SEPARATORS
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
if self._disp_separator is not None:
|
|||
|
return self._disp_separator
|
|||
|
matched, _dist = closest_match(self, DISPLAY_SEPARATORS.keys())
|
|||
|
self._disp_separator = DISPLAY_SEPARATORS[matched]
|
|||
|
return self._disp_separator
|
|||
|
|
|||
|
def autonym(self, max_distance: int = 9) -> str:
|
|||
|
"""
|
|||
|
Give the display name of this language *in* this language.
|
|||
|
Requires that `language_data` is installed.
|
|||
|
|
|||
|
>>> Language.get('fr').autonym()
|
|||
|
'français'
|
|||
|
>>> Language.get('es').autonym()
|
|||
|
'español'
|
|||
|
>>> Language.get('ja').autonym()
|
|||
|
'日本語'
|
|||
|
|
|||
|
This uses the `display_name()` method, so it can include the name of a
|
|||
|
script or territory when appropriate.
|
|||
|
|
|||
|
>>> Language.get('en-AU').autonym()
|
|||
|
'English (Australia)'
|
|||
|
>>> Language.get('sr-Latn').autonym()
|
|||
|
'srpski (latinica)'
|
|||
|
>>> Language.get('sr-Cyrl').autonym()
|
|||
|
'српски (ћирилица)'
|
|||
|
>>> Language.get('pa').autonym()
|
|||
|
'ਪੰਜਾਬੀ'
|
|||
|
>>> Language.get('pa-Arab').autonym()
|
|||
|
'پنجابی (عربی)'
|
|||
|
|
|||
|
This only works for language codes that CLDR has locale data for. You
|
|||
|
can't ask for the autonym of 'ja-Latn' and get 'nihongo (rōmaji)'.
|
|||
|
"""
|
|||
|
lang = self.prefer_macrolanguage()
|
|||
|
return lang.display_name(language=lang, max_distance=max_distance)
|
|||
|
|
|||
|
def script_name(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> str:
|
|||
|
"""
|
|||
|
Describe the script part of the language tag in a natural language.
|
|||
|
Requires that `language_data` is installed.
|
|||
|
"""
|
|||
|
return self._get_name('script', language, max_distance)
|
|||
|
|
|||
|
def territory_name(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> str:
|
|||
|
"""
|
|||
|
Describe the territory part of the language tag in a natural language.
|
|||
|
Requires that `language_data` is installed.
|
|||
|
"""
|
|||
|
return self._get_name('territory', language, max_distance)
|
|||
|
|
|||
|
def region_name(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> str:
|
|||
|
warnings.warn(
|
|||
|
"`region_name` has been renamed to `territory_name` for consistency",
|
|||
|
DeprecationWarning,
|
|||
|
)
|
|||
|
return self.territory_name(language, max_distance)
|
|||
|
|
|||
|
@property
|
|||
|
def region(self):
|
|||
|
warnings.warn(
|
|||
|
"The `region` property has been renamed to `territory` for consistency",
|
|||
|
DeprecationWarning,
|
|||
|
)
|
|||
|
return self.territory
|
|||
|
|
|||
|
def variant_names(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> Sequence[str]:
|
|||
|
"""
|
|||
|
Deprecated in version 3.0.
|
|||
|
|
|||
|
We don't store names for variants anymore, so this just returns the list
|
|||
|
of variant codes, such as ['oxendict'] for en-GB-oxendict.
|
|||
|
"""
|
|||
|
warnings.warn(
|
|||
|
"variant_names is deprecated and just returns the variant codes",
|
|||
|
DeprecationWarning,
|
|||
|
)
|
|||
|
return self.variants or []
|
|||
|
|
|||
|
def describe(
|
|||
|
self,
|
|||
|
language: Union[str, 'Language'] = DEFAULT_LANGUAGE,
|
|||
|
max_distance: int = 25,
|
|||
|
) -> dict:
|
|||
|
"""
|
|||
|
Return a dictionary that describes a given language tag in a specified
|
|||
|
natural language. Requires that `language_data` is installed.
|
|||
|
|
|||
|
See `language_name` and related methods for more specific versions of this.
|
|||
|
|
|||
|
The desired `language` will in fact be matched against the available
|
|||
|
options using the matching technique that this module provides. We can
|
|||
|
illustrate many aspects of this by asking for a description of Shavian
|
|||
|
script (a phonetic script for English devised by author George Bernard
|
|||
|
Shaw), and where you might find it, in various languages.
|
|||
|
|
|||
|
>>> shaw = Language.make(script='Shaw').maximize()
|
|||
|
>>> shaw.describe('en')
|
|||
|
{'language': 'English', 'script': 'Shavian', 'territory': 'United Kingdom'}
|
|||
|
|
|||
|
>>> shaw.describe('fr')
|
|||
|
{'language': 'anglais', 'script': 'shavien', 'territory': 'Royaume-Uni'}
|
|||
|
|
|||
|
>>> shaw.describe('es')
|
|||
|
{'language': 'inglés', 'script': 'shaviano', 'territory': 'Reino Unido'}
|
|||
|
|
|||
|
>>> shaw.describe('pt')
|
|||
|
{'language': 'inglês', 'script': 'shaviano', 'territory': 'Reino Unido'}
|
|||
|
|
|||
|
>>> shaw.describe('uk')
|
|||
|
{'language': 'англійська', 'script': 'шоу', 'territory': 'Велика Британія'}
|
|||
|
|
|||
|
>>> shaw.describe('arb')
|
|||
|
{'language': 'الإنجليزية', 'script': 'الشواني', 'territory': 'المملكة المتحدة'}
|
|||
|
|
|||
|
>>> shaw.describe('th')
|
|||
|
{'language': 'อังกฤษ', 'script': 'ซอเวียน', 'territory': 'สหราชอาณาจักร'}
|
|||
|
|
|||
|
>>> shaw.describe('zh-Hans')
|
|||
|
{'language': '英语', 'script': '萧伯纳式文', 'territory': '英国'}
|
|||
|
|
|||
|
>>> shaw.describe('zh-Hant')
|
|||
|
{'language': '英文', 'script': '簫柏納字符', 'territory': '英國'}
|
|||
|
|
|||
|
>>> shaw.describe('ja')
|
|||
|
{'language': '英語', 'script': 'ショー文字', 'territory': 'イギリス'}
|
|||
|
|
|||
|
When we don't have a localization for the language, we fall back on English,
|
|||
|
because the IANA provides names for all known codes in English.
|
|||
|
|
|||
|
>>> shaw.describe('lol')
|
|||
|
{'language': 'English', 'script': 'Shavian', 'territory': 'United Kingdom'}
|
|||
|
|
|||
|
When the language tag itself is a valid tag but with no known meaning, we
|
|||
|
say so in the appropriate language.
|
|||
|
|
|||
|
>>> Language.get('xyz-ZY').display_name()
|
|||
|
'Unknown language [xyz] (Unknown Region [ZY])'
|
|||
|
|
|||
|
>>> Language.get('xyz-ZY').display_name('es')
|
|||
|
'lengua desconocida [xyz] (Región desconocida [ZY])'
|
|||
|
"""
|
|||
|
names = {}
|
|||
|
if self.language:
|
|||
|
names['language'] = self.language_name(language, max_distance)
|
|||
|
if self.script:
|
|||
|
names['script'] = self.script_name(language, max_distance)
|
|||
|
if self.territory:
|
|||
|
names['territory'] = self.territory_name(language, max_distance)
|
|||
|
return names
|
|||
|
|
|||
|
def speaking_population(self) -> int:
|
|||
|
"""
|
|||
|
Get an estimate of how many people in the world speak this language,
|
|||
|
derived from CLDR data. Requires that `language_data` is installed.
|
|||
|
|
|||
|
Only the language and territory codes will be considered. If a
|
|||
|
territory code is included, the population will count only the
|
|||
|
speakers of the language in that territory.
|
|||
|
|
|||
|
Script subtags are disregarded, because it doesn't make sense to ask
|
|||
|
how many people speak in a particular writing script.
|
|||
|
|
|||
|
>>> Language.get('es').speaking_population()
|
|||
|
487664083
|
|||
|
>>> Language.get('pt').speaking_population()
|
|||
|
237135429
|
|||
|
>>> Language.get('es-BR').speaking_population()
|
|||
|
76218
|
|||
|
>>> Language.get('pt-BR').speaking_population()
|
|||
|
192661560
|
|||
|
>>> Language.get('vo').speaking_population()
|
|||
|
0
|
|||
|
"""
|
|||
|
try:
|
|||
|
from language_data.population_data import LANGUAGE_SPEAKING_POPULATION
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
lang = self._filter_attributes(['language', 'territory'])
|
|||
|
return LANGUAGE_SPEAKING_POPULATION.get(str(lang), 0)
|
|||
|
|
|||
|
def writing_population(self) -> int:
|
|||
|
"""
|
|||
|
Get an estimate of how many people in the world read and write
|
|||
|
this language, derived from CLDR data. Requires that `language_data`
|
|||
|
is installed.
|
|||
|
|
|||
|
For many languages that aren't typically written, this is an
|
|||
|
overestimate, according to CLDR -- the data often includes people who
|
|||
|
speak that language but write in a different language.
|
|||
|
|
|||
|
Only the language, script, and territory codes will be considered.
|
|||
|
If a territory code is included, the population will count only the
|
|||
|
speakers of the language in that territory.
|
|||
|
|
|||
|
>>> all = Language.get('zh').writing_population()
|
|||
|
>>> all
|
|||
|
1240326057
|
|||
|
|
|||
|
>>> traditional = Language.get('zh-Hant').writing_population()
|
|||
|
>>> traditional
|
|||
|
37019589
|
|||
|
|
|||
|
>>> simplified = Language.get('zh-Hans').writing_population()
|
|||
|
>>> all == traditional + simplified
|
|||
|
True
|
|||
|
|
|||
|
>>> Language.get('zh-Hant-HK').writing_population()
|
|||
|
6439733
|
|||
|
>>> Language.get('zh-Hans-HK').writing_population()
|
|||
|
338933
|
|||
|
|
|||
|
Note that if you want to get the total Chinese writing population
|
|||
|
of Hong Kong, you need to avoid normalization that would interpret
|
|||
|
'zh-HK' as 'zh-Hant-HK'.
|
|||
|
|
|||
|
>>> Language.get('zh-HK', normalize=False).writing_population()
|
|||
|
6778666
|
|||
|
|
|||
|
Unknown or unspecified language codes get a population of 0.
|
|||
|
|
|||
|
>>> Language.get('xyz').writing_population()
|
|||
|
0
|
|||
|
|
|||
|
>>> Language.get('und').writing_population()
|
|||
|
0
|
|||
|
"""
|
|||
|
try:
|
|||
|
from language_data.population_data import LANGUAGE_WRITING_POPULATION
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
lang = self._filter_attributes(['language', 'script', 'territory'])
|
|||
|
if str(lang) in LANGUAGE_WRITING_POPULATION:
|
|||
|
return LANGUAGE_WRITING_POPULATION[str(lang)]
|
|||
|
else:
|
|||
|
lang = lang.simplify_script()
|
|||
|
return LANGUAGE_WRITING_POPULATION.get(str(lang), 0)
|
|||
|
|
|||
|
@staticmethod
|
|||
|
def find_name(
|
|||
|
tagtype: str, name: str, language: Optional[Union[str, 'Language']] = None
|
|||
|
) -> 'Language':
|
|||
|
"""
|
|||
|
Find the subtag of a particular `tagtype` that has the given `name`.
|
|||
|
Requires that `language_data` is installed.
|
|||
|
|
|||
|
The default language, "und", will allow matching names in any language,
|
|||
|
so you can get the code 'fr' by looking up "French", "Français", or
|
|||
|
"francés".
|
|||
|
|
|||
|
Occasionally, names are ambiguous in a way that can be resolved by
|
|||
|
specifying what name the language is supposed to be in. For example,
|
|||
|
there is a language named 'Malayo' in English, but it's different from
|
|||
|
the language named 'Malayo' in Spanish (which is Malay). Specifying the
|
|||
|
language will look up the name in a trie that is only in that language.
|
|||
|
|
|||
|
In a previous version, we thought we were going to deprecate the
|
|||
|
`language` parameter, as there weren't significant cases of conflicts
|
|||
|
in names of things between languages. Well, we got more data, and
|
|||
|
conflicts in names are everywhere.
|
|||
|
|
|||
|
Specifying the language that the name should be in is still not
|
|||
|
required, but it will help to make sure that names can be
|
|||
|
round-tripped.
|
|||
|
|
|||
|
>>> Language.find_name('language', 'francés')
|
|||
|
Language.make(language='fr')
|
|||
|
|
|||
|
>>> Language.find_name('territory', 'United Kingdom')
|
|||
|
Language.make(territory='GB')
|
|||
|
|
|||
|
>>> Language.find_name('script', 'Arabic')
|
|||
|
Language.make(script='Arab')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'norsk bokmål')
|
|||
|
Language.make(language='nb')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'norsk')
|
|||
|
Language.make(language='no')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'norsk', 'en')
|
|||
|
Traceback (most recent call last):
|
|||
|
...
|
|||
|
LookupError: Can't find any language named 'norsk'
|
|||
|
|
|||
|
>>> Language.find_name('language', 'norsk', 'no')
|
|||
|
Language.make(language='no')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'malayo', 'en')
|
|||
|
Language.make(language='mbp')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'malayo', 'es')
|
|||
|
Language.make(language='ms')
|
|||
|
|
|||
|
Some langauge names resolve to more than a language. For example,
|
|||
|
the name 'Brazilian Portuguese' resolves to a language and a territory,
|
|||
|
and 'Simplified Chinese' resolves to a language and a script. In these
|
|||
|
cases, a Language object with multiple subtags will be returned.
|
|||
|
|
|||
|
>>> Language.find_name('language', 'Brazilian Portuguese', 'en')
|
|||
|
Language.make(language='pt', territory='BR')
|
|||
|
|
|||
|
>>> Language.find_name('language', 'Simplified Chinese', 'en')
|
|||
|
Language.make(language='zh', script='Hans')
|
|||
|
|
|||
|
A small amount of fuzzy matching is supported: if the name can be
|
|||
|
shortened to match a single language name, you get that language.
|
|||
|
This allows, for example, "Hakka dialect" to match "Hakka".
|
|||
|
|
|||
|
>>> Language.find_name('language', 'Hakka dialect')
|
|||
|
Language.make(language='hak')
|
|||
|
"""
|
|||
|
try:
|
|||
|
from language_data.names import name_to_code
|
|||
|
except ImportError:
|
|||
|
print(LANGUAGE_NAME_IMPORT_MESSAGE, file=sys.stdout)
|
|||
|
raise
|
|||
|
|
|||
|
# No matter what form of language we got, normalize it to a single
|
|||
|
# language subtag
|
|||
|
if isinstance(language, Language):
|
|||
|
language = language.language
|
|||
|
elif isinstance(language, str):
|
|||
|
language = get(language).language
|
|||
|
if language is None:
|
|||
|
language = 'und'
|
|||
|
|
|||
|
code = name_to_code(tagtype, name, language)
|
|||
|
if code is None:
|
|||
|
raise LookupError(f"Can't find any {tagtype} named {name!r}")
|
|||
|
if '-' in code:
|
|||
|
return Language.get(code)
|
|||
|
else:
|
|||
|
data = {tagtype: code}
|
|||
|
return Language.make(**data)
|
|||
|
|
|||
|
@staticmethod
|
|||
|
def find(
|
|||
|
name: str, language: Optional[Union[str, 'Language']] = None
|
|||
|
) -> 'Language':
|
|||
|
"""
|
|||
|
A concise version of `find_name`, used to get a language tag by its
|
|||
|
name in a natural language. The language can be omitted in the large
|
|||
|
majority of cases, where the language name is not ambiguous.
|
|||
|
|
|||
|
>>> Language.find('Türkçe')
|
|||
|
Language.make(language='tr')
|
|||
|
>>> Language.find('brazilian portuguese')
|
|||
|
Language.make(language='pt', territory='BR')
|
|||
|
>>> Language.find('simplified chinese')
|
|||
|
Language.make(language='zh', script='Hans')
|
|||
|
|
|||
|
Some language names are ambiguous: for example, there is a language
|
|||
|
named 'Fala' in English (with code 'fax'), but 'Fala' is also the
|
|||
|
Kwasio word for French. In this case, specifying the language that
|
|||
|
the name is in is necessary for disambiguation.
|
|||
|
|
|||
|
>>> Language.find('fala')
|
|||
|
Language.make(language='fr')
|
|||
|
>>> Language.find('fala', 'nmg')
|
|||
|
Language.make(language='fr')
|
|||
|
>>> Language.find('fala', 'en')
|
|||
|
Language.make(language='fax')
|
|||
|
"""
|
|||
|
return Language.find_name('language', name, language)
|
|||
|
|
|||
|
def to_dict(self) -> dict:
|
|||
|
"""
|
|||
|
Get a dictionary of the attributes of this Language object, which
|
|||
|
can be useful for constructing a similar object.
|
|||
|
"""
|
|||
|
if self._dict is not None:
|
|||
|
return self._dict
|
|||
|
|
|||
|
result = {}
|
|||
|
for key in self.ATTRIBUTES:
|
|||
|
value = getattr(self, key)
|
|||
|
if value:
|
|||
|
result[key] = value
|
|||
|
self._dict = result
|
|||
|
return result
|
|||
|
|
|||
|
def update(self, other: 'Language') -> 'Language':
|
|||
|
"""
|
|||
|
Update this Language with the fields of another Language.
|
|||
|
"""
|
|||
|
return Language.make(
|
|||
|
language=other.language or self.language,
|
|||
|
extlangs=other.extlangs or self.extlangs,
|
|||
|
script=other.script or self.script,
|
|||
|
territory=other.territory or self.territory,
|
|||
|
variants=other.variants or self.variants,
|
|||
|
extensions=other.extensions or self.extensions,
|
|||
|
private=other.private or self.private,
|
|||
|
)
|
|||
|
|
|||
|
def update_dict(self, newdata: dict) -> 'Language':
|
|||
|
"""
|
|||
|
Update the attributes of this Language from a dictionary.
|
|||
|
"""
|
|||
|
return Language.make(
|
|||
|
language=newdata.get('language', self.language),
|
|||
|
extlangs=newdata.get('extlangs', self.extlangs),
|
|||
|
script=newdata.get('script', self.script),
|
|||
|
territory=newdata.get('territory', self.territory),
|
|||
|
variants=newdata.get('variants', self.variants),
|
|||
|
extensions=newdata.get('extensions', self.extensions),
|
|||
|
private=newdata.get('private', self.private),
|
|||
|
)
|
|||
|
|
|||
|
@staticmethod
|
|||
|
def _filter_keys(d: dict, keys: Iterable[str]) -> dict:
|
|||
|
"""
|
|||
|
Select a subset of keys from a dictionary.
|
|||
|
"""
|
|||
|
return {key: d[key] for key in keys if key in d}
|
|||
|
|
|||
|
def _filter_attributes(self, keyset: Iterable[str]) -> 'Language':
|
|||
|
"""
|
|||
|
Return a copy of this object with a subset of its attributes set.
|
|||
|
"""
|
|||
|
filtered = self._filter_keys(self.to_dict(), keyset)
|
|||
|
return Language.make(**filtered)
|
|||
|
|
|||
|
def _searchable_form(self) -> 'Language':
|
|||
|
"""
|
|||
|
Convert a parsed language tag so that the information it contains is in
|
|||
|
the best form for looking up information in the CLDR.
|
|||
|
"""
|
|||
|
if self._searchable is not None:
|
|||
|
return self._searchable
|
|||
|
|
|||
|
self._searchable = (
|
|||
|
self._filter_attributes({'language', 'script', 'territory'})
|
|||
|
.simplify_script()
|
|||
|
.prefer_macrolanguage()
|
|||
|
)
|
|||
|
return self._searchable
|
|||
|
|
|||
|
def __eq__(self, other):
|
|||
|
if self is other:
|
|||
|
return True
|
|||
|
if not isinstance(other, Language):
|
|||
|
return False
|
|||
|
return self._str_tag == other._str_tag
|
|||
|
|
|||
|
def __hash__(self) -> int:
|
|||
|
return hash(id(self))
|
|||
|
|
|||
|
def __getitem__(self, key: str) -> Optional[Union[str, List[str]]]:
|
|||
|
if key in self.ATTRIBUTES:
|
|||
|
return getattr(self, key)
|
|||
|
else:
|
|||
|
raise KeyError(key)
|
|||
|
|
|||
|
def __contains__(self, key: str) -> bool:
|
|||
|
return key in self.ATTRIBUTES and getattr(self, key)
|
|||
|
|
|||
|
def __repr__(self) -> str:
|
|||
|
items = []
|
|||
|
for attr in self.ATTRIBUTES:
|
|||
|
if getattr(self, attr):
|
|||
|
value = getattr(self, attr)
|
|||
|
items.append(f'{attr}={value!r}')
|
|||
|
joined = ', '.join(items)
|
|||
|
return f"Language.make({joined})"
|
|||
|
|
|||
|
def __str__(self) -> str:
|
|||
|
return self.to_tag()
|
|||
|
|
|||
|
|
|||
|
# Make the get(), find(), and find_name() functions available at the top level
|
|||
|
get = Language.get
|
|||
|
find = Language.find
|
|||
|
find_name = Language.find_name
|
|||
|
|
|||
|
# Make the Language object available under the old name LanguageData
|
|||
|
LanguageData = Language
|
|||
|
|
|||
|
|
|||
|
def standardize_tag(tag: Union[str, Language], macro: bool = False) -> str:
|
|||
|
"""
|
|||
|
Standardize a language tag:
|
|||
|
|
|||
|
- Replace deprecated values with their updated versions (if those exist)
|
|||
|
- Remove script tags that are redundant with the language
|
|||
|
- If *macro* is True, use a macrolanguage to represent the most common
|
|||
|
standardized language within that macrolanguage. For example, 'cmn'
|
|||
|
(Mandarin) becomes 'zh' (Chinese), and 'arb' (Modern Standard Arabic)
|
|||
|
becomes 'ar' (Arabic).
|
|||
|
- Format the result according to the conventions of BCP 47
|
|||
|
|
|||
|
Macrolanguage replacement is not required by BCP 47, but it is required
|
|||
|
by the Unicode CLDR.
|
|||
|
|
|||
|
>>> standardize_tag('en_US')
|
|||
|
'en-US'
|
|||
|
|
|||
|
>>> standardize_tag('en-Latn')
|
|||
|
'en'
|
|||
|
|
|||
|
>>> standardize_tag('en-uk')
|
|||
|
'en-GB'
|
|||
|
|
|||
|
>>> standardize_tag('eng')
|
|||
|
'en'
|
|||
|
|
|||
|
>>> standardize_tag('arb-Arab', macro=True)
|
|||
|
'ar'
|
|||
|
|
|||
|
>>> standardize_tag('sh-QU')
|
|||
|
'sr-Latn-EU'
|
|||
|
|
|||
|
>>> standardize_tag('sgn-US')
|
|||
|
'ase'
|
|||
|
|
|||
|
>>> standardize_tag('zh-cmn-hans-cn')
|
|||
|
'zh-Hans-CN'
|
|||
|
|
|||
|
>>> standardize_tag('zsm', macro=True)
|
|||
|
'ms'
|
|||
|
|
|||
|
>>> standardize_tag('ja-latn-hepburn')
|
|||
|
'ja-Latn-hepburn'
|
|||
|
|
|||
|
>>> standardize_tag('spa-latn-mx')
|
|||
|
'es-MX'
|
|||
|
|
|||
|
If the tag can't be parsed according to BCP 47, this will raise a
|
|||
|
LanguageTagError (a subclass of ValueError):
|
|||
|
|
|||
|
>>> standardize_tag('spa-mx-latn')
|
|||
|
Traceback (most recent call last):
|
|||
|
...
|
|||
|
langcodes.tag_parser.LanguageTagError: This script subtag, 'latn', is out of place. Expected variant, extension, or end of string.
|
|||
|
"""
|
|||
|
langdata = Language.get(tag, normalize=True)
|
|||
|
if macro:
|
|||
|
langdata = langdata.prefer_macrolanguage()
|
|||
|
|
|||
|
return langdata.simplify_script().to_tag()
|
|||
|
|
|||
|
|
|||
|
def tag_is_valid(tag: Union[str, Language]) -> bool:
|
|||
|
"""
|
|||
|
Determines whether a string is a valid language tag. This is similar to
|
|||
|
Language.get(tag).is_valid(), but can return False in the case where
|
|||
|
the tag doesn't parse.
|
|||
|
|
|||
|
>>> tag_is_valid('ja')
|
|||
|
True
|
|||
|
>>> tag_is_valid('jp')
|
|||
|
False
|
|||
|
>>> tag_is_valid('spa-Latn-MX')
|
|||
|
True
|
|||
|
>>> tag_is_valid('spa-MX-Latn')
|
|||
|
False
|
|||
|
>>> tag_is_valid('')
|
|||
|
False
|
|||
|
>>> tag_is_valid('C.UTF-8')
|
|||
|
False
|
|||
|
"""
|
|||
|
try:
|
|||
|
langdata = Language.get(tag)
|
|||
|
return langdata.is_valid()
|
|||
|
except LanguageTagError:
|
|||
|
return False
|
|||
|
|
|||
|
|
|||
|
def tag_match_score(
|
|||
|
desired: Union[str, Language], supported: Union[str, Language]
|
|||
|
) -> int:
|
|||
|
"""
|
|||
|
DEPRECATED: use .distance() instead, which uses newer data and is _lower_
|
|||
|
for better matching languages.
|
|||
|
|
|||
|
Return a number from 0 to 100 indicating the strength of match between the
|
|||
|
language the user desires, D, and a supported language, S. Higher numbers
|
|||
|
are better. A reasonable cutoff for not messing with your users is to
|
|||
|
only accept scores of 75 or more.
|
|||
|
|
|||
|
A score of 100 means the languages are the same, possibly after normalizing
|
|||
|
and filling in likely values.
|
|||
|
"""
|
|||
|
warnings.warn(
|
|||
|
"tag_match_score is deprecated because it's based on deprecated CLDR info. "
|
|||
|
"Use tag_distance instead, which is _lower_ for better matching languages. ",
|
|||
|
DeprecationWarning,
|
|||
|
)
|
|||
|
desired_ld = Language.get(desired)
|
|||
|
supported_ld = Language.get(supported)
|
|||
|
return desired_ld.match_score(supported_ld)
|
|||
|
|
|||
|
|
|||
|
def tag_distance(desired: Union[str, Language], supported: Union[str, Language]) -> int:
|
|||
|
"""
|
|||
|
Tags that expand to the same thing when likely values are filled in get a
|
|||
|
distance of 0.
|
|||
|
|
|||
|
>>> tag_distance('en', 'en')
|
|||
|
0
|
|||
|
>>> tag_distance('en', 'en-US')
|
|||
|
0
|
|||
|
>>> tag_distance('zh-Hant', 'zh-TW')
|
|||
|
0
|
|||
|
>>> tag_distance('ru-Cyrl', 'ru')
|
|||
|
0
|
|||
|
|
|||
|
As a specific example, Serbo-Croatian is a politically contentious idea,
|
|||
|
but in CLDR, it's considered equivalent to Serbian in Latin characters.
|
|||
|
|
|||
|
>>> tag_distance('sh', 'sr-Latn')
|
|||
|
0
|
|||
|
|
|||
|
... which is very similar to Croatian but sociopolitically not the same.
|
|||
|
|
|||
|
>>> tag_distance('sh', 'hr')
|
|||
|
9
|
|||
|
|
|||
|
Unicode reorganized its distinction between 'no' (Norwegian) and 'nb'
|
|||
|
(Norwegian Bokmål) in 2021. 'no' is preferred in most contexts, and the more
|
|||
|
specific 'nb' is a distance of 1 from it:
|
|||
|
|
|||
|
>>> tag_distance('nb', 'no')
|
|||
|
1
|
|||
|
|
|||
|
These distances can be asymmetrical: this data includes the fact that speakers
|
|||
|
of Swiss German (gsw) know High German (de), but not at all the other way around.
|
|||
|
|
|||
|
The difference seems a little bit extreme, but the asymmetry is certainly
|
|||
|
there. And if your text is tagged as 'gsw', it must be that way for a
|
|||
|
reason.
|
|||
|
|
|||
|
>>> tag_distance('gsw', 'de')
|
|||
|
8
|
|||
|
>>> tag_distance('de', 'gsw')
|
|||
|
84
|
|||
|
|
|||
|
Unconnected languages get a distance of 80 to 134.
|
|||
|
|
|||
|
>>> tag_distance('en', 'zh')
|
|||
|
134
|
|||
|
>>> tag_distance('es', 'fr')
|
|||
|
84
|
|||
|
>>> tag_distance('fr-CH', 'de-CH')
|
|||
|
80
|
|||
|
|
|||
|
Different local variants of the same language get a distance from 3 to 5.
|
|||
|
>>> tag_distance('zh-HK', 'zh-MO') # Chinese is similar in Hong Kong and Macao
|
|||
|
4
|
|||
|
>>> tag_distance('en-AU', 'en-GB') # Australian English is similar to British English
|
|||
|
3
|
|||
|
>>> tag_distance('en-IN', 'en-GB') # Indian English is also similar to British English
|
|||
|
3
|
|||
|
>>> tag_distance('es-PE', 'es-419') # Peruvian Spanish is Latin American Spanish
|
|||
|
1
|
|||
|
>>> tag_distance('es-419', 'es-PE') # but Latin American Spanish is not necessarily Peruvian
|
|||
|
4
|
|||
|
>>> tag_distance('es-ES', 'es-419') # Spanish in Spain is further from Latin American Spanish
|
|||
|
5
|
|||
|
>>> tag_distance('en-US', 'en-GB') # American and British English are somewhat different
|
|||
|
5
|
|||
|
>>> tag_distance('es-MX', 'es-ES') # Mexican Spanish is different from Spanish Spanish
|
|||
|
5
|
|||
|
>>> # European Portuguese is different from the most common form (Brazilian Portuguese)
|
|||
|
>>> tag_distance('pt', 'pt-PT')
|
|||
|
5
|
|||
|
|
|||
|
>>> # Serbian has two scripts, and people might prefer one but understand both
|
|||
|
>>> tag_distance('sr-Latn', 'sr-Cyrl')
|
|||
|
5
|
|||
|
|
|||
|
A distance of 10 is used for matching a specific language to its
|
|||
|
more-commonly-used macrolanguage tag.
|
|||
|
|
|||
|
>>> tag_distance('arz', 'ar') # Egyptian Arabic to Modern Standard Arabic
|
|||
|
10
|
|||
|
>>> tag_distance('wuu', 'zh') # Wu Chinese to (Mandarin) Chinese
|
|||
|
10
|
|||
|
|
|||
|
Higher distances can arrive due to particularly contentious differences in
|
|||
|
the script for writing the language, where people who understand one script
|
|||
|
can learn the other but may not be happy with it. This specifically applies
|
|||
|
to Chinese.
|
|||
|
|
|||
|
>>> tag_distance('zh-TW', 'zh-CN')
|
|||
|
54
|
|||
|
>>> tag_distance('zh-Hans', 'zh-Hant')
|
|||
|
54
|
|||
|
>>> tag_distance('zh-CN', 'zh-HK')
|
|||
|
54
|
|||
|
>>> tag_distance('zh-CN', 'zh-TW')
|
|||
|
54
|
|||
|
>>> tag_distance('zh-Hant', 'zh-Hans')
|
|||
|
54
|
|||
|
|
|||
|
This distance range also applies to the differences between Norwegian
|
|||
|
Bokmål, Nynorsk, and Danish.
|
|||
|
|
|||
|
>>> tag_distance('no', 'da')
|
|||
|
12
|
|||
|
>>> tag_distance('no', 'nn')
|
|||
|
20
|
|||
|
|
|||
|
Differences of 20 to 50 can represent substantially different languages,
|
|||
|
in cases where speakers of the first may understand the second for demographic
|
|||
|
reasons.
|
|||
|
|
|||
|
>>> tag_distance('eu', 'es') # Basque to Spanish
|
|||
|
20
|
|||
|
>>> tag_distance('af', 'nl') # Afrikaans to Dutch
|
|||
|
24
|
|||
|
>>> tag_distance('mr', 'hi') # Marathi to Hindi
|
|||
|
30
|
|||
|
>>> tag_distance('ms', 'id') # Malay to Indonesian
|
|||
|
34
|
|||
|
>>> tag_distance('mg', 'fr') # Malagasy to French
|
|||
|
34
|
|||
|
>>> tag_distance('ta', 'en') # Tamil to English
|
|||
|
44
|
|||
|
|
|||
|
A complex example is the tag 'yue' for Cantonese. Written Chinese is usually
|
|||
|
presumed to be Mandarin Chinese, but colloquial Cantonese can be written as
|
|||
|
well. (Some things could not be written any other way, such as Cantonese
|
|||
|
song lyrics.)
|
|||
|
|
|||
|
The difference between Cantonese and Mandarin also implies script and
|
|||
|
territory differences by default, adding to the distance.
|
|||
|
|
|||
|
>>> tag_distance('yue', 'zh')
|
|||
|
64
|
|||
|
|
|||
|
When the supported script is a different one than desired, this is usually
|
|||
|
a major difference with score of 50 or more.
|
|||
|
|
|||
|
>>> tag_distance('ja', 'ja-Latn-US-hepburn')
|
|||
|
54
|
|||
|
|
|||
|
>>> # You can read the Shavian script, right?
|
|||
|
>>> tag_distance('en', 'en-Shaw')
|
|||
|
54
|
|||
|
"""
|
|||
|
desired_obj = Language.get(desired)
|
|||
|
supported_obj = Language.get(supported)
|
|||
|
return desired_obj.distance(supported_obj)
|
|||
|
|
|||
|
|
|||
|
def best_match(
|
|||
|
desired_language: Union[str, Language],
|
|||
|
supported_languages: Sequence[str],
|
|||
|
min_score: int = 75,
|
|||
|
) -> Tuple[str, int]:
|
|||
|
"""
|
|||
|
DEPRECATED: use .closest_match() instead. This function emulates the old
|
|||
|
matching behavior by subtracting the language distance from 100.
|
|||
|
|
|||
|
You have software that supports any of the `supported_languages`. You want
|
|||
|
to use `desired_language`. This function lets you choose the right language,
|
|||
|
even if there isn't an exact match.
|
|||
|
|
|||
|
Returns:
|
|||
|
|
|||
|
- The best-matching language code, which will be one of the
|
|||
|
`supported_languages` or 'und'
|
|||
|
- The score of the match, from 0 to 100; higher is better.
|
|||
|
|
|||
|
`min_score` sets the minimum match score. If all languages match with a lower
|
|||
|
score than that, the result will be 'und' with a score of 0.
|
|||
|
"""
|
|||
|
max_distance = 100 - min_score
|
|||
|
supported, distance = closest_match(
|
|||
|
desired_language, supported_languages, max_distance
|
|||
|
)
|
|||
|
score = max(0, 100 - distance)
|
|||
|
return supported, score
|
|||
|
|
|||
|
|
|||
|
def closest_match(
|
|||
|
desired_language: Union[str, Language],
|
|||
|
supported_languages: Sequence[str],
|
|||
|
max_distance: int = 25,
|
|||
|
) -> Tuple[str, int]:
|
|||
|
"""
|
|||
|
You have software that supports any of the `supported_languages`. You want
|
|||
|
to use `desired_language`. This function lets you choose the right language,
|
|||
|
even if there isn't an exact match.
|
|||
|
|
|||
|
Returns:
|
|||
|
|
|||
|
- The best-matching language code, which will be one of the
|
|||
|
`supported_languages` or 'und' for no match
|
|||
|
- The distance of the match, which is 0 for a perfect match and increases
|
|||
|
from there (see `tag_distance`)
|
|||
|
|
|||
|
`max_distance` sets the maximum match distance. If all matches are farther
|
|||
|
than that, the result will be 'und' with a distance of 1000. The default
|
|||
|
value is 25, and raising it can cause data to be processed in significantly
|
|||
|
the wrong language. The documentation for `tag_distance` describes the
|
|||
|
distance values in more detail.
|
|||
|
|
|||
|
When there is a tie for the best matching language, the first one in the
|
|||
|
tie will be used.
|
|||
|
|
|||
|
>>> closest_match('fr', ['de', 'en', 'fr'])
|
|||
|
('fr', 0)
|
|||
|
|
|||
|
>>> closest_match('pt', ['pt-BR', 'pt-PT'])
|
|||
|
('pt-BR', 0)
|
|||
|
|
|||
|
>>> closest_match('en-AU', ['en-GB', 'en-US'])
|
|||
|
('en-GB', 3)
|
|||
|
|
|||
|
>>> closest_match('af', ['en', 'nl', 'zu'])
|
|||
|
('nl', 24)
|
|||
|
|
|||
|
>>> closest_match('ja', ['ja-Latn-hepburn', 'en'])
|
|||
|
('und', 1000)
|
|||
|
"""
|
|||
|
desired_language = str(desired_language)
|
|||
|
|
|||
|
# Quickly return if the desired language is directly supported
|
|||
|
if desired_language in supported_languages:
|
|||
|
return desired_language, 0
|
|||
|
|
|||
|
# Reduce the desired language to a standard form that could also match
|
|||
|
desired_language = standardize_tag(desired_language)
|
|||
|
if desired_language in supported_languages:
|
|||
|
return desired_language, 0
|
|||
|
|
|||
|
match_distances = [
|
|||
|
(supported, tag_distance(desired_language, supported))
|
|||
|
for supported in supported_languages
|
|||
|
]
|
|||
|
match_distances = [
|
|||
|
(supported, distance)
|
|||
|
for (supported, distance) in match_distances
|
|||
|
if distance <= max_distance
|
|||
|
] + [('und', 1000)]
|
|||
|
|
|||
|
match_distances.sort(key=itemgetter(1))
|
|||
|
return match_distances[0]
|
|||
|
|
|||
|
|
|||
|
def closest_supported_match(
|
|||
|
desired_language: Union[str, Language],
|
|||
|
supported_languages: Sequence[str],
|
|||
|
max_distance: int = 25,
|
|||
|
) -> Optional[str]:
|
|||
|
"""
|
|||
|
Wraps `closest_match` with a simpler return type. Returns the language
|
|||
|
tag of the closest match if there is one, or None if there is not.
|
|||
|
|
|||
|
>>> closest_supported_match('fr', ['de', 'en', 'fr'])
|
|||
|
'fr'
|
|||
|
|
|||
|
>>> closest_supported_match('pt', ['pt-BR', 'pt-PT'])
|
|||
|
'pt-BR'
|
|||
|
|
|||
|
>>> closest_supported_match('en-AU', ['en-GB', 'en-US'])
|
|||
|
'en-GB'
|
|||
|
|
|||
|
>>> closest_supported_match('und', ['en', 'und'])
|
|||
|
'und'
|
|||
|
|
|||
|
>>> closest_supported_match('af', ['en', 'nl', 'zu'])
|
|||
|
'nl'
|
|||
|
|
|||
|
>>> print(closest_supported_match('af', ['en', 'nl', 'zu'], max_distance=10))
|
|||
|
None
|
|||
|
"""
|
|||
|
code, distance = closest_match(desired_language, supported_languages, max_distance)
|
|||
|
if distance == 1000:
|
|||
|
return None
|
|||
|
else:
|
|||
|
return code
|