ox1audio 0.3.5__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.
- ox1audio/AGENTS.md +96 -0
- ox1audio/__init__.py +83 -0
- ox1audio/__main__.py +10 -0
- ox1audio/_contract.py +26 -0
- ox1audio/_protocol.py +236 -0
- ox1audio/_tags.py +325 -0
- ox1audio/action.py +657 -0
- ox1audio/api.py +189 -0
- ox1audio/app.py +67 -0
- ox1audio/app.schema.json +189 -0
- ox1audio/audio.py +544 -0
- ox1audio/cli/__init__.py +130 -0
- ox1audio/cli/__main__.py +3 -0
- ox1audio/cli/_actionrun.py +121 -0
- ox1audio/cli/_describe.py +30 -0
- ox1audio/cli/_safety.py +369 -0
- ox1audio/cli/_testrun.py +93 -0
- ox1audio/cli/check.py +509 -0
- ox1audio/cli/dev.py +157 -0
- ox1audio/cli/docs.py +56 -0
- ox1audio/cli/doctor.py +45 -0
- ox1audio/cli/new.py +296 -0
- ox1audio/cli/out.py +72 -0
- ox1audio/cli/registry.py +301 -0
- ox1audio/cli/running.py +160 -0
- ox1audio/cli/testing.py +123 -0
- ox1audio/cloud/__init__.py +8 -0
- ox1audio/cloud/api/__init__.py +1 -0
- ox1audio/cloud/api/default/__init__.py +1 -0
- ox1audio/cloud/api/default/app_catalog.py +134 -0
- ox1audio/cloud/api/default/cli_login.py +178 -0
- ox1audio/cloud/api/default/cli_login_poll.py +186 -0
- ox1audio/cloud/api/default/cli_tokens.py +134 -0
- ox1audio/cloud/api/default/create_submission.py +216 -0
- ox1audio/cloud/api/default/health.py +134 -0
- ox1audio/cloud/api/default/me.py +150 -0
- ox1audio/cloud/api/default/revoke_cli_token.py +169 -0
- ox1audio/cloud/api/default/submission.py +168 -0
- ox1audio/cloud/api/default/submissions.py +172 -0
- ox1audio/cloud/client.py +282 -0
- ox1audio/cloud/errors.py +16 -0
- ox1audio/cloud/models/__init__.py +63 -0
- ox1audio/cloud/models/catalog_list.py +75 -0
- ox1audio/cloud/models/catalog_list_apps_item.py +165 -0
- ox1audio/cloud/models/cli_device.py +93 -0
- ox1audio/cloud/models/cli_login_body.py +42 -0
- ox1audio/cloud/models/cli_poll_body.py +42 -0
- ox1audio/cloud/models/cli_token.py +75 -0
- ox1audio/cloud/models/cli_token_account.py +61 -0
- ox1audio/cloud/models/cli_token_list.py +75 -0
- ox1audio/cloud/models/cli_token_list_tokens_item.py +113 -0
- ox1audio/cloud/models/error.py +67 -0
- ox1audio/cloud/models/error_error.py +69 -0
- ox1audio/cloud/models/health.py +69 -0
- ox1audio/cloud/models/me.py +129 -0
- ox1audio/cloud/models/me_founding.py +115 -0
- ox1audio/cloud/models/me_status.py +9 -0
- ox1audio/cloud/models/me_waitlist_type_1.py +71 -0
- ox1audio/cloud/models/notices.py +62 -0
- ox1audio/cloud/models/problem.py +91 -0
- ox1audio/cloud/models/problem_detail.py +98 -0
- ox1audio/cloud/models/revoke_cli_token_body.py +43 -0
- ox1audio/cloud/models/submission_body.py +70 -0
- ox1audio/cloud/models/submission_created.py +96 -0
- ox1audio/cloud/models/submission_created_status.py +8 -0
- ox1audio/cloud/models/submission_list.py +75 -0
- ox1audio/cloud/models/submission_status.py +145 -0
- ox1audio/cloud/models/submission_status_checks_item.py +77 -0
- ox1audio/cloud/models/submission_status_notes_item.py +79 -0
- ox1audio/cloud/models/submission_status_status.py +10 -0
- ox1audio/cloud/models/terms_standing.py +174 -0
- ox1audio/cloud/types.py +54 -0
- ox1audio/data.py +120 -0
- ox1audio/docs/agents.md +96 -0
- ox1audio/docs/app-json.md +22 -0
- ox1audio/docs/cli.md +36 -0
- ox1audio/docs/digest.md +8 -0
- ox1audio/docs/errors.md +106 -0
- ox1audio/docs/llms.txt +17 -0
- ox1audio/docs/permissions.md +14 -0
- ox1audio/docs/protocol.md +34 -0
- ox1audio/docs/ui.json +258 -0
- ox1audio/errors.py +56 -0
- ox1audio/fields.py +296 -0
- ox1audio/library.py +38 -0
- ox1audio/local.py +352 -0
- ox1audio/manifest.py +206 -0
- ox1audio/mcp.py +556 -0
- ox1audio/py.typed +0 -0
- ox1audio/runtime.py +11 -0
- ox1audio/templates/Apache-2.0.txt +202 -0
- ox1audio-0.3.5.dist-info/METADATA +264 -0
- ox1audio-0.3.5.dist-info/RECORD +98 -0
- ox1audio-0.3.5.dist-info/WHEEL +5 -0
- ox1audio-0.3.5.dist-info/entry_points.txt +2 -0
- ox1audio-0.3.5.dist-info/licenses/LICENSE +202 -0
- ox1audio-0.3.5.dist-info/licenses/NOTICE +5 -0
- ox1audio-0.3.5.dist-info/top_level.txt +1 -0
ox1audio/AGENTS.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Building a Mixel app
|
|
2
|
+
|
|
3
|
+
This folder is one app: Python actions that Mixel runs on the person's computer. Use only the API on this page. Don't read the SDK source.
|
|
4
|
+
|
|
5
|
+
## Loop
|
|
6
|
+
1. Edit `app.py`. If what the code touches changes, update `app.json` › `permissions`.
|
|
7
|
+
2. `mixel check` until it reports no errors. Each error ends with a `fix:` line; do that. `--fix` adds missing permissions and packages.
|
|
8
|
+
3. `mixel test` runs `tests/`. `mixel run ACTION --file song.wav --set NAME=VALUE` runs one action on a real file.
|
|
9
|
+
4. `mixel dev` opens the app in Mixel and reloads it on save. Never `mixel publish`, or change permissions, unless the person asks.
|
|
10
|
+
|
|
11
|
+
## An action
|
|
12
|
+
```python
|
|
13
|
+
import ox1audio as ox
|
|
14
|
+
|
|
15
|
+
@ox.action("Convert")
|
|
16
|
+
def convert(files: ox.Files("audio"), format: ox.Choice("wav", "flac", "mp3") = "wav"):
|
|
17
|
+
made = []
|
|
18
|
+
for i, path in enumerate(files): # each path is a pathlib.Path
|
|
19
|
+
ox.progress(i / len(files), path.name) # Stop takes effect here
|
|
20
|
+
out = ox.audio.convert(path, format) # a new file, in a temporary folder
|
|
21
|
+
made.append(ox.sound(out, title=path.stem))
|
|
22
|
+
return made # sounds are added to the Library
|
|
23
|
+
```
|
|
24
|
+
- One decorated function per action. The parameters are the controls; Python defaults are their defaults. Mixel draws the form, checks the input, runs the action in the background, and shows progress and Stop.
|
|
25
|
+
- Controls: `ox.Files("audio" | "midi" | "any", multiple=True)` gives `list[Path]` (one `Path` with `multiple=False`), `ox.Folder()` gives a `Path`, `ox.Choice("a", "b")` a `str`, `ox.Text(placeholder="")`, `ox.Number(min, max, step)`, `ox.Slider(min, max, step)` (`int` if all three are ints), `ox.Range(min, max, step)` → `(low, high)`, `ox.Toggle`. Number, Slider, Range take `unit="dB"`. Plain `str`/`int`/`float`/`bool` work too. Every control takes `label=`. `ox.Folder() | None = None` makes one optional. Save beside an input with `folder or ox.beside(path)` (`None` for a read-only Open with file: copies go to the Library).
|
|
26
|
+
- Return `None`, a `str` (shown to the person), a JSON-able `dict` or `list` (for a page), or `ox.sound(path, title)`, `ox.midi(...)`, `ox.file(...)`, or a list of those. Metadata (`bpm=120, key="A minor"`) follows the title.
|
|
27
|
+
- `ox.progress(fraction, label)`: fraction 0 to 1, or `None` when unknown. Call it often, also from library callbacks. When the person presses Stop it raises `ox.Cancelled` (a `BaseException`, so `except Exception` doesn't catch it): clean up in `except ox.Cancelled:` and re-raise. Every `ox.sound/midi/file` made so far is kept in the Library.
|
|
28
|
+
- A problem the person can fix: `raise ox.Problem("That file has no audio.", hint="Try a WAV or MP3.")`. Other exceptions show "The app ran into a problem"; details go to the log.
|
|
29
|
+
- `print` goes to the log. Don't write a server, threads, or `if __name__ == "__main__"`: Mixel runs the actions.
|
|
30
|
+
|
|
31
|
+
## Testing
|
|
32
|
+
```python
|
|
33
|
+
import unittest
|
|
34
|
+
|
|
35
|
+
import ox1audio as ox
|
|
36
|
+
import app
|
|
37
|
+
|
|
38
|
+
class ConvertTest(unittest.TestCase):
|
|
39
|
+
def test_converts_to_wav(self):
|
|
40
|
+
run = ox.test(app.convert, files=["tests/tone.wav"], format="wav")
|
|
41
|
+
self.assertEqual(run["items"][0]["title"], "tone") # what reached the Library
|
|
42
|
+
```
|
|
43
|
+
`ox.test` returns `{"result", "items", "progress", "events"}`: what a page gets, the Library items, each `(fraction, label)` and `(name, body)`. `stop_at=0.5` presses Stop halfway: it raises `ox.Cancelled`, whose `.kept` lists what was kept. `ox.Problem` is raised as is.
|
|
44
|
+
|
|
45
|
+
## Helpers
|
|
46
|
+
| Need | Use |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| File info | `ox.audio.probe(path)` → `.rate .channels .duration .codec` |
|
|
49
|
+
| Another format | `ox.audio.convert(path, "wav" / "aiff" / "flac" / "mp3" / "aac" / "ogg" / "opus", folder=None, bits=None)` → `Path` (keeps depth, tags, cover, loops; `.note` if it lowered an over) |
|
|
50
|
+
| Samples (numpy) | `ox.audio.read(path)` → `(samples, rate)`, float32 `(frames, channels)`; `ox.audio.write(samples, rate, "wav", folder=None, name="audio", bits=24)` → `Path`. `bits=32`: float; 16 dithers. Over ±1 raises, with the fix. |
|
|
51
|
+
| A folder for new files | `ox.temp()` → an empty folder, removed when the app closes |
|
|
52
|
+
| The app's own files | `ox.data.read / read_text / write / list / remove("presets/a.json")` (needs `data`) |
|
|
53
|
+
|
|
54
|
+
## app.json
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"$schema": "https://0x1audio.com/schema/app/v1.json",
|
|
58
|
+
"name": "my-converter",
|
|
59
|
+
"displayName": "My Converter",
|
|
60
|
+
"summary": "One file to many formats at once.",
|
|
61
|
+
"version": "0.1.0",
|
|
62
|
+
"sdk": "^0.3",
|
|
63
|
+
"entry": "app.py",
|
|
64
|
+
"license": "Apache-2.0",
|
|
65
|
+
"permissions": {
|
|
66
|
+
"files": "Open the songs you choose.",
|
|
67
|
+
"library": "Save the converted copies to your Library."
|
|
68
|
+
},
|
|
69
|
+
"accent": "#20b4c4"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
- Permissions are binding: `files` (Files and Folder controls), `library` (returning sounds), `library-read` (a page lists it), `data` (`ox.data`), `gpu` (torch, onnxruntime), `network` as `{"why": "…", "hosts": ["example.com"]}`. Ask only for what the code uses. Each reason: one sentence (12–120 chars) saying why.
|
|
73
|
+
- Optional: `icon` (a square `.svg`/`.png` in the app), `window` (`{"width", "height", "minWidth", "minHeight"}`), `tags` (up to 5), `license` (SPDX), `page` (below).
|
|
74
|
+
- Raise `version` before each release. Never change `name`.
|
|
75
|
+
|
|
76
|
+
## Dependencies
|
|
77
|
+
Python packages go in `pyproject.toml` › `[project].dependencies`, wheels only; Mixel installs only those (`mixel check --fix` adds what the code imports, then runs `uv sync`; commit uv.lock). numpy and scipy are fine. The SDK already has ffmpeg; don't add ffmpeg wrappers, pydub, or requests. Never commit models, songs or files over 5 MB.
|
|
78
|
+
|
|
79
|
+
## A page (only when the drawn form is not enough)
|
|
80
|
+
`mixel new --page` adds one: Vite + React, built to `dist/index.html`. It uses `@0x1audio/design-system` and the same actions:
|
|
81
|
+
```tsx
|
|
82
|
+
import { ox } from "@0x1audio/sdk/page"
|
|
83
|
+
import { ActionForm } from "@0x1audio/design-system"
|
|
84
|
+
|
|
85
|
+
export const App = () => <ActionForm action="convert" /> // the form Mixel draws; build around it
|
|
86
|
+
// your own controls: await ox.run("convert", { files, format }, { onProgress: (f, label) => {}, onEvent: (name, body) => {}, signal })
|
|
87
|
+
```
|
|
88
|
+
Page API: `ox.run`, `ox.actions`, `ox.files.pick / fromDrop / read / reveal`, `ox.library.list / add / file / onChange`, `ox.data.*`, `ox.app.open`. Errors are `OxError` with `.code` (`cancelled` after Stop, with `.kept`). `ox.emit("partial", body)` in Python reaches `onEvent`. Design-system components and tokens only, no hex colours. Mixel sets `.dark` on `<html>`: check both.
|
|
89
|
+
|
|
90
|
+
## Words (what the person sees)
|
|
91
|
+
Say what it does for a musician: song, sound, file, folder, Library. Never: worker, manifest, SDK, ffmpeg. Sentence case. Buttons are verbs. No exclamation marks. Errors say what happened, then what to do.
|
|
92
|
+
|
|
93
|
+
## Never
|
|
94
|
+
- Touch files other than the ones the person chose, files next to them, `ox.temp()` and `ox.data`.
|
|
95
|
+
- Use `subprocess`, `os.system`, `eval`/`exec`, raw sockets, or telemetry.
|
|
96
|
+
- Edit `AGENTS.md` or `dist/` by hand, or commit `.venv/` or `node_modules/`.
|
ox1audio/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Build Mixel apps: typed Python actions that run on the person's computer.
|
|
2
|
+
|
|
3
|
+
import ox1audio as ox
|
|
4
|
+
|
|
5
|
+
@ox.action("Make it louder")
|
|
6
|
+
def louder(files: ox.Files("audio"), gain: ox.Slider(0, 12, step=0.5) = 6.0):
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
The whole public API is ``__all__`` below. AGENTS.md in every app explains it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.3.5"
|
|
13
|
+
|
|
14
|
+
from ox1audio.action import Cancelled, Problem, action, beside, emit, progress, temp, test
|
|
15
|
+
from ox1audio.app import run
|
|
16
|
+
from ox1audio.fields import Choice, Files, Folder, Number, Range, Slider, Text, Toggle, file, midi, sound
|
|
17
|
+
|
|
18
|
+
from . import audio, data, library
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Cancelled",
|
|
22
|
+
"Choice",
|
|
23
|
+
"Files",
|
|
24
|
+
"Folder",
|
|
25
|
+
"Number",
|
|
26
|
+
"Problem",
|
|
27
|
+
"Range",
|
|
28
|
+
"Slider",
|
|
29
|
+
"Text",
|
|
30
|
+
"Toggle",
|
|
31
|
+
"action",
|
|
32
|
+
"audio",
|
|
33
|
+
"beside",
|
|
34
|
+
"data",
|
|
35
|
+
"emit",
|
|
36
|
+
"file",
|
|
37
|
+
"library",
|
|
38
|
+
"midi",
|
|
39
|
+
"progress",
|
|
40
|
+
"run",
|
|
41
|
+
"sound",
|
|
42
|
+
"temp",
|
|
43
|
+
"test",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
_DOCS = "https://docs.0x1audio.com"
|
|
47
|
+
|
|
48
|
+
# Names that 0.2 had. Using one says what replaced it instead of a bare AttributeError.
|
|
49
|
+
_removed = {
|
|
50
|
+
"screen": ('@ox.screen("Title") is now @ox.action("Title").', 'rename the decorator: @ox.action("Title")', "actions#an-action"),
|
|
51
|
+
"job": (
|
|
52
|
+
"@ox.job is gone: every @ox.action already runs in the background with progress and Stop.",
|
|
53
|
+
'use @ox.action("Title") with typed parameters, call ox.progress() as you go, and return the result',
|
|
54
|
+
"actions#progress-and-stop",
|
|
55
|
+
),
|
|
56
|
+
"serve": (
|
|
57
|
+
"ox.serve is gone: Mixel finds your @ox.action functions and runs them.",
|
|
58
|
+
'decorate each handler with @ox.action("Title") and delete the serve({...}) call',
|
|
59
|
+
"actions#an-action",
|
|
60
|
+
),
|
|
61
|
+
"Call": (
|
|
62
|
+
"The call object is gone: actions take typed parameters.",
|
|
63
|
+
"replace call.body[...] with parameters, call.progress(...) with ox.progress(...), and call.cancelled() with letting ox.Cancelled propagate",
|
|
64
|
+
"actions#controls",
|
|
65
|
+
),
|
|
66
|
+
"exercise": ("ox.exercise is now ox.test.", "ox.test(action, files=[...])", "actions#testing"),
|
|
67
|
+
"convert": ("ox.convert is now ox.audio.convert.", 'ox.audio.convert(path, "wav")', "actions#helpers"),
|
|
68
|
+
"Client": ("The cloud client is not part of the app API.", "remove it; apps reach the cloud only through what Mixel allows", "permissions"),
|
|
69
|
+
"AuthenticatedClient": ("The cloud client is not part of the app API.", "remove it; apps reach the cloud only through what Mixel allows", "permissions"),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RemovedError(ImportError):
|
|
74
|
+
"""A name from an older SDK. The message says what to use instead."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def __getattr__(name: str) -> object:
|
|
78
|
+
found = _removed.get(name)
|
|
79
|
+
if found is None:
|
|
80
|
+
raise AttributeError(f"module 'ox1audio' has no attribute {name!r}. The API is: {', '.join(__all__)}.")
|
|
81
|
+
problem, fix, page = found
|
|
82
|
+
# ImportError (not AttributeError), so `from ox1audio import job` shows this message too.
|
|
83
|
+
raise RemovedError(f"{problem}\nfix: {fix}\ndocs: {_DOCS}/{page}", name=name)
|
ox1audio/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""``python -m ox1audio app.py`` loads the app and serves its actions over stdin and stdout."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
from ox1audio.app import main
|
|
6
|
+
|
|
7
|
+
if len(sys.argv) != 2:
|
|
8
|
+
print("usage: python -m ox1audio app.py (Mixel runs this for you; to try an action use ox.test)", file=sys.stderr)
|
|
9
|
+
raise SystemExit(2)
|
|
10
|
+
raise SystemExit(main(sys.argv[1]))
|
ox1audio/_contract.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Generated from sdk-py/contract by contract/generate.py. Do not edit.
|
|
2
|
+
|
|
3
|
+
The app.json rules, permission codes, protocol version and error codes, in one place."""
|
|
4
|
+
|
|
5
|
+
SCHEMA = "https://0x1audio.com/schema/app/v1.json"
|
|
6
|
+
FIELDS = ["$schema", "name", "displayName", "summary", "version", "sdk", "entry", "page", "permissions", "accent", "icon", "window", "tags", "license"]
|
|
7
|
+
REQUIRED = ["$schema", "name", "displayName", "version", "sdk"]
|
|
8
|
+
PERMISSIONS = ["files", "library", "library-read", "data", "gpu", "network"]
|
|
9
|
+
PERMISSION_TEXT = {"files": "Read and write files you pick", "library": "Add to your Library", "library-read": "Read your Library", "data": "Keep its own files", "gpu": "Use the graphics chip", "network": "Use the network"}
|
|
10
|
+
NAME_PATTERN = "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
|
|
11
|
+
NAME_LENGTH = [2, 48]
|
|
12
|
+
DISPLAY_NAME_MAX = 48
|
|
13
|
+
SUMMARY_MAX = 80
|
|
14
|
+
VERSION_PATTERN = "^(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})\\.(0|[1-9][0-9]{0,8})$"
|
|
15
|
+
SDK_PATTERN = "^\\^(0|[1-9][0-9]{0,3})\\.(0|[1-9][0-9]{0,3})(\\.(0|[1-9][0-9]{0,3}))?$"
|
|
16
|
+
ACCENT_PATTERN = "^#[0-9a-fA-F]{6}$"
|
|
17
|
+
HOST_PATTERN = "^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$"
|
|
18
|
+
HOSTS_MAX = 16
|
|
19
|
+
REASON_LENGTH = [12, 120]
|
|
20
|
+
WINDOW = {"width": [400, 2560], "height": [320, 1600], "minWidth": [400, 2560], "minHeight": [320, 1600]}
|
|
21
|
+
TAGS_MAX = 5
|
|
22
|
+
LICENSE_PATTERN = "^[A-Za-z0-9.+-]+(?: (?:AND|OR|WITH) [A-Za-z0-9.+-]+)*$"
|
|
23
|
+
PROTOCOL = 1
|
|
24
|
+
CONTROLS = ["files", "folder", "choice", "text", "number", "slider", "toggle"]
|
|
25
|
+
RUN_ERRORS = ["bad_input", "problem", "cancelled", "crashed", "timeout", "permission_denied", "permission_undeclared", "unavailable"]
|
|
26
|
+
ERRORS = {"manifest_missing": {"area": "check", "means": "There is no app.json in the folder.", "fix": "mixel new \"My App\" starts one.", "severity": "error"}, "manifest_json": {"area": "check", "means": "app.json is not valid JSON.", "fix": "Fix the JSON at the line shown.", "severity": "error"}, "manifest_v0": {"area": "check", "means": "app.json has no \"$schema\": it is the old v0 format.", "fix": "Add \"$schema\" and \"sdk\", rename worker.entry to entry and frontend to page, give each permission a reason.", "severity": "error"}, "manifest_schema": {"area": "check", "means": "\"$schema\" is not the v1 URL.", "fix": "\"$schema\": \"https://0x1audio.com/schema/app/v1.json\"", "severity": "error"}, "manifest_field": {"area": "check", "means": "A field app.json doesn't have, or one that was renamed.", "fix": "Remove or rename it as the message says.", "severity": "error"}, "manifest_name": {"area": "check", "means": "name is not 2 to 48 lowercase letters, digits and dashes.", "fix": "\"name\": \"my-app\". It never changes after the first publish.", "severity": "error"}, "manifest_display_name": {"area": "check", "means": "displayName is missing or over 48 characters.", "fix": "\"displayName\": \"My App\"", "severity": "error"}, "manifest_summary": {"area": "check", "means": "summary is not one sentence of up to 80 characters.", "fix": "\"summary\": \"One file to many formats at once.\"", "severity": "error"}, "manifest_version": {"area": "check", "means": "version is not MAJOR.MINOR.PATCH, or not above the last published one.", "fix": "\"version\": \"0.1.0\", raised before each publish.", "severity": "error"}, "manifest_entry": {"area": "check", "means": "entry is missing, not a .py file, or not in the app.", "fix": "\"entry\": \"app.py\"", "severity": "error"}, "manifest_page": {"area": "check", "means": "page is not an .html file in the app, or it isn't built.", "fix": "npm run build, then \"page\": \"dist/index.html\"", "severity": "error"}, "manifest_accent": {"area": "check", "means": "accent is not a colour like #5b8cff.", "fix": "\"accent\": \"#5b8cff\"", "severity": "error"}, "manifest_icon": {"area": "check", "means": "icon is not a square .svg or .png in the app.", "fix": "\"icon\": \"icon.svg\"", "severity": "error"}, "manifest_window": {"area": "check", "means": "window sizes are whole pixels within the allowed range.", "fix": "\"window\": { \"width\": 900, \"height\": 640 }", "severity": "error"}, "manifest_license": {"area": "check", "means": "license is not an SPDX identifier.", "fix": "\"license\": \"Apache-2.0\" (or MIT, GPL-3.0-only, ...)", "severity": "error"}, "manifest_tags": {"area": "check", "means": "tags is more than 5 single words.", "fix": "\"tags\": [\"convert\"]", "severity": "error"}, "sdk_missing": {"area": "check", "means": "app.json has no sdk range.", "fix": "\"sdk\": \"^0.3\"", "severity": "error"}, "sdk_range": {"area": "check", "means": "sdk is not a caret range this SDK satisfies.", "fix": "\"sdk\": \"^0.3\"", "severity": "error"}, "permission_undeclared": {"area": "run", "means": "The app used a permission app.json doesn't declare. Declared is binding.", "fix": "Add it to app.json › permissions with a reason.", "severity": "error"}, "permission_unused": {"area": "check", "means": "app.json asks for a permission the code doesn't use.", "fix": "Remove it, so people aren't asked for more than the app needs.", "severity": "warn"}, "template_words": {"area": "check", "means": "The summary or a permission reason is still mixel new's words about converting, in an app that does something else.", "fix": "Write what your app does, and why it needs each permission, in your own words.", "severity": "warn"}, "permission_unknown": {"area": "check", "means": "Not a permission. They are files, library, library-read, data, gpu and network.", "fix": "Use one of files, library, library-read, data, gpu, network.", "severity": "error"}, "permission_reason": {"area": "check", "means": "A permission without a reason of 12 to 120 characters.", "fix": "One sentence that tells the person why, like \"Open the songs you choose.\"", "severity": "error"}, "network_hosts": {"area": "check", "means": "network without a list of hosts, or with the whole web, an IP or localhost.", "fix": "\"network\": { \"why\": \"Download the model once.\", \"hosts\": [\"github.com\"] }", "severity": "error"}, "dependency_missing": {"area": "check", "means": "The code imports a package pyproject.toml doesn't list, so Mixel won't install it.", "fix": "Add it to pyproject.toml › [project].dependencies.", "severity": "error"}, "dependency_not_installed": {"area": "check", "means": "A listed package isn't installed where mixel runs, so the actions couldn't be loaded.", "fix": "uv sync (mixel check --fix runs it)", "severity": "error"}, "lock_missing": {"area": "check", "means": "pyproject.toml lists packages but there is no uv.lock, so Mixel and the registry can't install exactly what was reviewed.", "fix": "uv lock, then commit uv.lock. mixel check --fix runs it.", "severity": "error"}, "banned_api": {"area": "check", "means": "The code runs programs, opens raw sockets, runs code from strings, or imports by a name it builds (subprocess, os.system, socket, ctypes, eval, exec, __import__, requests).", "fix": "Use ox.audio.convert, ox.audio.read or ox.audio.write instead of programs; urllib.request with a declared network for downloads; plain imports.", "severity": "error"}, "path_outside": {"area": "check", "means": "The code opens a fixed path outside the app, like /etc/passwd, /tmp or the home folder.", "fix": "Use the files the person chooses (ox.Files, ox.Folder), ox.temp() or ox.data.", "severity": "error"}, "app_failed_to_load": {"area": "check", "means": "The entry file raised an error while loading.", "fix": "Fix the error at the line shown; the message names the fix.", "severity": "error"}, "no_actions": {"area": "check", "means": "The entry file has no @ox.action functions.", "fix": "@ox.action(\"Title\") above a function makes it an action.", "severity": "error"}, "bad_input": {"area": "run", "means": "The input doesn't fit the action's controls. Python never sees it.", "fix": "Send each control's value, as the message says.", "severity": "error"}, "problem": {"area": "run", "means": "The action raised ox.Problem: something the person can fix.", "fix": "Show the message and hint to the person.", "severity": "error"}, "cancelled": {"area": "run", "means": "The person pressed Stop. kept lists what the action finished before.", "fix": "Nothing to fix. Show what was kept.", "severity": "error"}, "crashed": {"area": "run", "means": "The action raised an unexpected error, or the app stopped.", "fix": "Read the log (mixel dev prints it), fix the line, run again.", "severity": "error"}, "timeout": {"area": "run", "means": "The action ran longer than its timeout.", "fix": "@ox.action(\"Title\", timeout=3600), and call ox.progress so Stop works.", "severity": "error"}, "permission_denied": {"area": "run", "means": "The page used a file the person didn't choose, or a permission they turned off.", "fix": "Use ox.files.pick or a Files control; ask the person to allow it.", "severity": "error"}, "unavailable": {"area": "run", "means": "The page isn't running inside Mixel, or the app has no actions.", "fix": "Open the app in Mixel (mixel dev).", "severity": "error"}, "usage": {"area": "cli", "means": "The command line is wrong.", "fix": "mixel --help", "severity": "error"}, "folder_not_empty": {"area": "cli", "means": "mixel new would write into a folder that has files.", "fix": "Choose another name.", "severity": "error"}, "no_tests": {"area": "cli", "means": "There are no tests in tests/.", "fix": "Add tests/test_app.py with a unittest that calls ox.test(...).", "severity": "error"}, "tests_did_not_run": {"area": "cli", "means": "The test runner itself failed.", "fix": "Run python -m unittest in the app's folder to see the whole error.", "severity": "error"}, "no_such_action": {"area": "cli", "means": "mixel run named an action the app doesn't have.", "fix": "Use a name mixel check lists.", "severity": "error"}, "no_such_topic": {"area": "cli", "means": "mixel docs named a page that doesn't exist.", "fix": "mixel docs --list", "severity": "error"}, "desktop_unavailable": {"area": "cli", "means": "Mixel isn't running, or developer mode is off.", "fix": "Open Mixel, turn on Settings › Developer mode, then quit and reopen Mixel.", "severity": "error"}, "desktop_error": {"area": "cli", "means": "The desktop refused or failed a dev request.", "fix": "Follow the message; mixel doctor checks the setup.", "severity": "error"}, "not_signed_in": {"area": "cli", "means": "No sign-in, or it expired.", "fix": "mixel login", "severity": "error"}, "expired_token": {"area": "cli", "means": "The pairing code expired before it was confirmed.", "fix": "mixel login", "severity": "error"}, "network": {"area": "cli", "means": "Mixel's cloud couldn't be reached.", "fix": "Check the connection. MIXEL_API sets another address.", "severity": "error"}, "registry_unavailable": {"area": "cli", "means": "This cloud doesn't take that request from the CLI yet.", "fix": "Update the server, or set MIXEL_API.", "severity": "error"}, "registry_error": {"area": "cli", "means": "The registry refused the request.", "fix": "Follow the message and its doc link.", "severity": "error"}, "git_missing": {"area": "cli", "means": "git isn't installed.", "fix": "Install git.", "severity": "error"}, "git_failed": {"area": "cli", "means": "A git command failed.", "fix": "The app's folder must be a git repository pushed to GitHub.", "severity": "error"}, "git_dirty": {"area": "cli", "means": "There are changes that aren't committed.", "fix": "git add -A && git commit -m \"…\" && git push", "severity": "error"}, "git_not_pushed": {"area": "cli", "means": "The commit isn't on GitHub yet.", "fix": "git push", "severity": "error"}, "not_repo_root": {"area": "cli", "means": "The app isn't at the top of its git repository.", "fix": "Give the app its own repository.", "severity": "error"}, "not_github": {"area": "cli", "means": "The remote isn't a GitHub repository.", "fix": "Push the app to a public GitHub repository.", "severity": "error"}, "library_search_off": {"area": "library", "means": "Mixel is closed, or Let an assistant search your Library is off.", "fix": "The person opens Mixel and turns it on in Settings › Your assistant.", "severity": "error"}, "not_ready": {"area": "library", "means": "Mixel can't compare sounds yet: the sound model is off or still indexing. Search by words still works.", "fix": "Search with words instead, or ask the person to check Mixel.", "severity": "error"}, "unknown_id": {"area": "library", "means": "An id that no search in this Mixel session returned, or one it no longer remembers.", "fix": "Search again and use the ids it returns.", "severity": "error"}, "too_many": {"area": "library", "means": "More than a search or a crate takes: 5 avoid or unlike words, 50 sounds in a crate.", "fix": "Keep the ones that matter most.", "severity": "error"}, "crates_off": {"area": "library", "means": "Proposing crates is off in this Mixel, so nothing was made.", "fix": "Tell the person what you found; they can turn on Settings › Your assistant › It can propose crates.", "severity": "error"}, "unknown_tool": {"area": "library", "means": "Mixel has no tool by that name.", "fix": "Use library_overview, library_search or propose_crate.", "severity": "error"}, "too_long": {"area": "library", "means": "Mixel's answer was over its limit, so it wasn't sent.", "fix": "Ask for less: fewer words, or a narrower filter.", "severity": "error"}, "mixel_error": {"area": "library", "means": "Mixel couldn't answer, for a reason it keeps to itself.", "fix": "Try again; if it keeps failing, tell the person.", "severity": "error"}, "github_required": {"area": "registry", "means": "The Mixel account has no GitHub account connected, and reviewers read the code there.", "fix": "Connect GitHub on your account page, then publish again.", "severity": "error"}, "repository_invalid": {"area": "registry", "means": "repository isn't a GitHub repository like github.com/owner/repo.", "fix": "Push the app to GitHub and publish from its folder.", "severity": "error"}, "repository_not_yours": {"area": "registry", "means": "The repository isn't one of the public repositories GitHub shows for the connected account.", "fix": "Push the app to a public repository you own, or connect the GitHub account that owns it.", "severity": "error"}, "commit_invalid": {"area": "registry", "means": "commit isn't the full 40-character SHA of a commit.", "fix": "git rev-parse HEAD", "severity": "error"}, "commit_not_pushed": {"area": "registry", "means": "The commit isn't on GitHub yet.", "fix": "git push", "severity": "error"}, "message_invalid": {"area": "registry", "means": "The note for reviewers is over 500 characters.", "fix": "Shorten it.", "severity": "error"}, "submission_pending": {"area": "registry", "means": "A version of this app is already waiting for review.", "fix": "mixel status shows it; publish again once it is decided.", "severity": "error"}, "app_required": {"area": "registry", "means": "The request doesn't name the app.", "fix": "Name it as app.json does: mixel status in the app's folder does this.", "severity": "error"}, "rate_limited": {"area": "registry", "means": "Too many requests in a short time, like more than 10 submissions in an hour.", "fix": "Wait, then try again; the message says how long.", "severity": "error"}, "waitlisted": {"area": "registry", "means": "The account is on the waitlist, so only sign-in, sign-out and the account's own data answer.", "fix": "Wait for the email that says you're in.", "severity": "error"}, "terms_required": {"area": "registry", "means": "The account hasn't agreed to the Terms of Service.", "fix": "Open cloud.0x1audio.com, agree, then run mixel login again.", "severity": "error"}, "plan_required": {"area": "registry", "means": "The account's plan doesn't include this; the error names what it needs.", "fix": "See the plans on your account page.", "severity": "error"}}
|
ox1audio/_protocol.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""The worker side of protocol 1. Internal: apps never import this.
|
|
2
|
+
|
|
3
|
+
NDJSON on stdin and stdout, one JSON object per line. Stdout carries only the
|
|
4
|
+
protocol; ``print`` goes to stderr. Host call ids (``id``) and worker request
|
|
5
|
+
ids (``rid``) are separate spaces. The message shapes live in one place, the
|
|
6
|
+
``Message`` docstring below, so GAB-37 can generate them from a contract.
|
|
7
|
+
|
|
8
|
+
Worker -> host
|
|
9
|
+
{"type": "ready", "protocol": 1, "sdk": "0.3.0", "actions": [ActionSpec], "permissions": ["files"]}
|
|
10
|
+
{"type": "fatal", "error": Error} the app could not load; the worker exits
|
|
11
|
+
{"type": "progress", "id": 7, "fraction": 0.4 | null, "label": "Separating vocals"}
|
|
12
|
+
{"type": "event", "id": 7, "name": "partial", "body": ...} ox.emit; the host forwards it to the page that made call 7
|
|
13
|
+
{"type": "result", "id": 7, "body": ...}
|
|
14
|
+
{"type": "error", "id": 7, "error": Error}
|
|
15
|
+
{"type": "library", "rid": 3, "op": "add", "kind", "path", "title", "metadata"}
|
|
16
|
+
{"type": "cloud", "rid": 4, "method", "path", "body"}
|
|
17
|
+
|
|
18
|
+
Host -> worker
|
|
19
|
+
{"type": "call", "id": 7, "action": "convert", "input": {...}, "handed"?: [path]} handed: files from "Open with"
|
|
20
|
+
{"type": "cancel", "id": 7}
|
|
21
|
+
{"type": "library-result", "rid": 3, "status": 200, "body": {...}}
|
|
22
|
+
{"type": "cloud-result", "rid": 4, "status": 200, "body": {...}}
|
|
23
|
+
{"type": "shutdown"}
|
|
24
|
+
|
|
25
|
+
Error = {"code": "bad_input" | "problem" | "cancelled" | "crashed", "message": str, "hint"?: str, "detail"?: str,
|
|
26
|
+
"kept"?: [Item]} kept: on "cancelled", what the action finished before Stop (already in the Library)
|
|
27
|
+
ActionSpec = {"name", "title", "input": JSON Schema (object), "timeout": seconds}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import contextvars
|
|
33
|
+
import json
|
|
34
|
+
import queue
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
import traceback
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from typing import BinaryIO
|
|
40
|
+
|
|
41
|
+
from ox1audio import __version__, _contract
|
|
42
|
+
from ox1audio.action import Action, BadInput, Cancelled, Problem, call, stopped_message
|
|
43
|
+
from ox1audio.runtime import link_var
|
|
44
|
+
|
|
45
|
+
PROTOCOL = _contract.PROTOCOL
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Link:
|
|
49
|
+
def __init__(self, stdin: BinaryIO, stdout: BinaryIO) -> None:
|
|
50
|
+
self._stdin = stdin
|
|
51
|
+
self._stdout = stdout
|
|
52
|
+
self._write_lock = threading.Lock()
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._next = 1
|
|
55
|
+
self._pending: dict[int, queue.Queue[dict]] = {}
|
|
56
|
+
self._incoming: queue.Queue[dict | None] = queue.Queue()
|
|
57
|
+
self.reader = threading.Thread(target=self._read, name="ox1audio-link", daemon=True)
|
|
58
|
+
self.reader.start()
|
|
59
|
+
|
|
60
|
+
def next_message(self) -> dict | None:
|
|
61
|
+
return self._incoming.get()
|
|
62
|
+
|
|
63
|
+
def send(self, message: dict) -> None:
|
|
64
|
+
data = json.dumps(message, separators=(",", ":"), allow_nan=False).encode() + b"\n"
|
|
65
|
+
with self._write_lock:
|
|
66
|
+
self._stdout.write(data)
|
|
67
|
+
self._stdout.flush()
|
|
68
|
+
|
|
69
|
+
def request(self, message: dict) -> dict:
|
|
70
|
+
"""Ask the host for something (library, cloud) and wait for its answer."""
|
|
71
|
+
with self._lock:
|
|
72
|
+
rid = self._next
|
|
73
|
+
self._next += 1
|
|
74
|
+
box: queue.Queue[dict] = queue.Queue(maxsize=1)
|
|
75
|
+
self._pending[rid] = box
|
|
76
|
+
self.send({**message, "rid": rid})
|
|
77
|
+
answer = box.get()
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._pending.pop(rid, None)
|
|
80
|
+
if answer.get("type") == "closed":
|
|
81
|
+
raise ConnectionError("Mixel closed the app.")
|
|
82
|
+
return answer
|
|
83
|
+
|
|
84
|
+
def library(self, payload: dict) -> object:
|
|
85
|
+
answer = self.request({"type": "library", **payload})
|
|
86
|
+
status = int(answer.get("status") or 500)
|
|
87
|
+
body = answer.get("body")
|
|
88
|
+
if status >= 400:
|
|
89
|
+
text = body.get("message") if isinstance(body, dict) else ""
|
|
90
|
+
raise Problem(text or "Could not add that to the Library.")
|
|
91
|
+
return body
|
|
92
|
+
|
|
93
|
+
def _read(self) -> None:
|
|
94
|
+
while True:
|
|
95
|
+
line = self._stdin.readline()
|
|
96
|
+
if line == b"":
|
|
97
|
+
self._incoming.put(None)
|
|
98
|
+
with self._lock:
|
|
99
|
+
boxes = list(self._pending.values())
|
|
100
|
+
for box in boxes:
|
|
101
|
+
box.put({"type": "closed"})
|
|
102
|
+
return
|
|
103
|
+
try:
|
|
104
|
+
message = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
print("The app ignored a line from Mixel that was not JSON.", file=sys.stderr)
|
|
107
|
+
continue
|
|
108
|
+
if not isinstance(message, dict):
|
|
109
|
+
continue
|
|
110
|
+
rid = message.get("rid")
|
|
111
|
+
if message.get("type") in {"library-result", "cloud-result"} and isinstance(rid, int):
|
|
112
|
+
with self._lock:
|
|
113
|
+
box = self._pending.get(rid)
|
|
114
|
+
if box is not None:
|
|
115
|
+
box.put(message)
|
|
116
|
+
continue
|
|
117
|
+
self._incoming.put(message)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def ready(registered: Iterable[Action]) -> dict:
|
|
121
|
+
listed = list(registered)
|
|
122
|
+
needed: set[str] = set()
|
|
123
|
+
for act in listed:
|
|
124
|
+
needed |= act.permissions()
|
|
125
|
+
order = _contract.PERMISSIONS
|
|
126
|
+
return {
|
|
127
|
+
"type": "ready",
|
|
128
|
+
"protocol": PROTOCOL,
|
|
129
|
+
"sdk": __version__,
|
|
130
|
+
"actions": [act.spec() for act in listed],
|
|
131
|
+
"permissions": [code for code in order if code in needed],
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def serve(registered: Iterable[Action], *, stdin: BinaryIO | None = None, stdout: BinaryIO | None = None) -> None:
|
|
136
|
+
"""Announce the actions, then run calls until the host closes the pipe or says shutdown."""
|
|
137
|
+
out = stdout or sys.stdout.buffer
|
|
138
|
+
if stdout is None:
|
|
139
|
+
sys.stdout = sys.stderr
|
|
140
|
+
link = Link(stdin or sys.stdin.buffer, out)
|
|
141
|
+
link_var.set(link)
|
|
142
|
+
by_name = {act.name: act for act in registered}
|
|
143
|
+
cancels: dict[int, threading.Event] = {}
|
|
144
|
+
link.send(ready(by_name.values()))
|
|
145
|
+
workers: list[threading.Thread] = []
|
|
146
|
+
while True:
|
|
147
|
+
message = link.next_message()
|
|
148
|
+
if message is None or message.get("type") == "shutdown":
|
|
149
|
+
# Shutdown stops what is running; at end of input, calls get the same few seconds to finish.
|
|
150
|
+
if message is not None:
|
|
151
|
+
for event in list(cancels.values()):
|
|
152
|
+
event.set()
|
|
153
|
+
for thread in workers:
|
|
154
|
+
thread.join(timeout=3)
|
|
155
|
+
# Let the reader see the end of input before Python shuts down; a daemon thread
|
|
156
|
+
# still inside readline() at shutdown makes Python abort with a fatal error.
|
|
157
|
+
link.reader.join(timeout=0.5)
|
|
158
|
+
return
|
|
159
|
+
kind = message.get("type")
|
|
160
|
+
ident = message.get("id")
|
|
161
|
+
if kind == "cancel" and isinstance(ident, int):
|
|
162
|
+
event = cancels.get(ident)
|
|
163
|
+
if event is not None:
|
|
164
|
+
event.set()
|
|
165
|
+
continue
|
|
166
|
+
if kind != "call" or not isinstance(ident, int):
|
|
167
|
+
continue
|
|
168
|
+
name = message.get("action")
|
|
169
|
+
act = by_name.get(name) if isinstance(name, str) else None
|
|
170
|
+
if act is None:
|
|
171
|
+
link.send(_error(ident, "bad_input", f"This app has no action called {name}.", f"Its actions are: {', '.join(by_name) or 'none'}."))
|
|
172
|
+
continue
|
|
173
|
+
cancels[ident] = threading.Event()
|
|
174
|
+
thread = threading.Thread(
|
|
175
|
+
target=contextvars.copy_context().run,
|
|
176
|
+
args=(_run, link, act, ident, message.get("input"), cancels, message.get("handed")),
|
|
177
|
+
name=f"ox1audio-{act.name}-{ident}",
|
|
178
|
+
daemon=True,
|
|
179
|
+
)
|
|
180
|
+
thread.start()
|
|
181
|
+
workers = [item for item in workers if item.is_alive()] + [thread]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run(link: Link, act: Action, ident: int, given: object, cancels: dict[int, threading.Event], handed: object = None) -> None:
|
|
185
|
+
cancel = cancels[ident]
|
|
186
|
+
|
|
187
|
+
def report(fraction: float | None, label: str) -> None:
|
|
188
|
+
link.send({"type": "progress", "id": ident, "fraction": fraction, "label": label})
|
|
189
|
+
|
|
190
|
+
def send(name: str, body: object) -> None:
|
|
191
|
+
link.send({"type": "event", "id": ident, "name": name, "body": body})
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
body = call(act, given, ident=ident, cancel=cancel, report=report, send=send, handed=handed or ())
|
|
195
|
+
line = {"type": "result", "id": ident, "body": body}
|
|
196
|
+
if len(json.dumps(line, allow_nan=False)) > 1 << 20:
|
|
197
|
+
line = _error(ident, "crashed", "The result is too large to send.", detail=f"{act.name}() returned more than 1 MiB. Write large data to a file and return its path.")
|
|
198
|
+
print(line["error"]["detail"], file=sys.stderr)
|
|
199
|
+
except Cancelled as stop:
|
|
200
|
+
line = _error(ident, "cancelled", stopped_message(stop.kept))
|
|
201
|
+
if stop.kept:
|
|
202
|
+
line["error"]["kept"] = stop.kept
|
|
203
|
+
except Problem as problem:
|
|
204
|
+
line = _error(ident, "problem", problem.message, problem.hint)
|
|
205
|
+
except BadInput as bad:
|
|
206
|
+
line = _error(ident, "bad_input", str(bad))
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
if cancel.is_set():
|
|
209
|
+
line = _error(ident, "cancelled", "Stopped.")
|
|
210
|
+
else:
|
|
211
|
+
traceback.print_exc(file=sys.stderr)
|
|
212
|
+
line = _error(ident, "crashed", "The app ran into a problem.", detail=f"{type(exc).__name__}: {exc}")
|
|
213
|
+
finally:
|
|
214
|
+
cancels.pop(ident, None)
|
|
215
|
+
try:
|
|
216
|
+
link.send(line)
|
|
217
|
+
except (BrokenPipeError, ValueError, OSError):
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _error(ident: int, code: str, message: str, hint: str | None = None, detail: str | None = None) -> dict:
|
|
222
|
+
error: dict = {"code": code, "message": message}
|
|
223
|
+
if hint:
|
|
224
|
+
error["hint"] = hint
|
|
225
|
+
if detail:
|
|
226
|
+
error["detail"] = detail
|
|
227
|
+
return {"type": "error", "id": ident, "error": error}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def fatal(message: str, detail: str = "", *, stdout: BinaryIO | None = None) -> None:
|
|
231
|
+
out = stdout or sys.__stdout__.buffer
|
|
232
|
+
error = {"code": "crashed", "message": message}
|
|
233
|
+
if detail:
|
|
234
|
+
error["detail"] = detail
|
|
235
|
+
out.write(json.dumps({"type": "fatal", "error": error}).encode() + b"\n")
|
|
236
|
+
out.flush()
|