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/service/app.py ADDED
@@ -0,0 +1,182 @@
1
+ """Assembling the ASGI application, and the specification F-57 requires.
2
+
3
+ Three deliberate absences are worth naming, because each is a default that
4
+ had to be turned off rather than something forgotten:
5
+
6
+ * **No CORS middleware.** N-17 disallows CORS by default, and the way to
7
+ disallow it is to emit no ``Access-Control-Allow-*`` header at all. The
8
+ Origin check in :mod:`echoact.service.deps` refuses a cross-origin request
9
+ outright; nothing here would have let a browser read a reply anyway.
10
+ * **No interactive docs.** Swagger UI and ReDoc load their JavaScript from a
11
+ public CDN. N-01 says the app makes no automatic external communication
12
+ beyond model downloads and update checks the user asked for, and a docs
13
+ page that fetches from a CDN the moment it is opened would be exactly that.
14
+ The machine-readable document remains, which is what F-57 actually asks for.
15
+ * **No lifespan hooks.** The parts already exist: ``Application`` builds the
16
+ store, the engine, and the credentials before the service is started, and
17
+ F-79 requires the GUI to survive a service that never starts. Wiring
18
+ construction into the ASGI lifespan would put the app's lifetime inside the
19
+ server's, which is the inversion A.2 rules out.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ from fastapi import FastAPI
27
+ from fastapi.openapi.utils import get_openapi
28
+
29
+ from .. import __version__
30
+ from ..errors import Code
31
+ from ..policy import API_PREFIX, API_VERSION
32
+ from .deps import ServiceContext, ServiceGate
33
+ from .errors import error_document, install_error_handlers
34
+ from .routes import build_router
35
+ from .schemas import CreateJobRequest, ErrorBody
36
+
37
+ _METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"})
38
+
39
+ #: The refusals a caller most needs to have seen before it meets one, rendered
40
+ #: as F-57's "request/response examples". Busy and rate-limited are here
41
+ #: because F-47 calls contention the expected case rather than an edge case,
42
+ #: and a client that has never seen the hint will not honour it.
43
+ _ERROR_EXAMPLES: tuple[Code, ...] = (
44
+ Code.UNAUTHENTICATED,
45
+ Code.FORBIDDEN,
46
+ Code.NOT_FOUND,
47
+ Code.BUSY,
48
+ Code.IDEMPOTENCY_KEY_CONFLICT,
49
+ Code.RATE_LIMITED,
50
+ Code.INSUFFICIENT_RESOURCES,
51
+ )
52
+
53
+ #: The whole external contract, exactly as Section 2.10's table lists it, as
54
+ #: (method, path) pairs. Written down here rather than derived from the
55
+ #: router so that the test asserting "these twelve and no others" checks the
56
+ #: code against the document instead of against itself.
57
+ #:
58
+ #: Twelve operations over eleven paths: creating a job and listing history are
59
+ #: two methods on ``/api/v1/jobs``, which is one path to OpenAPI and two rows
60
+ #: in the contract table.
61
+ CONTRACT_OPERATIONS: tuple[tuple[str, str], ...] = (
62
+ ("GET", f"{API_PREFIX}/status"),
63
+ ("GET", f"{API_PREFIX}/models"),
64
+ ("POST", f"{API_PREFIX}/estimate"),
65
+ ("POST", f"{API_PREFIX}/jobs"),
66
+ ("GET", f"{API_PREFIX}/jobs"),
67
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}"),
68
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}/text"),
69
+ ("POST", f"{API_PREFIX}/jobs/{{job_id}}/cancel"),
70
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}/segments"),
71
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}/segments/{{segment_id}}/audio"),
72
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}/audio"),
73
+ ("GET", f"{API_PREFIX}/jobs/{{job_id}}/result"),
74
+ )
75
+
76
+ CONTRACT_PATHS: tuple[str, ...] = tuple(dict.fromkeys(path for _, path in CONTRACT_OPERATIONS))
77
+
78
+ _DESCRIPTION = """\
79
+ EchoAct's local generation service. It listens on the loopback address only,
80
+ requires authentication on every path including the specification itself, and
81
+ exists only while the desktop application is running.
82
+
83
+ Authenticate with `Authorization: Bearer <token>`, using a credential issued
84
+ in the application. Tokens never travel in a URL.
85
+
86
+ Errors carry a stable `code`, a `message`, `retryable`, and a `request_id`.
87
+ The three refusals that can succeed later -- busy, rate limited, and
88
+ insufficient resources -- also carry `retry_after_s` and a `Retry-After`
89
+ header; a permanent refusal carries neither.
90
+ """
91
+
92
+
93
+ def create_app(context: ServiceContext) -> FastAPI:
94
+ """Build the ASGI application for one service context."""
95
+ app = FastAPI(
96
+ title="EchoAct local service",
97
+ version=__version__,
98
+ description=_DESCRIPTION,
99
+ openapi_url="/openapi.json",
100
+ docs_url=None,
101
+ redoc_url=None,
102
+ )
103
+ install_error_handlers(app)
104
+ app.include_router(build_router(context), prefix=API_PREFIX)
105
+ # Added last so it wraps everything, routing and the specification route
106
+ # included: N-31's "no request succeeds without a valid credential" has to
107
+ # cover the paths that are not operations as well as the ones that are.
108
+ app.add_middleware(ServiceGate, context=context)
109
+ app.openapi = lambda: _specification(app) # type: ignore[method-assign]
110
+ app.state.echoact = context
111
+ return app
112
+
113
+
114
+ def _specification(app: FastAPI) -> dict[str, Any]:
115
+ """F-57's machine-readable contract, cached on the app as FastAPI does.
116
+
117
+ ``CreateJobRequest`` is merged in by hand. POST /api/v1/jobs declares two
118
+ alternative bodies -- inline JSON and a multipart upload -- which FastAPI
119
+ cannot express from a signature, so the operation supplies its own
120
+ ``requestBody`` and the model it refers to would otherwise never reach
121
+ ``components.schemas``. A specification that references a schema it does
122
+ not define is not machine-readable, which is the whole point of F-57.
123
+ """
124
+ if app.openapi_schema:
125
+ return app.openapi_schema
126
+ schema = get_openapi(
127
+ title=app.title,
128
+ version=app.version,
129
+ description=app.description,
130
+ routes=app.routes,
131
+ )
132
+ schema["info"]["x-api-version"] = API_VERSION
133
+ components = schema.setdefault("components", {}).setdefault("schemas", {})
134
+ body = CreateJobRequest.model_json_schema(ref_template="#/components/schemas/{model}")
135
+ for name, definition in body.pop("$defs", {}).items():
136
+ components.setdefault(name, definition)
137
+ components.setdefault("CreateJobRequest", body)
138
+ components.setdefault("ErrorBody", ErrorBody.model_json_schema())
139
+ _attach_error_responses(schema)
140
+ for unused in ("HTTPValidationError", "ValidationError"):
141
+ components.pop(unused, None)
142
+ app.openapi_schema = schema
143
+ return schema
144
+
145
+
146
+ def _attach_error_responses(schema: dict[str, Any]) -> None:
147
+ """Give every operation F-57's error envelope, with examples.
148
+
149
+ Attached here rather than declared on each route because the envelope is
150
+ the same for all twelve and Section 2.10 fixes the statuses centrally: a
151
+ per-route list would be twelve chances for the document and
152
+ ``echoact.errors`` to disagree about what a refusal looks like.
153
+ """
154
+ examples = {
155
+ code.value: {"summary": code.value, "value": error_document(code)}
156
+ for code in _ERROR_EXAMPLES
157
+ }
158
+ for path_item in schema.get("paths", {}).values():
159
+ for method, operation in path_item.items():
160
+ if method.lower() not in _METHODS:
161
+ continue
162
+ responses = operation.setdefault("responses", {})
163
+ # FastAPI adds a 422 of its own shape to every operation with a
164
+ # parameter. This service never answers one: a malformed request
165
+ # is 400 with a code, and the only 422 it produces is invalid
166
+ # voice settings, in the same envelope as every other refusal.
167
+ # Leaving FastAPI's in would document a response that cannot
168
+ # occur, in a schema nothing here returns.
169
+ responses.pop("422", None)
170
+ responses["default"] = {
171
+ "description": (
172
+ "A refusal. Carries a stable code, a message, whether a retry can "
173
+ "succeed, and a request identifier; the retryable ones also carry "
174
+ "retry_after_s and a Retry-After header."
175
+ ),
176
+ "content": {
177
+ "application/json": {
178
+ "schema": {"$ref": "#/components/schemas/ErrorBody"},
179
+ "examples": examples,
180
+ }
181
+ },
182
+ }