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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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
|