tithon 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.
- tithon/__init__.py +3 -0
- tithon/__main__.py +7 -0
- tithon/artifacts.py +91 -0
- tithon/cli.py +241 -0
- tithon/daemon.py +804 -0
- tithon/folding.py +165 -0
- tithon/journal.py +228 -0
- tithon/kernel.py +121 -0
- tithon/widgets.py +92 -0
- tithon-0.1.0.dist-info/METADATA +312 -0
- tithon-0.1.0.dist-info/RECORD +14 -0
- tithon-0.1.0.dist-info/WHEEL +4 -0
- tithon-0.1.0.dist-info/entry_points.txt +2 -0
- tithon-0.1.0.dist-info/licenses/LICENSE +21 -0
tithon/__init__.py
ADDED
tithon/__main__.py
ADDED
tithon/artifacts.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Rich-output artifact store: image payloads become real files on disk.
|
|
2
|
+
|
|
3
|
+
Base64 image data never enters the journal (SPEC.md) — it is decoded
|
|
4
|
+
on receipt, written to ``<workdir>/.tithon/outputs/`` with an sha-based
|
|
5
|
+
filename, deduplicated by sha256, and the message content carries only a
|
|
6
|
+
``$tithon_artifact`` reference.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .journal import Journal
|
|
15
|
+
|
|
16
|
+
EXTENSIONS = {"image/png": "png", "image/jpeg": "jpg"}
|
|
17
|
+
OUTPUTS_REL = Path(".tithon") / "outputs"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArtifactStore:
|
|
21
|
+
def __init__(self, workdir: Path, journal: Journal):
|
|
22
|
+
self.workdir = workdir
|
|
23
|
+
self.outputs_dir = workdir / OUTPUTS_REL
|
|
24
|
+
self.journal = journal
|
|
25
|
+
self._counter = 0
|
|
26
|
+
|
|
27
|
+
def extract(self, exec_id: str, content: dict) -> list[str]:
|
|
28
|
+
"""Replace rich image mime payloads in ``content['data']`` with refs.
|
|
29
|
+
|
|
30
|
+
Returns the artifact ids referenced (possibly empty). Mutates content.
|
|
31
|
+
"""
|
|
32
|
+
data = content.get("data")
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
return []
|
|
35
|
+
refs: list[str] = []
|
|
36
|
+
for mime, ext in EXTENSIONS.items():
|
|
37
|
+
payload = data.get(mime)
|
|
38
|
+
if not isinstance(payload, str):
|
|
39
|
+
continue
|
|
40
|
+
try:
|
|
41
|
+
raw = base64.b64decode(payload, validate=False)
|
|
42
|
+
except Exception:
|
|
43
|
+
continue
|
|
44
|
+
sha = hashlib.sha256(raw).hexdigest()
|
|
45
|
+
existing = self.journal.find_artifact(sha)
|
|
46
|
+
if existing is not None:
|
|
47
|
+
rel_path = existing[3]
|
|
48
|
+
else:
|
|
49
|
+
self.outputs_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
fname = f"{exec_id}_{self._counter}_{sha[:8]}.{ext}"
|
|
51
|
+
self._counter += 1
|
|
52
|
+
rel_path = str(OUTPUTS_REL / fname)
|
|
53
|
+
(self.workdir / rel_path).write_bytes(raw)
|
|
54
|
+
self.journal.register_artifact(sha, sha, mime, rel_path, len(raw))
|
|
55
|
+
data[mime] = {
|
|
56
|
+
"$tithon_artifact": {
|
|
57
|
+
"artifact_id": sha,
|
|
58
|
+
"mime": mime,
|
|
59
|
+
"rel_path": rel_path,
|
|
60
|
+
"sha256": sha,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
refs.append(sha)
|
|
64
|
+
return refs
|
|
65
|
+
|
|
66
|
+
def delete(self, artifact_id: str) -> None:
|
|
67
|
+
"""Remove a superseded artifact's file and journal row.
|
|
68
|
+
|
|
69
|
+
Called when no live folded snapshot references the artifact anymore (a
|
|
70
|
+
live-updating matplotlib plot supersedes its previous frame every step,
|
|
71
|
+
so without this ``.tithon/outputs/`` grows one file per step forever).
|
|
72
|
+
"""
|
|
73
|
+
row = self.journal.find_artifact(artifact_id)
|
|
74
|
+
if row is not None:
|
|
75
|
+
rel_path = row[3]
|
|
76
|
+
(self.workdir / rel_path).unlink(missing_ok=True)
|
|
77
|
+
self.journal.delete_artifact(artifact_id)
|
|
78
|
+
|
|
79
|
+
def sweep(self, keep: set[str]) -> int:
|
|
80
|
+
"""Delete every registered artifact whose id is not in ``keep``.
|
|
81
|
+
|
|
82
|
+
Run once after the daemon rebuilds its folds on (re)start, to reclaim
|
|
83
|
+
frames that accumulated before this GC existed or while the daemon was
|
|
84
|
+
down. Returns the number of artifacts removed.
|
|
85
|
+
"""
|
|
86
|
+
removed = 0
|
|
87
|
+
for artifact_id, _rel_path in self.journal.all_artifacts():
|
|
88
|
+
if artifact_id not in keep:
|
|
89
|
+
self.delete(artifact_id)
|
|
90
|
+
removed += 1
|
|
91
|
+
return removed
|
tithon/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Tithon CLI: ``tithon daemon | run | attach | status``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_home() -> Path:
|
|
14
|
+
return Path(os.environ.get("TITHON_HOME", str(Path.home() / ".tithon"))).expanduser()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sock_path() -> str:
|
|
18
|
+
return str(get_home() / "daemon.sock")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def _connect():
|
|
22
|
+
from websockets.asyncio.client import unix_connect
|
|
23
|
+
|
|
24
|
+
return unix_connect(sock_path(), max_size=None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_daemon(args) -> int:
|
|
28
|
+
home = get_home()
|
|
29
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
level = getattr(logging, args.log_level.upper())
|
|
31
|
+
fmt = "%(asctime)s %(name)s %(levelname)s %(message)s"
|
|
32
|
+
root = logging.getLogger()
|
|
33
|
+
root.setLevel(level)
|
|
34
|
+
# Always write to daemon.log for post-mortem analysis.
|
|
35
|
+
fh = logging.FileHandler(str(home / "daemon.log"))
|
|
36
|
+
fh.setFormatter(logging.Formatter(fmt))
|
|
37
|
+
root.addHandler(fh)
|
|
38
|
+
# Always echo to stderr so `tithon daemon` shows live activity in the terminal.
|
|
39
|
+
sh = logging.StreamHandler()
|
|
40
|
+
sh.setFormatter(logging.Formatter(fmt))
|
|
41
|
+
root.addHandler(sh)
|
|
42
|
+
from .daemon import Daemon
|
|
43
|
+
|
|
44
|
+
asyncio.run(Daemon(home, Path.cwd()).run())
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _render_output(payload: dict) -> None:
|
|
49
|
+
msg_type = payload.get("msg_type")
|
|
50
|
+
content = payload.get("content", {})
|
|
51
|
+
if msg_type == "stream":
|
|
52
|
+
out = sys.stderr if content.get("name") == "stderr" else sys.stdout
|
|
53
|
+
out.write(content.get("text", ""))
|
|
54
|
+
out.flush()
|
|
55
|
+
elif msg_type == "execute_result":
|
|
56
|
+
text = (content.get("data") or {}).get("text/plain", "")
|
|
57
|
+
print(text)
|
|
58
|
+
elif msg_type == "error":
|
|
59
|
+
for line in content.get("traceback", []):
|
|
60
|
+
print(line, file=sys.stderr)
|
|
61
|
+
elif msg_type in ("display_data", "update_display_data"):
|
|
62
|
+
data = content.get("data") or {}
|
|
63
|
+
for value in data.values():
|
|
64
|
+
if isinstance(value, dict) and "$tithon_artifact" in value:
|
|
65
|
+
print(f"[artifact] {value['$tithon_artifact']['rel_path']}")
|
|
66
|
+
break
|
|
67
|
+
else:
|
|
68
|
+
print(f"[display] {sorted(data)}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _run(args) -> int:
|
|
72
|
+
async with await _connect() as ws:
|
|
73
|
+
await ws.send(json.dumps({"op": "attach", "last_seen_seq": -1, "session": args.session}))
|
|
74
|
+
while True:
|
|
75
|
+
m = json.loads(await ws.recv())
|
|
76
|
+
if m.get("op") == "sync":
|
|
77
|
+
break
|
|
78
|
+
await ws.send(json.dumps({"op": "execute", "code": args.code, "session": args.session}))
|
|
79
|
+
exec_id = None
|
|
80
|
+
buffered: list[dict] = []
|
|
81
|
+
|
|
82
|
+
def handle(ev: dict) -> str | None:
|
|
83
|
+
"""Returns final status if this event completes our execution."""
|
|
84
|
+
if ev.get("exec_id") != exec_id:
|
|
85
|
+
return None
|
|
86
|
+
kind = ev.get("kind")
|
|
87
|
+
if kind == "output":
|
|
88
|
+
_render_output(ev.get("payload", {}))
|
|
89
|
+
elif kind == "done":
|
|
90
|
+
return ev.get("payload", {}).get("status", "ok")
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
while True:
|
|
94
|
+
m = json.loads(await ws.recv())
|
|
95
|
+
op = m.get("op")
|
|
96
|
+
if op == "execute_ack":
|
|
97
|
+
exec_id = m["exec_id"]
|
|
98
|
+
if args.no_wait:
|
|
99
|
+
print(exec_id)
|
|
100
|
+
return 0
|
|
101
|
+
for ev in buffered:
|
|
102
|
+
status = handle(ev)
|
|
103
|
+
if status is not None:
|
|
104
|
+
return 0 if status == "ok" else 1
|
|
105
|
+
buffered.clear()
|
|
106
|
+
elif op == "event":
|
|
107
|
+
if exec_id is None:
|
|
108
|
+
buffered.append(m)
|
|
109
|
+
continue
|
|
110
|
+
status = handle(m)
|
|
111
|
+
if status is not None:
|
|
112
|
+
return 0 if status == "ok" else 1
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_run(args) -> int:
|
|
116
|
+
coro = _run(args)
|
|
117
|
+
if args.timeout > 0:
|
|
118
|
+
coro = asyncio.wait_for(coro, args.timeout)
|
|
119
|
+
return asyncio.run(coro)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _attach(args) -> int:
|
|
123
|
+
async with await _connect() as ws:
|
|
124
|
+
await ws.send(json.dumps(
|
|
125
|
+
{"op": "attach", "last_seen_seq": args.since, "session": args.session}))
|
|
126
|
+
async for raw in ws:
|
|
127
|
+
text = raw if isinstance(raw, str) else raw.decode()
|
|
128
|
+
print(text, flush=True)
|
|
129
|
+
m = json.loads(text)
|
|
130
|
+
if args.once and m.get("op") == "sync":
|
|
131
|
+
return 0
|
|
132
|
+
if args.until_done and m.get("op") == "event" and m.get("kind") == "done":
|
|
133
|
+
return 0
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def cmd_attach(args) -> int:
|
|
138
|
+
return asyncio.run(_attach(args))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _status(args) -> int:
|
|
142
|
+
async with await _connect() as ws:
|
|
143
|
+
op = {"op": "status"}
|
|
144
|
+
if args.session is not None:
|
|
145
|
+
op["session"] = args.session
|
|
146
|
+
await ws.send(json.dumps(op))
|
|
147
|
+
m = json.loads(await ws.recv())
|
|
148
|
+
print(json.dumps(m, indent=2))
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def cmd_status(args) -> int:
|
|
153
|
+
return asyncio.run(_status(args))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def _kernel_op(op: str, session: str) -> int:
|
|
157
|
+
async with await _connect() as ws:
|
|
158
|
+
await ws.send(json.dumps({"op": op, "session": session}))
|
|
159
|
+
m = json.loads(await ws.recv())
|
|
160
|
+
print(json.dumps(m, indent=2))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def cmd_restart(args) -> int:
|
|
165
|
+
return asyncio.run(_kernel_op("restart_kernel", args.session))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_interrupt(args) -> int:
|
|
169
|
+
return asyncio.run(_kernel_op("interrupt", args.session))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def _shutdown(kill_kernels: bool) -> int:
|
|
173
|
+
async with await _connect() as ws:
|
|
174
|
+
await ws.send(json.dumps({"op": "shutdown", "kill_kernels": kill_kernels}))
|
|
175
|
+
try:
|
|
176
|
+
m = json.loads(await ws.recv())
|
|
177
|
+
print(json.dumps(m, indent=2))
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def cmd_shutdown(args) -> int:
|
|
184
|
+
return asyncio.run(_shutdown(args.kill_kernels))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def main(argv=None) -> None:
|
|
188
|
+
p = argparse.ArgumentParser(prog="tithon")
|
|
189
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
190
|
+
|
|
191
|
+
sp = sub.add_parser("daemon", help="run the tithon daemon (foreground)")
|
|
192
|
+
sp.add_argument(
|
|
193
|
+
"--log-level",
|
|
194
|
+
default="INFO",
|
|
195
|
+
metavar="LEVEL",
|
|
196
|
+
help="logging verbosity: DEBUG|INFO|WARNING|ERROR (default: INFO)",
|
|
197
|
+
)
|
|
198
|
+
sp.set_defaults(fn=cmd_daemon)
|
|
199
|
+
|
|
200
|
+
sp = sub.add_parser("run", help="execute code in a session")
|
|
201
|
+
sp.add_argument("-c", "--code", required=True)
|
|
202
|
+
sp.add_argument("--session", default="default", help="session id (file uri); per-file kernel")
|
|
203
|
+
sp.add_argument("--no-wait", action="store_true", help="submit and exit (prints exec_id)")
|
|
204
|
+
sp.add_argument("--timeout", type=float, default=0.0)
|
|
205
|
+
sp.set_defaults(fn=cmd_run)
|
|
206
|
+
|
|
207
|
+
sp = sub.add_parser("attach", help="attach and stream events as NDJSON")
|
|
208
|
+
sp.add_argument("--session", default="default", help="session id (file uri); per-file kernel")
|
|
209
|
+
sp.add_argument("--since", type=int, default=0, help="last seen seq (0=full snapshot)")
|
|
210
|
+
sp.add_argument("--once", action="store_true", help="exit after backlog sync")
|
|
211
|
+
sp.add_argument("--until-done", action="store_true", help="exit after a done event")
|
|
212
|
+
sp.set_defaults(fn=cmd_attach)
|
|
213
|
+
|
|
214
|
+
sp = sub.add_parser("status", help="print daemon status (all sessions) or one session")
|
|
215
|
+
sp.add_argument("--session", default=None, help="session id; omit for all-sessions status")
|
|
216
|
+
sp.set_defaults(fn=cmd_status)
|
|
217
|
+
|
|
218
|
+
sp = sub.add_parser("restart", help="restart a session's kernel (fresh namespace)")
|
|
219
|
+
sp.add_argument("--session", default="default", help="session id (file uri)")
|
|
220
|
+
sp.set_defaults(fn=cmd_restart)
|
|
221
|
+
|
|
222
|
+
sp = sub.add_parser("interrupt", help="interrupt the running cell (SIGINT)")
|
|
223
|
+
sp.add_argument("--session", default="default", help="session id (file uri)")
|
|
224
|
+
sp.set_defaults(fn=cmd_interrupt)
|
|
225
|
+
|
|
226
|
+
sp = sub.add_parser("shutdown", help="stop the daemon (kernels stay detached unless --kill-kernels)")
|
|
227
|
+
sp.add_argument("--kill-kernels", action="store_true",
|
|
228
|
+
help="also kill kernels (fresh start, e.g. interpreter switch)")
|
|
229
|
+
sp.set_defaults(fn=cmd_shutdown)
|
|
230
|
+
|
|
231
|
+
args = p.parse_args(argv)
|
|
232
|
+
try:
|
|
233
|
+
sys.exit(args.fn(args))
|
|
234
|
+
except (ConnectionRefusedError, FileNotFoundError) as e:
|
|
235
|
+
print(f"tithon: cannot reach daemon at {sock_path()}: {e}", file=sys.stderr)
|
|
236
|
+
sys.exit(2)
|
|
237
|
+
except (asyncio.TimeoutError, TimeoutError):
|
|
238
|
+
print("tithon: timed out", file=sys.stderr)
|
|
239
|
+
sys.exit(3)
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
sys.exit(130)
|