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.
- gettext_translator/__init__.py +17 -0
- gettext_translator/cache.py +65 -0
- gettext_translator/gettext_translator.py +308 -0
- gettext_translator/settings.py +178 -0
- gettext_translator/translator_factory.py +36 -0
- gettext_translator/version.py +19 -0
- gettext_translator-0.1.1.dist-info/METADATA +206 -0
- gettext_translator-0.1.1.dist-info/RECORD +12 -0
- gettext_translator-0.1.1.dist-info/WHEEL +5 -0
- gettext_translator-0.1.1.dist-info/entry_points.txt +2 -0
- gettext_translator-0.1.1.dist-info/licenses/LICENSE +674 -0
- gettext_translator-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -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"
|