gettext-translator 0.1.1__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.
@@ -0,0 +1,17 @@
1
+ """
2
+ AI-based Gettext automatic translator.
3
+ Copyright (C) 2026 Rodolfo González González.
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
@@ -0,0 +1,65 @@
1
+ """
2
+ AI-based Gettext automatic translator.
3
+ Copyright (C) 2026 Rodolfo González González.
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
18
+
19
+ # -----------------------------------------------------------------------------
20
+
21
+ import json
22
+ import sqlite3
23
+
24
+ # -----------------------------------------------------------------------------
25
+
26
+
27
+ class TranslationCache:
28
+ """
29
+ Persistent cache of validated translations, stored in SQLite.
30
+
31
+ scope identifies what produced the translation (backend, model, languages),
32
+ so changing any of them doesn't reuse old results.
33
+ A key is (ctx, msgid, msgid_plural, nplurals), a value is a str or a list of plural forms.
34
+ """
35
+
36
+ def __init__(self, path: str, scope: tuple) -> None:
37
+ self.scope = json.dumps(scope)
38
+ self.db = sqlite3.connect(path)
39
+ self.db.execute(
40
+ "CREATE TABLE IF NOT EXISTS translations ("
41
+ " scope TEXT, ctx TEXT, msgid TEXT, plural TEXT, nplurals INTEGER, value TEXT,"
42
+ " PRIMARY KEY (scope, ctx, msgid, plural, nplurals))"
43
+ )
44
+ # __init__
45
+
46
+ # -------------------------------------------------------------------------
47
+
48
+ def get(self, key: tuple):
49
+ row = self.db.execute(
50
+ "SELECT value FROM translations WHERE scope=? AND ctx=? AND msgid=? AND plural=? AND nplurals=?",
51
+ (self.scope, *key),
52
+ ).fetchone()
53
+ return json.loads(row[0]) if row else None
54
+ # get
55
+
56
+ # -------------------------------------------------------------------------
57
+
58
+ def put(self, key: tuple, value) -> None:
59
+ with self.db:
60
+ self.db.execute(
61
+ "INSERT OR REPLACE INTO translations VALUES (?, ?, ?, ?, ?, ?)",
62
+ (self.scope, *key, json.dumps(value, ensure_ascii=False)),
63
+ )
64
+ # put
65
+ # TranslationCache
@@ -0,0 +1,308 @@
1
+ """
2
+ Gettext Translator
3
+
4
+ This Python script provides a tool for translating gettext .po files
5
+ using cloud services (OpenAI, Gemini, Azure Translator) or local AI models.
6
+
7
+ ---
8
+ AI-based Gettext automatic translator.
9
+ Copyright (C) 2026 Rodolfo González González.
10
+
11
+ This program is free software: you can redistribute it and/or modify
12
+ it under the terms of the GNU General Public License as published by
13
+ the Free Software Foundation, either version 3 of the License, or
14
+ (at your option) any later version.
15
+
16
+ This program is distributed in the hope that it will be useful,
17
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ GNU General Public License for more details.
20
+
21
+ You should have received a copy of the GNU General Public License
22
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+ """
24
+
25
+ # -------------------------------------------------------------------------
26
+
27
+ import logging
28
+ import re
29
+ import sys
30
+ from collections import Counter
31
+ from pathlib import Path
32
+
33
+ import polib
34
+ from langcodes import Language, LanguageTagError
35
+ from pydantic import ValidationError
36
+ from rich.console import Console
37
+ from rich.table import Table
38
+ from rich.text import Text
39
+
40
+ from .settings import Settings
41
+ from .translator_factory import TranslatorFactory
42
+
43
+ # -------------------------------------------------------------------------
44
+
45
+ AI_COMMENT = "AI-translated"
46
+
47
+ # printf-style (%s, %1$s, %(name)s, %.2f, %%), brace-style ({name}) and HTML tags (<b, </b)
48
+ PLACEHOLDER_RE = re.compile(r"%(?:\d+\$|\([^)]*\))?[-+#0]*\d*(?:\.\d+)?[sdifouxXeEgGcr%]|\{[^{}]*\}|</?[a-zA-Z][\w-]*")
49
+
50
+ # -------------------------------------------------------------------------
51
+
52
+
53
+ def placeholders(text: str) -> Counter:
54
+ return Counter(PLACEHOLDER_RE.findall(text))
55
+ # placeholders
56
+
57
+ # -------------------------------------------------------------------------
58
+
59
+
60
+ def nplurals(po_file) -> int:
61
+ match = re.search(r"nplurals\s*=\s*(\d+)", po_file.metadata.get("Plural-Forms", ""))
62
+ return int(match.group(1)) if match else 2
63
+ # nplurals
64
+
65
+ # -------------------------------------------------------------------------
66
+
67
+
68
+ def validate(key, result):
69
+ """Returns the translation in result if it can be used for key, None otherwise."""
70
+ _, msgid, plural, n = key
71
+
72
+ if plural:
73
+ forms = result.get("msgstr_plural")
74
+ if not (isinstance(forms, list) and len(forms) == n and all(isinstance(f, str) and f for f in forms)):
75
+ return None
76
+ # Some forms may drop placeholders ("One file"), but the last and most general one must keep them
77
+ allowed = placeholders(msgid) | placeholders(plural)
78
+ if all(placeholders(f) <= allowed for f in forms) and placeholders(forms[-1]) == placeholders(plural):
79
+ return forms
80
+ return None
81
+
82
+ msgstr = result.get("msgstr")
83
+ if isinstance(msgstr, str) and msgstr and placeholders(msgstr) == placeholders(msgid):
84
+ return msgstr
85
+ return None
86
+ # validate
87
+
88
+ # -------------------------------------------------------------------------
89
+
90
+
91
+ class GettextTranslator:
92
+ def __init__(self, service, cache=None) -> None:
93
+ self.service = service
94
+ self.config = service.config
95
+ self.cache = cache
96
+ # __init__
97
+
98
+ # -------------------------------------------------------------------------
99
+
100
+ def po_files(self):
101
+ """Returns (file, root) pairs. root is the directory the --output path is relative to."""
102
+ files = []
103
+ for path in map(Path, self.config.po):
104
+ if path.is_dir():
105
+ files += [(f, path) for f in sorted(path.rglob("*.po"))]
106
+ else:
107
+ files.append((path, path.parent))
108
+ return files
109
+ # po_files
110
+
111
+ # -------------------------------------------------------------------------
112
+
113
+ def output_path(self, path, root):
114
+ if not self.config.output:
115
+ return path
116
+ output = Path(self.config.output)
117
+ return output if output.suffix == ".po" else output / path.relative_to(root)
118
+ # output_path
119
+
120
+ # -------------------------------------------------------------------------
121
+
122
+ def load(self, path):
123
+ """Loads a .po file, or returns None if its language is not the destination language."""
124
+ po_file = polib.pofile(str(path))
125
+ file_lang = po_file.metadata.get("Language", "")
126
+
127
+ try:
128
+ language = Language.get(file_lang).language
129
+ except LanguageTagError:
130
+ language = None
131
+
132
+ if language != self.config.dst.language:
133
+ logging.warning("[💣] Skipping %s: its language %r is not %s", path, file_lang, self.config.dst.language)
134
+ return None
135
+
136
+ if self.config.fuzzy:
137
+ for entry in po_file:
138
+ entry.fuzzy = False
139
+ logging.info("[✔️] Fuzzy translations disabled in file: %s", path)
140
+
141
+ return po_file
142
+ # load
143
+
144
+ # -------------------------------------------------------------------------
145
+
146
+ @staticmethod
147
+ def item(n, key):
148
+ ctx, msgid, plural, count = key
149
+ item = {"n": n, "id": msgid}
150
+ if ctx:
151
+ item["ctx"] = ctx
152
+ if plural:
153
+ item["plural"] = plural
154
+ item["nplurals"] = count
155
+ return item
156
+ # item
157
+
158
+ # -------------------------------------------------------------------------
159
+
160
+ def apply(self, entry, value):
161
+ if isinstance(value, list):
162
+ entry.msgstr_plural = dict(enumerate(value))
163
+ else:
164
+ entry.msgstr = value
165
+
166
+ if self.config.ascribe and AI_COMMENT not in entry.comment:
167
+ entry.comment = f"{entry.comment}\n{AI_COMMENT}".strip()
168
+ # apply
169
+
170
+ # -------------------------------------------------------------------------
171
+
172
+ def request(self, pending):
173
+ """Sends the pending keys to the backend and returns the valid translations."""
174
+ self.service.configure()
175
+ results = self.service.translate([self.item(n, key) for n, key in enumerate(pending)])
176
+
177
+ translations = {}
178
+ for result in results:
179
+ n = result.get("n")
180
+ if not isinstance(n, int) or not 0 <= n < len(pending):
181
+ logging.warning("[❌] Discarding result with unknown item number: %r", result)
182
+ continue
183
+ value = validate(pending[n], result)
184
+ if value is None:
185
+ logging.warning("[❌] Discarding invalid translation for %r: %r", pending[n][1], result)
186
+ continue
187
+ translations[pending[n]] = value
188
+ # for
189
+
190
+ return translations
191
+ # request
192
+
193
+ # -------------------------------------------------------------------------
194
+
195
+ def show(self, translations):
196
+ table = Table("msgctxt", "msgid", "msgstr", show_lines=True)
197
+ for (ctx, msgid, plural, _), value in translations.items():
198
+ source = f"{msgid}\n{plural}" if plural else msgid
199
+ target = "\n".join(value) if isinstance(value, list) else value
200
+ table.add_row(Text(ctx), Text(source), Text(target))
201
+ Console().print(table)
202
+ # show
203
+
204
+ # -------------------------------------------------------------------------
205
+
206
+ def translate(self) -> bool:
207
+ """
208
+ Translates the .po files. Returns False if there was something to translate but nothing was translated.
209
+ """
210
+ files = self.po_files()
211
+ if self.config.output.endswith(".po") and len(files) != 1:
212
+ raise ValueError("--output can be a .po file only when translating a single .po file")
213
+
214
+ # Group the untranslated entries of all the files by what has to be translated,
215
+ # so each unique string is translated only once
216
+ groups = {}
217
+ loaded = []
218
+ for path, root in files:
219
+ po_file = self.load(path)
220
+ if po_file is None:
221
+ continue
222
+
223
+ count = nplurals(po_file)
224
+ for entry in po_file.untranslated_entries():
225
+ key = (entry.msgctxt or self.config.context, entry.msgid, entry.msgid_plural,
226
+ count if entry.msgid_plural else 0)
227
+ groups.setdefault(key, []).append(entry)
228
+ loaded.append((path, root, po_file))
229
+ # for
230
+
231
+ translations = {}
232
+ if self.cache:
233
+ for key in groups:
234
+ value = self.cache.get(key)
235
+ if value is not None:
236
+ translations[key] = value
237
+ cached = len(translations)
238
+
239
+ pending = [key for key in groups if key not in translations]
240
+ logging.info("[⚙️] %d strings to translate, %d from cache", len(pending), cached)
241
+
242
+ new = self.request(pending) if pending else {}
243
+ if self.cache:
244
+ for key, value in new.items():
245
+ self.cache.put(key, value)
246
+ translations.update(new)
247
+
248
+ for key, entries in groups.items():
249
+ if key in translations:
250
+ for entry in entries:
251
+ self.apply(entry, translations[key])
252
+
253
+ missing = len(pending) - len(new)
254
+ if missing:
255
+ logging.warning("[❌] %d of %d strings were not translated", missing, len(pending))
256
+
257
+ if self.config.dry_run:
258
+ self.show(translations)
259
+ print(f"{len(new)} translated, {cached} from cache, {missing} not translated. No file was saved.")
260
+ else:
261
+ for path, root, po_file in loaded:
262
+ output = self.output_path(path, root)
263
+ output.parent.mkdir(parents=True, exist_ok=True)
264
+ po_file.save(str(output))
265
+ logging.info("[✅] Saved %s", output)
266
+
267
+ return not pending or bool(new)
268
+ # translate
269
+ # GettextTranslator
270
+
271
+ # -----------------------------------------------------------------------------
272
+
273
+
274
+ def main(argv=None) -> int:
275
+ try:
276
+ settings = Settings(**vars(Settings.parse_cli_args(argv)))
277
+ except ValidationError as e:
278
+ print(e)
279
+ return 1
280
+
281
+ logging.basicConfig(level=settings.verbose.upper())
282
+
283
+ try:
284
+ backend = TranslatorFactory().create_translator(settings)
285
+
286
+ # Shows the info for the given --backend and exit
287
+ if settings.info:
288
+ backend.show_info()
289
+ return 0
290
+
291
+ cache = None
292
+ if settings.cache:
293
+ # Imported here because some Python builds lack sqlite3, which is only needed for --cache
294
+ from .cache import TranslationCache
295
+ scope = (settings.backend, str(getattr(settings, "model", "")), str(settings.src), str(settings.dst))
296
+ cache = TranslationCache(settings.cache, scope)
297
+
298
+ ok = GettextTranslator(backend, cache).translate()
299
+ except Exception as e: # pylint: disable=W0718
300
+ logging.error("[☠️] %s", e, exc_info=settings.verbose == "debug")
301
+ return 1
302
+
303
+ return 0 if ok else 1
304
+ # main
305
+
306
+
307
+ if __name__ == "__main__":
308
+ sys.exit(main())
@@ -0,0 +1,178 @@
1
+ """
2
+ AI-based Gettext automatic translator.
3
+ Copyright (C) 2026 Rodolfo González González.
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
18
+
19
+ # -----------------------------------------------------------------------------
20
+
21
+
22
+ import argparse
23
+ import os
24
+ import yaml
25
+ from pydantic_settings import BaseSettings, SettingsConfigDict
26
+ from pydantic_settings.sources import (
27
+ PydanticBaseSettingsSource
28
+ )
29
+ from pydantic import Field, field_validator, model_validator
30
+ from typing import Any, Dict, Self, Tuple
31
+ from langcodes import Language
32
+ from .version import __version__
33
+
34
+ # -----------------------------------------------------------------------------
35
+
36
+
37
+ class YamlConfigSettingsSource(PydanticBaseSettingsSource):
38
+ def __init__(self, settings_cls, yaml_file: str = None):
39
+ super().__init__(settings_cls)
40
+ self.yaml_file = yaml_file
41
+ self._yaml_data = {}
42
+
43
+ if yaml_file:
44
+ try:
45
+ with open(yaml_file, 'r', encoding='utf-8') as f:
46
+ self._yaml_data = yaml.safe_load(f) or {}
47
+ except FileNotFoundError:
48
+ self._yaml_data = {}
49
+
50
+ # Values like "env:MY_VAR" are read from the environment variable MY_VAR
51
+ for key, value in self._yaml_data.items():
52
+ if isinstance(value, str) and value.startswith("env:"):
53
+ self._yaml_data[key] = os.getenv(value[len("env:"):])
54
+
55
+ def get_field_value(self, field_info, field_name: str) -> Tuple[Any, str, bool]:
56
+ value = self._yaml_data.get(field_name)
57
+ if value is None:
58
+ return None, field_name, False
59
+ return value, field_name, True
60
+
61
+ def prepare_field_value(self, field_name: str, value: Any, value_origin: Any) -> Any:
62
+ return value
63
+
64
+ def __call__(self) -> Dict[str, Any]:
65
+ return self._yaml_data
66
+ # YamlConfigSettingsSource
67
+
68
+ # -----------------------------------------------------------------------------
69
+
70
+
71
+ class Settings(BaseSettings):
72
+ model_config = SettingsConfigDict(
73
+ extra="allow",
74
+ )
75
+
76
+ # -------------------------------------------------------------------------
77
+
78
+ info: bool = Field(default=False)
79
+ backend: str = Field(default="")
80
+ po: list[str] = Field(default_factory=list)
81
+ src: str = Field(default=Language.make(language='en').language)
82
+ dst: str = Field(default=Language.make(language='en').language)
83
+ fuzzy: bool = Field(default=False)
84
+ context: str = Field(default="")
85
+ verbose: str = Field(default="warning")
86
+ ascribe: bool = Field(default=False)
87
+ config: str = Field(default="config.yaml")
88
+ dry_run: bool = Field(default=False)
89
+ output: str = Field(default="")
90
+ cache: str = Field(default="")
91
+
92
+ # -------------------------------------------------------------------------
93
+
94
+ @field_validator('po')
95
+ @classmethod
96
+ def validate_po(cls, v, info):
97
+ if info.data.get('info'):
98
+ return v # Skip validation if info is present
99
+ if not v:
100
+ raise ValueError('po must be provided unless --info is used.')
101
+ return v
102
+ # validate_po
103
+
104
+ # -------------------------------------------------------------------------
105
+
106
+ @field_validator("src", "dst")
107
+ @classmethod
108
+ def validate_iso639(cls, v, info):
109
+ if Language.get(v).is_valid():
110
+ return Language.get(v).maximize()
111
+ raise ValueError("invalid language code")
112
+ # validate_iso639
113
+
114
+ # -------------------------------------------------------------------------
115
+
116
+ @model_validator(mode='after')
117
+ def validate_info_backend(self) -> Self:
118
+ if self.info and not self.backend:
119
+ raise ValueError("please tell me which backend do you want to see information about")
120
+ return self
121
+ # validate_info_backend
122
+
123
+ # -------------------------------------------------------------------------
124
+
125
+ @classmethod
126
+ def parse_cli_args(cls, argv=None):
127
+ # Only the arguments given in the command line are returned, so the
128
+ # YAML file and the field defaults can fill in the rest
129
+ parser = argparse.ArgumentParser(argument_default=argparse.SUPPRESS)
130
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
131
+ parser.add_argument("--info", action='store_true',
132
+ help="Shows information about the backend")
133
+ parser.add_argument("--backend", type=str,
134
+ help="Which backend to use")
135
+ parser.add_argument("--po", type=str, nargs="+",
136
+ help="The .po file(s) or directories containing .po files")
137
+ parser.add_argument("--src", type=str,
138
+ help="The source language. Default: en")
139
+ parser.add_argument("--dst", type=str,
140
+ help="The language to translate to")
141
+ parser.add_argument("--fuzzy", action='store_true',
142
+ help="Remove the fuzzy flag from all entries before translating")
143
+ parser.add_argument("--context", type=str,
144
+ help="Optional default translation context for msgid without msgctxt")
145
+ parser.add_argument("--verbose", type=str.lower,
146
+ choices=["debug", "info", "warning", "error", "critical"],
147
+ help="Logging level. Default: warning")
148
+ parser.add_argument("--ascribe", action='store_true',
149
+ help="Include a comment in each entry indicating that it was translated with AI")
150
+ parser.add_argument("--config", type=str,
151
+ help="Path to the .yaml configuration file for the backend. Default: config.yaml")
152
+ parser.add_argument("--dry-run", action='store_true',
153
+ help="Show the translations without saving any file")
154
+ parser.add_argument("--output", type=str,
155
+ help="Save to this .po file (single input) or directory, instead of overwriting the input")
156
+ parser.add_argument("--cache", type=str,
157
+ help="Path to a SQLite file used to cache translations between runs")
158
+ return parser.parse_args(argv)
159
+ # parse_cli_args
160
+
161
+ # -------------------------------------------------------------------------
162
+
163
+ @classmethod
164
+ def settings_customise_sources(
165
+ cls,
166
+ settings_cls,
167
+ init_settings: PydanticBaseSettingsSource,
168
+ **kwargs
169
+ ):
170
+ config_file = init_settings.init_kwargs.get('config', cls.model_fields['config'].default)
171
+ yaml_source = YamlConfigSettingsSource(settings_cls, config_file)
172
+
173
+ return (
174
+ init_settings, # CLI args
175
+ yaml_source, # YAML
176
+ )
177
+ # settings_customise_sources
178
+ # Settings
@@ -0,0 +1,36 @@
1
+ """
2
+ AI-based Gettext automatic translator.
3
+ Copyright (C) 2026 Rodolfo González González.
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
18
+
19
+ from pathlib import Path
20
+
21
+ from gettext_translator_service import load_plugins
22
+
23
+
24
+ class TranslatorFactory:
25
+ @staticmethod
26
+ def create_translator(settings):
27
+ plugins = load_plugins()
28
+ if settings.backend in plugins:
29
+ impl_cls = plugins[settings.backend]
30
+ if impl_cls.REQUIRES_CONFIG and not settings.info and not Path(settings.config).is_file():
31
+ raise ValueError(f"❌ Configuration file {settings.config} not found, use --config")
32
+ return impl_cls(settings)
33
+ else:
34
+ installed = ', '.join(plugins) or 'none'
35
+ raise ValueError(f"❌ Unknown translation backend {settings.backend!r}. Installed: {installed}")
36
+ # TranslatorFactory
@@ -0,0 +1,19 @@
1
+ """
2
+ AI-based Gettext automatic translator.
3
+ Copyright (C) 2026 Rodolfo González González.
4
+
5
+ This program is free software: you can redistribute it and/or modify
6
+ it under the terms of the GNU General Public License as published by
7
+ the Free Software Foundation, either version 3 of the License, or
8
+ (at your option) any later version.
9
+
10
+ This program is distributed in the hope that it will be useful,
11
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ GNU General Public License for more details.
14
+
15
+ You should have received a copy of the GNU General Public License
16
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+ """
18
+
19
+ __version__ = "0.1.1"