gini-toolkit 6.0.1.dev0__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.
Files changed (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,482 @@
1
+ """Teaching Center client — the GINI Learning Protocol (GLP), git-style.
2
+
3
+ The Teaching Center is the system of record for both Lessons and student Profiles; the client
4
+ **checks out** a course (its released lessons + the student's profile) into a local cache, plays
5
+ offline, and **checks in** results. Because profile data is monotonic (best-band max, attempts
6
+ sum, mastery union), the merge on checkin is deterministic and conflict-free — the property that
7
+ makes offline-first work (see `domain.profile.merge`).
8
+
9
+ Endpoints (see the design doc §8):
10
+ GET /courses/{course}/manifest → released lessons (id,title,release,due,pack_hash)
11
+ GET /lessons/{id}/pack → the Lesson Pack (yaml text in Phase 5)
12
+ GET /students/{id}/profile → authoritative profile
13
+ PUT /students/{id}/profile → reconcile (server merges monotonically)
14
+ POST /courses/{course}/submissions → a Mission result (queued offline, flushed on sync)
15
+
16
+ The HTTP transport is injected (`transport(method, path, body) -> (status, obj)`), so the whole
17
+ client is unit-testable with a fake and degrades to the on-disk cache when offline. Pure stdlib.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ from ..domain import lesson as _lesson
29
+ from ..domain import profile as _profile
30
+
31
+
32
+ class InsecureTransport(RuntimeError):
33
+ """Raised rather than sending a password over plaintext HTTP to a remote host."""
34
+
35
+
36
+ def is_local(url: str) -> bool:
37
+ host = urllib.parse.urlsplit(url).hostname or ""
38
+ return host in ("localhost", "127.0.0.1", "::1", "")
39
+
40
+
41
+ def refuse_plaintext_password(url: str, *, allow_insecure: bool = False) -> None:
42
+ """A password on plain HTTP over campus wifi is readable by everyone on that wifi — and
43
+ 'classroom-scale' doesn't change that, because it's the same wifi. So we refuse, loudly, rather
44
+ than quietly doing the unsafe thing. localhost is exempt (it never leaves the machine), and an
45
+ explicit override exists for demos — but it has to be a conscious act."""
46
+ if allow_insecure or is_local(url):
47
+ return
48
+ if not url.lower().startswith("https://"):
49
+ raise InsecureTransport(
50
+ "Refusing to send your password over an unencrypted connection.\n\n"
51
+ f"The course server ({url}) isn't using HTTPS, so anyone on the same network could read "
52
+ "your password. Ask your instructor for the https:// address.\n\n"
53
+ "(If this is a demo on a trusted network, tick “Allow insecure connection” in "
54
+ "Settings → Teaching Center.)")
55
+
56
+
57
+ class TeachingCenterClient:
58
+ def __init__(self, base_url: str, *, course: str, student_id: str, token: str = "",
59
+ session: str = "", cache_dir: str | Path = "", transport=None,
60
+ timeout: float = 8.0, allow_insecure: bool = False) -> None:
61
+ self.base_url = base_url.rstrip("/")
62
+ self.course = course
63
+ self.student_id = student_id
64
+ self.token = token # the ENROLMENT token (one-time, spent on claim)
65
+ self.timeout = timeout
66
+ self.allow_insecure = allow_insecure
67
+ # honour GINI_HOME_DIR like everything else. Hard-coding ~/.gini meant the cache (and now the
68
+ # SESSION TOKEN) escaped the app's own home-directory override — including under test.
69
+ if cache_dir:
70
+ self.cache = Path(cache_dir)
71
+ else:
72
+ from ..app.paths import gini_home
73
+ self.cache = gini_home() / "teaching_center"
74
+ self._transport = transport or self._http
75
+ self._queue_path = self.cache / "submission_queue.json"
76
+ # The SESSION token is what authenticates every request. It is stored (per course+student) in
77
+ # the cache — never the password, which we hold only long enough to exchange it for this.
78
+ self.session = ""
79
+ self.role = "student" # 'teacher' unlocks author mode in gBuilder
80
+ self._load_session(session)
81
+
82
+ # -- session persistence ------------------------------------------------- #
83
+ def _session_path(self) -> Path:
84
+ return self.cache / f"session_{self.course}_{self.student_id}.json"
85
+
86
+ def _load_session(self, session: str = "") -> None:
87
+ if session:
88
+ self.session = session
89
+ return
90
+ try:
91
+ d = json.loads(self._session_path().read_text())
92
+ self.session = d.get("session", "")
93
+ self.role = d.get("role", "student")
94
+ except (OSError, json.JSONDecodeError, AttributeError):
95
+ self.session = ""
96
+
97
+ def _store_session(self, session: str, role: str = "") -> None:
98
+ self.session = session
99
+ if role:
100
+ self.role = role
101
+ self.cache.mkdir(parents=True, exist_ok=True)
102
+ if session:
103
+ self._session_path().write_text(json.dumps({"session": session, "role": self.role}))
104
+ else:
105
+ self.role = "student"
106
+ self._session_path().unlink(missing_ok=True)
107
+
108
+ def is_teacher(self) -> bool:
109
+ return self.signed_in() and self.role == "teacher"
110
+
111
+ # -- sign-in ------------------------------------------------------------- #
112
+ def claim(self, password: str, enrolment_token: str = "") -> dict:
113
+ """First login: exchange the teacher-issued enrolment token for a password + session."""
114
+ refuse_plaintext_password(self.base_url, allow_insecure=self.allow_insecure)
115
+ status, obj = self._transport("POST", "/auth/claim", {
116
+ "id": self.student_id, "enrolment_token": enrolment_token or self.token,
117
+ "password": password})
118
+ return self._after_auth(status, obj)
119
+
120
+ def login(self, password: str) -> dict:
121
+ refuse_plaintext_password(self.base_url, allow_insecure=self.allow_insecure)
122
+ status, obj = self._transport("POST", "/auth/login",
123
+ {"id": self.student_id, "password": password})
124
+ return self._after_auth(status, obj)
125
+
126
+ def _after_auth(self, status, obj) -> dict:
127
+ if status == 0:
128
+ return {"ok": False, "error": "Can't reach the course server."}
129
+ if not isinstance(obj, dict):
130
+ return {"ok": False, "error": f"Unexpected reply from the course server ({status})."}
131
+ if obj.get("ok") and obj.get("session"):
132
+ self._store_session(obj["session"], obj.get("role", "student"))
133
+ return obj
134
+
135
+ def logout(self) -> None:
136
+ try:
137
+ self._transport("POST", "/auth/logout", {})
138
+ except Exception: # noqa: BLE001 — going offline is still logout
139
+ pass
140
+ self._store_session("")
141
+
142
+ def signed_in(self) -> bool:
143
+ return bool(self.session)
144
+
145
+ # -- transport ---------------------------------------------------------- #
146
+ def _http(self, method: str, path: str, body=None):
147
+ url = self.base_url + path
148
+ data = json.dumps(body).encode() if body is not None else None
149
+ req = urllib.request.Request(url, data=data, method=method,
150
+ headers={"Content-Type": "application/json",
151
+ "Authorization": f"Bearer {self.session}"})
152
+ try:
153
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
154
+ raw = resp.read().decode()
155
+ if not raw:
156
+ return resp.status, None
157
+ try:
158
+ return resp.status, json.loads(raw)
159
+ except json.JSONDecodeError:
160
+ # NOT every endpoint is JSON — a Lesson Pack is served as YAML *text*. Treating a
161
+ # non-JSON body as a failure made every lesson fetch look like "offline".
162
+ return resp.status, raw
163
+ except urllib.error.HTTPError as e:
164
+ return e.code, None
165
+ except (urllib.error.URLError, OSError):
166
+ return 0, None # offline / unreachable
167
+
168
+ def online(self) -> bool:
169
+ status, _ = self._transport("GET", f"/courses/{self.course}/manifest", None)
170
+ return status == 200
171
+
172
+ # -- the social plane (Phases B–E) --------------------------------------- #
173
+ def heartbeat(self, progress: dict | None = None) -> bool:
174
+ """'I'm here', plus where I am on the current mission. Progress is what makes a group view
175
+ worth looking at — 'Ana is on Level 3' beats a green dot."""
176
+ status, _ = self._transport("POST", f"/courses/{self.course}/presence",
177
+ {"progress": progress or {}})
178
+ return status == 200
179
+
180
+ def my_group(self) -> dict:
181
+ status, obj = self._transport("GET", f"/courses/{self.course}/group", None)
182
+ return obj if status == 200 and isinstance(obj, dict) else {"group": "", "members": []}
183
+
184
+ def channels(self) -> list[dict]:
185
+ status, obj = self._transport("GET", f"/courses/{self.course}/channels", None)
186
+ return obj if status == 200 and isinstance(obj, list) else []
187
+
188
+ def messages(self, since: float = 0.0) -> list[dict]:
189
+ status, obj = self._transport(
190
+ "GET", f"/courses/{self.course}/messages?since={since}", None)
191
+ return obj if status == 200 and isinstance(obj, list) else []
192
+
193
+ def send_message(self, to: str, body: str) -> dict:
194
+ status, obj = self._transport("POST", f"/courses/{self.course}/messages",
195
+ {"to": to, "body": body})
196
+ if status == 0:
197
+ return {"ok": False, "error": "Can't reach the course server."}
198
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
199
+
200
+ def report_message(self, message_id: str, note: str = "") -> dict:
201
+ status, obj = self._transport("POST", f"/courses/{self.course}/messages/report",
202
+ {"message_id": message_id, "note": note})
203
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
204
+
205
+ def pull_content(self) -> dict:
206
+ """OTA: pull the course's teacher-authored fragments into the local user content layer.
207
+
208
+ The version gate lives HERE, on the receiving end: each fragment is checked against THIS
209
+ client's vocabulary (engine version + full validation). One authored on a newer gBuilder than
210
+ the student has is DECLINED with a reason and skipped — the rest still install. A pack can
211
+ never brick a student's client. Returns {installed, skipped:[{id,reason}]}."""
212
+ status, items = self._transport("GET", f"/courses/{self.course}/content", None)
213
+ if status != 200 or not isinstance(items, list):
214
+ return {"installed": [], "skipped": []}
215
+ from ..domain import content as _content
216
+ from ..domain import fragment_yaml as _fy
217
+ from ..domain import fragments as _frag
218
+ from ..domain import vocabulary as _vocab
219
+ import hashlib
220
+ d = _content.ensure_user_content_dir()
221
+ installed, skipped, changed = [], [], False
222
+ served: dict[str, str] = {} # id -> hash of what the TC serves now
223
+ for it in items:
224
+ fid, text = it.get("id", ""), it.get("yaml", "")
225
+ served[fid] = hashlib.sha256(text.encode()).hexdigest()
226
+ ok, why = _vocab.is_compatible(str(it.get("engine_version", "")))
227
+ if not ok:
228
+ skipped.append({"id": fid, "reason": why}); continue
229
+ try:
230
+ problems = _fy.validate(_fy.from_yaml(text))
231
+ except Exception as e: # noqa: BLE001 — malformed pull
232
+ problems = [str(e)]
233
+ if problems:
234
+ skipped.append({"id": fid, "reason": "; ".join(problems)}); continue
235
+ path = d / f"{fid}.yaml"
236
+ if not path.exists() or path.read_text(encoding="utf-8") != text:
237
+ path.write_text(text, encoding="utf-8"); changed = True
238
+ installed.append(fid)
239
+
240
+ # Deletion propagation: a fragment a PRIOR pull installed that the TC no longer serves is
241
+ # removed locally — but ONLY if the on-disk copy is still the untouched OTA copy (its hash
242
+ # matches what we recorded). A locally-authored or edited fragment is never deleted.
243
+ removed: list[str] = []
244
+ prev = self._cache_read("ota_content.json") or {}
245
+ if isinstance(prev, dict):
246
+ for old_id, old_hash in prev.items():
247
+ if old_id in served:
248
+ continue
249
+ p = d / f"{old_id}.yaml"
250
+ if p.exists() and hashlib.sha256(
251
+ p.read_text(encoding="utf-8").encode()).hexdigest() == old_hash:
252
+ p.unlink(missing_ok=True); removed.append(old_id); changed = True
253
+ self._cache_write("ota_content.json", served)
254
+
255
+ if changed:
256
+ _frag.reload() # pulled/removed fragments take effect now
257
+ return {"installed": installed, "skipped": skipped, "removed": removed}
258
+
259
+ def upload_fragment(self, yaml_text: str) -> dict:
260
+ """Push a blessed fragment (its YAML — nothing else) to the Teaching Center, which validates
261
+ it against its own vocabulary (the version gate) and registers it for composing experiments."""
262
+ status, obj = self._transport("POST", "/api/fragments", {"yaml": yaml_text})
263
+ if status == 0:
264
+ return {"ok": False, "error": "Can't reach the course server."}
265
+ if status == 401:
266
+ return {"ok": False, "error": "Teacher sign-in required to upload."}
267
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
268
+
269
+ def fragment_library(self) -> list | None:
270
+ """What the Teaching Center currently holds — used to show, per fragment, whether it has been
271
+ published. Returns None (not []) when the answer is UNKNOWN (offline, not a teacher), so the
272
+ UI can stay silent rather than wrongly claiming "not published"."""
273
+ status, obj = self._transport("GET", "/api/fragments", None)
274
+ if status != 200 or not isinstance(obj, list):
275
+ return None
276
+ return obj
277
+
278
+ def delete_fragment(self, fragment_id: str) -> dict:
279
+ """Remove a fragment from the Teaching Center library.
280
+
281
+ The teacher owns both sides of a fragment's life, so deleting locally should delete
282
+ centrally too — otherwise the local copy is simply re-pulled on the next sign-in and the
283
+ 'deletion' silently undoes itself."""
284
+ status, obj = self._transport("POST", "/api/fragments/delete", {"id": fragment_id})
285
+ if status == 0:
286
+ return {"ok": False, "error": "Can't reach the course server."}
287
+ if status == 401:
288
+ return {"ok": False, "error": "Teacher sign-in required to delete."}
289
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
290
+
291
+ def set_photo(self, data_url: str) -> dict:
292
+ """Upload (or clear) my profile photo — a small data-URL. Stored on the course server so the
293
+ instructor sees a real face next to a name."""
294
+ status, obj = self._transport("POST", f"/courses/{self.course}/photo",
295
+ {"photo": data_url})
296
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
297
+
298
+ def delete_message(self, message_id: str, deleted: bool = True) -> dict:
299
+ status, obj = self._transport("POST", f"/courses/{self.course}/messages/delete",
300
+ {"message_id": message_id, "deleted": deleted})
301
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
302
+
303
+ def set_ai_proxy(self, on: bool, blurb: str = "") -> dict:
304
+ """May an AI answer on my behalf when I'm away? Off unless the teacher granted hosting AND
305
+ I say yes — either alone is not consent."""
306
+ status, obj = self._transport("POST", f"/courses/{self.course}/ai/pref",
307
+ {"on": bool(on), "blurb": blurb})
308
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
309
+
310
+ def session_expired(self) -> bool:
311
+ """The server answered, but rejected us. Distinguishing this from an outage matters: an
312
+ expired session must say "sign in again", not "the server is down" — sending a student to
313
+ debug the network when they just need to re-enter a password is a small betrayal."""
314
+ status, _ = self._transport("GET", f"/courses/{self.course}/manifest", None)
315
+ return status in (401, 403)
316
+
317
+ # -- cache helpers ------------------------------------------------------ #
318
+ def _cache_write(self, name: str, obj) -> None:
319
+ self.cache.mkdir(parents=True, exist_ok=True)
320
+ (self.cache / name).write_text(json.dumps(obj) if not isinstance(obj, str) else obj)
321
+
322
+ def _cache_read(self, name: str):
323
+ try:
324
+ text = (self.cache / name).read_text()
325
+ except OSError:
326
+ return None
327
+ try:
328
+ return json.loads(text)
329
+ except json.JSONDecodeError:
330
+ return text
331
+
332
+ # -- lessons (pull, cached) --------------------------------------------- #
333
+ def manifest(self) -> list[dict]:
334
+ """The course's released lessons. Falls back to the cached manifest when offline."""
335
+ status, obj = self._transport("GET", f"/courses/{self.course}/manifest", None)
336
+ if status == 200 and isinstance(obj, list):
337
+ self._cache_write("manifest.json", obj)
338
+ return obj
339
+ return self._cache_read("manifest.json") or []
340
+
341
+ def available_lessons(self) -> list[dict]:
342
+ """Released and not past-due, newest first (what Missions mode should offer). Drafts are
343
+ filtered here too (defence in depth — the server already excludes them from the manifest)."""
344
+ now = time.time()
345
+ out = []
346
+ for m in self.manifest():
347
+ if m.get("status", "released") == "draft":
348
+ continue
349
+ rel = _epoch(m.get("release"))
350
+ due = _epoch(m.get("due"))
351
+ if (rel is None or rel <= now) and (due is None or due >= now):
352
+ out.append(m)
353
+ return sorted(out, key=lambda m: _epoch(m.get("release")) or 0, reverse=True)
354
+
355
+ # -- teacher: draft experiments (playtest + approve, Build 5) ------------- #
356
+ def list_lessons(self) -> list[dict]:
357
+ """All experiments incl. drafts, with status — teacher view (uses the console API, so needs a
358
+ teacher session)."""
359
+ status, obj = self._transport("GET", "/api/lessons", None)
360
+ return obj if status == 200 and isinstance(obj, list) else []
361
+
362
+ def mark_playtested(self, lesson_id: str) -> dict:
363
+ """Record that the teacher playtested this experiment on the canvas — the gate that lets it
364
+ be approved (assigned experiments are part of the students' grading loop)."""
365
+ status, obj = self._transport("POST", "/api/lessons/playtest", {"id": lesson_id})
366
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
367
+
368
+ def approve_lesson(self, lesson_id: str, *, release: str = "", due: str = "") -> dict:
369
+ """Release a draft experiment to the class — the teacher's sign-off after playtesting."""
370
+ status, obj = self._transport("POST", "/api/lessons/approve",
371
+ {"id": lesson_id, "release": release, "due": due})
372
+ return obj if isinstance(obj, dict) else {"ok": False, "error": f"Server said {status}."}
373
+
374
+ def fetch_lesson(self, lesson_id: str, *, expected_hash: str = ""):
375
+ """Return a playable Lesson; verify the pack hash (when known) and cache on success. Uses the
376
+ on-disk cache when offline. A hash mismatch is rejected.
377
+
378
+ The pack may be a **composition-by-reference** (references local fragment ids) or a
379
+ self-contained lesson. A composition that references fragments/roles this GINI doesn't have
380
+ fails gracefully (the version/existence check), returning None rather than mis-grading."""
381
+ import hashlib
382
+
383
+ import yaml
384
+
385
+ from ..domain import composition as _comp
386
+ status, obj = self._transport("GET", f"/lessons/{lesson_id}/pack", None)
387
+ text = obj if isinstance(obj, str) else (obj or {}).get("yaml") if isinstance(obj, dict) else None
388
+ if status == 200 and text:
389
+ if expected_hash and hashlib.sha256(text.encode()).hexdigest() != expected_hash:
390
+ return None # tampered / corrupt pack
391
+ self._cache_write(f"pack_{lesson_id}.yaml", text)
392
+ else:
393
+ text = self._cache_read(f"pack_{lesson_id}.yaml")
394
+ if not text:
395
+ return None
396
+ try:
397
+ spec = yaml.safe_load(text)
398
+ except yaml.YAMLError:
399
+ return None
400
+ try:
401
+ if _comp.is_composition(spec):
402
+ return _comp.from_composition(spec, lesson_id=lesson_id)
403
+ return _lesson.from_yaml(text)
404
+ except (_comp.CompositionError, _lesson.LessonError):
405
+ return None # incompatible / invalid pack → don't play a broken one
406
+
407
+
408
+ # -- profile (git-style checkout / checkin) ----------------------------- #
409
+ def _profile_path(self) -> Path:
410
+ return self.cache / f"profile_{self.student_id}.json"
411
+
412
+ def checkout_profile(self) -> _profile.Profile:
413
+ """Pull the authoritative profile into a local working copy (merged with any local copy so
414
+ offline progress isn't lost). Offline → the local working copy."""
415
+ local = _profile.Profile.load(self._profile_path(), student_id=self.student_id)
416
+ status, obj = self._transport("GET", f"/students/{self.student_id}/profile", None)
417
+ if status == 200 and isinstance(obj, dict):
418
+ server = _profile.Profile.from_dict(obj)
419
+ merged = _profile.merge(local, server)
420
+ merged.save(self._profile_path())
421
+ return merged
422
+ return local
423
+
424
+ def checkin_profile(self, profile: _profile.Profile) -> bool:
425
+ """Push the working copy back; the server merges monotonically. Always saves locally; when
426
+ offline the local copy stands until the next successful checkin."""
427
+ profile.save(self._profile_path())
428
+ status, _ = self._transport("PUT", f"/students/{self.student_id}/profile", profile.to_dict())
429
+ return status in (200, 204)
430
+
431
+ # -- submissions (queue offline, flush on sync) ------------------------- #
432
+ def submit(self, lesson_id: str, mission, *, snapshot: str = "") -> bool:
433
+ rec = {"student": self.student_id, "lesson_id": lesson_id,
434
+ "attempt": mission.attempt, "band": mission.last_band or mission.score().band,
435
+ "objective_results": [(r.id, r.status) for r in mission.last_results],
436
+ "time_taken": mission.elapsed(), "snapshot": snapshot, "ts": time.time()}
437
+ status, _ = self._transport("POST", f"/courses/{self.course}/submissions", rec)
438
+ if status in (200, 201, 202):
439
+ return True
440
+ self._enqueue(rec) # offline → queue for later
441
+ return False
442
+
443
+ def _enqueue(self, rec: dict) -> None:
444
+ q = self._cache_read("submission_queue.json") or []
445
+ q.append(rec)
446
+ self._cache_write("submission_queue.json", q)
447
+
448
+ def flush(self) -> int:
449
+ """Try to send any queued submissions; returns how many were flushed."""
450
+ q = self._cache_read("submission_queue.json") or []
451
+ remaining, sent = [], 0
452
+ for rec in q:
453
+ status, _ = self._transport("POST", f"/courses/{self.course}/submissions", rec)
454
+ if status in (200, 201, 202):
455
+ sent += 1
456
+ else:
457
+ remaining.append(rec)
458
+ self._cache_write("submission_queue.json", remaining)
459
+ return sent
460
+
461
+ def sync(self, profile: _profile.Profile | None = None) -> dict:
462
+ """A full checkin: flush queued submissions + reconcile the profile. Returns a summary."""
463
+ flushed = self.flush()
464
+ merged = None
465
+ if profile is not None:
466
+ self.checkin_profile(profile)
467
+ merged = self.checkout_profile()
468
+ return {"online": self.online(), "flushed": flushed,
469
+ "profile": merged.summary() if merged else ""}
470
+
471
+
472
+ def _epoch(v):
473
+ """Parse a manifest date (ISO 'YYYY-MM-DD' or epoch seconds) → epoch, or None."""
474
+ if v is None or v == "":
475
+ return None
476
+ if isinstance(v, (int, float)):
477
+ return float(v)
478
+ try:
479
+ import datetime
480
+ return datetime.datetime.fromisoformat(str(v)).timestamp()
481
+ except ValueError:
482
+ return None
@@ -0,0 +1,3 @@
1
+ from .registry import ToolRegistry, ToolSpec, build_registry
2
+
3
+ __all__ = ["ToolRegistry", "ToolSpec", "build_registry"]