argonaut-translator-cli 2.0.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.
- argonaut_cli/__init__.py +4 -0
- argonaut_cli/__main__.py +7 -0
- argonaut_cli/batch.py +309 -0
- argonaut_cli/cli.py +633 -0
- argonaut_cli/download.py +40 -0
- argonaut_cli/history.py +174 -0
- argonaut_cli/nllb.py +257 -0
- argonaut_cli/packages.py +122 -0
- argonaut_cli/pdf.py +254 -0
- argonaut_cli/translation.py +333 -0
- argonaut_cli/typesetting.py +290 -0
- argonaut_translator_cli-2.0.0.dist-info/METADATA +161 -0
- argonaut_translator_cli-2.0.0.dist-info/RECORD +17 -0
- argonaut_translator_cli-2.0.0.dist-info/WHEEL +5 -0
- argonaut_translator_cli-2.0.0.dist-info/entry_points.txt +2 -0
- argonaut_translator_cli-2.0.0.dist-info/licenses/LICENSE +232 -0
- argonaut_translator_cli-2.0.0.dist-info/top_level.txt +1 -0
argonaut_cli/__init__.py
ADDED
argonaut_cli/__main__.py
ADDED
argonaut_cli/batch.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Batch translation: a list of documents into one target language.
|
|
2
|
+
|
|
3
|
+
Language detection per file, a cache shared across the batch, skipping
|
|
4
|
+
outputs that already exist, the translation history and cooperative
|
|
5
|
+
cancellation, with no interface attached: what happens is reported to a
|
|
6
|
+
:class:`Listener`. The window turns each report into a Qt signal; the
|
|
7
|
+
command line prints it."""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from argostranslatefiles import argostranslatefiles
|
|
13
|
+
|
|
14
|
+
from argonaut_cli import nllb, packages
|
|
15
|
+
from argonaut_cli.history import TranslationHistory
|
|
16
|
+
from argonaut_cli.pdf import FastPdfTranslator
|
|
17
|
+
from argonaut_cli.translation import (
|
|
18
|
+
SUPPORTED_EXTS,
|
|
19
|
+
CancelledError,
|
|
20
|
+
ProgressTranslation,
|
|
21
|
+
TranslationCache,
|
|
22
|
+
detect_language,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FileError(Exception):
|
|
27
|
+
"""A document the batch turned down, for a reason it can name.
|
|
28
|
+
|
|
29
|
+
``str()`` is an English sentence, which is what the command line prints;
|
|
30
|
+
an interface in another language renders the attributes instead."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, path):
|
|
33
|
+
super().__init__(path)
|
|
34
|
+
self.path = path
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self):
|
|
38
|
+
return os.path.basename(self.path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LanguageNotDetected(FileError):
|
|
42
|
+
def __str__(self):
|
|
43
|
+
return f"{self.name}: could not detect the language"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AlreadyInLanguage(FileError):
|
|
47
|
+
def __init__(self, path, lang):
|
|
48
|
+
super().__init__(path)
|
|
49
|
+
self.lang = lang
|
|
50
|
+
|
|
51
|
+
def __str__(self):
|
|
52
|
+
return f"{self.name}: the document is already in {self.lang}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NoModel(FileError):
|
|
56
|
+
def __init__(self, path, src, dst):
|
|
57
|
+
super().__init__(path)
|
|
58
|
+
self.src = src
|
|
59
|
+
self.dst = dst
|
|
60
|
+
|
|
61
|
+
def __str__(self):
|
|
62
|
+
return f"{self.name}: no model {self.src} → {self.dst}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Listener:
|
|
66
|
+
"""What a batch reports while it runs. Every method does nothing here,
|
|
67
|
+
so an interface overrides only what it shows. Languages are passed as
|
|
68
|
+
the engine's Language objects: naming them is the interface's job."""
|
|
69
|
+
|
|
70
|
+
def on_file_started(self, index, path):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
def on_language_detected(self, index, lang):
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def on_progress(self, done, total):
|
|
77
|
+
"""Chunks of the current file done, out of ``total`` (0 while the
|
|
78
|
+
file has not said how much work it holds)."""
|
|
79
|
+
|
|
80
|
+
def on_phase(self, key, kwargs):
|
|
81
|
+
"""A long phase of a PDF: "reading", "translating_page",
|
|
82
|
+
"generating" (with the page as ``done`` of ``total``) or "saving".
|
|
83
|
+
``kwargs`` always carries the file ``name``."""
|
|
84
|
+
|
|
85
|
+
def on_file_done(self, index, out_path, seconds):
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
def on_file_failed(self, index, error):
|
|
89
|
+
"""``error`` is the exception: a :class:`FileError` for a document
|
|
90
|
+
the batch turned down, anything else for one that broke."""
|
|
91
|
+
|
|
92
|
+
def on_file_skipped(self, index, out_path):
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def on_cache_stats(self, index, file_reused, batch_reused, batch_segments):
|
|
96
|
+
"""Segments this file served from the cache, the batch's reuse so
|
|
97
|
+
far and how many segments that reuse is out of."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def documents_in(paths):
|
|
101
|
+
"""The supported documents among ``paths``, folders walked recursively
|
|
102
|
+
in name order and duplicates dropped, the way the window adds them."""
|
|
103
|
+
seen = set()
|
|
104
|
+
for path in paths:
|
|
105
|
+
if os.path.isdir(path):
|
|
106
|
+
found = []
|
|
107
|
+
for root, dirs, files in os.walk(path):
|
|
108
|
+
dirs.sort()
|
|
109
|
+
found.extend(os.path.join(root, name) for name in sorted(files))
|
|
110
|
+
else:
|
|
111
|
+
found = [path]
|
|
112
|
+
for doc in found:
|
|
113
|
+
if os.path.splitext(doc)[1].lower() in SUPPORTED_EXTS and doc not in seen:
|
|
114
|
+
seen.add(doc)
|
|
115
|
+
yield doc
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Batch:
|
|
119
|
+
"""Translates ``files`` into ``dst_lang``. With ``src_lang`` None each
|
|
120
|
+
file's language is detected separately. Segments repeated across the
|
|
121
|
+
batch are served from one shared cache, and with ``skip_existing`` a
|
|
122
|
+
file whose output is already there is left alone.
|
|
123
|
+
|
|
124
|
+
The cache and the history are opened by :meth:`run`, not here: opening
|
|
125
|
+
either prunes its expired entries, which is work for the thread that
|
|
126
|
+
translates rather than for whoever sets the batch up. A path of None
|
|
127
|
+
keeps that one in memory only (the cache) or off (the history)."""
|
|
128
|
+
|
|
129
|
+
PROGRESS_EVERY = 0.2 # seconds between progress reports within a file
|
|
130
|
+
|
|
131
|
+
def __init__(self, src_lang, dst_lang, languages, files, output_dir=None,
|
|
132
|
+
skip_existing=False, engine_id="argos", greedy=False,
|
|
133
|
+
cache_db_path=None, cache_ttl_days=0,
|
|
134
|
+
history_db_path=None, history_ttl_days=0,
|
|
135
|
+
listener=None, is_cancelled=None):
|
|
136
|
+
self.src_lang = src_lang
|
|
137
|
+
self.dst_lang = dst_lang
|
|
138
|
+
self.languages = languages
|
|
139
|
+
self.files = files
|
|
140
|
+
self.output_dir = output_dir # None = next to each original
|
|
141
|
+
self.skip_existing = skip_existing
|
|
142
|
+
self.engine_id = engine_id
|
|
143
|
+
self.greedy = greedy
|
|
144
|
+
self._cache_db_path = cache_db_path
|
|
145
|
+
self._cache_ttl_days = cache_ttl_days
|
|
146
|
+
self._history_db_path = history_db_path
|
|
147
|
+
self._history_ttl_days = history_ttl_days
|
|
148
|
+
self.listener = listener or Listener()
|
|
149
|
+
self.is_cancelled = is_cancelled or (lambda: False)
|
|
150
|
+
self.cache = None
|
|
151
|
+
self.history = None
|
|
152
|
+
self._last_progress = 0.0
|
|
153
|
+
self._current_name = ""
|
|
154
|
+
self._total_chunks = 0 # 0 until the file says how much work it holds
|
|
155
|
+
self._batch_segments = 0 # cacheable segments seen across the batch
|
|
156
|
+
self._pkg_versions = None # pair -> version, read once per batch
|
|
157
|
+
|
|
158
|
+
def output_path_for(self, to_code, file_path):
|
|
159
|
+
# same naming scheme as the library, but honouring the output
|
|
160
|
+
# folder if one was chosen
|
|
161
|
+
name, ext = os.path.splitext(os.path.basename(file_path))
|
|
162
|
+
dir_path = self.output_dir or os.path.dirname(file_path)
|
|
163
|
+
return os.path.join(dir_path, f"{name}_{to_code}{ext}")
|
|
164
|
+
|
|
165
|
+
def get_output_path(self, underlying_translation, file_path):
|
|
166
|
+
return self.output_path_for(underlying_translation.to_lang.code, file_path)
|
|
167
|
+
|
|
168
|
+
def expected_output_path(self, file_path):
|
|
169
|
+
# the target is always the destination language, whatever the source
|
|
170
|
+
# turns out to be, so the skip check needs no language detection
|
|
171
|
+
return self.output_path_for(self.dst_lang.code, file_path)
|
|
172
|
+
|
|
173
|
+
def resolve_translation(self, index, path):
|
|
174
|
+
src = self.src_lang
|
|
175
|
+
if src is None:
|
|
176
|
+
src = detect_language(path, self.languages)
|
|
177
|
+
if src is None:
|
|
178
|
+
raise LanguageNotDetected(path)
|
|
179
|
+
self.listener.on_language_detected(index, src)
|
|
180
|
+
if src is self.dst_lang:
|
|
181
|
+
raise AlreadyInLanguage(path, self.dst_lang)
|
|
182
|
+
translation = src.get_translation(self.dst_lang)
|
|
183
|
+
if translation is None:
|
|
184
|
+
raise NoModel(path, src, self.dst_lang)
|
|
185
|
+
return translation
|
|
186
|
+
|
|
187
|
+
def engine_version(self, translation):
|
|
188
|
+
"""Version tag for the cache namespace, so entries produced by an
|
|
189
|
+
older model stop being served after a package upgrade."""
|
|
190
|
+
if self.engine_id != "argos":
|
|
191
|
+
version = nllb.MODEL_REPO # one fixed model, replaced only by us
|
|
192
|
+
else:
|
|
193
|
+
if self._pkg_versions is None:
|
|
194
|
+
self._pkg_versions = packages.installed_versions()
|
|
195
|
+
pair = (translation.from_lang.code, translation.to_lang.code)
|
|
196
|
+
version = self._pkg_versions.get(pair, "")
|
|
197
|
+
# greedy output is not beam output: the modes never serve each
|
|
198
|
+
# other's entries, and quality-mode keys stay as they always were
|
|
199
|
+
return f"{version}+greedy" if self.greedy else version
|
|
200
|
+
|
|
201
|
+
def run(self):
|
|
202
|
+
self.cache = TranslationCache(self._cache_db_path, ttl_days=self._cache_ttl_days)
|
|
203
|
+
self.history = TranslationHistory(
|
|
204
|
+
self._history_db_path, ttl_days=self._history_ttl_days
|
|
205
|
+
)
|
|
206
|
+
try:
|
|
207
|
+
self._translate_files()
|
|
208
|
+
finally:
|
|
209
|
+
# a cancelled batch has to let go of its database connection and
|
|
210
|
+
# of the segments it has translated so far, just like a finished one
|
|
211
|
+
self.cache.close()
|
|
212
|
+
self.history.close()
|
|
213
|
+
|
|
214
|
+
def _translate_files(self):
|
|
215
|
+
for i, path in enumerate(self.files):
|
|
216
|
+
if self.is_cancelled():
|
|
217
|
+
break
|
|
218
|
+
self.listener.on_file_started(i, path)
|
|
219
|
+
self._current_name = os.path.basename(path)
|
|
220
|
+
file_start = time.monotonic()
|
|
221
|
+
try:
|
|
222
|
+
if self.skip_existing:
|
|
223
|
+
existing = self.expected_output_path(path)
|
|
224
|
+
if os.path.exists(existing):
|
|
225
|
+
self.listener.on_file_skipped(i, existing)
|
|
226
|
+
continue
|
|
227
|
+
translation = self.resolve_translation(i, path)
|
|
228
|
+
self._total_chunks = 0
|
|
229
|
+
self.listener.on_progress(0, 0)
|
|
230
|
+
proxy = ProgressTranslation(
|
|
231
|
+
translation,
|
|
232
|
+
self._report_progress,
|
|
233
|
+
self.is_cancelled,
|
|
234
|
+
cache=self.cache,
|
|
235
|
+
engine_id=self.engine_id,
|
|
236
|
+
engine_version=self.engine_version(translation),
|
|
237
|
+
)
|
|
238
|
+
if path.lower().endswith(".pdf"):
|
|
239
|
+
out = self.get_output_path(translation, path)
|
|
240
|
+
FastPdfTranslator(
|
|
241
|
+
pdf_path=path,
|
|
242
|
+
output_path=out,
|
|
243
|
+
underlying_translation=proxy,
|
|
244
|
+
on_count_ready=self._report_total,
|
|
245
|
+
on_read=self._report_read,
|
|
246
|
+
on_translated_page=self._report_translated_page,
|
|
247
|
+
on_page=self._report_page,
|
|
248
|
+
on_save=self._report_save,
|
|
249
|
+
is_cancelled=self.is_cancelled,
|
|
250
|
+
).translate_pdf()
|
|
251
|
+
else:
|
|
252
|
+
out = argostranslatefiles.translate_file(
|
|
253
|
+
proxy, path, get_output_path=self.get_output_path
|
|
254
|
+
)
|
|
255
|
+
self._batch_segments += proxy.segments
|
|
256
|
+
self.listener.on_cache_stats(
|
|
257
|
+
i, proxy.reused, self.cache.reused, self._batch_segments
|
|
258
|
+
)
|
|
259
|
+
elapsed = time.monotonic() - file_start
|
|
260
|
+
self.history.record(
|
|
261
|
+
path, out or "",
|
|
262
|
+
translation.from_lang.code, translation.to_lang.code,
|
|
263
|
+
self.engine_id, elapsed,
|
|
264
|
+
reused=proxy.reused, segments=proxy.segments,
|
|
265
|
+
)
|
|
266
|
+
self.listener.on_file_done(i, out or "", elapsed)
|
|
267
|
+
except CancelledError:
|
|
268
|
+
break
|
|
269
|
+
except Exception as exc: # noqa: BLE001
|
|
270
|
+
self.listener.on_file_failed(i, exc)
|
|
271
|
+
|
|
272
|
+
def _report_total(self, total):
|
|
273
|
+
"""How many chunks the current file holds, known once it is parsed."""
|
|
274
|
+
self._total_chunks = total
|
|
275
|
+
self.listener.on_progress(0, total)
|
|
276
|
+
|
|
277
|
+
def _report_progress(self, done):
|
|
278
|
+
now = time.monotonic()
|
|
279
|
+
total = self._total_chunks
|
|
280
|
+
if done == total or now - self._last_progress > self.PROGRESS_EVERY:
|
|
281
|
+
self._last_progress = now
|
|
282
|
+
self.listener.on_progress(done, total)
|
|
283
|
+
|
|
284
|
+
def _report_pages(self, key, done, total, move_bar=True):
|
|
285
|
+
"""Says which page of the current file a phase is on. On a long book
|
|
286
|
+
the percentage can sit on the same figure for a minute, so the page
|
|
287
|
+
number is what tells the user the run is alive."""
|
|
288
|
+
if move_bar:
|
|
289
|
+
self.listener.on_progress(done, total)
|
|
290
|
+
self.listener.on_phase(
|
|
291
|
+
key, {"name": self._current_name, "done": done, "total": total}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _report_read(self, done, total):
|
|
295
|
+
# reading is preparation: moving the bar here would sweep it to 100%
|
|
296
|
+
# and send it back to 0% when the translation itself starts, so the
|
|
297
|
+
# page number goes in the message and the bar stays indeterminate
|
|
298
|
+
self._report_pages("reading", done, total, move_bar=False)
|
|
299
|
+
|
|
300
|
+
def _report_translated_page(self, done, total):
|
|
301
|
+
# the bar is already counting paragraphs, a finer measure than pages
|
|
302
|
+
self._report_pages("translating_page", done, total, move_bar=False)
|
|
303
|
+
|
|
304
|
+
def _report_page(self, done, total):
|
|
305
|
+
self._report_pages("generating", done, total)
|
|
306
|
+
|
|
307
|
+
def _report_save(self):
|
|
308
|
+
self.listener.on_progress(0, 0)
|
|
309
|
+
self.listener.on_phase("saving", {"name": self._current_name})
|