kivy-garden-i18n 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,168 @@
1
+ __all__ =(
2
+ "enum_pre_installed_fonts", "default_filter", "enum_langs", "register_lang",
3
+ "font_provides_glyphs", "font_supports_lang",
4
+ )
5
+
6
+ from typing import Union
7
+ from collections.abc import Callable, Iterator
8
+ from pathlib import Path, PurePath
9
+ from kivy.core.text import LabelBase, Label as CoreLabel
10
+
11
+
12
+ def default_filter(font: PurePath, suffixes={".ttf", ".otf", ".ttc", ".woff", ".woff2"}) -> bool:
13
+ '''
14
+ The default filter for :func:`enum_pre_installed_fonts`.
15
+
16
+ Returns True if ``font`` has one of the specified suffixes and its filename
17
+ does not contain ``"fallback"``.
18
+ '''
19
+ return font.suffix in suffixes and "fallback" not in font.name.lower()
20
+
21
+
22
+ def enum_pre_installed_fonts(*, filter: Callable[[PurePath], bool]=default_filter) -> Iterator[Path]:
23
+ for dir in LabelBase.get_system_fonts_dir():
24
+ for child in Path(dir).iterdir():
25
+ if filter(child):
26
+ yield child
27
+
28
+
29
+ def font_provides_glyphs(font: str | Path, glyphs: str) -> bool:
30
+ '''
31
+ Whether a specified ``font`` provides all of the given ``glyphs``.
32
+
33
+ :param glyphs: A string consisting of three or more unique characters.
34
+
35
+ .. code-block::
36
+
37
+ from kivy_garden.i18n.fontfinder import font_provides_glyphs as f
38
+
39
+ # Roboto, Kivy's default font, lacks CJK glyphs.
40
+ assert f("Roboto", "ABC")
41
+ assert not f("Roboto", "漢字한글ひら")
42
+ assert not f("Roboto", "漢字한글ひらABC")
43
+
44
+ # NotoSerifCJK provides ASCII and CJK glyphs.
45
+ assert f("NotoSerifCJK-Regular.ttc", "ABC")
46
+ assert f("NotoSerifCJK-Regular.ttc", "漢字한글ひら")
47
+ assert f("NotoSerifCJK-Regular.ttc", "漢字한글ひらABC")
48
+
49
+ # Fallback fonts may lack ASCII glyphs.
50
+ assert not f("DroidSansFallbackFull.ttf", "ABC")
51
+ assert f("DroidSansFallbackFull.ttf", "漢字한글ひら")
52
+ assert not f("DroidSansFallbackFull.ttf", "漢字한글ひらABC")
53
+
54
+ .. warning::
55
+
56
+ This function does not produce 100% accurate results.
57
+ Providing more glyphs improves accuracy at the cost of execution time.
58
+ '''
59
+ if not _validate_discriminant(glyphs):
60
+ raise ValueError(f"'glyphs' must consist of three or more unique characters (was {glyphs!r})")
61
+ label = CoreLabel()
62
+ label._size = (16, 16, )
63
+ label.options['font_name'] = str(font)
64
+ label.resolve_font_name()
65
+ rendered_results = set()
66
+ for c in glyphs:
67
+ label.text = c
68
+ label._render_begin()
69
+ label._render_text(c, 0, 0)
70
+ pixels = label._render_end().data
71
+ if pixels in rendered_results:
72
+ return False
73
+ rendered_results.add(pixels)
74
+ return True
75
+
76
+
77
+ DISCRIMINANTS = {
78
+ 'ar': 'الجزيرةAB',
79
+ 'hi': 'भारतAB',
80
+ 'ja': (v := '経伝説あAB'),
81
+ 'ja-JP': v,
82
+ 'ja_JP': v,
83
+ 'ko': (v := '안녕조AB'),
84
+ 'ko-KR': v,
85
+ 'ko_KR': v,
86
+ 'zh-Hans': (v := '哪经传说AB'),
87
+ 'zh_Hans': v,
88
+ 'zh-CN': v,
89
+ 'zh_CN': v,
90
+ 'zh-SG': v,
91
+ 'zh_SG': v,
92
+ 'zh-Hant': (v := '哪經傳說AB'),
93
+ 'zh_Hant': v,
94
+ 'zh-TW': v,
95
+ 'zh_TW': v,
96
+ 'zh-HK': v,
97
+ 'zh_HK': v,
98
+ 'zh-MO': v,
99
+ 'zh_MO': v,
100
+ 'zh': '哪經傳說经传说AB',
101
+ }
102
+ '''
103
+ あるフォントがある言語に対応しているか否かを判定するために使われる辞書。
104
+ 辞書の値に含まれている文字全てがフォントに含まれている時のみ、そのフォントは対応する鍵の言語に対応していると見做される。
105
+ '''
106
+
107
+
108
+ def font_supports_lang(font: Union[str, Path], lang: str) -> bool:
109
+ '''
110
+ Whether a specified ``font`` provides the glyphs required for a specified ``lang``.
111
+
112
+ .. code-block::
113
+
114
+ from kivy_garden.i18n.fontfinder import font_supports_lang as f
115
+
116
+ assert not f("Roboto", "zh")
117
+ assert not f("Roboto", "ko")
118
+ assert not f("Roboto", "ja")
119
+
120
+ assert f("NotoSerifCJK-Regular.ttc", "zh")
121
+ assert f("NotoSerifCJK-Regular.ttc", "ko")
122
+ assert f("NotoSerifCJK-Regular.ttc", "ja")
123
+
124
+ # A font that lacks ASCII characters is considered unable to support any language.
125
+ assert not f("DroidSansFallbackFull.ttf", "zh")
126
+ assert not f("DroidSansFallbackFull.ttf", "ko")
127
+ assert not f("DroidSansFallbackFull.ttf", "ja")
128
+
129
+ .. warning::
130
+
131
+ This function does not produce 100% accurate results.
132
+ '''
133
+ try:
134
+ glyphs = DISCRIMINANTS[lang]
135
+ except KeyError:
136
+ raise ValueError(f"Unable to check language support: {lang = }.\n"
137
+ "Register the language first using 'register_lang' function.")
138
+ return font_provides_glyphs(font, glyphs)
139
+
140
+
141
+ def enum_langs() -> Iterator[str]:
142
+ '''
143
+ Available languages for :func:`font_supports_lang`.
144
+ '''
145
+ return DISCRIMINANTS.keys()
146
+
147
+
148
+ def register_lang(lang: str, discriminant: str):
149
+ '''
150
+ Enables a language in the :func:`font_supports_lang`.
151
+
152
+ .. code-block::
153
+
154
+ register_lang('th', "ราชอAB") # Thai language
155
+ '''
156
+ if not _validate_discriminant(discriminant):
157
+ raise ValueError(f"'discriminant' must consist of three or more unique characters (was {discriminant!r})")
158
+ DISCRIMINANTS[lang] = discriminant
159
+
160
+
161
+ def _validate_discriminant(discriminant: str, len=len, set=set) -> bool:
162
+ l = len(discriminant)
163
+ return l >= 3 and len(set(discriminant)) == l
164
+
165
+
166
+ # Aliases for backward compatibility
167
+ can_render_text = font_provides_glyphs
168
+ can_render_lang = font_supports_lang
@@ -0,0 +1,237 @@
1
+ __all__ = (
2
+ # type hints
3
+ "Translator", "TranslatorFactory", "FontPicker",
4
+
5
+ # exceptions
6
+ "FontNotFoundError",
7
+
8
+ # concrete TranslatorFactory
9
+ "GettextBasedTranslatorFactory", "MappingBasedTranslatorFactory",
10
+
11
+ # concrete FontPicker
12
+ "DefaultFontPicker",
13
+
14
+ #
15
+ "Localizer",
16
+ )
17
+
18
+ from collections.abc import Callable, Mapping
19
+ from typing import TypeAlias, Union
20
+ import itertools
21
+ from functools import cached_property
22
+
23
+ from kivy.properties import StringProperty, ObjectProperty
24
+ from kivy.event import EventDispatcher
25
+ from kivy.logger import Logger
26
+ from kivy.uix.label import Label
27
+
28
+ from .fontfinder import enum_pre_installed_fonts, font_supports_lang
29
+
30
+ Msgid: TypeAlias = str
31
+ Msgstr: TypeAlias = str
32
+ Lang: TypeAlias = str
33
+ Translator: TypeAlias = Callable[[Msgid], Msgstr]
34
+ TranslatorFactory : TypeAlias = Callable[[Lang], Translator]
35
+ Font: TypeAlias = str
36
+ FontPicker: TypeAlias = Callable[[Lang], Font]
37
+
38
+
39
+ class FontNotFoundError(Exception):
40
+ @cached_property
41
+ def lang(self) -> Lang:
42
+ '''The language for which a localizer couldn't find a suitable font.'''
43
+ return self.args[0]
44
+
45
+
46
+ class Localizer(EventDispatcher):
47
+ lang: Lang = StringProperty()
48
+ '''
49
+ The "current language" of this localizer.
50
+ '''
51
+
52
+ _: Translator = ObjectProperty(lambda msgid: msgid)
53
+ '''
54
+ (read-only)
55
+ A callable object that takes a ``msgid``, and returns the corresponging ``msgstr`` according to the "current language".
56
+
57
+ .. code-block::
58
+
59
+ loc = Localizer(...)
60
+ loc.lang = "en"
61
+ print(loc._("app title")) # => "My First Kivy Program"
62
+ loc.lang = "ja"
63
+ print(loc._("app title")) # => "初めてのKivyプログラム"
64
+
65
+ :meta public:
66
+ '''
67
+
68
+ font_name: Font = StringProperty(Label.font_name.defaultvalue)
69
+ '''
70
+ (read-only)
71
+ A font that provides the glyphs required for rendering the "current language".
72
+
73
+ .. code-block::
74
+
75
+ loc = Localizer(...)
76
+ loc.lang = "en"
77
+ print(loc.font_name) # => "Roboto"
78
+ loc.lang = "ko"
79
+ print(loc.font_name) # => "<a pre-installed Korean font>"
80
+ '''
81
+
82
+ def __init__(self, translator_factory: TranslatorFactory=None, *, lang: Lang='en', font_picker: FontPicker=None):
83
+ if translator_factory is None:
84
+ Logger.warning(f"kivy_garden.i18n: No translator_factory was provided. Msgid's themselves will be displayed.")
85
+ translator_factory = lambda lang: lambda msgid: msgid
86
+ if font_picker is None:
87
+ font_picker = DefaultFontPicker()
88
+ self.translator_factory = translator_factory
89
+ self.font_picker = font_picker
90
+ super().__init__(lang=lang)
91
+
92
+ def install(self, *, name):
93
+ '''
94
+ Makes the localizer accessble from kv without any import-statements.
95
+
96
+ .. code-block::
97
+
98
+ loc = Localizer(...)
99
+ loc.install(name='l')
100
+
101
+ .. code-block:: yaml
102
+
103
+ Label:
104
+ font_name: l.font_name
105
+ text: l._("msgid")
106
+
107
+ :raises ValueError: if the ``name`` has already been used.
108
+ '''
109
+ from kivy.lang import global_idmap
110
+ if name in global_idmap:
111
+ raise ValueError(f"The name {name!r} has already been used.")
112
+ global_idmap[name] = self
113
+
114
+ def uninstall(self, *, name):
115
+ from kivy.lang import global_idmap
116
+ if name not in global_idmap:
117
+ raise ValueError(f"The name {name!r} not found.")
118
+ if global_idmap[name] is not self:
119
+ raise ValueError(f"The object referenced by {name!r} is not me.")
120
+ del global_idmap[name]
121
+
122
+ @staticmethod
123
+ def on_lang(self, lang):
124
+ ''':meta private:'''
125
+ self._ = self.translator_factory(lang)
126
+ self.font_name = self.font_picker(lang)
127
+
128
+
129
+ class DefaultFontPicker:
130
+ PRESET = {
131
+ "en": (v := "Roboto"),
132
+ "fr": v,
133
+ "it": v,
134
+ "pt": v,
135
+ "ru": v,
136
+ }
137
+ ''':meta private:'''
138
+
139
+ del v
140
+
141
+ def __init__(self, *, fallback: Union[Lang, None]="Roboto"):
142
+ self._lang2font = self.PRESET.copy()
143
+ self._fallback = fallback
144
+
145
+ def __call__(self, lang: Lang) -> Font:
146
+ try:
147
+ return self._lang2font[lang]
148
+ except KeyError:
149
+ pass
150
+
151
+ name = None
152
+ for font in enum_pre_installed_fonts():
153
+ if font_supports_lang(font, lang):
154
+ name = font.name
155
+ break
156
+ if name is None:
157
+ fallback = self._fallback
158
+ if fallback is None:
159
+ raise FontNotFoundError(lang)
160
+ Logger.warning(f"kivy_garden.i18n: Couldn't find a font for lang '{lang}'. Use {fallback} as a fallback.")
161
+ name = fallback
162
+ self._lang2font[lang] = name
163
+ return name
164
+
165
+
166
+ class GettextBasedTranslatorFactory:
167
+ def __init__(self, domain, localedir):
168
+ self.domain = domain
169
+ self.localedir = localedir
170
+
171
+ def __call__(self, lang: Lang) -> Translator:
172
+ from gettext import translation
173
+ return translation(domain=self.domain, localedir=self.localedir, languages=(lang, )).gettext
174
+
175
+
176
+ class MappingBasedTranslatorFactory:
177
+ def __init__(self, translations: Mapping[Msgid, Mapping[Lang, Msgstr]], /, strict=False):
178
+ '''
179
+ :param strict:
180
+ If False (default), a missing translation falls back to the ``msgid`` itself.
181
+ If True, a missing translation raises ``ValueError``.
182
+ '''
183
+ self._compiled_translations = self._compile_translations(translations, strict=strict)
184
+
185
+ def __call__(self, lang: Lang) -> Translator:
186
+ return self._compiled_translations[lang].__getitem__
187
+
188
+ @staticmethod
189
+ def _compile_translations(d: Mapping[Msgid, Mapping[Lang, Msgstr]], *, strict) -> dict[Lang, dict[Msgid, Msgstr]]:
190
+ '''
191
+ アプリ開発者側にとって嬉しいのは次のような形式の翻訳表だと思うが
192
+
193
+ .. code-block::
194
+
195
+ 翻訳表 = {
196
+ "greeting": {
197
+ "ja": "おはよう",
198
+ "en": "morning",
199
+ },
200
+ "app title": {
201
+ "ja": "初めてのKivyプログラム",
202
+ "en": "My First Kivy App",
203
+ },
204
+ }
205
+
206
+ ライブラリが内部で持ちたいのは次の形式の翻訳表である。
207
+
208
+ .. code-block::
209
+
210
+ 翻訳表 = {
211
+ "ja": {
212
+ "greeting": "おはよう",
213
+ "app title": "初めてのKivyプログラム",
214
+ },
215
+ "en": {
216
+ "greeting": "morning",
217
+ "app title": "My First Kivy App",
218
+ },
219
+ }
220
+
221
+ この関数は前者を後者に変換する。
222
+ '''
223
+ msgids = tuple(d.keys())
224
+ langs = set(itertools.chain.from_iterable(d.values()))
225
+ if strict:
226
+ try:
227
+ return {
228
+ lang: {msgid: d[msgid][lang] for msgid in msgids}
229
+ for lang in langs
230
+ }
231
+ except KeyError as e:
232
+ raise ValueError(f"Msgid '{e.args[0]}' is missing one or more translations") from e
233
+ else:
234
+ return {
235
+ lang: {msgid: d[msgid].get(lang, msgid) for msgid in msgids}
236
+ for lang in langs
237
+ }
@@ -0,0 +1 @@
1
+ from ._extract_msgids_from_string_literals import extract_msgids_from_string_literals
@@ -0,0 +1,64 @@
1
+ __all__ = ('extract_msgids_from_string_literals', )
2
+
3
+ import re
4
+ from typing import Iterator
5
+
6
+
7
+ def extract_string_literals(python_code: str) -> Iterator[str]:
8
+ from ast import parse, walk, Constant
9
+ str_ = str
10
+ isinstance_ = isinstance
11
+ for node in walk(parse(python_code)):
12
+ if isinstance_(node, Constant) and isinstance_(node.value, str_):
13
+ yield node.value
14
+
15
+
16
+ PATTERN = re.compile(r"""
17
+ (^|\W)_\("(.*?)"\) # _("")で括られた文字列
18
+ | # もしくは
19
+ (^|\W)_\('(.*?)'\) # _('')で括られた文字列
20
+ """, re.VERBOSE | re.MULTILINE)
21
+
22
+
23
+ def extract_msgid(s:str) -> Iterator[str]:
24
+ for m in PATTERN.finditer(s):
25
+ yield (m.group(2) or m.group(4))
26
+
27
+
28
+ def extract_msgids_from_string_literals(python_code: str) -> Iterator[str]:
29
+ '''
30
+ .. code-block::
31
+
32
+ msgids = extract_msgids_from_string_literals("""
33
+ Label:
34
+ font_name: _("AAA")
35
+ text: _("BBB")
36
+ """)
37
+ assert list(msgids) == ["AAA", "BBB"]
38
+ '''
39
+ return (
40
+ ls
41
+ for s in extract_string_literals(python_code)
42
+ for ls in extract_msgid(s)
43
+ )
44
+
45
+
46
+ def cli_main():
47
+ from textwrap import dedent
48
+ import sys
49
+ from pathlib import Path
50
+ from io import StringIO
51
+
52
+ if len(sys.argv) == 1:
53
+ print(dedent("""
54
+ Usage:
55
+ extract-msgids filename1.py filename2.py ...
56
+ """),
57
+ file=sys.stderr)
58
+ return
59
+ output = StringIO()
60
+ for file in sys.argv[1:]:
61
+ for ls in extract_msgids_from_string_literals(Path(file).read_text(encoding='utf-8')):
62
+ print(ls, file=output)
63
+ print(output.getvalue())
64
+ output.close()
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: kivy-garden-i18n
3
+ Version: 0.3.0
4
+ Summary: i18n for Kivy
5
+ Keywords: kivy
6
+ Author: Nattōsai Mitō
7
+ Author-email: Nattōsai Mitō <flow4re2c@gmail.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Classifier: Operating System :: OS Independent
20
+ Requires-Python: >=3.10, <4.0
21
+ Project-URL: Repository, https://github.com/gottadiveintopython/i18n
22
+ Project-URL: Documentation, https://gottadiveintopython.github.io/i18n/
23
+ Description-Content-Type: text/markdown
24
+
25
+ # kivy_garden.i18n
26
+
27
+ Internationalization support for Kivy applications.
28
+
29
+ ```python
30
+ from kivy_garden.i18n.localizer import MappingBasedTranslatorFactory, Localizer
31
+
32
+ translations = {
33
+ "greeting": {
34
+ "ja": "おはよう",
35
+ "en": "morning",
36
+ },
37
+ "app title": {
38
+ "ja": "初めてのKivyプログラム",
39
+ "en": "My First Kivy App",
40
+ },
41
+ }
42
+ localizer = Localizer(MappingBasedTranslatorFactory(translations))
43
+
44
+ l = localizer
45
+ l.lang = "en" # Set the "current language" to "en"
46
+ assert l.font_name == "Roboto"
47
+ assert l._("greeting") == "morning"
48
+ assert l._("app title") == "My First Kivy App"
49
+
50
+ l.lang = "ja"
51
+ assert l.font_name == "<a pre-installed Japanese font>"
52
+ assert l._("greeting") == "おはよう"
53
+ assert l._("app title") == "初めてのKivyプログラム"
54
+ ```
55
+
56
+ Bindings:
57
+
58
+ ```python
59
+ from kivy.lang import Builder
60
+
61
+ localizer.install(name="l")
62
+ label = Builder.load_string("""
63
+ Label:
64
+ font_name: l.font_name
65
+ text: l._("greeting")
66
+ """)
67
+ localizer.lang = "en"
68
+ assert label.font_name == "Roboto"
69
+ assert label.text == "morning"
70
+ localizer.lang = "ja"
71
+ assert label.font_name == "<a pre-installed Japanese font>"
72
+ assert label.text == "おはよう"
73
+ ```
74
+
75
+ # Installation
76
+
77
+ Pin the minor version.
78
+
79
+ ```
80
+ pip install "kivy-garden-i18n>=0.3,<0.4"
81
+ ```
82
+
83
+ # Tested on
84
+
85
+ - CPython 3.10 + Kivy 2.3
86
+ - CPython 3.11 + Kivy 2.3
87
+ - CPython 3.12 + Kivy 2.3
88
+ - CPython 3.13 + Kivy 2.3
@@ -0,0 +1,9 @@
1
+ kivy_garden/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ kivy_garden/i18n/fontfinder.py,sha256=aQkQcKPM3dFWugwVK1qUYiY3pS2crmuTM_jGaBvIkPY,5474
3
+ kivy_garden/i18n/localizer.py,sha256=956llaf2yk09cN7EjWVWf7sJD9LyMGUJmgXkCh_UFCI,7350
4
+ kivy_garden/i18n/utils/__init__.py,sha256=EaG7vkSf72tc6GAV_v0TqA2urlMOyKaXsF48zuS4afA,86
5
+ kivy_garden/i18n/utils/_extract_msgids_from_string_literals.py,sha256=min0LFN3XZ9RAP-4F-c7js9EHeZxSBCDYt_IMrRVyG0,1708
6
+ kivy_garden_i18n-0.3.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
7
+ kivy_garden_i18n-0.3.0.dist-info/entry_points.txt,sha256=1HK5Z_gLLUsXJIYxBV3Zbjk7z2rRR14OUT6us33SkQI,105
8
+ kivy_garden_i18n-0.3.0.dist-info/METADATA,sha256=XtJk6YimSzIRs4ApJ_NFu0nKPUp4GhiuUUsxYX1kuBU,2342
9
+ kivy_garden_i18n-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.26
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ extract-msgids = kivy_garden.i18n.utils._extract_msgids_from_string_literals:cli_main
3
+