echoact 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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
echoact/text/loader.py
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
"""Turning a chosen file, or an uploaded body, into the text of an input box.
|
|
2
|
+
|
|
3
|
+
This is the one place F-02's "open a text file" actually happens, and it is
|
|
4
|
+
shared: F-37 requires the GUI, REST and MCP to apply the same limits and the
|
|
5
|
+
same support policy, so the REST upload path calls ``load_bytes`` and the
|
|
6
|
+
file dialog calls ``load_file``, and neither has rules of its own.
|
|
7
|
+
|
|
8
|
+
Three requirements shape the order of operations here.
|
|
9
|
+
|
|
10
|
+
* F-03's 2,000,000-byte ceiling is applied to the file's size *before* the
|
|
11
|
+
bytes are read, not to what was read, so an oversized file is never pulled
|
|
12
|
+
into memory to be measured. The read is still bounded, because a file can
|
|
13
|
+
grow between the two calls.
|
|
14
|
+
* F-32 says that on any failure the caller's input, documents and playback
|
|
15
|
+
job are unchanged. Nothing in this module writes anything, mutates any
|
|
16
|
+
argument, or touches application state; it reads and returns, and every
|
|
17
|
+
refusal is an exception raised before a value exists. That is why the size
|
|
18
|
+
and support checks live here rather than in the widget that will replace
|
|
19
|
+
its own contents.
|
|
20
|
+
* F-36 forbids getting under the 50,000 code-point limit by truncating. The
|
|
21
|
+
limit is therefore a rejection with the real count attached, never a slice.
|
|
22
|
+
|
|
23
|
+
Newlines are normalised to ``\\n`` because a CRLF file would otherwise count
|
|
24
|
+
its line breaks twice against F-03's limit and make every 4.2 code-point
|
|
25
|
+
offset disagree with what the reader sees. The substitution is recorded on
|
|
26
|
+
the result, since it is the one respect in which the returned text is not
|
|
27
|
+
byte-for-byte what the file held.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import stat as stat_module
|
|
33
|
+
from dataclasses import dataclass, replace
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Final
|
|
36
|
+
|
|
37
|
+
from ..errors import Code, EchoActError, Problem
|
|
38
|
+
from ..policy import MAX_IMPORT_FILE_BYTES, MAX_INPUT_CODEPOINTS
|
|
39
|
+
from .sniff import (
|
|
40
|
+
SELECTABLE_ENCODINGS,
|
|
41
|
+
SUPPORTED_FORMATS,
|
|
42
|
+
Confidence,
|
|
43
|
+
FileKind,
|
|
44
|
+
Verdict,
|
|
45
|
+
bom_encoding,
|
|
46
|
+
sniff,
|
|
47
|
+
verdict_for_text,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: Encodings ``load_file`` will honour when one is passed explicitly. The
|
|
51
|
+
#: selector's own list plus the forms a byte-order mark implies, and nothing
|
|
52
|
+
#: else: an encoding that cannot fail would let F-34's "never silently
|
|
53
|
+
#: corrupted" be defeated by asking for it.
|
|
54
|
+
ALLOWED_ENCODINGS: Final = frozenset(SELECTABLE_ENCODINGS) | {
|
|
55
|
+
"utf-8-sig",
|
|
56
|
+
"utf-32",
|
|
57
|
+
"utf-32-le",
|
|
58
|
+
"utf-32-be",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_REMEDIES_TOO_LARGE: Final = (
|
|
62
|
+
"Split the text into files of at most 2,000,000 bytes each and open them one at a time.",
|
|
63
|
+
"Or paste just the part you want spoken.",
|
|
64
|
+
)
|
|
65
|
+
_REMEDIES_TOO_LONG: Final = (
|
|
66
|
+
"Split the text into parts of at most 50,000 characters and generate them one at a time.",
|
|
67
|
+
"Or shorten the text; EchoAct will not cut it for you, so that nothing is lost silently.",
|
|
68
|
+
)
|
|
69
|
+
_REMEDIES_EMPTY: Final = (
|
|
70
|
+
"Choose a file that contains text.",
|
|
71
|
+
"Or type the text you want spoken directly into EchoAct.",
|
|
72
|
+
)
|
|
73
|
+
_REMEDIES_ENCODING: Final = (
|
|
74
|
+
"Choose the encoding the file was saved in, then check the preview.",
|
|
75
|
+
"Or re-save the file as UTF-8 in a text editor and open it again.",
|
|
76
|
+
)
|
|
77
|
+
_REMEDIES_CONFIRM: Final = (
|
|
78
|
+
"Check the preview and confirm that this file really is the text you want read.",
|
|
79
|
+
"Or save the content as a plain-text file (.txt) encoded in UTF-8 and open that file.",
|
|
80
|
+
)
|
|
81
|
+
_REMEDIES_MISSING: Final = (
|
|
82
|
+
"Check that the file still exists at that location, then choose it again.",
|
|
83
|
+
"Or type the text you want spoken directly into EchoAct.",
|
|
84
|
+
)
|
|
85
|
+
_REMEDIES_PERMISSION: Final = (
|
|
86
|
+
"Grant your account permission to read the file, or copy it somewhere you can read.",
|
|
87
|
+
"Or open the file yourself and paste the text into EchoAct.",
|
|
88
|
+
)
|
|
89
|
+
_REMEDIES_UNREADABLE = _REMEDIES_MISSING
|
|
90
|
+
|
|
91
|
+
#: Refusals a confirmed encoding may overturn; see ``_resolve_text``.
|
|
92
|
+
_OVERRIDABLE_CODES: Final = frozenset({Code.FILE_ENCODING, Code.FILE_NOT_TEXT})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class LoadedText:
|
|
97
|
+
"""What a successful open produced, and what was done to it.
|
|
98
|
+
|
|
99
|
+
``text`` is the job's source text as F-29 means it: everything the file
|
|
100
|
+
contained, in the order it contained it, with no truncation and no
|
|
101
|
+
replacement characters. The remaining fields exist so a surface can be
|
|
102
|
+
honest about the one edit that was made -- the line endings -- because a
|
|
103
|
+
user counting characters, or a caller counting the 4.2 code-point offsets
|
|
104
|
+
it will later be handed, would otherwise be counting something else.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
text: str
|
|
108
|
+
encoding: str
|
|
109
|
+
kind: FileKind
|
|
110
|
+
confidence: Confidence
|
|
111
|
+
source_name: str
|
|
112
|
+
byte_size: int
|
|
113
|
+
line_endings_normalised: bool
|
|
114
|
+
crlf_count: int
|
|
115
|
+
cr_count: int
|
|
116
|
+
had_bom: bool
|
|
117
|
+
#: True when the content needed F-35's confirmation and the caller gave it.
|
|
118
|
+
confirmed_as_text: bool
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def codepoints(self) -> int:
|
|
122
|
+
"""The count F-03 displays and limits. Code points, per 4.2, and of
|
|
123
|
+
the normalised text, which is what the reader will see."""
|
|
124
|
+
return len(self.text)
|
|
125
|
+
|
|
126
|
+
def to_dict(self) -> dict[str, Any]:
|
|
127
|
+
"""A description with no body text in it, for logs and diagnostics
|
|
128
|
+
(N-20). The file's name is included because the user chose it and
|
|
129
|
+
the GUI shows it; no directory ever appears here."""
|
|
130
|
+
return {
|
|
131
|
+
"source_name": self.source_name,
|
|
132
|
+
"kind": self.kind.value,
|
|
133
|
+
"encoding": self.encoding,
|
|
134
|
+
"confidence": self.confidence.value,
|
|
135
|
+
"byte_size": self.byte_size,
|
|
136
|
+
"codepoints": self.codepoints,
|
|
137
|
+
"line_endings_normalised": self.line_endings_normalised,
|
|
138
|
+
"had_bom": self.had_bom,
|
|
139
|
+
"confirmed_as_text": self.confirmed_as_text,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def inspect_file(path: str | Path) -> Verdict:
|
|
144
|
+
"""What would happen if this file were opened, without opening it into
|
|
145
|
+
anything.
|
|
146
|
+
|
|
147
|
+
The GUI calls this to build F-34's encoding selector and F-35's preview
|
|
148
|
+
before it disturbs the input box, which is how F-36's "if validation
|
|
149
|
+
fails the existing content is retained" costs nothing to honour. That
|
|
150
|
+
only works if the answer is the whole answer, so the verdict carries
|
|
151
|
+
F-03's content rules as well as the sniffer's: a file that is blank or
|
|
152
|
+
over 50,000 code points comes back with a problem here rather than
|
|
153
|
+
passing inspection and then raising out of ``load_file`` -- which is the
|
|
154
|
+
replacement of unsaved input that F-36 exists to prevent.
|
|
155
|
+
|
|
156
|
+
The cost is decoding the text twice for a file that will be opened
|
|
157
|
+
anyway. A GUI that must not lose the user's input pays it once per
|
|
158
|
+
chosen file, against a 2,000,000-byte ceiling.
|
|
159
|
+
"""
|
|
160
|
+
data, verdict = _probe(Path(path))
|
|
161
|
+
return verdict if data is None else _with_content_limits(data, verdict)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def inspect_bytes(data: bytes, *, filename: str | None = None) -> Verdict:
|
|
165
|
+
"""``inspect_file`` for a body that is already in memory, such as an
|
|
166
|
+
upload (F-37). The size limit is the same one, since 4.1 caps the
|
|
167
|
+
request body at the same 2,000,000 bytes."""
|
|
168
|
+
if len(data) > MAX_IMPORT_FILE_BYTES:
|
|
169
|
+
return _too_large_verdict(len(data))
|
|
170
|
+
return _with_content_limits(data, sniff(data, filename=filename))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_file(
|
|
174
|
+
path: str | Path,
|
|
175
|
+
*,
|
|
176
|
+
encoding: str | None = None,
|
|
177
|
+
confirm_unknown: bool = False,
|
|
178
|
+
) -> LoadedText:
|
|
179
|
+
"""Read a file as the text to be spoken (F-02, F-03, F-32 to F-36).
|
|
180
|
+
|
|
181
|
+
``encoding`` is F-34's confirmed choice and is honoured only for a file
|
|
182
|
+
whose encoding could not be determined; it can never overrule *what the
|
|
183
|
+
file is*, so naming an encoding is not a way to have a PNG read aloud.
|
|
184
|
+
``confirm_unknown`` is F-35's confirmation, and it is the caller's to
|
|
185
|
+
give only after the preview in the verdict has been shown.
|
|
186
|
+
|
|
187
|
+
Raises ``EchoActError`` for every rejection, with F-33's remedies in the
|
|
188
|
+
error's detail. Nothing is read, changed, or kept when it does.
|
|
189
|
+
"""
|
|
190
|
+
file_path = Path(path)
|
|
191
|
+
data, verdict = _probe(file_path)
|
|
192
|
+
if data is None:
|
|
193
|
+
raise verdict.as_error()
|
|
194
|
+
return _build(
|
|
195
|
+
data,
|
|
196
|
+
verdict,
|
|
197
|
+
filename=file_path.name,
|
|
198
|
+
encoding=encoding,
|
|
199
|
+
confirm_unknown=confirm_unknown,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def load_bytes(
|
|
204
|
+
data: bytes,
|
|
205
|
+
*,
|
|
206
|
+
filename: str | None = None,
|
|
207
|
+
encoding: str | None = None,
|
|
208
|
+
confirm_unknown: bool = False,
|
|
209
|
+
) -> LoadedText:
|
|
210
|
+
"""The same read for a body already in memory (F-37).
|
|
211
|
+
|
|
212
|
+
An automated caller reaches the identical checks in the identical order.
|
|
213
|
+
In particular it is never given an encoding by default: if the bytes do
|
|
214
|
+
not settle the question, this raises ``FILE_ENCODING`` carrying the
|
|
215
|
+
candidates, and the caller either re-sends with ``encoding=`` or converts
|
|
216
|
+
the file, which is what F-37 means by a correctable error.
|
|
217
|
+
"""
|
|
218
|
+
if len(data) > MAX_IMPORT_FILE_BYTES:
|
|
219
|
+
raise _too_large_verdict(len(data)).as_error()
|
|
220
|
+
verdict = sniff(data, filename=filename)
|
|
221
|
+
return _build(
|
|
222
|
+
data,
|
|
223
|
+
verdict,
|
|
224
|
+
filename=filename,
|
|
225
|
+
encoding=encoding,
|
|
226
|
+
confirm_unknown=confirm_unknown,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def normalise_newlines(text: str) -> tuple[str, int, int]:
|
|
231
|
+
"""Fold CRLF and lone CR to ``\\n``, reporting how many of each there were.
|
|
232
|
+
|
|
233
|
+
Returned rather than hidden because it moves every offset after the first
|
|
234
|
+
line break, and 4.2's segment ranges are offsets into exactly this text.
|
|
235
|
+
"""
|
|
236
|
+
crlf_count = text.count("\r\n")
|
|
237
|
+
cr_count = text.count("\r") - crlf_count
|
|
238
|
+
if crlf_count or cr_count:
|
|
239
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
240
|
+
return text, crlf_count, cr_count
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ------------------------------------------------------------- internals ---
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _problem_error(
|
|
247
|
+
code: Code, message: str, remedies: tuple[str, ...], **detail: Any
|
|
248
|
+
) -> EchoActError:
|
|
249
|
+
problem = Problem(code=code, message=message, remedies=remedies)
|
|
250
|
+
return EchoActError(
|
|
251
|
+
problem.code,
|
|
252
|
+
problem.message,
|
|
253
|
+
detail={
|
|
254
|
+
"remedies": list(problem.remedies),
|
|
255
|
+
"supported_formats": list(SUPPORTED_FORMATS),
|
|
256
|
+
**detail,
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _with_content_limits(data: bytes, verdict: Verdict) -> Verdict:
|
|
262
|
+
"""Add F-03's content rules to a verdict the sniffer already settled.
|
|
263
|
+
|
|
264
|
+
The sniffer answers "may these bytes be read as text"; F-03 also asks
|
|
265
|
+
whether the resulting text is empty or past 50,000 code points. Both
|
|
266
|
+
have to be in the verdict, because a caller that inspects, shows a
|
|
267
|
+
preview, gets a yes, and only then discovers the file is too long has
|
|
268
|
+
already replaced the input F-36 exists to protect.
|
|
269
|
+
|
|
270
|
+
Never raises. An inspection reports; it is ``load_file`` that refuses.
|
|
271
|
+
A verdict that already carries a problem is returned untouched: the
|
|
272
|
+
reason a file cannot be read is the first one found, not the last.
|
|
273
|
+
"""
|
|
274
|
+
if verdict.problem is not None or not verdict.readable_as_text:
|
|
275
|
+
return verdict
|
|
276
|
+
if verdict.encoding is None:
|
|
277
|
+
return verdict
|
|
278
|
+
try:
|
|
279
|
+
text = data.decode(verdict.encoding)
|
|
280
|
+
except (UnicodeDecodeError, LookupError):
|
|
281
|
+
# The sniffer decided this decodes; if it does not, that is the
|
|
282
|
+
# encoding problem to report rather than a content one.
|
|
283
|
+
return replace(
|
|
284
|
+
verdict,
|
|
285
|
+
readable_as_text=False,
|
|
286
|
+
problem=Problem(
|
|
287
|
+
code=Code.FILE_ENCODING,
|
|
288
|
+
message="The file could not be read with the encoding that was detected.",
|
|
289
|
+
remedies=_REMEDIES_ENCODING,
|
|
290
|
+
),
|
|
291
|
+
)
|
|
292
|
+
if text.startswith(""):
|
|
293
|
+
text = text[1:]
|
|
294
|
+
text, _crlf, _cr = normalise_newlines(text)
|
|
295
|
+
|
|
296
|
+
if not text.strip():
|
|
297
|
+
return replace(
|
|
298
|
+
verdict,
|
|
299
|
+
readable_as_text=False,
|
|
300
|
+
problem=Problem(
|
|
301
|
+
code=Code.INPUT_EMPTY,
|
|
302
|
+
message="That file contains no text, only blank space.",
|
|
303
|
+
remedies=_REMEDIES_EMPTY,
|
|
304
|
+
),
|
|
305
|
+
detail={**verdict.detail, "codepoints": len(text)},
|
|
306
|
+
)
|
|
307
|
+
if len(text) > MAX_INPUT_CODEPOINTS:
|
|
308
|
+
return replace(
|
|
309
|
+
verdict,
|
|
310
|
+
readable_as_text=False,
|
|
311
|
+
problem=Problem(
|
|
312
|
+
code=Code.INPUT_TOO_LONG,
|
|
313
|
+
message=(
|
|
314
|
+
f"That file holds {len(text):,} characters, and the limit is "
|
|
315
|
+
f"{MAX_INPUT_CODEPOINTS:,}."
|
|
316
|
+
),
|
|
317
|
+
remedies=_REMEDIES_TOO_LONG,
|
|
318
|
+
),
|
|
319
|
+
detail={
|
|
320
|
+
**verdict.detail,
|
|
321
|
+
"codepoints": len(text),
|
|
322
|
+
"limit_codepoints": MAX_INPUT_CODEPOINTS,
|
|
323
|
+
},
|
|
324
|
+
)
|
|
325
|
+
return replace(verdict, detail={**verdict.detail, "codepoints": len(text)})
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _fs_verdict(code: Code, message: str, remedies: tuple[str, ...]) -> Verdict:
|
|
329
|
+
return Verdict(
|
|
330
|
+
kind=FileKind.UNKNOWN,
|
|
331
|
+
readable_as_text=False,
|
|
332
|
+
problem=Problem(code=code, message=message, remedies=remedies),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _too_large_verdict(size: int) -> Verdict:
|
|
337
|
+
return Verdict(
|
|
338
|
+
kind=FileKind.UNKNOWN,
|
|
339
|
+
readable_as_text=False,
|
|
340
|
+
problem=Problem(
|
|
341
|
+
code=Code.FILE_TOO_LARGE,
|
|
342
|
+
message=(
|
|
343
|
+
f"This file is {size:,} bytes, and EchoAct opens files up to "
|
|
344
|
+
f"{MAX_IMPORT_FILE_BYTES:,} bytes."
|
|
345
|
+
),
|
|
346
|
+
remedies=_REMEDIES_TOO_LARGE,
|
|
347
|
+
),
|
|
348
|
+
byte_size=size,
|
|
349
|
+
detail={"byte_size": size, "limit_bytes": MAX_IMPORT_FILE_BYTES},
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _probe(path: Path) -> tuple[bytes | None, Verdict]:
|
|
354
|
+
"""Stat, gate on size, read, and sniff -- in that order (F-03, F-32).
|
|
355
|
+
|
|
356
|
+
Returns ``(None, verdict)`` when nothing was read. The read is capped one
|
|
357
|
+
byte above the limit so that a file which grew between the stat and the
|
|
358
|
+
open is still refused rather than admitted at whatever size it reached.
|
|
359
|
+
"""
|
|
360
|
+
try:
|
|
361
|
+
info = path.stat()
|
|
362
|
+
except FileNotFoundError:
|
|
363
|
+
return None, _fs_verdict(
|
|
364
|
+
Code.FILE_NOT_FOUND, "That file no longer exists.", _REMEDIES_MISSING
|
|
365
|
+
)
|
|
366
|
+
except PermissionError:
|
|
367
|
+
return None, _fs_verdict(
|
|
368
|
+
Code.FILE_PERMISSION,
|
|
369
|
+
"That file cannot be read with your current permissions.",
|
|
370
|
+
_REMEDIES_PERMISSION,
|
|
371
|
+
)
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
return None, _fs_verdict(
|
|
374
|
+
Code.FILE_CORRUPT,
|
|
375
|
+
f"That file could not be read from the storage device ({exc.strerror or 'I/O error'}).",
|
|
376
|
+
_REMEDIES_UNREADABLE,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
if stat_module.S_ISDIR(info.st_mode):
|
|
380
|
+
return None, _fs_verdict(
|
|
381
|
+
Code.FILE_UNSUPPORTED, "That is a folder, not a file.", _REMEDIES_MISSING
|
|
382
|
+
)
|
|
383
|
+
if info.st_size > MAX_IMPORT_FILE_BYTES:
|
|
384
|
+
return None, _too_large_verdict(info.st_size)
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
with path.open("rb") as handle:
|
|
388
|
+
data = handle.read(MAX_IMPORT_FILE_BYTES + 1)
|
|
389
|
+
except FileNotFoundError:
|
|
390
|
+
return None, _fs_verdict(
|
|
391
|
+
Code.FILE_NOT_FOUND, "That file no longer exists.", _REMEDIES_MISSING
|
|
392
|
+
)
|
|
393
|
+
except PermissionError:
|
|
394
|
+
return None, _fs_verdict(
|
|
395
|
+
Code.FILE_PERMISSION,
|
|
396
|
+
"That file cannot be read with your current permissions.",
|
|
397
|
+
_REMEDIES_PERMISSION,
|
|
398
|
+
)
|
|
399
|
+
except OSError as exc:
|
|
400
|
+
return None, _fs_verdict(
|
|
401
|
+
Code.FILE_CORRUPT,
|
|
402
|
+
f"That file could not be read from the storage device ({exc.strerror or 'I/O error'}).",
|
|
403
|
+
_REMEDIES_UNREADABLE,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
if len(data) > MAX_IMPORT_FILE_BYTES:
|
|
407
|
+
return None, _too_large_verdict(len(data))
|
|
408
|
+
return data, sniff(data, filename=path.name)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _normalise_encoding_name(name: str) -> str:
|
|
412
|
+
return name.strip().lower().replace("_", "-")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _decode_strictly(data: bytes, encoding: str) -> str:
|
|
416
|
+
"""Decode or refuse. There is no third outcome: F-34 forbids handing
|
|
417
|
+
back text repaired with replacement characters, so no lossy decode
|
|
418
|
+
happens on this path -- only in a preview, which is labelled as one.
|
|
419
|
+
|
|
420
|
+
A U+FEFF at offset zero is dropped whatever codec produced it. Only the
|
|
421
|
+
endianness-detecting codecs consume the mark themselves (see
|
|
422
|
+
``sniff.bom_encoding``), and the explicit ones -- ``utf-8``,
|
|
423
|
+
``utf-16-le``, ``utf-16-be`` -- are exactly what F-34's selector offers
|
|
424
|
+
the user and what a REST caller passes. Left in, the mark becomes a
|
|
425
|
+
zero-width character at offset 0 of the source text: every 4.2 range
|
|
426
|
+
after it is off by one against what the GUI shows, and the synthesiser
|
|
427
|
+
is handed a character to speak. At offset 0 it is a byte-order mark by
|
|
428
|
+
definition, never content, which is why no table of codec-to-mark is
|
|
429
|
+
needed here.
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
text = data.decode(encoding)
|
|
433
|
+
return text[1:] if text.startswith("\ufeff") else text
|
|
434
|
+
except UnicodeDecodeError as exc:
|
|
435
|
+
raise _problem_error(
|
|
436
|
+
Code.FILE_ENCODING,
|
|
437
|
+
f"The file is not valid {encoding.upper()}: the bytes at position {exc.start:,} "
|
|
438
|
+
"cannot be read that way.",
|
|
439
|
+
(
|
|
440
|
+
"Check the preview and choose a different encoding.",
|
|
441
|
+
"Or re-save the file as UTF-8 in a text editor and open it again.",
|
|
442
|
+
),
|
|
443
|
+
encoding=encoding,
|
|
444
|
+
failure_offset=exc.start,
|
|
445
|
+
failure_reason=exc.reason,
|
|
446
|
+
selectable_encodings=list(SELECTABLE_ENCODINGS),
|
|
447
|
+
) from exc
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _resolve_text(
|
|
451
|
+
data: bytes,
|
|
452
|
+
verdict: Verdict,
|
|
453
|
+
*,
|
|
454
|
+
filename: str | None,
|
|
455
|
+
encoding: str | None,
|
|
456
|
+
) -> tuple[str, Verdict]:
|
|
457
|
+
"""Produce the decoded text and the verdict that governs confirmation.
|
|
458
|
+
|
|
459
|
+
An explicitly chosen encoding answers one question only -- how to read
|
|
460
|
+
the bytes -- so it is accepted only where that was the question. A file
|
|
461
|
+
refused for *what it is*, such as a PDF, an archive or an encrypted
|
|
462
|
+
document, stays refused however it is asked for, which is what keeps
|
|
463
|
+
F-35's "not force-interpreted as text" from having a parameter that
|
|
464
|
+
switches it off. The two codes it may override are the two the sniffer
|
|
465
|
+
reaches by judgement rather than by a signature: an undecidable encoding,
|
|
466
|
+
and text that looked binary -- which is exactly what a UTF-16 file with
|
|
467
|
+
no byte-order mark looks like. Both are then re-judged on the decoded
|
|
468
|
+
result, so choosing an encoding can reveal text but never invent it.
|
|
469
|
+
"""
|
|
470
|
+
if encoding is None:
|
|
471
|
+
if verdict.problem is not None:
|
|
472
|
+
raise verdict.as_error()
|
|
473
|
+
assert verdict.encoding is not None # an ok verdict always names one
|
|
474
|
+
return _decode_strictly(data, verdict.encoding), verdict
|
|
475
|
+
|
|
476
|
+
chosen = _normalise_encoding_name(encoding)
|
|
477
|
+
if chosen not in ALLOWED_ENCODINGS:
|
|
478
|
+
raise _problem_error(
|
|
479
|
+
Code.FILE_ENCODING,
|
|
480
|
+
f"EchoAct cannot read files as {encoding!r}.",
|
|
481
|
+
(
|
|
482
|
+
"Choose one of the encodings EchoAct offers.",
|
|
483
|
+
"Or re-save the file as UTF-8 in a text editor and open it again.",
|
|
484
|
+
),
|
|
485
|
+
encoding=encoding,
|
|
486
|
+
selectable_encodings=list(SELECTABLE_ENCODINGS),
|
|
487
|
+
)
|
|
488
|
+
if verdict.problem is not None and verdict.problem.code not in _OVERRIDABLE_CODES:
|
|
489
|
+
raise verdict.as_error()
|
|
490
|
+
|
|
491
|
+
text = _decode_strictly(data, chosen)
|
|
492
|
+
rechecked = verdict_for_text(
|
|
493
|
+
text,
|
|
494
|
+
chosen,
|
|
495
|
+
byte_size=len(data),
|
|
496
|
+
filename=filename,
|
|
497
|
+
confidence=Confidence.CERTAIN,
|
|
498
|
+
)
|
|
499
|
+
if rechecked.problem is not None:
|
|
500
|
+
raise rechecked.as_error()
|
|
501
|
+
return text, rechecked
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _build(
|
|
505
|
+
data: bytes,
|
|
506
|
+
verdict: Verdict,
|
|
507
|
+
*,
|
|
508
|
+
filename: str | None,
|
|
509
|
+
encoding: str | None,
|
|
510
|
+
confirm_unknown: bool,
|
|
511
|
+
) -> LoadedText:
|
|
512
|
+
text, governing = _resolve_text(data, verdict, filename=filename, encoding=encoding)
|
|
513
|
+
|
|
514
|
+
if governing.needs_confirmation and not confirm_unknown:
|
|
515
|
+
# F-35. The name gives no assurance, so the content is offered as a
|
|
516
|
+
# preview and the answer comes back from a person -- or, for F-37's
|
|
517
|
+
# non-interactive caller, as an explicit acknowledgement on the next
|
|
518
|
+
# call. Either way it is corrected by the caller, not assumed here.
|
|
519
|
+
raise _problem_error(
|
|
520
|
+
Code.FILE_UNSUPPORTED,
|
|
521
|
+
"This file is not a recognised text file, though its content does read as text. "
|
|
522
|
+
"Confirm that you want it opened as plain text.",
|
|
523
|
+
_REMEDIES_CONFIRM,
|
|
524
|
+
kind=governing.kind.value,
|
|
525
|
+
needs_confirmation=True,
|
|
526
|
+
encoding=governing.encoding,
|
|
527
|
+
preview=governing.preview,
|
|
528
|
+
extension_mismatch=bool(governing.detail.get("extension_mismatch")),
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
text, crlf_count, cr_count = normalise_newlines(text)
|
|
532
|
+
|
|
533
|
+
if not text.strip():
|
|
534
|
+
raise _problem_error(
|
|
535
|
+
Code.INPUT_EMPTY,
|
|
536
|
+
"That file contains no text, only blank space.",
|
|
537
|
+
_REMEDIES_EMPTY,
|
|
538
|
+
codepoints=len(text),
|
|
539
|
+
)
|
|
540
|
+
if len(text) > MAX_INPUT_CODEPOINTS:
|
|
541
|
+
# F-36: report the real count and stop. Truncating here would be the
|
|
542
|
+
# one failure mode the user cannot see, since the text that vanished
|
|
543
|
+
# is the text they were not looking at.
|
|
544
|
+
raise _problem_error(
|
|
545
|
+
Code.INPUT_TOO_LONG,
|
|
546
|
+
f"That file holds {len(text):,} characters, and the limit is "
|
|
547
|
+
f"{MAX_INPUT_CODEPOINTS:,}.",
|
|
548
|
+
_REMEDIES_TOO_LONG,
|
|
549
|
+
codepoints=len(text),
|
|
550
|
+
limit_codepoints=MAX_INPUT_CODEPOINTS,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
assert governing.encoding is not None
|
|
554
|
+
return LoadedText(
|
|
555
|
+
text=text,
|
|
556
|
+
encoding=governing.encoding,
|
|
557
|
+
kind=governing.kind,
|
|
558
|
+
confidence=governing.confidence,
|
|
559
|
+
source_name=filename or "",
|
|
560
|
+
byte_size=len(data),
|
|
561
|
+
line_endings_normalised=bool(crlf_count or cr_count),
|
|
562
|
+
crlf_count=crlf_count,
|
|
563
|
+
cr_count=cr_count,
|
|
564
|
+
had_bom=bom_encoding(data) is not None,
|
|
565
|
+
confirmed_as_text=governing.needs_confirmation,
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
__all__ = [
|
|
570
|
+
"ALLOWED_ENCODINGS",
|
|
571
|
+
"LoadedText",
|
|
572
|
+
"inspect_bytes",
|
|
573
|
+
"inspect_file",
|
|
574
|
+
"load_bytes",
|
|
575
|
+
"load_file",
|
|
576
|
+
"normalise_newlines",
|
|
577
|
+
]
|