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
@@ -0,0 +1,241 @@
1
+ """F-57's error envelope, and the one place an exception becomes a response.
2
+
3
+ Every rejection this service produces goes through :func:`error_response`.
4
+ That matters for three separate promises:
5
+
6
+ * F-57 fixes the shape -- a stable code, a user message, whether a retry can
7
+ succeed, and a request identifier -- and ``EchoActError.to_payload`` already
8
+ produces exactly that, so no route builds a body of its own.
9
+ * Section 2.10 fixes the status for each situation and ``echoact.errors``
10
+ already maps every ``Code`` to one. Statuses are therefore never written at
11
+ a call site; a route raises the code that describes what happened and the
12
+ status follows.
13
+ * N-23 requires the three retryable rejections -- busy, rate limited,
14
+ insufficient resources -- to carry a retry-after hint, and rule 8 forbids a
15
+ permanent one from carrying it. ``EchoActError`` drops a hint attached to a
16
+ permanent code, so the header written here can simply follow the payload.
17
+
18
+ Nothing here ever renders a path, a token, or body text. ``EchoActError``'s
19
+ ``detail`` is the only free-form field and every raise site in this package
20
+ puts identifiers and numbers in it.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import math
26
+ from typing import Any
27
+
28
+ from fastapi import FastAPI
29
+ from fastapi.exceptions import RequestValidationError
30
+ from starlette.exceptions import HTTPException as StarletteHTTPException
31
+ from starlette.requests import Request
32
+ from starlette.responses import JSONResponse, Response
33
+
34
+ from ..errors import Code, EchoActError
35
+ from ..util import ids
36
+ from ..util.logging import get_logger
37
+
38
+ log = get_logger("service.errors")
39
+
40
+ #: Where the request identifier F-57 requires is echoed. It is a response
41
+ #: header as well as a body field so that a caller can correlate a streamed
42
+ #: audio response, which has no JSON body to put it in.
43
+ REQUEST_ID_HEADER = "X-Request-Id"
44
+
45
+ #: 4.2's leaf names inside a voice-settings object, each mapped to the code
46
+ #: Section 2.10 wants for it. Used only when a body fails schema validation
47
+ #: before any of this package's own checks could name the problem.
48
+ _VOICE_FIELD_CODES: dict[str, Code] = {
49
+ "model_id": Code.MODEL_UNKNOWN,
50
+ "voice_id": Code.VOICE_UNKNOWN,
51
+ "language": Code.LANGUAGE_UNKNOWN,
52
+ "gender": Code.VOICE_UNKNOWN,
53
+ "style": Code.STYLE_UNKNOWN,
54
+ "tempo": Code.TEMPO_OUT_OF_RANGE,
55
+ }
56
+
57
+
58
+ def request_id_of(request: Request) -> str:
59
+ """The identifier minted for this request, or a fresh one.
60
+
61
+ A fallback is minted rather than omitted: F-57 says every error carries
62
+ one, and an error raised before the gate stored it is exactly the kind a
63
+ user reports.
64
+ """
65
+ existing = request.scope.get("state", {}).get("request_id")
66
+ return existing if isinstance(existing, str) and existing else ids.request_id()
67
+
68
+
69
+ def error_response(exc: EchoActError, request_id: str) -> JSONResponse:
70
+ """The wire form of a refusal.
71
+
72
+ ``Retry-After`` is written only when the payload carries a hint, and HTTP
73
+ fixes it as whole seconds, so a sub-second hint rounds *up*: rounding down
74
+ would invite a retry that is still too early, which is the one thing the
75
+ header exists to prevent.
76
+ """
77
+ payload = exc.to_payload(request_id)
78
+ headers = {REQUEST_ID_HEADER: request_id}
79
+ if exc.retry_after_s is not None:
80
+ headers["Retry-After"] = str(max(1, math.ceil(exc.retry_after_s)))
81
+ return JSONResponse(payload, status_code=exc.http_status, headers=headers)
82
+
83
+
84
+ def validation_error(exc: RequestValidationError, path: str) -> EchoActError:
85
+ """Turn a schema failure into a coded refusal.
86
+
87
+ Section 2.10 gives malformed requests 400 and invalid voice settings 422,
88
+ and insists a detailed code accompanies each. ``echoact.errors`` has no
89
+ generic "the body could not be parsed" code, so the code is derived from
90
+ *which* field failed, and the last resort is chosen per endpoint from what
91
+ a body arriving there must state at minimum: a job request that fails to
92
+ parse has, among other things, stated no job kind, and an estimate request
93
+ that fails to parse has supplied no text. Both are true statements about
94
+ the body rather than a stand-in for one.
95
+ """
96
+ errors = exc.errors()
97
+ field_names = [str(part) for err in errors for part in err.get("loc", ())]
98
+ unknown = sorted(
99
+ {
100
+ str(err["loc"][-1])
101
+ for err in errors
102
+ if err.get("type") == "extra_forbidden" and err.get("loc")
103
+ }
104
+ )
105
+ if unknown:
106
+ return _unknown_field_error(unknown, field_names)
107
+ if "voice" in field_names:
108
+ for name in field_names:
109
+ if name in _VOICE_FIELD_CODES:
110
+ return EchoActError(_VOICE_FIELD_CODES[name], detail={"field": name})
111
+ return EchoActError(Code.VOICE_SETTINGS_INVALID, detail={"field": "voice"})
112
+ if "kind" in field_names:
113
+ return EchoActError(Code.JOB_KIND_MISSING, detail={"field": "kind"})
114
+ if "idempotency_key" in field_names:
115
+ return EchoActError(Code.IDEMPOTENCY_KEY_MISSING, detail={"field": "idempotency_key"})
116
+ if "text" in field_names:
117
+ return EchoActError(Code.INPUT_EMPTY, detail={"field": "text"})
118
+ if path.endswith("/estimate"):
119
+ return EchoActError(Code.INPUT_EMPTY, detail={"field": "text"})
120
+ return EchoActError(Code.MALFORMED_REQUEST, detail={"fields": field_names})
121
+
122
+
123
+ def _unknown_field_error(unknown: list[str], field_names: list[str]) -> EchoActError:
124
+ """F-54: an option this version does not define is refused, by name.
125
+
126
+ The catalogue has no code for "unrecognised field", so the code is the
127
+ same last resort the rest of the translation uses and carries the status
128
+ Section 2.10 fixes for where the field sat -- 422 inside the voice
129
+ settings, 400 for the body itself -- while ``detail`` names every field
130
+ that was not understood. Naming them is the point: a client that is not
131
+ a person cannot compare its request against a document, and "something in
132
+ your body is wrong" costs it the round trip without telling it what to
133
+ change.
134
+ """
135
+ listed = ", ".join(unknown)
136
+ if "voice" in field_names:
137
+ # 422 rather than 400 because Section 2.10 fixes that status for
138
+ # the voice settings. The status says where the fault is; the
139
+ # code says what it is, and they are allowed to differ.
140
+ return EchoActError(
141
+ Code.VOICE_SETTINGS_INVALID,
142
+ f"The voice settings name options this version does not define: {listed}.",
143
+ detail={"field": "voice", "unknown_fields": unknown},
144
+ )
145
+ return EchoActError(
146
+ Code.UNKNOWN_OPTION,
147
+ f"The request names fields this version does not define: {listed}.",
148
+ detail={"unknown_fields": unknown},
149
+ )
150
+
151
+
152
+ def http_exception_error(exc: StarletteHTTPException) -> EchoActError:
153
+ """A routing-layer refusal, given a code.
154
+
155
+ An unknown path and an unsupported method both answer NOT_FOUND. 405
156
+ would tell a caller that the path exists and only the verb is wrong, and
157
+ on a service whose whole contract is twelve fixed operations that is a
158
+ map of the surface for no benefit to a legitimate client.
159
+ """
160
+ if exc.status_code in (404, 405):
161
+ return EchoActError(Code.NOT_FOUND, "No such endpoint.")
162
+ if exc.status_code == 413:
163
+ return EchoActError(Code.PAYLOAD_TOO_LARGE)
164
+ if exc.status_code == 401:
165
+ return EchoActError(Code.UNAUTHENTICATED)
166
+ if exc.status_code == 403:
167
+ return EchoActError(Code.FORBIDDEN)
168
+ if exc.status_code == 400:
169
+ # Starlette raises a bare 400 for a body its parsers refuse.
170
+ # Falling through to INTERNAL reported a server fault for a
171
+ # client's mistake, and 500 is the one status a client must not
172
+ # be told to stop retrying on.
173
+ return EchoActError(Code.MALFORMED_REQUEST)
174
+ return EchoActError(Code.INTERNAL)
175
+
176
+
177
+ def install_error_handlers(app: FastAPI) -> None:
178
+ """Route every exception class to the one envelope.
179
+
180
+ The unexpected-exception handler answers INTERNAL and logs the type only.
181
+ An exception's text can quote a file path or a fragment of the caller's
182
+ own body, and F-57 keeps internal paths out of a response while N-20 keeps
183
+ body text out of a log, so neither the client nor the log file gets it.
184
+ """
185
+
186
+ async def on_echoact_error(request: Request, exc: Exception) -> Response:
187
+ assert isinstance(exc, EchoActError)
188
+ request_id = request_id_of(request)
189
+ _log_refusal(request, exc, request_id)
190
+ return error_response(exc, request_id)
191
+
192
+ async def on_validation_error(request: Request, exc: Exception) -> Response:
193
+ assert isinstance(exc, RequestValidationError)
194
+ translated = validation_error(exc, request.scope.get("path", ""))
195
+ request_id = request_id_of(request)
196
+ _log_refusal(request, translated, request_id)
197
+ return error_response(translated, request_id)
198
+
199
+ async def on_http_exception(request: Request, exc: Exception) -> Response:
200
+ assert isinstance(exc, StarletteHTTPException)
201
+ translated = http_exception_error(exc)
202
+ request_id = request_id_of(request)
203
+ _log_refusal(request, translated, request_id)
204
+ return error_response(translated, request_id)
205
+
206
+ async def on_unexpected(request: Request, exc: Exception) -> Response:
207
+ request_id = request_id_of(request)
208
+ log.exception(
209
+ "unhandled %s in %s (request=%s)",
210
+ type(exc).__name__,
211
+ request.scope.get("path", ""),
212
+ request_id,
213
+ )
214
+ return error_response(EchoActError(Code.INTERNAL), request_id)
215
+
216
+ app.add_exception_handler(EchoActError, on_echoact_error)
217
+ app.add_exception_handler(RequestValidationError, on_validation_error)
218
+ app.add_exception_handler(StarletteHTTPException, on_http_exception)
219
+ app.add_exception_handler(Exception, on_unexpected)
220
+
221
+
222
+ def _log_refusal(request: Request, exc: EchoActError, request_id: str) -> None:
223
+ """N-25's traceable line for a refusal: identifiers and a code, nothing else.
224
+
225
+ The path is logged without its query string. Nothing in this contract
226
+ puts a token in a URL (N-17), but a log line that would print one if it
227
+ ever appeared is a defect waiting for a future endpoint.
228
+ """
229
+ log.info(
230
+ "%s %s -> %d %s (request=%s)",
231
+ request.scope.get("method", "?"),
232
+ request.scope.get("path", "?"),
233
+ exc.http_status,
234
+ exc.code.value,
235
+ request_id,
236
+ )
237
+
238
+
239
+ def error_document(code: Code) -> dict[str, Any]:
240
+ """One example error body, for the OpenAPI document F-57 requires."""
241
+ return EchoActError(code).to_payload("req_example")