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.
@@ -0,0 +1,4 @@
1
+ """Argonaut's translation engine and command line: offline document
2
+ translation with Argos Translate or NLLB-200."""
3
+
4
+ __version__ = "2.0.0"
@@ -0,0 +1,7 @@
1
+ """``python -m argonaut_cli`` runs the command line."""
2
+
3
+ import sys
4
+
5
+ from argonaut_cli.cli import main
6
+
7
+ sys.exit(main())
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})