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,171 @@
1
+ """Long-running Source/Sink execution — start, stream, and cleanly stop a rider on its donor.
2
+
3
+ Unlike the one-shot `RiderRunner` (used for deterministic grading), a rider in the app runs
4
+ CONTINUOUSLY: start its tool inside the donor, stream output live, stop on demand. Two things make
5
+ this safe:
6
+
7
+ * **streaming** — a reader thread reads the tool's stdout line by line and pushes a rolling
8
+ {raw, measurement, running} snapshot to an `on_update` callback (the caller marshals it to the
9
+ GUI via a Qt signal);
10
+ * **clean teardown** — `docker exec` does not reliably kill the in-container process when the
11
+ client goes away, so each rider is launched via a tiny wrapper that writes its PID to a file;
12
+ `stop()` kills that PID inside the container, then terminates the local client. No orphans.
13
+
14
+ Popen + the kill call are injectable so the whole lifecycle is unit-testable without Docker.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import shlex
19
+ import subprocess
20
+ import threading
21
+
22
+ from ..domain import riders as _riders
23
+
24
+ # donor neighbours a ping/http rider can auto-target (reachable by service name)
25
+ _TARGETABLE = {"host", "router", "instance", "container", "kinstance", "web_app", "firewall"}
26
+
27
+
28
+ class _Sess:
29
+ def __init__(self, rider_id, type_key, service, pidfile, proc, donor, inferred) -> None:
30
+ self.rider_id = rider_id
31
+ self.type_key = type_key
32
+ self.service = service
33
+ self.pidfile = pidfile
34
+ self.proc = proc
35
+ self.donor = donor
36
+ self.inferred = inferred
37
+ self.lines: list[str] = []
38
+ self.stop_flag = False
39
+
40
+
41
+ class RiderSessions:
42
+ def __init__(self, orchestrator, *, popen_factory=None, timeout: float = 8.0) -> None:
43
+ self.orch = orchestrator
44
+ self._popen = popen_factory or subprocess.Popen
45
+ self.timeout = timeout
46
+ self._sessions: dict[str, _Sess] = {}
47
+ self._svc_cache: dict | None = None
48
+
49
+ # -- state -------------------------------------------------------------- #
50
+ def is_running(self, rider_id: str) -> bool:
51
+ return rider_id in self._sessions
52
+
53
+ def running_ids(self) -> list[str]:
54
+ return list(self._sessions)
55
+
56
+ def available(self) -> bool:
57
+ try:
58
+ states = self.orch.status()
59
+ except Exception:
60
+ return False
61
+ return any(str(s).startswith("running") for s in (states or {}).values())
62
+
63
+ def _service(self, name: str) -> str:
64
+ if self._svc_cache is None:
65
+ try:
66
+ self._svc_cache = {k.lower(): k for k in (self.orch.status() or {})}
67
+ except Exception:
68
+ self._svc_cache = {}
69
+ return self._svc_cache.get(name.lower(), name.lower())
70
+
71
+ def _infer_target(self, topo, donor_id: str, rider_id: str) -> str:
72
+ for nb in topo.neighbors(donor_id):
73
+ if nb.id != rider_id and nb.type_key in _TARGETABLE:
74
+ return nb.name
75
+ for d in topo.devices.values():
76
+ if d.id not in (donor_id, rider_id) and d.type_key in _TARGETABLE:
77
+ return d.name
78
+ return ""
79
+
80
+ # -- lifecycle ---------------------------------------------------------- #
81
+ def start(self, topo, rider_id: str, on_update) -> dict:
82
+ """Launch the rider on its donor and stream updates. Returns a start result dict."""
83
+ if rider_id in self._sessions:
84
+ return {"ok": True, "running": True, "already": True}
85
+ rider = topo.devices.get(rider_id)
86
+ if rider is None:
87
+ return {"ok": False, "error": "no such rider"}
88
+ donor = topo.donor_of(rider_id)
89
+ if donor is None:
90
+ return {"ok": False, "error": f"{rider.name} isn't attached to a donor yet."}
91
+
92
+ props = dict(rider.properties)
93
+ inferred = ""
94
+ if rider.type_key in ("ping_probe", "http_probe") and not (props.get("Target") or "").strip():
95
+ inferred = self._infer_target(topo, donor.id, rider_id)
96
+ if inferred:
97
+ props["Target"] = inferred
98
+ try:
99
+ argv = _riders.build_command(rider.type_key, props)
100
+ except _riders.RiderError as e:
101
+ return {"ok": False, "error": str(e)}
102
+
103
+ service = self._service(donor.name)
104
+ pidfile = f"/tmp/gini-rider-{rider_id}.pid"
105
+ # write our PID, then exec the tool (so `stop` can kill exactly this process in the container)
106
+ wrapped = ["sh", "-lc", f"echo $$ > {pidfile}; exec {shlex.join(argv)}"]
107
+ cmd = [*self.orch._dc, "exec", "-T", service, *wrapped]
108
+ wd = getattr(self.orch, "workdir", None)
109
+ try:
110
+ proc = self._popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
111
+ text=True, bufsize=1, cwd=str(wd) if wd else None)
112
+ except (FileNotFoundError, OSError) as e:
113
+ return {"ok": False, "error": f"couldn't start: {e}"}
114
+
115
+ sess = _Sess(rider_id, rider.type_key, service, pidfile, proc, donor.name, inferred)
116
+ self._sessions[rider_id] = sess
117
+ threading.Thread(target=self._reader, args=(sess, on_update), daemon=True).start()
118
+ return {"ok": True, "running": True, "donor": donor.name, "inferred_target": inferred}
119
+
120
+ def _snapshot(self, sess: _Sess, running: bool) -> dict:
121
+ raw = "\n".join(sess.lines)
122
+ m = _riders.parse_measurement(sess.type_key, raw)
123
+ return {"ok": True, "running": running, "raw": raw, "measurement": m,
124
+ "summary": _riders.summarize(sess.type_key, m), "donor": sess.donor,
125
+ "inferred_target": sess.inferred}
126
+
127
+ def _reader(self, sess: _Sess, on_update) -> None:
128
+ import time
129
+ last = 0.0
130
+ try:
131
+ for line in iter(sess.proc.stdout.readline, ""):
132
+ if sess.stop_flag:
133
+ break
134
+ sess.lines.append(line.rstrip("\n"))
135
+ if len(sess.lines) > 300:
136
+ sess.lines = sess.lines[-300:]
137
+ # THROTTLE: a high-rate rider (tcpdump under load prints per packet) would flood the
138
+ # GUI with rider_ran signals and freeze it. Coalesce to ~4 updates/sec; the final
139
+ # snapshot below always fires, so no data is lost — only intermediate redraws.
140
+ now = time.monotonic()
141
+ if now - last >= 0.25:
142
+ on_update(sess.rider_id, self._snapshot(sess, running=True))
143
+ last = now
144
+ except Exception: # noqa: BLE001 — a dead pipe just ends the run
145
+ pass
146
+ self._sessions.pop(sess.rider_id, None) # finite run finished, or we were stopped
147
+ on_update(sess.rider_id, self._snapshot(sess, running=False))
148
+
149
+ def stop(self, rider_id: str) -> None:
150
+ sess = self._sessions.get(rider_id)
151
+ if sess is None:
152
+ return
153
+ sess.stop_flag = True
154
+ wd = getattr(self.orch, "workdir", None)
155
+ try: # kill the tool INSIDE the container
156
+ subprocess.run(
157
+ [*self.orch._dc, "exec", "-T", sess.service, "sh", "-lc",
158
+ f"kill $(cat {sess.pidfile}) 2>/dev/null; rm -f {sess.pidfile}"],
159
+ cwd=str(wd) if wd else None, capture_output=True, timeout=self.timeout)
160
+ except Exception: # noqa: BLE001
161
+ pass
162
+ for close in (lambda: sess.proc.terminate(), lambda: sess.proc.stdout.close()):
163
+ try:
164
+ close()
165
+ except Exception: # noqa: BLE001
166
+ pass
167
+ self._sessions.pop(rider_id, None)
168
+
169
+ def stop_all(self) -> None:
170
+ for rid in list(self._sessions):
171
+ self.stop(rid)
@@ -0,0 +1,52 @@
1
+ """Housekeeping for the per-element shadow folders under ``~/.gini/xv6-shadows/``.
2
+
3
+ The Load loop bind-mounts one folder per xv6 element (``~/.gini/xv6-shadows/<name>/gini_sched.c``)
4
+ and, by design, never deletes it — a student's shadow persists across Stop/Run. The flip side is
5
+ they accumulate (a renamed/removed element leaves an orphan). This module provides the explicit
6
+ housekeeping: **reset** one element's folder (so the next Run re-seeds the stub) and **prune**
7
+ orphans. Nothing here runs automatically — deleting student work is always a deliberate act.
8
+
9
+ Pure (os/shutil/pathlib only); unit-tested with a ``GINI_HOME_DIR`` override.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+
18
+ def _home() -> Path:
19
+ # Same rule as app.paths.gini_home / content.py, replicated to avoid an app import.
20
+ return Path(os.environ.get("GINI_HOME_DIR") or (Path.home() / ".gini")).expanduser()
21
+
22
+
23
+ def shadows_root() -> Path:
24
+ return _home() / "xv6-shadows"
25
+
26
+
27
+ def list_shadows() -> list[str]:
28
+ """Element names that currently have a shadow folder."""
29
+ root = shadows_root()
30
+ return sorted(p.name for p in root.iterdir() if p.is_dir()) if root.is_dir() else []
31
+
32
+
33
+ def reset_shadow(name: str) -> bool:
34
+ """Delete one element's shadow folder so the next Run re-seeds a fresh stub. Returns True if a
35
+ folder was removed. (Live, the Machine Lab's Revert restores the stub *contents* without a Stop;
36
+ this is the offline / start-over path.)"""
37
+ d = shadows_root() / name
38
+ if d.is_dir():
39
+ shutil.rmtree(d, ignore_errors=True)
40
+ return True
41
+ return False
42
+
43
+
44
+ def prune_shadows(keep) -> list[str]:
45
+ """Remove shadow folders whose element name is NOT in ``keep`` (orphans from deleted/renamed
46
+ elements). Pass the CURRENT set of xv6 element names. Returns the names removed."""
47
+ keep = set(keep or ())
48
+ removed = []
49
+ for name in list_shadows():
50
+ if name not in keep and reset_shadow(name):
51
+ removed.append(name)
52
+ return removed
@@ -0,0 +1,45 @@
1
+ """Open a platform terminal running a command — the 'double-click to log in' plumbing.
2
+
3
+ Mirrors old gBuilder's xterm-on-double-click, but cross-platform: a real terminal
4
+ window the student types into (docker exec into a machine, or a console into a
5
+ network element).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ from pathlib import Path
13
+
14
+
15
+ def open_terminal(title: str, cwd: str | Path, command: str) -> tuple[bool, str]:
16
+ cwd = str(cwd)
17
+ if sys.platform == "darwin":
18
+ script = f'cd {_q(cwd)} && clear && echo {_q("== " + title + " ==")} && {command}'
19
+ osa = f'tell application "Terminal" to do script "{script.replace(chr(34), chr(92) + chr(34))}"'
20
+ try:
21
+ subprocess.Popen(["osascript", "-e", osa,
22
+ "-e", 'tell application "Terminal" to activate'])
23
+ return True, "opened Terminal"
24
+ except FileNotFoundError:
25
+ return False, "osascript not found"
26
+ # Linux: try the common terminal emulators
27
+ for term, args in (
28
+ ("x-terminal-emulator", ["-e"]),
29
+ ("gnome-terminal", ["--"]),
30
+ ("konsole", ["-e"]),
31
+ ("xterm", ["-e"]),
32
+ ):
33
+ if shutil.which(term):
34
+ full = f"cd {_q(cwd)}; {command}; exec ${{SHELL:-sh}}"
35
+ try:
36
+ subprocess.Popen([term, *args, "bash", "-lc", full])
37
+ return True, f"opened {term}"
38
+ except OSError:
39
+ continue
40
+ return False, ("no terminal emulator found — run manually:\n"
41
+ f" cd {cwd} && {command}")
42
+
43
+
44
+ def _q(s: str) -> str:
45
+ return "'" + s.replace("'", "'\\''") + "'"
gini/setup/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """GINI setup — the `gini-setup` command that brings in the container runtime + images.
2
+
3
+ `pip install gini-toolkit` gives the desktop app; the heavy runtime (Docker/Colima) and the custom
4
+ container images live outside the wheel. `gini-setup` detects/guides the runtime install and pulls
5
+ the images from the registry, then writes a marker (~/.gini/setup.json) so `gbuilder` knows live Run
6
+ is ready (Demo mode always works without it).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ # Registry namespace for the published images. Placeholder until the GHCR org is created + CI pushes;
11
+ # override with the GINI_REGISTRY env var.
12
+ import os
13
+
14
+ REGISTRY = os.environ.get("GINI_REGISTRY", "ghcr.io/gini-toolkit")
15
+
16
+ # The custom images the app needs (third-party images like alpine/postgres are pulled by Compose).
17
+ IMAGES = ["gini-xv6", "gini-oszoo", "gini-grouter", "gini-pox"]
gini/setup/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """`gini-setup` — one-time bootstrap of the container runtime + images.
2
+
3
+ gini-setup # detect/guide the runtime, then pull the images
4
+ gini-setup --check # report status and exit
5
+ gini-setup --update # re-pull images (after `pip install -U gini-toolkit`)
6
+ gini-setup --yes # run auto-install steps without prompting
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+
13
+ from . import images, marker, runtime
14
+
15
+
16
+ def _confirm(question: str) -> bool:
17
+ try:
18
+ return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
19
+ except EOFError:
20
+ return False
21
+
22
+
23
+ def _app_version() -> str:
24
+ from .. import __version__
25
+ return __version__
26
+
27
+
28
+ def _do_check(os_name: str) -> int:
29
+ print(f"gini-toolkit {_app_version()} · {os_name}")
30
+ print(" runtime:", "available" if runtime.docker_available() else "NOT found")
31
+ if marker.is_setup_done():
32
+ print(f" setup: done (version {marker.setup_version()})")
33
+ if marker.needs_update(_app_version()):
34
+ print(" note: app was upgraded — run `gini-setup --update` to refresh images.")
35
+ else:
36
+ print(" setup: not run yet")
37
+ return 0
38
+
39
+
40
+ def _ensure_runtime(os_name: str, assume_yes: bool) -> bool:
41
+ if runtime.docker_available():
42
+ return True
43
+ plan = runtime.runtime_plan(os_name)
44
+ print(f"No container runtime found. GINI uses {plan['runtime']} on {os_name}.")
45
+ if plan["auto"]:
46
+ print("These commands can set it up:")
47
+ for c in plan["auto"]:
48
+ print(" ", c)
49
+ if assume_yes or _confirm("Run them now?"):
50
+ for c in plan["auto"]:
51
+ if runtime.run_shell(c) != 0:
52
+ print("Step failed:", c, "\n\n", plan["manual"])
53
+ return False
54
+ else:
55
+ print("\n" + plan["manual"])
56
+ return False
57
+ else:
58
+ print("\n" + plan["manual"])
59
+ return False
60
+ if not runtime.docker_available():
61
+ print("Runtime installed but not reachable yet — start it and re-run `gini-setup`.")
62
+ return False
63
+ return True
64
+
65
+
66
+ def _pull(os_name: str) -> int:
67
+ refs = images.image_refs(_app_version())
68
+ print("Pulling images:")
69
+ for r in refs:
70
+ print(" ", r)
71
+ results = images.pull_images(refs)
72
+ ok = [r for r, s in results if s]
73
+ bad = [r for r, s in results if not s]
74
+ for r, s in results:
75
+ print(" ok " if s else " FAIL", r)
76
+ marker.write_marker({"version": _app_version(), "os": os_name,
77
+ "tag": images.image_tag(_app_version()), "images": ok})
78
+ print("\nRecorded", marker.marker_path())
79
+ if bad:
80
+ print("\nSome images could not be pulled — they may not be published yet, or the registry "
81
+ "is unreachable. Live Run will be limited until they're available.")
82
+ return 2
83
+ print("\nSetup complete. Launch the app with: gbuilder")
84
+ return 0
85
+
86
+
87
+ def main(argv=None) -> int:
88
+ ap = argparse.ArgumentParser(prog="gini-setup",
89
+ description="Bring in the GINI container runtime + images.")
90
+ ap.add_argument("--check", action="store_true", help="report status and exit")
91
+ ap.add_argument("--update", action="store_true", help="re-pull images for the current version")
92
+ ap.add_argument("--yes", "-y", action="store_true", help="run auto-install steps without asking")
93
+ args = ap.parse_args(argv)
94
+ os_name = runtime.detect_os()
95
+
96
+ if args.check:
97
+ return _do_check(os_name)
98
+ if args.update:
99
+ if not runtime.docker_available():
100
+ print("No runtime — run `gini-setup` first.")
101
+ return 1
102
+ return _pull(os_name)
103
+ if not _ensure_runtime(os_name, args.yes):
104
+ return 1
105
+ return _pull(os_name)
106
+
107
+
108
+ if __name__ == "__main__":
109
+ sys.exit(main())
gini/setup/images.py ADDED
@@ -0,0 +1,33 @@
1
+ """Pull the custom GINI images from the registry, pinned to the app version."""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+
6
+ from . import IMAGES, REGISTRY
7
+
8
+
9
+ def image_tag(version: str | None) -> str:
10
+ """A released version (e.g. 6.1.0) pins images to that tag; a dev/unreleased build uses 'latest'
11
+ (there's no matching published tag yet)."""
12
+ if not version:
13
+ return "latest"
14
+ if "dev" in version or "+" in version or version.startswith("0"):
15
+ return "latest"
16
+ return version
17
+
18
+
19
+ def image_refs(version: str | None) -> list[str]:
20
+ tag = image_tag(version)
21
+ return [f"{REGISTRY}/{name}:{tag}" for name in IMAGES]
22
+
23
+
24
+ def pull_images(refs, run=subprocess.run) -> list[tuple[str, bool]]:
25
+ """Pull each ref; return [(ref, ok)]. A failure is captured, not raised (image may be unpublished)."""
26
+ out = []
27
+ for ref in refs:
28
+ try:
29
+ r = run(["docker", "pull", ref], timeout=1800)
30
+ out.append((ref, r.returncode == 0))
31
+ except Exception:
32
+ out.append((ref, False))
33
+ return out
gini/setup/marker.py ADDED
@@ -0,0 +1,43 @@
1
+ """The setup marker — ~/.gini/setup.json — records that the runtime + images were brought in, so
2
+ `gbuilder` can tell whether live Run is ready without re-probing everything on launch."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def gini_home() -> Path:
11
+ return Path(os.environ.get("GINI_HOME", str(Path.home() / ".gini")))
12
+
13
+
14
+ def marker_path() -> Path:
15
+ return gini_home() / "setup.json"
16
+
17
+
18
+ def read_marker() -> dict:
19
+ try:
20
+ return json.loads(marker_path().read_text())
21
+ except Exception:
22
+ return {}
23
+
24
+
25
+ def write_marker(info: dict) -> None:
26
+ p = marker_path()
27
+ p.parent.mkdir(parents=True, exist_ok=True)
28
+ p.write_text(json.dumps(info, indent=2))
29
+
30
+
31
+ def is_setup_done() -> bool:
32
+ """True once at least one image has been pulled and recorded."""
33
+ return bool(read_marker().get("images"))
34
+
35
+
36
+ def setup_version() -> str | None:
37
+ return read_marker().get("version")
38
+
39
+
40
+ def needs_update(app_version: str) -> bool:
41
+ """The app was upgraded past the version setup last ran for -> user should re-run gini-setup."""
42
+ v = setup_version()
43
+ return is_setup_done() and v is not None and v != app_version
gini/setup/runtime.py ADDED
@@ -0,0 +1,69 @@
1
+ """Container-runtime detection + per-OS install guidance.
2
+
3
+ The runtime can't be bundled, and it differs per OS (Colima is macOS/Linux only; Windows uses Docker
4
+ Desktop/Podman). We detect a working Docker socket and, where we can, offer the auto-install commands
5
+ — but only after the user consents (we never silently run privileged installers)."""
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import shutil
10
+ import subprocess
11
+
12
+
13
+ def detect_os() -> str:
14
+ return {"Darwin": "macos", "Linux": "linux", "Windows": "windows"}.get(
15
+ platform.system(), "unknown")
16
+
17
+
18
+ def docker_available(run=subprocess.run) -> bool:
19
+ """True if a Docker-compatible CLI is present AND a daemon answers (Colima/Desktop/Engine/Podman)."""
20
+ if not shutil.which("docker"):
21
+ return False
22
+ try:
23
+ r = run(["docker", "info"], capture_output=True, timeout=15)
24
+ return r.returncode == 0
25
+ except Exception:
26
+ return False
27
+
28
+
29
+ # Per-OS plan: the runtime we recommend, the commands we CAN auto-run (with consent), and the manual
30
+ # fallback text. Colima is macOS/Linux; Windows has no Colima.
31
+ _PLANS = {
32
+ "macos": {
33
+ "runtime": "Colima + docker CLI",
34
+ "auto": ["brew install colima docker",
35
+ "colima start --cpu 2 --memory 4 --disk 30"],
36
+ "needs": "Homebrew",
37
+ "manual": ("Install Homebrew from https://brew.sh, then run:\n"
38
+ " brew install colima docker\n"
39
+ " colima start --cpu 2 --memory 4 --disk 30"),
40
+ },
41
+ "linux": {
42
+ "runtime": "Docker Engine",
43
+ "auto": [], # distro-specific + needs sudo -> we guide rather than run
44
+ "needs": "sudo / your package manager",
45
+ "manual": ("Install Docker Engine for your distro "
46
+ "(https://docs.docker.com/engine/install/) and add your user to the 'docker' "
47
+ "group: sudo usermod -aG docker $USER (then log out/in). Podman also works."),
48
+ },
49
+ "windows": {
50
+ "runtime": "Docker Desktop (or Podman Desktop)",
51
+ "auto": ["winget install -e --id Docker.DockerDesktop"],
52
+ "needs": "winget + admin",
53
+ "manual": ("Install Docker Desktop (https://www.docker.com/products/docker-desktop) or "
54
+ "Podman Desktop, then start it. (Colima is not available on Windows.)"),
55
+ },
56
+ }
57
+
58
+
59
+ def runtime_plan(os_name: str) -> dict:
60
+ return _PLANS.get(os_name, {"runtime": "a Docker-compatible runtime", "auto": [], "needs": "",
61
+ "manual": "Install Docker or a compatible runtime and start it."})
62
+
63
+
64
+ def run_shell(cmd: str, run=subprocess.run) -> int:
65
+ """Run one auto-install command, streaming to the console. Returns the exit code."""
66
+ try:
67
+ return run(cmd, shell=True).returncode
68
+ except Exception:
69
+ return 1
gini/ui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main_window import MainWindow
2
+
3
+ __all__ = ["MainWindow"]
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ test
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file