arborito-sdk 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.
- arborito_sdk/__init__.py +36 -0
- arborito_sdk/__main__.py +6 -0
- arborito_sdk/archive.py +51 -0
- arborito_sdk/cli.py +322 -0
- arborito_sdk/client.py +813 -0
- arborito_sdk/errors.py +29 -0
- arborito_sdk/json_extract.py +40 -0
- arborito_sdk/login_password.py +79 -0
- arborito_sdk/nostr_client.py +809 -0
- arborito_sdk/nostr_loader.py +111 -0
- arborito_sdk/nostr_protocol.py +86 -0
- arborito_sdk/nostr_relays.py +73 -0
- arborito_sdk/play_session.py +274 -0
- arborito_sdk/quiz_v2.py +1322 -0
- arborito_sdk-0.1.0.dist-info/METADATA +176 -0
- arborito_sdk-0.1.0.dist-info/RECORD +21 -0
- arborito_sdk-0.1.0.dist-info/WHEEL +5 -0
- arborito_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- arborito_sdk-0.1.0.dist-info/licenses/LICENSE +674 -0
- arborito_sdk-0.1.0.dist-info/licenses/NOTICE +55 -0
- arborito_sdk-0.1.0.dist-info/top_level.txt +1 -0
arborito_sdk/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Arborito Python SDK — load courses and Quiz V2 outside the browser.
|
|
2
|
+
|
|
3
|
+
This is **not** the Arcade cartridge SDK injected as ``window.arborito`` in HTML
|
|
4
|
+
games. Use this package for terminal tools, Pygame, desktop apps, bots, etc.
|
|
5
|
+
|
|
6
|
+
Install: ``pip install -e .`` CLI: ``arborito-sdk`` Import: ``arborito_sdk``
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = '0.1.0'
|
|
10
|
+
|
|
11
|
+
from .archive import load_arborito_archive
|
|
12
|
+
from .client import Arborito, User, attach_helpers
|
|
13
|
+
from .errors import (
|
|
14
|
+
AI_EMPTY_RESPONSE,
|
|
15
|
+
AI_NETWORK,
|
|
16
|
+
AI_PARSE_ERROR,
|
|
17
|
+
AI_SAGE_ERROR,
|
|
18
|
+
AI_TIMEOUT,
|
|
19
|
+
ArboritoError,
|
|
20
|
+
ERROR_CODES,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
'__version__',
|
|
25
|
+
"Arborito",
|
|
26
|
+
"User",
|
|
27
|
+
"attach_helpers",
|
|
28
|
+
"load_arborito_archive",
|
|
29
|
+
"ArboritoError",
|
|
30
|
+
"ERROR_CODES",
|
|
31
|
+
"AI_TIMEOUT",
|
|
32
|
+
"AI_SAGE_ERROR",
|
|
33
|
+
"AI_PARSE_ERROR",
|
|
34
|
+
"AI_EMPTY_RESPONSE",
|
|
35
|
+
"AI_NETWORK",
|
|
36
|
+
]
|
arborito_sdk/__main__.py
ADDED
arborito_sdk/archive.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Load lessons from exported .arborito archives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .quiz_v2 import (
|
|
9
|
+
clean_lesson_text,
|
|
10
|
+
load_arborito_archive as _load_archive,
|
|
11
|
+
parse_all_challenges_from_content,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_arborito_archive(path: str | Path, lang: str = "ES") -> list[dict[str, Any]]:
|
|
16
|
+
"""Walk an `.arborito` ZIP and return its leaf/exam lessons in order.
|
|
17
|
+
|
|
18
|
+
The archive's folder layout is the source of truth (no `tree:` block in
|
|
19
|
+
the manifest), so we just reconstruct the in-memory tree and iterate
|
|
20
|
+
through every leaf / exam node, preserving the on-disk ordering.
|
|
21
|
+
"""
|
|
22
|
+
data = _load_archive(path)
|
|
23
|
+
languages = (data.get("tree") or {}).get("languages") or {}
|
|
24
|
+
lang_key = lang.upper()
|
|
25
|
+
root = languages.get(lang_key) or languages.get("ES") or next(iter(languages.values()), None)
|
|
26
|
+
if not root:
|
|
27
|
+
raise ValueError(f"No language tree in {path}")
|
|
28
|
+
|
|
29
|
+
lessons: list[dict[str, Any]] = []
|
|
30
|
+
|
|
31
|
+
def walk(node: dict[str, Any]) -> None:
|
|
32
|
+
ntype = node.get("type")
|
|
33
|
+
if ntype in ("leaf", "exam"):
|
|
34
|
+
raw = node.get("content") or ""
|
|
35
|
+
challenges = parse_all_challenges_from_content(raw)
|
|
36
|
+
lesson: dict[str, Any] = {
|
|
37
|
+
"id": node["id"],
|
|
38
|
+
"title": node.get("name") or node["id"],
|
|
39
|
+
"text": clean_lesson_text(raw),
|
|
40
|
+
"raw": raw,
|
|
41
|
+
}
|
|
42
|
+
if challenges:
|
|
43
|
+
lesson["challenge"] = challenges[0]
|
|
44
|
+
lesson["challenges"] = challenges
|
|
45
|
+
lessons.append(lesson)
|
|
46
|
+
for child in node.get("children") or []:
|
|
47
|
+
if isinstance(child, dict):
|
|
48
|
+
walk(child)
|
|
49
|
+
|
|
50
|
+
walk(root)
|
|
51
|
+
return lessons
|
arborito_sdk/cli.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Arborito Python SDK CLI — browse ``.arborito`` courses from the terminal.
|
|
2
|
+
|
|
3
|
+
Not the Arborito desktop app. Not the browser Arcade SDK (``window.arborito``).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .client import Arborito
|
|
14
|
+
from .errors import ArboritoError
|
|
15
|
+
from .nostr_protocol import normalize_tree_share_code, parse_nostr_tree_url
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_CLI_MSG = {
|
|
19
|
+
"ES": {
|
|
20
|
+
"pick": "Elige (número, q=salir): ",
|
|
21
|
+
"correct": "¡Correcto!",
|
|
22
|
+
"wrong": "Incorrecto —",
|
|
23
|
+
"skip_order": "(Modos chips/pasos: implementa tu propia UI con buildCard.)",
|
|
24
|
+
"skip_enter": "Enter para continuar, q=salir: ",
|
|
25
|
+
"correct_label": "Respuesta:",
|
|
26
|
+
"score": "Puntuación:",
|
|
27
|
+
"session_end": "Sesión terminada.",
|
|
28
|
+
"no_session": "No hay sesión jugable (árbol vacío o sin @quiz).",
|
|
29
|
+
"play_hint": "Consejo: exporta un curso con bloques @quiz, o ejecuta: arborito-sdk quiz course.arborito",
|
|
30
|
+
"play_start": "Sesión Arborito. Escribe respuestas; q para salir.\n",
|
|
31
|
+
},
|
|
32
|
+
"EN": {
|
|
33
|
+
"pick": "Pick (number, q=quit): ",
|
|
34
|
+
"correct": "Correct!",
|
|
35
|
+
"wrong": "Wrong —",
|
|
36
|
+
"skip_order": "(Chips/steps: build your own UI with buildCard.)",
|
|
37
|
+
"skip_enter": "Press Enter to continue, q=quit: ",
|
|
38
|
+
"correct_label": "Correct:",
|
|
39
|
+
"score": "Score:",
|
|
40
|
+
"session_end": "Session ended.",
|
|
41
|
+
"no_session": "No playable session (empty tree or no quiz blocks?).",
|
|
42
|
+
"play_hint": "Tip: export a course with @quiz blocks, or run: arborito-sdk quiz course.arborito",
|
|
43
|
+
"play_start": "Arborito play session. Type answers; q to quit.\n",
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cli(lang: str) -> dict[str, str]:
|
|
49
|
+
key = lang.upper() if lang.upper() in _CLI_MSG else "EN"
|
|
50
|
+
return _CLI_MSG[key]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_api(path: Path, lang: str) -> Arborito:
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
raise FileNotFoundError(f"Not found: {path}")
|
|
56
|
+
return Arborito.from_arborito(path, lang=lang, username="cli", avatar="🌳")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_api_source(
|
|
60
|
+
*,
|
|
61
|
+
lang: str,
|
|
62
|
+
arborito: Path | None = None,
|
|
63
|
+
code: str | None = None,
|
|
64
|
+
nostr_url: str | None = None,
|
|
65
|
+
relays: list[str] | None = None,
|
|
66
|
+
) -> tuple[Arborito, str]:
|
|
67
|
+
if sum(1 for x in (arborito, code, nostr_url) if x) != 1:
|
|
68
|
+
raise ValueError("Provide exactly one source: file path, --code, or --nostr.")
|
|
69
|
+
if code:
|
|
70
|
+
api = Arborito.from_share_code(code, lang=lang, relays=relays, username="cli", avatar="🌳")
|
|
71
|
+
label = f"share:{normalize_tree_share_code(code) or code}"
|
|
72
|
+
return api, label
|
|
73
|
+
if nostr_url:
|
|
74
|
+
ref = parse_nostr_tree_url(nostr_url)
|
|
75
|
+
if not ref:
|
|
76
|
+
raise ValueError(f"Invalid Nostr tree URL: {nostr_url}")
|
|
77
|
+
api = Arborito.from_nostr(
|
|
78
|
+
ref["pub"],
|
|
79
|
+
ref["universe_id"],
|
|
80
|
+
lang=lang,
|
|
81
|
+
relays=relays,
|
|
82
|
+
username="cli",
|
|
83
|
+
avatar="🌳",
|
|
84
|
+
)
|
|
85
|
+
return api, f"nostr:{ref['pub'][:12]}…/{ref['universe_id']}"
|
|
86
|
+
assert arborito is not None
|
|
87
|
+
return _load_api(arborito, lang), str(arborito.name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_info(api: Arborito, label: str) -> int:
|
|
91
|
+
lessons = api.lesson.list()
|
|
92
|
+
challenges = sum(len(api.challenge.fromLesson(api.lesson.at(i))) for i in range(len(lessons)))
|
|
93
|
+
print(f"Course: {label}")
|
|
94
|
+
print(f"Lessons: {len(lessons)}")
|
|
95
|
+
print(f"Challenges: {challenges}")
|
|
96
|
+
print(f"Language: {api.user.lang}")
|
|
97
|
+
print(f"AI mode: {api.getAIMode()}")
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_lessons(api: Arborito) -> int:
|
|
102
|
+
items = api.lesson.list()
|
|
103
|
+
for i, title in enumerate(items):
|
|
104
|
+
lesson = api.lesson.at(i)
|
|
105
|
+
n = len(api.challenge.fromLesson(lesson)) if lesson else 0
|
|
106
|
+
tag = f" ({n} quiz)" if n else ""
|
|
107
|
+
print(f"{i:3d} {title}{tag}")
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_show(api: Arborito, index: int) -> int:
|
|
112
|
+
lesson = api.lesson.at(index)
|
|
113
|
+
if not lesson:
|
|
114
|
+
print(f"No lesson at index {index}", file=sys.stderr)
|
|
115
|
+
return 1
|
|
116
|
+
title = lesson.get("title") or "?"
|
|
117
|
+
text = (lesson.get("text") or "").strip()
|
|
118
|
+
print(f"# {title}\n")
|
|
119
|
+
print(text or "(empty body)")
|
|
120
|
+
challenges = api.challenge.fromLesson(lesson)
|
|
121
|
+
if challenges:
|
|
122
|
+
print(f"\n--- {len(challenges)} challenge block(s) ---")
|
|
123
|
+
for j, c in enumerate(challenges):
|
|
124
|
+
concept = c.get("core_concept") or "?"
|
|
125
|
+
modes = ", ".join(api.challenge.modes.playable(c)) or "none"
|
|
126
|
+
print(f" [{j}] {concept} · modes: {modes}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_line(prompt: str) -> Optional[str]:
|
|
131
|
+
try:
|
|
132
|
+
raw = input(prompt).strip()
|
|
133
|
+
except (EOFError, KeyboardInterrupt):
|
|
134
|
+
return None
|
|
135
|
+
if raw.lower() in ("q", "quit", "exit"):
|
|
136
|
+
return None
|
|
137
|
+
return raw
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def cmd_quiz(api: Arborito, rounds: int, mode_only: Optional[str]) -> int:
|
|
141
|
+
"""Minimal interactive quiz (multiple / cloze / chips / …)."""
|
|
142
|
+
msg = _cli(api.user.lang)
|
|
143
|
+
score = 0
|
|
144
|
+
attempted = 0
|
|
145
|
+
lessons = [api.lesson.at(i) for i in range(len(api.lesson.list()))]
|
|
146
|
+
for lesson in lessons:
|
|
147
|
+
if attempted >= rounds:
|
|
148
|
+
break
|
|
149
|
+
if not lesson:
|
|
150
|
+
continue
|
|
151
|
+
for challenge in api.challenge.fromLesson(lesson):
|
|
152
|
+
if attempted >= rounds:
|
|
153
|
+
break
|
|
154
|
+
playable = api.challenge.modes.playable(challenge)
|
|
155
|
+
if mode_only:
|
|
156
|
+
if mode_only not in playable:
|
|
157
|
+
continue
|
|
158
|
+
mode = mode_only
|
|
159
|
+
else:
|
|
160
|
+
if not playable:
|
|
161
|
+
continue
|
|
162
|
+
mode = playable[0]
|
|
163
|
+
card = api.challenge.modes.buildCard(
|
|
164
|
+
challenge,
|
|
165
|
+
mode,
|
|
166
|
+
lesson_title=lesson.get("title") or "",
|
|
167
|
+
lang=api.user.lang,
|
|
168
|
+
)
|
|
169
|
+
if not card:
|
|
170
|
+
continue
|
|
171
|
+
attempted += 1
|
|
172
|
+
title = (lesson.get("title") or "?")[:50]
|
|
173
|
+
print(f"\n--- {attempted}/{rounds} · {title} [{mode}] ---")
|
|
174
|
+
print(f"Q: {card.get('question')}")
|
|
175
|
+
options = list(card.get("options") or [])
|
|
176
|
+
if mode in ("multiple", "recall", "cloze") and options:
|
|
177
|
+
for i, opt in enumerate(options, 1):
|
|
178
|
+
print(f" {i}) {opt}")
|
|
179
|
+
raw = _read_line(msg["pick"])
|
|
180
|
+
if raw is None:
|
|
181
|
+
break
|
|
182
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
183
|
+
ok = options[int(raw) - 1].strip().lower() == str(card["correct"]).strip().lower()
|
|
184
|
+
if ok:
|
|
185
|
+
score += 1
|
|
186
|
+
print(msg["correct"])
|
|
187
|
+
else:
|
|
188
|
+
print(f"{msg['wrong']} {card['correct']}")
|
|
189
|
+
elif mode in ("chips", "steps"):
|
|
190
|
+
chips = list(card.get("chips") or [])
|
|
191
|
+
for i, chip in enumerate(chips, 1):
|
|
192
|
+
print(f" {i}) {chip}")
|
|
193
|
+
print(msg["skip_order"])
|
|
194
|
+
raw = _read_line(msg["skip_enter"])
|
|
195
|
+
if raw is None:
|
|
196
|
+
break
|
|
197
|
+
else:
|
|
198
|
+
print(f"({msg['correct_label']} {card.get('correct')})")
|
|
199
|
+
raw = _read_line(msg["skip_enter"])
|
|
200
|
+
if raw is None:
|
|
201
|
+
break
|
|
202
|
+
print(f"\n{msg['score']} {score}/{attempted}")
|
|
203
|
+
return 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_play(api: Arborito) -> int:
|
|
207
|
+
msg = _cli(api.user.lang)
|
|
208
|
+
session = api.play.boot()
|
|
209
|
+
if not session:
|
|
210
|
+
print(msg["no_session"], file=sys.stderr)
|
|
211
|
+
print(msg["play_hint"], file=sys.stderr)
|
|
212
|
+
return 1
|
|
213
|
+
print(msg["play_start"])
|
|
214
|
+
while session and not session.get("done"):
|
|
215
|
+
task = session.get("currentTask")
|
|
216
|
+
if task:
|
|
217
|
+
q = task.get("question") or task.get("prompt") or task.get("label") or ""
|
|
218
|
+
if q:
|
|
219
|
+
print(q)
|
|
220
|
+
line = _read_line("> ")
|
|
221
|
+
if line is None:
|
|
222
|
+
break
|
|
223
|
+
result = api.play.submit(line or "")
|
|
224
|
+
reply = result.get("output") or result.get("message") or ""
|
|
225
|
+
if reply:
|
|
226
|
+
print(reply)
|
|
227
|
+
session = api.play.snapshot()
|
|
228
|
+
print(msg["session_end"])
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _add_common_args(sp: argparse.ArgumentParser) -> None:
|
|
233
|
+
sp.add_argument("--lang", default="ES", help="Course language code (ES, EN, …).")
|
|
234
|
+
sp.add_argument(
|
|
235
|
+
"arborito",
|
|
236
|
+
nargs="?",
|
|
237
|
+
type=Path,
|
|
238
|
+
help="Path to a .arborito archive (omit when using --code or --nostr).",
|
|
239
|
+
)
|
|
240
|
+
sp.add_argument("--code", metavar="XXXX-XXXX", help="Public share code (loads from Nostr).")
|
|
241
|
+
sp.add_argument("--nostr", metavar="URL", help="nostr://<pubhex>/<universeId>")
|
|
242
|
+
sp.add_argument(
|
|
243
|
+
"--relay",
|
|
244
|
+
action="append",
|
|
245
|
+
dest="relays",
|
|
246
|
+
metavar="wss://…",
|
|
247
|
+
help="Extra relay (repeatable). Defaults match Arborito suggested relays.",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
252
|
+
p = argparse.ArgumentParser(
|
|
253
|
+
prog="arborito-sdk",
|
|
254
|
+
description="Arborito Python SDK — browse .arborito courses and run quizzes.",
|
|
255
|
+
)
|
|
256
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
257
|
+
|
|
258
|
+
for name, help_text in (
|
|
259
|
+
("info", "Summary of a course."),
|
|
260
|
+
("lessons", "List lesson titles."),
|
|
261
|
+
("show", "Print one lesson body."),
|
|
262
|
+
("quiz", "Run an interactive quiz session."),
|
|
263
|
+
("play", "Start a text play session (SDK play loop)."),
|
|
264
|
+
("refresh", "Reload a Nostr-backed course if the publisher updated it."),
|
|
265
|
+
):
|
|
266
|
+
sp = sub.add_parser(name, help=help_text)
|
|
267
|
+
_add_common_args(sp)
|
|
268
|
+
if name == "show":
|
|
269
|
+
sp.add_argument("--index", type=int, default=0, help="Lesson index (see `lessons`).")
|
|
270
|
+
if name == "quiz":
|
|
271
|
+
sp.add_argument("--rounds", type=int, default=5, help="Max challenges to play.")
|
|
272
|
+
sp.add_argument(
|
|
273
|
+
"--mode",
|
|
274
|
+
choices=["multiple", "recall", "cloze", "chips", "steps"],
|
|
275
|
+
help="Restrict to one quiz modality.",
|
|
276
|
+
)
|
|
277
|
+
return p
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main(argv: list[str] | None = None) -> int:
|
|
281
|
+
args = build_parser().parse_args(argv)
|
|
282
|
+
lang = args.lang.upper()
|
|
283
|
+
relays = getattr(args, "relays", None)
|
|
284
|
+
try:
|
|
285
|
+
api, label = _load_api_source(
|
|
286
|
+
lang=lang,
|
|
287
|
+
arborito=getattr(args, "arborito", None),
|
|
288
|
+
code=getattr(args, "code", None),
|
|
289
|
+
nostr_url=getattr(args, "nostr", None),
|
|
290
|
+
relays=relays,
|
|
291
|
+
)
|
|
292
|
+
except FileNotFoundError as e:
|
|
293
|
+
print(str(e), file=sys.stderr)
|
|
294
|
+
return 1
|
|
295
|
+
except (ArboritoError, ValueError) as e:
|
|
296
|
+
print(f"Failed to load course: {e}", file=sys.stderr)
|
|
297
|
+
return 2
|
|
298
|
+
|
|
299
|
+
cmd = args.command
|
|
300
|
+
if cmd == "info":
|
|
301
|
+
return cmd_info(api, label)
|
|
302
|
+
if cmd == "lesson" or cmd == "lessons":
|
|
303
|
+
return cmd_lessons(api)
|
|
304
|
+
if cmd == "show":
|
|
305
|
+
return cmd_show(api, args.index)
|
|
306
|
+
if cmd == "quiz":
|
|
307
|
+
return cmd_quiz(api, args.rounds, getattr(args, "mode", None))
|
|
308
|
+
if cmd == "play":
|
|
309
|
+
return cmd_play(api)
|
|
310
|
+
if cmd == "refresh":
|
|
311
|
+
if not getattr(api, "_nostr_ref", None):
|
|
312
|
+
print("Not a Nostr-backed course (use --code or --nostr).", file=sys.stderr)
|
|
313
|
+
return 1
|
|
314
|
+
changed = api.refresh()
|
|
315
|
+
print("Updated from relays." if changed else "No change on relays.")
|
|
316
|
+
return 0
|
|
317
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
318
|
+
return 1
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
if __name__ == "__main__":
|
|
322
|
+
raise SystemExit(main())
|