ramure 0.0.1__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.
- ramure/__init__.py +49 -0
- ramure/agent.py +68 -0
- ramure/cli.py +168 -0
- ramure/context.py +18 -0
- ramure/control.py +139 -0
- ramure/extension.py +10 -0
- ramure/extension.ts +377 -0
- ramure/helpers/__init__.py +17 -0
- ramure/helpers/agent.py +91 -0
- ramure/helpers/schema.py +81 -0
- ramure/log.py +169 -0
- ramure/machines.py +130 -0
- ramure/notes +3 -0
- ramure/process.py +359 -0
- ramure/runtime.py +262 -0
- ramure/server.py +142 -0
- ramure/stream.py +81 -0
- ramure/types.py +55 -0
- ramure-0.0.1.dist-info/METADATA +207 -0
- ramure-0.0.1.dist-info/RECORD +22 -0
- ramure-0.0.1.dist-info/WHEEL +4 -0
- ramure-0.0.1.dist-info/entry_points.txt +2 -0
ramure/__init__.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from ramure.agent import Agent
|
|
2
|
+
from ramure.log import Log, LogEntry
|
|
3
|
+
from ramure.machines import Image, LocalImage, LocalMachine, Machine
|
|
4
|
+
from ramure.process import (
|
|
5
|
+
ProcessHandle,
|
|
6
|
+
ProcessScope,
|
|
7
|
+
agent,
|
|
8
|
+
agent_process,
|
|
9
|
+
connect,
|
|
10
|
+
current_runtime,
|
|
11
|
+
done,
|
|
12
|
+
emit,
|
|
13
|
+
expose,
|
|
14
|
+
fail,
|
|
15
|
+
machine,
|
|
16
|
+
spawn,
|
|
17
|
+
wait,
|
|
18
|
+
)
|
|
19
|
+
from ramure.runtime import Runtime
|
|
20
|
+
from ramure.stream import Event, Stream
|
|
21
|
+
from ramure.types import ExecResult, ExecutionFailed
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Agent",
|
|
25
|
+
"Event",
|
|
26
|
+
"ExecResult",
|
|
27
|
+
"ExecutionFailed",
|
|
28
|
+
"Image",
|
|
29
|
+
"LocalImage",
|
|
30
|
+
"LocalMachine",
|
|
31
|
+
"Log",
|
|
32
|
+
"LogEntry",
|
|
33
|
+
"Machine",
|
|
34
|
+
"ProcessHandle",
|
|
35
|
+
"ProcessScope",
|
|
36
|
+
"Runtime",
|
|
37
|
+
"Stream",
|
|
38
|
+
"agent",
|
|
39
|
+
"agent_process",
|
|
40
|
+
"connect",
|
|
41
|
+
"current_runtime",
|
|
42
|
+
"done",
|
|
43
|
+
"emit",
|
|
44
|
+
"expose",
|
|
45
|
+
"fail",
|
|
46
|
+
"machine",
|
|
47
|
+
"spawn",
|
|
48
|
+
"wait",
|
|
49
|
+
]
|
ramure/agent.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The ``Agent`` type.
|
|
2
|
+
|
|
3
|
+
An Agent owns its own state: a replicated ``Log``, a dict of tool
|
|
4
|
+
handlers, a registration ``Event``, and a handle to the process scope
|
|
5
|
+
that created it. It is the single source of truth for that agent inside
|
|
6
|
+
the runtime; nothing else shadows its fields.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import inspect
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
15
|
+
|
|
16
|
+
from ramure.helpers.schema import build_tool_definition
|
|
17
|
+
from ramure.log import Log
|
|
18
|
+
from ramure.machines import Machine
|
|
19
|
+
from ramure.stream import Stream
|
|
20
|
+
from ramure.types import ExecResult
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from ramure.process import ProcessScope
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Handler = Callable[..., Awaitable[Any]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Agent:
|
|
31
|
+
name: str
|
|
32
|
+
machine: Machine
|
|
33
|
+
log: Log
|
|
34
|
+
system_prompt: str | None = None
|
|
35
|
+
handlers: dict[str, Handler] = field(default_factory=dict)
|
|
36
|
+
registered: asyncio.Event = field(default_factory=asyncio.Event)
|
|
37
|
+
scope: "ProcessScope | None" = field(default=None, repr=False)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def events(self) -> Stream:
|
|
41
|
+
"""Async-iterable stream of this agent's events (no replication metadata)."""
|
|
42
|
+
return self.log.stream
|
|
43
|
+
|
|
44
|
+
def on(self, tool_name: str) -> Callable[[Handler], Handler]:
|
|
45
|
+
"""Register an async tool handler for this agent."""
|
|
46
|
+
|
|
47
|
+
def decorator(fn: Handler) -> Handler:
|
|
48
|
+
if not inspect.iscoroutinefunction(fn):
|
|
49
|
+
raise TypeError("Tool handlers must be async")
|
|
50
|
+
self.register_handler(tool_name, fn)
|
|
51
|
+
return fn
|
|
52
|
+
|
|
53
|
+
return decorator
|
|
54
|
+
|
|
55
|
+
def register_handler(self, name: str, fn: Handler) -> None:
|
|
56
|
+
"""Install a tool handler. If the agent is already live, announce it."""
|
|
57
|
+
self.handlers[name] = fn
|
|
58
|
+
if self.registered.is_set():
|
|
59
|
+
self.log.emit("tool_registered", build_tool_definition(name, fn))
|
|
60
|
+
|
|
61
|
+
async def send(self, text: str) -> None:
|
|
62
|
+
"""Deliver a user-style message to this agent."""
|
|
63
|
+
self.log.emit("message", {"text": text})
|
|
64
|
+
|
|
65
|
+
async def exec(
|
|
66
|
+
self, command: str, *, user: str = "agent", timeout: int | None = None
|
|
67
|
+
) -> ExecResult:
|
|
68
|
+
return await self.machine.exec(command, user=user, timeout=timeout)
|
ramure/cli.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Ramure CLI.
|
|
2
|
+
|
|
3
|
+
Commands talk to a live runtime over its Unix socket at
|
|
4
|
+
``~/.ramure/runtimes/{execution_id}.sock``. Finished runs have no
|
|
5
|
+
socket; their logs stay in ``~/.ramure/logs/{execution_id}/``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import socket
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from ramure.control import SOCKET_DIR, socket_path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="ramure",
|
|
24
|
+
help="Inspect and interact with ramure executions.",
|
|
25
|
+
no_args_is_help=True,
|
|
26
|
+
add_completion=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
ID_OPT = typer.Option(None, "--id", "-i", help="Execution id or prefix (default: the only live run).")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Discovery
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _live_ids() -> list[str]:
|
|
38
|
+
if not SOCKET_DIR.exists():
|
|
39
|
+
return []
|
|
40
|
+
return sorted(p.stem for p in SOCKET_DIR.glob("*.sock"))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pick(prefix: str | None) -> str:
|
|
44
|
+
"""Resolve a live run id by prefix, or the only live run if omitted."""
|
|
45
|
+
ids = [i for i in _live_ids() if prefix is None or i.startswith(prefix)]
|
|
46
|
+
if not ids:
|
|
47
|
+
die(f"No live run matches '{prefix}'." if prefix else "No live runs.")
|
|
48
|
+
if len(ids) > 1:
|
|
49
|
+
label = "Ambiguous prefix" if prefix else "Multiple live runs; pass --id"
|
|
50
|
+
die(f"{label}:\n" + "\n".join(f" {i[:8]}" for i in ids))
|
|
51
|
+
return ids[0]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Socket RPC
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def call(execution_id: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
60
|
+
"""Send one request to the runtime's control socket, return the reply."""
|
|
61
|
+
try:
|
|
62
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s:
|
|
63
|
+
s.connect(str(socket_path(execution_id)))
|
|
64
|
+
s.sendall((json.dumps(request) + "\n").encode())
|
|
65
|
+
reply = _recv_line(s)
|
|
66
|
+
except (FileNotFoundError, ConnectionRefusedError) as exc:
|
|
67
|
+
die(f"Runtime unreachable: {exc}")
|
|
68
|
+
if not reply:
|
|
69
|
+
die("empty reply from runtime")
|
|
70
|
+
data = json.loads(reply)
|
|
71
|
+
if "error" in data:
|
|
72
|
+
die(data["error"])
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _recv_line(s: socket.socket) -> str:
|
|
77
|
+
buf = bytearray()
|
|
78
|
+
while True:
|
|
79
|
+
chunk = s.recv(4096)
|
|
80
|
+
if not chunk:
|
|
81
|
+
break
|
|
82
|
+
buf.extend(chunk)
|
|
83
|
+
if b"\n" in chunk:
|
|
84
|
+
break
|
|
85
|
+
return buf.decode().rstrip("\n")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Commands
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("ls")
|
|
94
|
+
def cmd_ls() -> None:
|
|
95
|
+
"""List live runs."""
|
|
96
|
+
ids = _live_ids()
|
|
97
|
+
if not ids:
|
|
98
|
+
typer.echo("No live runs.")
|
|
99
|
+
return
|
|
100
|
+
for i in ids:
|
|
101
|
+
reply = call(i, {"cmd": "status"})
|
|
102
|
+
typer.echo(f"{i[:8]} {reply.get('program', '?')}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("status")
|
|
106
|
+
def cmd_status(id_: str = ID_OPT) -> None:
|
|
107
|
+
"""Show structure of a live run: agents, machines, connections."""
|
|
108
|
+
s = call(pick(id_), {"cmd": "status"})
|
|
109
|
+
|
|
110
|
+
typer.echo(f"Execution: {s['execution_id']}")
|
|
111
|
+
typer.echo(f"Program: {s.get('program', '?')}")
|
|
112
|
+
typer.echo(f"PID: {s.get('pid', '?')}")
|
|
113
|
+
typer.echo(f"Server: {s.get('server_url', '?')}")
|
|
114
|
+
|
|
115
|
+
if s.get("agents"):
|
|
116
|
+
typer.echo("\nAgents:")
|
|
117
|
+
for ag in s["agents"]:
|
|
118
|
+
m = ag["machine"]
|
|
119
|
+
bits = [m.get("kind", "?")]
|
|
120
|
+
if "workdir" in m:
|
|
121
|
+
bits.append(f"workdir={m['workdir']}")
|
|
122
|
+
tmux = f" tmux={ag['tmux_session']}" if ag.get("tmux_session") else ""
|
|
123
|
+
typer.echo(f" {ag['name']} [{' '.join(bits)}]{tmux}")
|
|
124
|
+
|
|
125
|
+
if s.get("connections"):
|
|
126
|
+
typer.echo("\nConnections:")
|
|
127
|
+
for c in s["connections"]:
|
|
128
|
+
typer.echo(f" {c['a']} -> {c['b']}")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command("send")
|
|
132
|
+
def cmd_send(
|
|
133
|
+
agent: str = typer.Argument(..., help="Agent name."),
|
|
134
|
+
message: str = typer.Argument(..., help="Message text."),
|
|
135
|
+
id_: str = ID_OPT,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Send a message to an agent."""
|
|
138
|
+
call(pick(id_), {"cmd": "send", "agent": agent, "text": message})
|
|
139
|
+
typer.echo(f"Sent to {agent}.")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("connect")
|
|
143
|
+
def cmd_connect(agent: str = typer.Argument(...), id_: str = ID_OPT) -> None:
|
|
144
|
+
"""Attach to an agent's tmux session."""
|
|
145
|
+
eid = pick(id_)
|
|
146
|
+
info = call(eid, {"cmd": "agent", "name": agent})
|
|
147
|
+
session = info.get("tmux_session") or f"ramure-{eid}-{agent}"
|
|
148
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", session])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command("ssh")
|
|
152
|
+
def cmd_ssh(agent: str = typer.Argument(...), id_: str = ID_OPT) -> None:
|
|
153
|
+
"""Open a shell on an agent's machine."""
|
|
154
|
+
info = call(pick(id_), {"cmd": "agent", "name": agent})
|
|
155
|
+
m = info["machine"]
|
|
156
|
+
if m.get("kind") != "LocalMachine":
|
|
157
|
+
die(f"ssh not supported for machine kind '{m.get('kind')}' yet.")
|
|
158
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
159
|
+
os.execvp(shell, [shell, "-c", f"cd {shlex.quote(m.get('workdir', os.getcwd()))} && exec {shell}"])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def die(msg: str) -> None:
|
|
163
|
+
typer.echo(msg, err=True)
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
app()
|
ramure/context.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Context variables shared across the runtime and process modules.
|
|
2
|
+
|
|
3
|
+
Extracted so ``ramure.runtime`` doesn't need a lazy import of
|
|
4
|
+
``ramure.process`` to reach the active process scope.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ramure.process import ProcessScope
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_current_process: ContextVar["ProcessScope | None"] = ContextVar(
|
|
17
|
+
"ramure_current_process", default=None
|
|
18
|
+
)
|
ramure/control.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Unix-socket control server for CLI interaction.
|
|
2
|
+
|
|
3
|
+
The runtime listens on ``~/.ramure/runtimes/{execution_id}.sock``.
|
|
4
|
+
The CLI connects, sends one line of JSON, reads one line back, closes.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
|
|
8
|
+
- ``{"cmd":"status"}`` -> ``{agents, connections, program, pid, started_at}``
|
|
9
|
+
- ``{"cmd":"agent", "name":<str>}`` -> ``{name, machine, tmux_session}``
|
|
10
|
+
- ``{"cmd":"send", "agent":<str>, "text":<str>}`` -> ``{"ok":true}``
|
|
11
|
+
|
|
12
|
+
Errors come back as ``{"error":<msg>}``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from ramure.helpers import agent_session_name
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from ramure.runtime import Runtime
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SOCKET_DIR = Path.home() / ".ramure" / "runtimes"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def socket_path(execution_id: str) -> Path:
|
|
34
|
+
return SOCKET_DIR / f"{execution_id}.sock"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ControlServer:
|
|
38
|
+
def __init__(self, runtime: Runtime) -> None:
|
|
39
|
+
self.runtime = runtime
|
|
40
|
+
self._server: asyncio.base_events.Server | None = None
|
|
41
|
+
self._path: Path | None = None
|
|
42
|
+
|
|
43
|
+
async def start(self) -> None:
|
|
44
|
+
assert self.runtime.execution_id
|
|
45
|
+
SOCKET_DIR.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self._path = socket_path(self.runtime.execution_id)
|
|
47
|
+
# Clean up a stale socket from a crashed previous run.
|
|
48
|
+
try:
|
|
49
|
+
self._path.unlink()
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
pass
|
|
52
|
+
self._server = await asyncio.start_unix_server(self._handle, path=str(self._path))
|
|
53
|
+
|
|
54
|
+
async def stop(self) -> None:
|
|
55
|
+
if self._server is not None:
|
|
56
|
+
self._server.close()
|
|
57
|
+
await self._server.wait_closed()
|
|
58
|
+
self._server = None
|
|
59
|
+
if self._path is not None:
|
|
60
|
+
try:
|
|
61
|
+
self._path.unlink()
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
pass
|
|
64
|
+
self._path = None
|
|
65
|
+
|
|
66
|
+
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
67
|
+
try:
|
|
68
|
+
line = await reader.readline()
|
|
69
|
+
if not line:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
msg = json.loads(line)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
reply = {"error": "invalid json"}
|
|
75
|
+
else:
|
|
76
|
+
reply = await self._dispatch(msg)
|
|
77
|
+
writer.write((json.dumps(reply) + "\n").encode())
|
|
78
|
+
await writer.drain()
|
|
79
|
+
finally:
|
|
80
|
+
writer.close()
|
|
81
|
+
try:
|
|
82
|
+
await writer.wait_closed()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
async def _dispatch(self, msg: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
cmd = msg.get("cmd")
|
|
88
|
+
if cmd == "status":
|
|
89
|
+
return self._cmd_status()
|
|
90
|
+
if cmd == "agent":
|
|
91
|
+
return self._cmd_agent(msg.get("name", ""))
|
|
92
|
+
if cmd == "send":
|
|
93
|
+
return await self._cmd_send(msg.get("agent", ""), msg.get("text", ""))
|
|
94
|
+
return {"error": f"unknown cmd '{cmd}'"}
|
|
95
|
+
|
|
96
|
+
# -- handlers --
|
|
97
|
+
|
|
98
|
+
def _cmd_status(self) -> dict[str, Any]:
|
|
99
|
+
rt = self.runtime
|
|
100
|
+
return {
|
|
101
|
+
"execution_id": rt.execution_id,
|
|
102
|
+
"pid": os.getpid(),
|
|
103
|
+
"program": _program_name(),
|
|
104
|
+
"started_at": rt.started_at,
|
|
105
|
+
"server_url": rt.server_url,
|
|
106
|
+
"agents": [self._agent_info(ag.name) for ag in rt.agents.values()],
|
|
107
|
+
"connections": [
|
|
108
|
+
{"a": a, "b": b} for (a, b) in sorted(rt.edges)
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def _cmd_agent(self, name: str) -> dict[str, Any]:
|
|
113
|
+
if name not in self.runtime.agents:
|
|
114
|
+
return {"error": f"unknown agent '{name}'"}
|
|
115
|
+
return self._agent_info(name)
|
|
116
|
+
|
|
117
|
+
async def _cmd_send(self, name: str, text: str) -> dict[str, Any]:
|
|
118
|
+
ag = self.runtime.agents.get(name)
|
|
119
|
+
if ag is None:
|
|
120
|
+
return {"error": f"unknown agent '{name}'"}
|
|
121
|
+
if not text:
|
|
122
|
+
return {"error": "missing text"}
|
|
123
|
+
await ag.send(text)
|
|
124
|
+
return {"ok": True}
|
|
125
|
+
|
|
126
|
+
def _agent_info(self, name: str) -> dict[str, Any]:
|
|
127
|
+
ag = self.runtime.agents[name]
|
|
128
|
+
return {
|
|
129
|
+
"name": name,
|
|
130
|
+
"machine": ag.machine.describe(),
|
|
131
|
+
"tmux_session": agent_session_name(self.runtime.execution_id or "", name),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _program_name() -> str:
|
|
136
|
+
argv0 = sys.argv[0] if sys.argv else ""
|
|
137
|
+
if argv0 and Path(argv0).exists():
|
|
138
|
+
return str(Path(argv0).resolve())
|
|
139
|
+
return argv0 or "<unknown>"
|
ramure/extension.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def extension_source() -> str:
|
|
7
|
+
path = Path(__file__).with_name("extension.ts")
|
|
8
|
+
if not path.exists():
|
|
9
|
+
raise FileNotFoundError(f"Bundled extension not found: {path}")
|
|
10
|
+
return path.read_text()
|