buchelib 0.0.1__tar.gz

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.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.3
2
+ Name: buchelib
3
+ Version: 0.0.1
4
+ Summary: Add your description here
5
+ Author: Olivier Breuleux
6
+ Author-email: Olivier Breuleux <breuleux@gmail.com>
7
+ Requires-Dist: hypetext>=0.0.4
8
+ Requires-Dist: ovld>=0.5.17
9
+ Requires-Dist: serieux>=0.3.13
10
+ Requires-Python: >=3.14
11
+ Description-Content-Type: text/markdown
12
+
13
+
14
+ # buchelib
15
+
16
+ **WIP**
17
+
18
+ A library to easily generate interface for the `buche` shell.
@@ -0,0 +1,6 @@
1
+
2
+ # buchelib
3
+
4
+ **WIP**
5
+
6
+ A library to easily generate interface for the `buche` shell.
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "buchelib"
3
+ version = "0.0.1"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Olivier Breuleux", email = "breuleux@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "hypetext>=0.0.4",
12
+ "ovld>=0.5.17",
13
+ "serieux>=0.3.13",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["uv_build>=0.9.3,<0.10.0"]
18
+ build-backend = "uv_build"
19
+
20
+ [tool.ruff]
21
+ line-length = 99
22
+
23
+ [tool.ruff.lint]
24
+ extend-select = ["I"]
25
+ ignore = ["F811", "F722"]
26
+
27
+ [tool.ruff.lint.isort]
28
+ combine-as-imports = true
@@ -0,0 +1,44 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from serieux import Serieux
5
+
6
+ from .bridge import Bridge, Cell
7
+ from .htmlgen import BucheInterpreter
8
+ from .srx import BucheSerieux
9
+
10
+ here = Path(__file__).parent
11
+
12
+ __bridge = None
13
+ __main_cell = None
14
+
15
+
16
+ def bridge():
17
+ global __bridge
18
+ if not __bridge:
19
+ __bridge = Bridge()
20
+ return __bridge
21
+
22
+
23
+ def main_cell():
24
+ global __main_cell
25
+ if not __main_cell:
26
+ __main_cell = Cell(
27
+ bridge=bridge(),
28
+ interpreter_class=BucheInterpreter,
29
+ srx=(Serieux + BucheSerieux)(),
30
+ id="main",
31
+ )
32
+ __main_cell.body().exec(here / "lib.js")
33
+ return __main_cell
34
+
35
+
36
+ def is_available():
37
+ fd_str = os.environ.get("BUCHE_CONTROL_FD")
38
+ if not fd_str:
39
+ return False
40
+ try:
41
+ os.fstat(int(fd_str))
42
+ return True
43
+ except ValueError, OSError:
44
+ return False
@@ -0,0 +1,294 @@
1
+ import asyncio
2
+ import json
3
+ import os
4
+ import select
5
+ from dataclasses import dataclass, field
6
+ from functools import cached_property
7
+ from glob import glob
8
+ from itertools import count
9
+ from pathlib import Path
10
+ from types import FunctionType
11
+ from typing import Any, Iterable
12
+ from uuid import uuid4
13
+
14
+ from ovld import ovld, recurse
15
+ from serieux import JSON, Context, Serieux, serieux
16
+ from serieux.features.tagset import TagDict
17
+
18
+ here = Path(__file__).parent
19
+
20
+ _current_id = count()
21
+
22
+
23
+ @ovld
24
+ def _expand_paths(p: str):
25
+ if "*" in p:
26
+ for path in glob(p):
27
+ yield from recurse(Path(path))
28
+ else:
29
+ yield from recurse(Path(p))
30
+
31
+
32
+ @ovld
33
+ def _expand_paths(p: Path):
34
+ if p.is_dir():
35
+ raise Exception("Availing a directory is not allowed. Avail individual files.")
36
+ yield p
37
+
38
+
39
+ @ovld
40
+ def _expand_paths(p: Iterable):
41
+ for x in p:
42
+ yield from recurse(x)
43
+
44
+
45
+ @dataclass
46
+ class CellMessage:
47
+ message: JSON
48
+ cell: Cell
49
+
50
+ def parse(self):
51
+ return self.cell.deserialize(self.message)
52
+
53
+ async def dispatch(self):
54
+ await self.parse().call()
55
+
56
+
57
+ @dataclass
58
+ class PromptMessage:
59
+ message: JSON
60
+ prompt: Prompt
61
+
62
+ async def dispatch(self):
63
+ return await self.prompt.handler(self.prompt, self.message)
64
+
65
+
66
+ class Bridge:
67
+ def __init__(self, fd=None):
68
+ if fd is None:
69
+ fd = int(os.environ.get("BUCHE_CONTROL_FD", 5))
70
+ self.ctlin = os.fdopen(fd, "r", buffering=1)
71
+ self._outfd = os.dup(fd)
72
+ self.catalogue = {}
73
+ self.cells = {}
74
+ self.prompts = {}
75
+
76
+ def send(self, payload):
77
+ data = (json.dumps(payload) + "\n").encode()
78
+ written = 0
79
+ while written < len(data):
80
+ select.select([], [self._outfd], [])
81
+ try:
82
+ written += os.write(self._outfd, data[written:])
83
+ except BlockingIOError:
84
+ pass
85
+
86
+ def _pack_file(self, nonce, rel, p):
87
+ match p.suffix:
88
+ case ".css":
89
+ packed = {"mimetype": "text/css", "content": p.read_text()}
90
+ case ".js":
91
+ packed = {"mimetype": "text/javascript", "content": p.read_text()}
92
+ case other:
93
+ raise Exception(f"Unsupported format: {other}")
94
+ self.catalogue[str(p)] = f"buche://nonce/{nonce}/{rel}"
95
+ return packed
96
+
97
+ def avail(self, *files):
98
+ concrete = list(_expand_paths(files))
99
+ paths = [Path(f).resolve() for f in concrete]
100
+ base = os.path.commonpath([str(p.parent) for p in paths])
101
+ nonce = str(uuid4())
102
+ self.send(
103
+ {
104
+ "type": "library",
105
+ "nonce": nonce,
106
+ "files": {
107
+ str(rel := p.relative_to(base)): self._pack_file(nonce, rel, p) for p in paths
108
+ },
109
+ }
110
+ )
111
+
112
+ def url(self, p):
113
+ sp = str(Path(p).resolve())
114
+ if sp not in self.catalogue:
115
+ self.avail(p)
116
+ return self.catalogue[sp]
117
+
118
+ @cached_property
119
+ def srx(self):
120
+ from .srx import BucheSerieux
121
+
122
+ return (Serieux + BucheSerieux)()
123
+
124
+ def __setup_cell(self, cell):
125
+ self.cells[cell.id] = cell
126
+ cell.body().exec(here / "lib.js")
127
+ return cell
128
+
129
+ @cached_property
130
+ def main_cell(self):
131
+ from .htmlgen import BucheInterpreter
132
+
133
+ cell = Cell(
134
+ bridge=self,
135
+ interpreter_class=BucheInterpreter,
136
+ srx=self.srx,
137
+ id="main",
138
+ )
139
+ self.__setup_cell(cell)
140
+ return cell
141
+
142
+ def cell(self, echo=None):
143
+ from .htmlgen import BucheInterpreter
144
+
145
+ _id = uuid4().hex
146
+
147
+ cell = Cell(
148
+ bridge=self,
149
+ interpreter_class=BucheInterpreter,
150
+ srx=self.srx,
151
+ id=_id,
152
+ )
153
+ self.send(
154
+ {
155
+ "type": "cell_create",
156
+ "address": {"cell_id": _id},
157
+ "to": {"target": "terminal", "cell": _id},
158
+ "mode": "data",
159
+ "echo_html": echo,
160
+ }
161
+ )
162
+ self.__setup_cell(cell)
163
+ return cell
164
+
165
+ def prompt(self, label, handler=None, language=None):
166
+ prompt = Prompt(bridge=self, label=label, handler=handler)
167
+ self.send(
168
+ {
169
+ "type": "prompt_create",
170
+ "to": {"target": "terminal", "prompt": "python"},
171
+ "address": {"prompt_id": label},
172
+ "prompt": "<span style='color:#4ec9b0;'>sktk ]]]</span>",
173
+ "tab_html": label,
174
+ "name": label,
175
+ "tag": "python",
176
+ "language": language,
177
+ }
178
+ )
179
+ self.prompts[label] = prompt
180
+ return prompt
181
+
182
+ async def __aiter__(self):
183
+ loop = asyncio.get_event_loop()
184
+ reader = asyncio.StreamReader()
185
+ protocol = asyncio.StreamReaderProtocol(reader)
186
+ await loop.connect_read_pipe(lambda: protocol, self.ctlin)
187
+ async for line in reader:
188
+ data = json.loads(line)
189
+ to = data.pop("to", {})
190
+ if prid := to.get("prompt_id", None):
191
+ yield PromptMessage(message=data, prompt=self.prompts.get(prid, None))
192
+ else:
193
+ cell_id = to.get("cell_id", "main")
194
+ yield CellMessage(message=data, cell=self.cells.get(cell_id, None))
195
+
196
+
197
+ _tagdict_defaults = {}
198
+
199
+
200
+ @dataclass(kw_only=True)
201
+ class Cell:
202
+ bridge: Bridge
203
+ id: str = "main"
204
+ interpreter_class: type
205
+ tagset: TagDict = field(default_factory=lambda: TagDict(_tagdict_defaults))
206
+ srx: Serieux = field(default_factory=lambda: serieux)
207
+ function_registry: dict = field(default_factory=dict)
208
+ handler: Any = None
209
+
210
+ def __post_init__(self):
211
+ self.address = {"cell_id": self.id} if self.id != "main" else {}
212
+ self.interpreter = self.interpreter_class(self)
213
+
214
+ @classmethod
215
+ def register_type_default(cls, fn):
216
+ _tagdict_defaults[f"{fn.__module__}:{fn.__qualname__}"] = fn
217
+
218
+ def register_type(self, *args, **kwargs):
219
+ self.tagset.register(*args, **kwargs)
220
+
221
+ def register_function(self, fn):
222
+ if fn not in self.function_registry:
223
+ self.function_registry[fn] = f"F{next(_current_id)}"
224
+ return self.function_registry[fn]
225
+
226
+ async def inputs(self):
227
+ loop = asyncio.get_event_loop()
228
+ reader = asyncio.StreamReader()
229
+ protocol = asyncio.StreamReaderProtocol(reader)
230
+ await loop.connect_read_pipe(lambda: protocol, self.bridge.ctlin)
231
+ async for line in reader:
232
+ data = json.loads(line)
233
+ data.pop("to", None)
234
+ yield self.deserialize(data)
235
+
236
+ def avail(self, *files):
237
+ return self.bridge.avail(*files)
238
+
239
+ def command(self, **args):
240
+ return self.bridge.send(
241
+ {
242
+ "type": "cell_send",
243
+ "message": args,
244
+ "address": self.address,
245
+ "to": {"target": "terminal", "cell": self.id},
246
+ }
247
+ )
248
+
249
+ def configure(self, **options):
250
+ self.bridge.send(
251
+ {
252
+ "type": "cell_configure",
253
+ "address": self.address,
254
+ "to": {"target": "terminal", "cell": self.id},
255
+ **options,
256
+ }
257
+ )
258
+
259
+ def close(self, return_code=0):
260
+ self.bridge.send(
261
+ {
262
+ "type": "cell_close",
263
+ "address": self.address,
264
+ "to": {"target": "terminal", "cell": self.id},
265
+ "return_code": return_code,
266
+ }
267
+ )
268
+
269
+ @ovld
270
+ def serialize(self, obj: FunctionType):
271
+ return t"embed({self.register_function(obj)})"
272
+
273
+ @ovld
274
+ def serialize(self, obj: object):
275
+ return self.srx.serialize(Any @ self.tagset, obj, CellContext(self))
276
+
277
+ def deserialize(self, data):
278
+ return self.srx.deserialize(Any @ self.tagset, data, CellContext(self))
279
+
280
+ def body(self):
281
+ from .selection import Selection
282
+
283
+ return Selection(cell=self)
284
+
285
+
286
+ class CellContext(Context):
287
+ cell: Cell
288
+
289
+
290
+ @dataclass(kw_only=True)
291
+ class Prompt:
292
+ bridge: Bridge
293
+ label: str
294
+ handler: Any
@@ -0,0 +1,41 @@
1
+ from html import escape
2
+ from pathlib import Path
3
+ from string.templatelib import Interpolation
4
+ from typing import Any, Literal
5
+
6
+ from hypetext import Interpreter
7
+ from ovld import ovld
8
+ from serieux import JSON
9
+
10
+ from .bridge import Cell, CellContext
11
+ from .js import js
12
+
13
+
14
+ class BucheInterpreter(Interpreter):
15
+ cell: Cell
16
+
17
+ def _js_serialize(self, value):
18
+ t = type(value)
19
+ if t in (list, dict):
20
+ t = JSON
21
+ return js(self.cell.srx.serialize(t, value, CellContext(self.cell)))
22
+
23
+ def gen(self, value: Path, fmt: str, tag: str, attr: object):
24
+ yield ("lit", str(self.cell.bridge.url(value)))
25
+
26
+ @ovld(priority=1)
27
+ def gen(self, value: object, fmt: Literal["js"], tag: str, attr: str):
28
+ yield ("lit", escape(self._js_serialize(value)))
29
+
30
+ @ovld(priority=1)
31
+ def gen(self, value: object, fmt: Literal["js"], tag: str, attr: None):
32
+ yield ("lit", self._js_serialize(value))
33
+
34
+ @ovld(priority=1)
35
+ def gen(self, value: Interpolation, fmt: str, tag: Literal["script"], attr: Any):
36
+ if isinstance(value.value, Path):
37
+ yield ("lit", repr(self.cell.bridge.url(value.value)))
38
+ elif value.format_spec == "raw":
39
+ yield ("lit", str(value.value))
40
+ else:
41
+ yield ("lit", self._js_serialize(value.value))
@@ -0,0 +1,36 @@
1
+ class Interpreter:
2
+ async def eval(self, cell, command):
3
+ raise NotImplementedError()
4
+
5
+ async def on_error(self, cell, error):
6
+ raise error
7
+
8
+ async def handle_parse(self, prompt, message):
9
+ pass
10
+
11
+ async def handle_prompt_submit(self, prompt, message):
12
+ cell = prompt.bridge.cell(echo=message.get("echo_html", None))
13
+ try:
14
+ result = await self.eval(cell, message["text"])
15
+ if result is not None:
16
+ cell.body().print(result)
17
+ cell.close()
18
+ except Exception as exc:
19
+ await self.on_error(cell, exc)
20
+ cell.close(1)
21
+
22
+ async def handle_prompt_close(self, prompt, message):
23
+ raise SystemExit(0)
24
+
25
+ async def __call__(self, prompt, message):
26
+ msg_type = message.get("type")
27
+ handler_name = f"handle_{msg_type}"
28
+ handler = getattr(self, handler_name, None)
29
+ if handler is not None and callable(handler):
30
+ return await handler(prompt, message)
31
+
32
+
33
+ class PythonInterpreter:
34
+ async def eval(self, cell, command):
35
+ result = eval(command)
36
+ return result if result is None else str(result)
@@ -0,0 +1,75 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from string.templatelib import Template
4
+
5
+ from ovld import ovld, recurse
6
+
7
+
8
+ @dataclass
9
+ class SerializationState:
10
+ indent: int = 0
11
+ prefix: str = ""
12
+
13
+ def deeper(self):
14
+ ind = " " * self.indent
15
+ return SerializationState(self.indent, self.prefix + ind)
16
+
17
+
18
+ @ovld
19
+ def js(x: object, *, indent: int = 0):
20
+ return recurse(x, SerializationState(indent))
21
+
22
+
23
+ @ovld
24
+ def js(x: None, state: SerializationState):
25
+ return "null"
26
+
27
+
28
+ @ovld
29
+ def js(x: bool, state: SerializationState):
30
+ return "true" if x else "false"
31
+
32
+
33
+ @ovld
34
+ def js(x: int, state: SerializationState):
35
+ return str(x)
36
+
37
+
38
+ @ovld
39
+ def js(x: float, state: SerializationState):
40
+ return str(x)
41
+
42
+
43
+ @ovld
44
+ def js(x: str, state: SerializationState):
45
+ return json.dumps(x)
46
+
47
+
48
+ @ovld
49
+ def js(x: list, state: SerializationState):
50
+ if not state.indent:
51
+ return "[" + ", ".join(recurse(item, state) for item in x) + "]"
52
+ inner = state.deeper()
53
+ items = (",\n" + inner.prefix).join(recurse(item, inner) for item in x)
54
+ return f"[\n{inner.prefix}{items}\n{state.prefix}]"
55
+
56
+
57
+ @ovld
58
+ def js(x: dict, state: SerializationState):
59
+ if not state.indent:
60
+ return "{" + ", ".join(f"{json.dumps(k)}: {recurse(v, state)}" for k, v in x.items()) + "}"
61
+ inner = state.deeper()
62
+ items = (",\n" + inner.prefix).join(
63
+ f"{json.dumps(k)}: {recurse(v, inner)}" for k, v in x.items()
64
+ )
65
+ return f"{{\n{inner.prefix}{items}\n{state.prefix}}}"
66
+
67
+
68
+ @ovld
69
+ def js(x: Template, state: SerializationState):
70
+ parts = []
71
+ for s, i in zip(x.strings, x.interpolations):
72
+ parts.append(s)
73
+ parts.append(recurse(i.value, state))
74
+ parts.append(x.strings[-1])
75
+ return "".join(parts)
@@ -0,0 +1,46 @@
1
+ async function indicate(f, args, eventTarget, selector, indicatorClass = "buche-indicator") {
2
+ if (!selector) {
3
+ selector = eventTarget.getAttribute("indicator-selector") || null;
4
+ }
5
+
6
+ let targets;
7
+ if (!selector) {
8
+ targets = [eventTarget];
9
+ } else if (selector.startsWith("closest ")) {
10
+ const closestSelector = selector.slice(8);
11
+ const found = eventTarget.closest(closestSelector);
12
+ targets = found ? [found] : [];
13
+ } else {
14
+ targets = Array.from(document.querySelectorAll(selector));
15
+ }
16
+
17
+ for (const el of targets) {
18
+ el.classList.add(indicatorClass);
19
+ }
20
+
21
+ try {
22
+ return await f(...args);
23
+ } finally {
24
+ for (const el of targets) {
25
+ el.classList.remove(indicatorClass);
26
+ }
27
+ }
28
+ }
29
+
30
+ function embed(function_id) {
31
+ return async (...args) => {
32
+ const serial = JSON.parse(JSON.stringify(args));
33
+ return await window.buche.request({
34
+ $class: "buchelib.srx:Call",
35
+ function: function_id,
36
+ args: serial
37
+ });
38
+ }
39
+ }
40
+
41
+ Event.prototype.indicate = function(fn, ...args) {
42
+ return indicate(fn, args, this.currentTarget || this.target);
43
+ };
44
+
45
+ window.indicate = indicate;
46
+ window.embed = embed;
File without changes
@@ -0,0 +1,49 @@
1
+ from dataclasses import dataclass
2
+ from pathlib import Path
3
+ from string.templatelib import Template
4
+
5
+ from hypetext import html
6
+ from ovld import ovld, recurse
7
+
8
+ from .bridge import Cell
9
+
10
+
11
+ @dataclass(kw_only=True)
12
+ class Selection:
13
+ cell: Cell
14
+ selector: str = None
15
+
16
+ def __getitem__(self, selector):
17
+ sel = f"{self.selector} {selector}" if self.selector else selector
18
+ return type(self)(cell=self.cell, selector=sel)
19
+
20
+ @ovld
21
+ def exec(self, code: Path):
22
+ recurse(code.read_text())
23
+
24
+ @ovld
25
+ def exec(self, code: Template):
26
+ scode = "".join(self.cell.interpreter.string_parts(code, "", "script"))
27
+ recurse(scode)
28
+
29
+ @ovld
30
+ def exec(self, code: str):
31
+ self.cell.command(type="exec", code=code, selector=self.selector)
32
+
33
+ def print(self, tpl):
34
+ node = html(tpl)
35
+ self.cell.command(
36
+ type="html",
37
+ mode="append",
38
+ selector=self.selector,
39
+ content="".join(self.cell.interpreter.string_parts(node)),
40
+ )
41
+
42
+ def set(self, tpl):
43
+ node = html(tpl)
44
+ self.cell.command(
45
+ type="html",
46
+ mode="set",
47
+ selector=self.selector,
48
+ content="".join(self.cell.interpreter.string_parts(node)),
49
+ )
@@ -0,0 +1,72 @@
1
+ import inspect
2
+ from dataclasses import dataclass
3
+ from types import FunctionType
4
+
5
+ from ovld import Medley
6
+ from serieux import JSON
7
+
8
+ from .bridge import Cell, CellContext
9
+
10
+
11
+ @dataclass
12
+ class ResponseId:
13
+ id: str
14
+ cell: Cell
15
+
16
+ def resolve(self, value):
17
+ self.cell.command(type="resolve", value=value, response_id=self.id)
18
+
19
+ def reject(self, error):
20
+ self.cell.command(type="resolve", error=str(error), response_id=self.id)
21
+
22
+
23
+ @Cell.register_type_default
24
+ @dataclass
25
+ class Call:
26
+ function: FunctionType
27
+ args: list[JSON]
28
+ response_id: ResponseId
29
+
30
+ def __post_init__(self):
31
+ self.cell = self.response_id.cell
32
+
33
+ async def call(self):
34
+ try:
35
+ params = list(inspect.signature(self.function).parameters.values())
36
+ for p in params:
37
+ if p.annotation is inspect.Parameter.empty:
38
+ raise TypeError(
39
+ f"Parameter '{p.name}' of {self.function.__name__} has no type annotation"
40
+ )
41
+ deserialized = [
42
+ self.cell.srx.deserialize(p.annotation, arg) for p, arg in zip(params, self.args)
43
+ ]
44
+ result = await self.function(*deserialized)
45
+ self.response_id.resolve(result)
46
+ return result
47
+ except Exception as exc:
48
+ self.response_id.reject(exc)
49
+ raise
50
+
51
+
52
+ class BucheSerieux(Medley):
53
+ ######################
54
+ # Custom serializers #
55
+ ######################
56
+
57
+ def serialize(self, t: type[FunctionType], obj: FunctionType, ctx: CellContext):
58
+ return t"embed({ctx.cell.register_function(obj)})"
59
+
60
+ ########################
61
+ # Custom deserializers #
62
+ ########################
63
+
64
+ def deserialize(self, t: type[FunctionType], obj: str, ctx: CellContext):
65
+ for fn, tag in ctx.cell.function_registry.items():
66
+ if tag == obj:
67
+ return fn
68
+ else:
69
+ raise Exception(f"No function with tag: {obj}")
70
+
71
+ def deserialize(self, t: type[ResponseId], obj: str, ctx: CellContext):
72
+ return ResponseId(obj, ctx.cell)