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.
Files changed (97) hide show
  1. composeui/__init__.py +11 -0
  2. composeui/core/__init__.py +0 -0
  3. composeui/core/application.py +368 -0
  4. composeui/core/chartcolors.py +23 -0
  5. composeui/core/chartimage.py +336 -0
  6. composeui/core/dataframesource.py +307 -0
  7. composeui/core/jobs.py +426 -0
  8. composeui/core/model.py +66 -0
  9. composeui/core/registry.py +66 -0
  10. composeui/core/reload.py +476 -0
  11. composeui/core/renderer.py +193 -0
  12. composeui/core/signal.py +358 -0
  13. composeui/core/sqlitemodel.py +1183 -0
  14. composeui/core/study.py +121 -0
  15. composeui/core/translation.py +221 -0
  16. composeui/core/translationcheck.py +254 -0
  17. composeui/core/view.py +5508 -0
  18. composeui/images/composeui.svg +15 -0
  19. composeui/locales/fr.json +73 -0
  20. composeui/mypy.py +46 -0
  21. composeui/py.typed +0 -0
  22. composeui/qtcore/__init__.py +0 -0
  23. composeui/qtcore/maprenderer.py +115 -0
  24. composeui/qtcore/qtapplication.py +222 -0
  25. composeui/qtcore/qtrenderer.py +2862 -0
  26. composeui/qtcore/widgets/__init__.py +0 -0
  27. composeui/qtcore/widgets/map.qml +123 -0
  28. composeui/qtcore/widgets/qchart.py +393 -0
  29. composeui/qtcore/widgets/qdatefield.py +101 -0
  30. composeui/qtcore/widgets/qexpander.py +60 -0
  31. composeui/qtcore/widgets/qfilepathfield.py +34 -0
  32. composeui/qtcore/widgets/qfiltertable.py +394 -0
  33. composeui/qtcore/widgets/qformdialog.py +64 -0
  34. composeui/qtcore/widgets/qformlabel.py +53 -0
  35. composeui/qtcore/widgets/qmap.py +68 -0
  36. composeui/qtcore/widgets/qmarkicon.py +37 -0
  37. composeui/qtcore/widgets/qmetric.py +44 -0
  38. composeui/qtcore/widgets/qplaintext.py +29 -0
  39. composeui/qtcore/widgets/qradiogroup.py +57 -0
  40. composeui/qtcore/widgets/qsidebar.py +116 -0
  41. composeui/qtcore/widgets/qsourcetable.py +216 -0
  42. composeui/qtcore/widgets/qsplitter.py +69 -0
  43. composeui/qtcore/widgets/qtexttable.py +703 -0
  44. composeui/qtcore/widgets/qvaluecombobox.py +41 -0
  45. composeui/qtcore/widgets/qvalueslider.py +64 -0
  46. composeui/qtcore/widgets/qwindowrelease.py +32 -0
  47. composeui/textualcore/__init__.py +0 -0
  48. composeui/textualcore/textualapplication.py +121 -0
  49. composeui/textualcore/textualrenderer.py +2893 -0
  50. composeui/textualcore/widgets/__init__.py +0 -0
  51. composeui/textualcore/widgets/composebutton.py +17 -0
  52. composeui/textualcore/widgets/composechart.py +212 -0
  53. composeui/textualcore/widgets/composecheckbox.py +35 -0
  54. composeui/textualcore/widgets/composecollapsible.py +41 -0
  55. composeui/textualcore/widgets/composecombobox.py +54 -0
  56. composeui/textualcore/widgets/composedateinput.py +325 -0
  57. composeui/textualcore/widgets/composedialog.py +90 -0
  58. composeui/textualcore/widgets/composefilepathinput.py +85 -0
  59. composeui/textualcore/widgets/composefiltertable.py +367 -0
  60. composeui/textualcore/widgets/composeformdialog.py +86 -0
  61. composeui/textualcore/widgets/composeinput.py +50 -0
  62. composeui/textualcore/widgets/composelist.py +20 -0
  63. composeui/textualcore/widgets/composemarkdown.py +17 -0
  64. composeui/textualcore/widgets/composemetric.py +60 -0
  65. composeui/textualcore/widgets/composeradioset.py +54 -0
  66. composeui/textualcore/widgets/composesidebar.py +111 -0
  67. composeui/textualcore/widgets/composeslider.py +144 -0
  68. composeui/textualcore/widgets/composesourcetable.py +288 -0
  69. composeui/textualcore/widgets/composesplitter.py +211 -0
  70. composeui/textualcore/widgets/composetabbedcontent.py +35 -0
  71. composeui/textualcore/widgets/composetable.py +476 -0
  72. composeui/textualcore/widgets/composetoolbar.py +62 -0
  73. composeui/textualcore/widgets/composetree.py +139 -0
  74. composeui/webcore/__init__.py +0 -0
  75. composeui/webcore/headers.py +57 -0
  76. composeui/webcore/icons.py +58 -0
  77. composeui/webcore/limits.py +55 -0
  78. composeui/webcore/origin.py +63 -0
  79. composeui/webcore/page.py +146 -0
  80. composeui/webcore/routes.py +303 -0
  81. composeui/webcore/session.py +436 -0
  82. composeui/webcore/static/app.css +2 -0
  83. composeui/webcore/static/cally.js +1312 -0
  84. composeui/webcore/static/daisyui.LICENSE +21 -0
  85. composeui/webcore/static/deckgl.LICENSE +19 -0
  86. composeui/webcore/static/deckgl.min.js +6423 -0
  87. composeui/webcore/static/htmx.min.js +1 -0
  88. composeui/webcore/static/page.js +1175 -0
  89. composeui/webcore/static/uPlot.LICENSE +21 -0
  90. composeui/webcore/static/uPlot.iife.min.js +2 -0
  91. composeui/webcore/webapplication.py +495 -0
  92. composeui/webcore/webrenderer.py +5113 -0
  93. composeui-0.1.0.dist-info/METADATA +222 -0
  94. composeui-0.1.0.dist-info/RECORD +97 -0
  95. composeui-0.1.0.dist-info/WHEEL +5 -0
  96. composeui-0.1.0.dist-info/licenses/LICENSE +9 -0
  97. composeui-0.1.0.dist-info/top_level.txt +1 -0
@@ -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())