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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. 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
+ ]