composeui 0.1.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.
- composeui/__init__.py +11 -0
- composeui/core/__init__.py +0 -0
- composeui/core/application.py +368 -0
- composeui/core/chartcolors.py +23 -0
- composeui/core/chartimage.py +336 -0
- composeui/core/dataframesource.py +307 -0
- composeui/core/jobs.py +426 -0
- composeui/core/model.py +66 -0
- composeui/core/registry.py +66 -0
- composeui/core/reload.py +476 -0
- composeui/core/renderer.py +193 -0
- composeui/core/signal.py +358 -0
- composeui/core/sqlitemodel.py +1183 -0
- composeui/core/study.py +121 -0
- composeui/core/translation.py +221 -0
- composeui/core/translationcheck.py +254 -0
- composeui/core/view.py +5508 -0
- composeui/images/composeui.svg +15 -0
- composeui/locales/fr.json +73 -0
- composeui/mypy.py +46 -0
- composeui/py.typed +0 -0
- composeui/qtcore/__init__.py +0 -0
- composeui/qtcore/maprenderer.py +115 -0
- composeui/qtcore/qtapplication.py +222 -0
- composeui/qtcore/qtrenderer.py +2862 -0
- composeui/qtcore/widgets/__init__.py +0 -0
- composeui/qtcore/widgets/map.qml +123 -0
- composeui/qtcore/widgets/qchart.py +393 -0
- composeui/qtcore/widgets/qdatefield.py +101 -0
- composeui/qtcore/widgets/qexpander.py +60 -0
- composeui/qtcore/widgets/qfilepathfield.py +34 -0
- composeui/qtcore/widgets/qfiltertable.py +394 -0
- composeui/qtcore/widgets/qformdialog.py +64 -0
- composeui/qtcore/widgets/qformlabel.py +53 -0
- composeui/qtcore/widgets/qmap.py +68 -0
- composeui/qtcore/widgets/qmarkicon.py +37 -0
- composeui/qtcore/widgets/qmetric.py +44 -0
- composeui/qtcore/widgets/qplaintext.py +29 -0
- composeui/qtcore/widgets/qradiogroup.py +57 -0
- composeui/qtcore/widgets/qsidebar.py +116 -0
- composeui/qtcore/widgets/qsourcetable.py +216 -0
- composeui/qtcore/widgets/qsplitter.py +69 -0
- composeui/qtcore/widgets/qtexttable.py +703 -0
- composeui/qtcore/widgets/qvaluecombobox.py +41 -0
- composeui/qtcore/widgets/qvalueslider.py +64 -0
- composeui/qtcore/widgets/qwindowrelease.py +32 -0
- composeui/textualcore/__init__.py +0 -0
- composeui/textualcore/textualapplication.py +121 -0
- composeui/textualcore/textualrenderer.py +2893 -0
- composeui/textualcore/widgets/__init__.py +0 -0
- composeui/textualcore/widgets/composebutton.py +17 -0
- composeui/textualcore/widgets/composechart.py +212 -0
- composeui/textualcore/widgets/composecheckbox.py +35 -0
- composeui/textualcore/widgets/composecollapsible.py +41 -0
- composeui/textualcore/widgets/composecombobox.py +54 -0
- composeui/textualcore/widgets/composedateinput.py +325 -0
- composeui/textualcore/widgets/composedialog.py +90 -0
- composeui/textualcore/widgets/composefilepathinput.py +85 -0
- composeui/textualcore/widgets/composefiltertable.py +367 -0
- composeui/textualcore/widgets/composeformdialog.py +86 -0
- composeui/textualcore/widgets/composeinput.py +50 -0
- composeui/textualcore/widgets/composelist.py +20 -0
- composeui/textualcore/widgets/composemarkdown.py +17 -0
- composeui/textualcore/widgets/composemetric.py +60 -0
- composeui/textualcore/widgets/composeradioset.py +54 -0
- composeui/textualcore/widgets/composesidebar.py +111 -0
- composeui/textualcore/widgets/composeslider.py +144 -0
- composeui/textualcore/widgets/composesourcetable.py +288 -0
- composeui/textualcore/widgets/composesplitter.py +211 -0
- composeui/textualcore/widgets/composetabbedcontent.py +35 -0
- composeui/textualcore/widgets/composetable.py +476 -0
- composeui/textualcore/widgets/composetoolbar.py +62 -0
- composeui/textualcore/widgets/composetree.py +139 -0
- composeui/webcore/__init__.py +0 -0
- composeui/webcore/headers.py +57 -0
- composeui/webcore/icons.py +58 -0
- composeui/webcore/limits.py +55 -0
- composeui/webcore/origin.py +63 -0
- composeui/webcore/page.py +146 -0
- composeui/webcore/routes.py +303 -0
- composeui/webcore/session.py +436 -0
- composeui/webcore/static/app.css +2 -0
- composeui/webcore/static/cally.js +1312 -0
- composeui/webcore/static/daisyui.LICENSE +21 -0
- composeui/webcore/static/deckgl.LICENSE +19 -0
- composeui/webcore/static/deckgl.min.js +6423 -0
- composeui/webcore/static/htmx.min.js +1 -0
- composeui/webcore/static/page.js +1175 -0
- composeui/webcore/static/uPlot.LICENSE +21 -0
- composeui/webcore/static/uPlot.iife.min.js +2 -0
- composeui/webcore/webapplication.py +495 -0
- composeui/webcore/webrenderer.py +5113 -0
- composeui-0.1.0.dist-info/METADATA +222 -0
- composeui-0.1.0.dist-info/RECORD +97 -0
- composeui-0.1.0.dist-info/WHEEL +5 -0
- composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
- composeui-0.1.0.dist-info/top_level.txt +1 -0
composeui/core/study.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""A study: the views' state and the model's data, started, saved and opened together.
|
|
2
|
+
|
|
3
|
+
Without data of its own in the model (`Model.data_name` is None), a study is the views'
|
|
4
|
+
state as JSON (`MainView.save_state`). With it, a study is a zip archive of the views'
|
|
5
|
+
state (`views.json`) and the model's file, so the two are never saved or opened apart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import tempfile
|
|
12
|
+
import zipfile
|
|
13
|
+
import zlib
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from composeui.core.model import InvalidStateError
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from composeui.core.model import Model
|
|
21
|
+
from composeui.core.view import MainView
|
|
22
|
+
|
|
23
|
+
__all__ = ["open_study", "save_study"]
|
|
24
|
+
|
|
25
|
+
#: the views' state inside a study's archive, with its versions (`MainView.file_data`)
|
|
26
|
+
VIEWS_NAME = "views.json"
|
|
27
|
+
#: what reading a damaged archive can raise
|
|
28
|
+
_BAD_ARCHIVE_ERRORS = (zipfile.BadZipFile, zlib.error, EOFError)
|
|
29
|
+
#: the compression methods a study's archive may use: what `save_study` writes. `zipfile`
|
|
30
|
+
#: unpacks a bzip2 or LZMA member whole, whatever size is read, so a few bytes of one can
|
|
31
|
+
#: unpack into gigabytes; another method it can't unpack at all (`NotImplementedError`)
|
|
32
|
+
_COMPRESSIONS = frozenset({zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED})
|
|
33
|
+
#: a member's flag of an encrypted one, which `zipfile` won't read without a password
|
|
34
|
+
_ENCRYPTED = 0x1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def has_data(model: Model | None) -> bool:
|
|
38
|
+
"""Tell whether a study with this model is an archive, rather than the views' JSON."""
|
|
39
|
+
return model is not None and model.data_name is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def new_study(main_view: MainView, model: Model | None) -> None:
|
|
43
|
+
"""Start a new study: the model's data and the views back to their start."""
|
|
44
|
+
main_view.reset_state()
|
|
45
|
+
if model is not None:
|
|
46
|
+
model.new()
|
|
47
|
+
model.loaded()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def save_study(main_view: MainView, model: Model | None, path: Path) -> None:
|
|
51
|
+
"""Save the views' state and the model's data into the file `path`."""
|
|
52
|
+
if model is None or model.data_name is None:
|
|
53
|
+
main_view.save_state(path)
|
|
54
|
+
return
|
|
55
|
+
# written beside `path` and renamed over it, so a failed save leaves the old study whole
|
|
56
|
+
partial = path.with_name(f".{path.name}.partial")
|
|
57
|
+
try:
|
|
58
|
+
with tempfile.TemporaryDirectory(prefix="composeui-") as directory:
|
|
59
|
+
data_path = Path(directory, model.data_name)
|
|
60
|
+
model.save_data(data_path)
|
|
61
|
+
with zipfile.ZipFile(partial, "w", zipfile.ZIP_DEFLATED) as archive:
|
|
62
|
+
archive.writestr(VIEWS_NAME, json.dumps(main_view.file_data()))
|
|
63
|
+
archive.write(data_path, model.data_name)
|
|
64
|
+
partial.replace(path)
|
|
65
|
+
finally:
|
|
66
|
+
partial.unlink(missing_ok=True)
|
|
67
|
+
main_view.state_filepath = path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def open_study(
|
|
71
|
+
main_view: MainView, model: Model | None, path: Path, *, max_size: int | None = None
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Open the study saved in the file `path`, into the views and the model.
|
|
74
|
+
|
|
75
|
+
Everything is checked before anything changes: raises `InvalidStateError`, leaving the
|
|
76
|
+
views and the model unchanged, for a file that doesn't fit them. `max_size` caps the
|
|
77
|
+
archive's unpacked size, in bytes, against a small file unpacking into a huge one.
|
|
78
|
+
"""
|
|
79
|
+
if model is None or model.data_name is None:
|
|
80
|
+
main_view.load_state(path)
|
|
81
|
+
if model is not None:
|
|
82
|
+
model.loaded()
|
|
83
|
+
return
|
|
84
|
+
invalid = f"it isn't a valid {main_view.extension} file"
|
|
85
|
+
try:
|
|
86
|
+
with zipfile.ZipFile(path) as archive:
|
|
87
|
+
members = archive.infolist()
|
|
88
|
+
if sorted(member.filename for member in members) != sorted(
|
|
89
|
+
[VIEWS_NAME, model.data_name]
|
|
90
|
+
) or any(
|
|
91
|
+
member.compress_type not in _COMPRESSIONS or member.flag_bits & _ENCRYPTED
|
|
92
|
+
for member in members
|
|
93
|
+
):
|
|
94
|
+
raise InvalidStateError(invalid)
|
|
95
|
+
# The sizes the archive declares, which it may lie about: a member that unpacks
|
|
96
|
+
# into more is cut there, and then fails its CRC (`BadZipFile`).
|
|
97
|
+
if max_size is not None and sum(member.file_size for member in members) > max_size:
|
|
98
|
+
msg = "the file is too large"
|
|
99
|
+
raise InvalidStateError(msg)
|
|
100
|
+
views = archive.getinfo(VIEWS_NAME)
|
|
101
|
+
try:
|
|
102
|
+
# not `archive.read`: it unpacks the whole member before cutting it
|
|
103
|
+
with archive.open(views) as member:
|
|
104
|
+
data = json.loads(member.read(views.file_size))
|
|
105
|
+
except ValueError as error: # also covers bytes that aren't text
|
|
106
|
+
raise InvalidStateError(invalid) from error
|
|
107
|
+
data = main_view.state_data_from_file(data)
|
|
108
|
+
main_view.check_state_data(data)
|
|
109
|
+
# before the model's data is replaced: a job writing to it would write into the
|
|
110
|
+
# study opened (see `Jobs`), so it stops once its open fails too
|
|
111
|
+
main_view.jobs.drop()
|
|
112
|
+
with tempfile.TemporaryDirectory(prefix="composeui-") as directory:
|
|
113
|
+
# the member is named after the model's file, never a path of the archive's
|
|
114
|
+
archive.extract(model.data_name, directory)
|
|
115
|
+
model.open_data(Path(directory, model.data_name))
|
|
116
|
+
except _BAD_ARCHIVE_ERRORS as error:
|
|
117
|
+
raise InvalidStateError(invalid) from error
|
|
118
|
+
# checked above, so it can't fail now that the model has changed
|
|
119
|
+
main_view.load_state_data(data)
|
|
120
|
+
main_view.state_filepath = path
|
|
121
|
+
model.loaded()
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Translate the text views show, from JSON catalogs.
|
|
2
|
+
|
|
3
|
+
A catalog is a ``<language>.json`` file mapping the text as written in the code to its
|
|
4
|
+
translation: ``{"Radius": "Rayon"}``. Text missing from every catalog is shown as written,
|
|
5
|
+
like text translated as "": a translation still to fill.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from types import MappingProxyType
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
__all__ = ["T", "TranslatedText", "translator"]
|
|
21
|
+
|
|
22
|
+
#: translates a text as written in the code into a language
|
|
23
|
+
Translate = Callable[[str], str]
|
|
24
|
+
|
|
25
|
+
#: ComposeUI's own catalogs, for the text of the views it defines (menus, dialogs...)
|
|
26
|
+
CATALOGS_DIR = Path(__file__).parent.parent / "locales"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
#: languages by code, each named in itself, as a menu offering them shows them
|
|
30
|
+
LANGUAGE_NAMES: Mapping[str, str] = MappingProxyType(
|
|
31
|
+
{
|
|
32
|
+
"ar": "العربية",
|
|
33
|
+
"bg": "Български",
|
|
34
|
+
"ca": "Català",
|
|
35
|
+
"cs": "Čeština",
|
|
36
|
+
"da": "Dansk",
|
|
37
|
+
"de": "Deutsch",
|
|
38
|
+
"el": "Ελληνικά",
|
|
39
|
+
"en": "English",
|
|
40
|
+
"es": "Español",
|
|
41
|
+
"et": "Eesti",
|
|
42
|
+
"fa": "فارسی",
|
|
43
|
+
"fi": "Suomi",
|
|
44
|
+
"fr": "Français",
|
|
45
|
+
"he": "עברית",
|
|
46
|
+
"hi": "हिन्दी",
|
|
47
|
+
"hr": "Hrvatski",
|
|
48
|
+
"hu": "Magyar",
|
|
49
|
+
"id": "Bahasa Indonesia",
|
|
50
|
+
"it": "Italiano",
|
|
51
|
+
"ja": "日本語",
|
|
52
|
+
"ko": "한국어",
|
|
53
|
+
"lt": "Lietuvių",
|
|
54
|
+
"lv": "Latviešu",
|
|
55
|
+
"nb": "Norsk bokmål",
|
|
56
|
+
"nl": "Nederlands",
|
|
57
|
+
"pl": "Polski",
|
|
58
|
+
"pt": "Português",
|
|
59
|
+
"ro": "Română",
|
|
60
|
+
"ru": "Русский",
|
|
61
|
+
"sk": "Slovenčina",
|
|
62
|
+
"sl": "Slovenščina",
|
|
63
|
+
"sr": "Српски",
|
|
64
|
+
"sv": "Svenska",
|
|
65
|
+
"th": "ไทย",
|
|
66
|
+
"tr": "Türkçe",
|
|
67
|
+
"uk": "Українська",
|
|
68
|
+
"vi": "Tiếng Việt",
|
|
69
|
+
"zh": "中文",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def language_name(language: str, names: Mapping[str, str] = LANGUAGE_NAMES) -> str:
|
|
75
|
+
"""Name `language` in itself, from `names`: "fr-CA" is "Français (CA)".
|
|
76
|
+
|
|
77
|
+
A language `names` doesn't know is named by its code.
|
|
78
|
+
"""
|
|
79
|
+
if language in names:
|
|
80
|
+
return names[language]
|
|
81
|
+
base, _, region = language.replace("_", "-").partition("-")
|
|
82
|
+
if base not in names:
|
|
83
|
+
return language
|
|
84
|
+
return f"{names[base]} ({region})" if region else names[base]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def no_translation(text: str) -> str:
|
|
88
|
+
"""Show `text` as written in the code."""
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _language_names(language: str) -> list[str]:
|
|
93
|
+
"""Get the catalog names to look for, most specific first: "fr-CA" then "fr"."""
|
|
94
|
+
names = [language, language.replace("_", "-")]
|
|
95
|
+
names.append(names[-1].split("-")[0])
|
|
96
|
+
return list(dict.fromkeys(names))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_catalog(directory: Path, language: str) -> dict[str, str]:
|
|
100
|
+
"""Load `language`'s catalog in `directory`, or an empty one if it has none.
|
|
101
|
+
|
|
102
|
+
A regional language ("fr-CA", "fr_CA") falls back on its base language's ("fr").
|
|
103
|
+
"""
|
|
104
|
+
for name in _language_names(language):
|
|
105
|
+
path = directory / f"{name}.json"
|
|
106
|
+
if path.is_file():
|
|
107
|
+
with path.open(encoding="utf-8") as f:
|
|
108
|
+
catalog = json.load(f)
|
|
109
|
+
if not isinstance(catalog, dict) or not all(
|
|
110
|
+
isinstance(key, str) and isinstance(value, str) for key, value in catalog.items()
|
|
111
|
+
):
|
|
112
|
+
msg = f"{path} must map each text to its translation, both strings"
|
|
113
|
+
raise ValueError(msg)
|
|
114
|
+
return catalog
|
|
115
|
+
return {}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def translator(language: str, directories: Sequence[Path]) -> Translate:
|
|
119
|
+
"""Get the translation into `language` from the catalogs in `directories`.
|
|
120
|
+
|
|
121
|
+
The first directory with a translation of a text wins: an app's catalogs, listed
|
|
122
|
+
before ComposeUI's, can change the text of ComposeUI's views.
|
|
123
|
+
"""
|
|
124
|
+
catalog: dict[str, str] = {}
|
|
125
|
+
for directory in reversed(directories):
|
|
126
|
+
# "" is still to fill: a catalog after it can translate the text
|
|
127
|
+
catalog.update(
|
|
128
|
+
(text, translation)
|
|
129
|
+
for text, translation in load_catalog(directory, language).items()
|
|
130
|
+
if translation != ""
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def translate(text: str) -> str:
|
|
134
|
+
return catalog.get(text, text)
|
|
135
|
+
|
|
136
|
+
return translate
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# a `__dict__` for what it remembers: a str subclass can't have non-empty `__slots__`
|
|
140
|
+
class TranslatedText(str): # noqa: SLOT000
|
|
141
|
+
"""Text `View.tr` translated, remembering it as written to translate it again.
|
|
142
|
+
|
|
143
|
+
`MainView.set_language` translates it again wherever a view keeps it: in a field, a
|
|
144
|
+
list (a table's headers) or a dict's keys (a choice's labels). Formatting it keeps the
|
|
145
|
+
arguments, to format the next translation with them. Other str methods return a plain
|
|
146
|
+
str, which stays in its language.
|
|
147
|
+
|
|
148
|
+
Built from `text` alone (as `T`), it is the text as written. It is translated once a
|
|
149
|
+
view keeps it and the main view sets a language. Use it for text declared where no
|
|
150
|
+
view can `tr` it, e.g. a field's default or a class variable.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
source: str
|
|
154
|
+
#: the `format` arguments, or `None` if it isn't formatted
|
|
155
|
+
arguments: tuple[tuple[Any, ...], dict[str, Any]] | None
|
|
156
|
+
|
|
157
|
+
def __new__(
|
|
158
|
+
cls,
|
|
159
|
+
text: str,
|
|
160
|
+
source: str | None = None,
|
|
161
|
+
arguments: tuple[tuple[Any, ...], dict[str, Any]] | None = None,
|
|
162
|
+
) -> Self:
|
|
163
|
+
self = super().__new__(cls, text)
|
|
164
|
+
self.source = text if source is None else source
|
|
165
|
+
self.arguments = arguments
|
|
166
|
+
return self
|
|
167
|
+
|
|
168
|
+
def __getnewargs__(self) -> tuple[str, str, tuple[tuple[Any, ...], dict[str, Any]] | None]: # type: ignore[override]
|
|
169
|
+
# copying it (`copy.deepcopy`) keeps what it remembers
|
|
170
|
+
return (str(self), self.source, self.arguments)
|
|
171
|
+
|
|
172
|
+
def format(self, *args: Any, **kwargs: Any) -> TranslatedText:
|
|
173
|
+
"""Format it, remembering the arguments: translated text among them follows too."""
|
|
174
|
+
return TranslatedText(str.format(self, *args, **kwargs), self.source, (args, kwargs))
|
|
175
|
+
|
|
176
|
+
def translated(self, translate: Translate) -> TranslatedText:
|
|
177
|
+
"""Translate it again, with `translate`."""
|
|
178
|
+
text = translate(self.source)
|
|
179
|
+
if self.arguments is None:
|
|
180
|
+
return TranslatedText(text, self.source)
|
|
181
|
+
args, kwargs = self.arguments
|
|
182
|
+
args = tuple(translate_again(arg, translate) for arg in args)
|
|
183
|
+
kwargs = {key: translate_again(value, translate) for key, value in kwargs.items()}
|
|
184
|
+
return TranslatedText(text.format(*args, **kwargs), self.source, (args, kwargs))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
#: text to translate, as written in the code, wherever it's declared:
|
|
188
|
+
#: ``add: ButtonView = child(ButtonView, text=T("Add"))``
|
|
189
|
+
T = TranslatedText
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@runtime_checkable
|
|
193
|
+
class Translatable(Protocol):
|
|
194
|
+
"""A value holding `TranslatedText`, e.g. a table cell's button with its text."""
|
|
195
|
+
|
|
196
|
+
def translated(self, translate: Translate) -> Translatable:
|
|
197
|
+
"""Get it with its text translated again; itself if it holds none."""
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def translate_again(value: Any, translate: Translate) -> Any:
|
|
201
|
+
"""Translate the `TranslatedText` in `value` again, itself or in a list, tuple or dict.
|
|
202
|
+
|
|
203
|
+
A `Translatable` translates its own. Return `value` itself when it holds none, so a
|
|
204
|
+
caller can tell nothing changed.
|
|
205
|
+
"""
|
|
206
|
+
if isinstance(value, (TranslatedText, Translatable)):
|
|
207
|
+
return value.translated(translate)
|
|
208
|
+
if isinstance(value, (list, tuple)):
|
|
209
|
+
items = [translate_again(item, translate) for item in value]
|
|
210
|
+
if all(new is old for new, old in zip(items, value)):
|
|
211
|
+
return value
|
|
212
|
+
return type(value)(items) if type(value) in (list, tuple) else value
|
|
213
|
+
if isinstance(value, dict):
|
|
214
|
+
entries = [
|
|
215
|
+
(translate_again(key, translate), translate_again(item, translate))
|
|
216
|
+
for key, item in value.items()
|
|
217
|
+
]
|
|
218
|
+
if all(k is k0 and v is v0 for (k, v), (k0, v0) in zip(entries, value.items())):
|
|
219
|
+
return value
|
|
220
|
+
return dict(entries)
|
|
221
|
+
return value
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Check an app's catalogs against the text its code translates.
|
|
2
|
+
|
|
3
|
+
Run it on the app's main view; it fails on text a catalog misses or on a translation whose
|
|
4
|
+
``{placeholders}`` differ from the text's, and lists the entries no code uses::
|
|
5
|
+
|
|
6
|
+
python -m composeui.core.translationcheck examples.pipeform:PipeMainView
|
|
7
|
+
|
|
8
|
+
With ``--write fr``, it first adds the text ``fr.json`` misses to it, translated as "" for a
|
|
9
|
+
translator to fill; until then, the text shows as written.
|
|
10
|
+
|
|
11
|
+
The text is found two ways: the string literals passed to ``tr`` or ``T`` (or set in a
|
|
12
|
+
``SqliteTable``'s ``refusals``) in the source of every view of the main view, and the text
|
|
13
|
+
the views translate as they show it, which finds text ``tr`` gets from a variable, e.g. an
|
|
14
|
+
enum's. A table's refusals are found only in a module of a view.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import ast
|
|
21
|
+
import importlib
|
|
22
|
+
import inspect
|
|
23
|
+
import json
|
|
24
|
+
import string
|
|
25
|
+
import sys
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
import attrs
|
|
30
|
+
|
|
31
|
+
from composeui.core.translation import CATALOGS_DIR, load_catalog
|
|
32
|
+
from composeui.core.view import _retranslate
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
36
|
+
|
|
37
|
+
from composeui.core.view import MainView, View
|
|
38
|
+
|
|
39
|
+
__all__ = ["CatalogReport", "check_main_view", "main", "placeholders"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@attrs.define
|
|
43
|
+
class CatalogReport:
|
|
44
|
+
"""What a catalog lacks, or has too much of, for the text of the code."""
|
|
45
|
+
|
|
46
|
+
path: Path
|
|
47
|
+
#: text no catalog translates, or as "": it shows as written in the code
|
|
48
|
+
missing: list[str] = attrs.Factory(list)
|
|
49
|
+
#: text and translation whose ``{placeholders}`` differ: formatting it fails or loses one
|
|
50
|
+
mismatched: list[tuple[str, str]] = attrs.Factory(list)
|
|
51
|
+
#: entries for text the code doesn't translate, or no longer
|
|
52
|
+
unused: list[str] = attrs.Factory(list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_ok(self) -> bool:
|
|
56
|
+
"""Tell whether every text is translated with its placeholders."""
|
|
57
|
+
return not self.missing and not self.mismatched
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
lines = [
|
|
61
|
+
f"{self.path}: {len(self.missing)} missing, {len(self.mismatched)} with other "
|
|
62
|
+
f"placeholders, {len(self.unused)} unused"
|
|
63
|
+
]
|
|
64
|
+
lines.extend(f" missing: {text!r}" for text in self.missing)
|
|
65
|
+
lines.extend(
|
|
66
|
+
f" other placeholders: {text!r} -> {translation!r}"
|
|
67
|
+
for text, translation in self.mismatched
|
|
68
|
+
)
|
|
69
|
+
lines.extend(f" unused: {text!r}" for text in self.unused)
|
|
70
|
+
return "\n".join(lines)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_texts(paths: Iterable[Path]) -> set[str]:
|
|
74
|
+
"""Find the text the code in `paths` translates: `_TEXT_CALLS` literals, `_TEXT_DICTS`."""
|
|
75
|
+
texts: set[str] = set()
|
|
76
|
+
for path in paths:
|
|
77
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
texts.update(_literal_texts(node))
|
|
80
|
+
return texts
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: what translates the text it's called with: a view's `tr`, and `T` declaring it
|
|
84
|
+
_TEXT_CALLS = ("tr", "T")
|
|
85
|
+
#: class variables whose dict values are text to translate: a `SqliteTable`'s refusals
|
|
86
|
+
#: (`View.refusing` translates them)
|
|
87
|
+
_TEXT_DICTS = ("refusals",)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _literal_texts(node: ast.AST) -> Iterator[str]:
|
|
91
|
+
if isinstance(node, ast.Call) and node.args:
|
|
92
|
+
func = node.func
|
|
93
|
+
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None)
|
|
94
|
+
if name in _TEXT_CALLS:
|
|
95
|
+
yield from _strings([node.args[0]])
|
|
96
|
+
elif isinstance(node, (ast.Assign, ast.AnnAssign)) and isinstance(node.value, ast.Dict):
|
|
97
|
+
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
98
|
+
if any(getattr(target, "id", None) in _TEXT_DICTS for target in targets):
|
|
99
|
+
yield from _strings(node.value.values)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _strings(nodes: Iterable[ast.expr | None]) -> Iterator[str]:
|
|
103
|
+
for node in nodes:
|
|
104
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
105
|
+
yield node.value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def shown_texts(main_view: MainView) -> set[str]:
|
|
109
|
+
"""Find the text `main_view`'s views translate to show it, retranslating them."""
|
|
110
|
+
texts: set[str] = set()
|
|
111
|
+
|
|
112
|
+
def record(text: str) -> str:
|
|
113
|
+
texts.add(text)
|
|
114
|
+
return text
|
|
115
|
+
|
|
116
|
+
_retranslate(main_view, record)
|
|
117
|
+
return texts
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _views(view: View) -> Iterator[View]:
|
|
121
|
+
yield view
|
|
122
|
+
for child in view.children_views:
|
|
123
|
+
yield from _views(child)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def source_paths(main_view: MainView) -> set[Path]:
|
|
127
|
+
"""Get the source files of `main_view`'s view classes and their base classes."""
|
|
128
|
+
paths: set[Path] = set()
|
|
129
|
+
for view in _views(main_view):
|
|
130
|
+
for cls in type(view).__mro__:
|
|
131
|
+
module = sys.modules.get(cls.__module__)
|
|
132
|
+
file = getattr(module, "__file__", None)
|
|
133
|
+
if file is not None and file.endswith(".py"):
|
|
134
|
+
paths.add(Path(file))
|
|
135
|
+
return paths
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def placeholders(text: str) -> set[str] | None:
|
|
139
|
+
"""Get the ``{fields}`` `text` formats, or `None` if it isn't a valid format."""
|
|
140
|
+
try:
|
|
141
|
+
return {name for _, name, _, _ in string.Formatter().parse(text) if name is not None}
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def check_catalog(
|
|
147
|
+
path: Path, texts: set[str], catalog: dict[str, str], others: Sequence[dict[str, str]] = ()
|
|
148
|
+
) -> CatalogReport:
|
|
149
|
+
"""Check the catalog at `path` (`catalog`) against `texts`.
|
|
150
|
+
|
|
151
|
+
`others` are the catalogs it falls back on, e.g. ComposeUI's: their text isn't missing.
|
|
152
|
+
"""
|
|
153
|
+
report = CatalogReport(path)
|
|
154
|
+
report.missing = sorted(texts - _translated(catalog, *others))
|
|
155
|
+
report.unused = sorted(set(catalog) - texts)
|
|
156
|
+
report.mismatched = [
|
|
157
|
+
(text, translation)
|
|
158
|
+
for text, translation in catalog.items()
|
|
159
|
+
if translation != "" and placeholders(text) != placeholders(translation)
|
|
160
|
+
]
|
|
161
|
+
return report
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _translated(*catalogs: dict[str, str]) -> set[str]:
|
|
165
|
+
return {text for catalog in catalogs for text, translation in catalog.items() if translation}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _app_texts(main_view_class: type[MainView]) -> tuple[Path, set[str]]:
|
|
169
|
+
"""Get the directory of the app's catalogs and the text its code translates."""
|
|
170
|
+
directory = main_view_class.translations_dir
|
|
171
|
+
if directory is None:
|
|
172
|
+
msg = f"{main_view_class.__name__} sets no translations_dir"
|
|
173
|
+
raise ValueError(msg)
|
|
174
|
+
main_view = main_view_class()
|
|
175
|
+
return directory, find_texts(source_paths(main_view)) | shown_texts(main_view)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def write_catalog(main_view_class: type[MainView], language: str) -> tuple[Path, list[str]]:
|
|
179
|
+
"""Add the text `language`'s catalog misses to it, translated as "" for a translator.
|
|
180
|
+
|
|
181
|
+
Its entries are kept, unused ones too. A regional language's new catalog ("fr-CA")
|
|
182
|
+
starts from its base language's: it replaces it. Text ComposeUI's catalog translates
|
|
183
|
+
isn't added: the app's catalog only has it to translate it otherwise.
|
|
184
|
+
Return the catalog's path and the text added.
|
|
185
|
+
"""
|
|
186
|
+
directory, texts = _app_texts(main_view_class)
|
|
187
|
+
catalog = load_catalog(directory, language)
|
|
188
|
+
added = sorted(texts - set(catalog) - _translated(load_catalog(CATALOGS_DIR, language)))
|
|
189
|
+
catalog.update(dict.fromkeys(added, ""))
|
|
190
|
+
path = directory / f"{language}.json"
|
|
191
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
path.write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
193
|
+
return path, added
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_main_view(main_view_class: type[MainView]) -> list[CatalogReport]:
|
|
197
|
+
"""Check each catalog in `main_view_class.translations_dir` against the app's text."""
|
|
198
|
+
directory, texts = _app_texts(main_view_class)
|
|
199
|
+
reports = []
|
|
200
|
+
for path in sorted(directory.glob("*.json")):
|
|
201
|
+
language = path.stem
|
|
202
|
+
reports.append(
|
|
203
|
+
check_catalog(
|
|
204
|
+
path,
|
|
205
|
+
texts,
|
|
206
|
+
load_catalog(directory, language),
|
|
207
|
+
[load_catalog(CATALOGS_DIR, language)],
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
return reports
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _main_view_class(spec: str) -> type[MainView]:
|
|
214
|
+
module_name, _, class_name = spec.partition(":")
|
|
215
|
+
try:
|
|
216
|
+
module = importlib.import_module(module_name)
|
|
217
|
+
except ImportError as error:
|
|
218
|
+
raise argparse.ArgumentTypeError(str(error)) from error
|
|
219
|
+
cls = getattr(module, class_name, None)
|
|
220
|
+
if not inspect.isclass(cls):
|
|
221
|
+
msg = f"{spec} isn't a class: expected module:MainViewClass"
|
|
222
|
+
raise argparse.ArgumentTypeError(msg)
|
|
223
|
+
return cls
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
227
|
+
"""Print a report for each catalog of a main view; 1 if one isn't ok, else 0."""
|
|
228
|
+
parser = argparse.ArgumentParser(
|
|
229
|
+
prog="python -m composeui.core.translationcheck", description=__doc__.splitlines()[0]
|
|
230
|
+
)
|
|
231
|
+
parser.add_argument(
|
|
232
|
+
"main_view", type=_main_view_class, help="the app's main view, as module:Class"
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"--write",
|
|
236
|
+
action="append",
|
|
237
|
+
default=[],
|
|
238
|
+
metavar="LANGUAGE",
|
|
239
|
+
help='first add the text to translate to LANGUAGE\'s catalog, translated as ""',
|
|
240
|
+
)
|
|
241
|
+
args = parser.parse_args(argv)
|
|
242
|
+
for language in args.write:
|
|
243
|
+
path, added = write_catalog(args.main_view, language)
|
|
244
|
+
print(f"{path}: {len(added)} entries added to translate") # noqa: T201
|
|
245
|
+
reports = check_main_view(args.main_view)
|
|
246
|
+
for report in reports:
|
|
247
|
+
print(report) # noqa: T201
|
|
248
|
+
if not reports:
|
|
249
|
+
print(f"no catalog in {args.main_view.translations_dir}") # noqa: T201
|
|
250
|
+
return 0 if all(report.is_ok for report in reports) else 1
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == "__main__":
|
|
254
|
+
sys.exit(main())
|