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,488 @@
|
|
|
1
|
+
"""Objective predicate engine — the GINI-checkable win conditions behind Missions.
|
|
2
|
+
|
|
3
|
+
An Objective is one win condition. Two kinds:
|
|
4
|
+
|
|
5
|
+
• **structural** — a boolean predicate over the topology GRAPH, evaluated instantly and
|
|
6
|
+
offline (no run). Written in a tiny, safe expression language:
|
|
7
|
+
exists(<type>) · count(<type>) >= n · linked(a, b) · connected(a, b) ·
|
|
8
|
+
contains(box, member) · property(dev, key) == 'val'
|
|
9
|
+
combined with `and` / `or` / `not` and comparisons.
|
|
10
|
+
• **behavioral** — a probe over the RUNNING system ("reach(a -> b) == ok"). GINI's runtime
|
|
11
|
+
is the oracle; these need a run, so in Phase 1 they evaluate to **pending** (Phase 2 wires
|
|
12
|
+
the probe harness). The string is stored opaquely here.
|
|
13
|
+
|
|
14
|
+
Design notes:
|
|
15
|
+
- The expression evaluator uses Python's `ast` in a *whitelist* — no `eval`, no attribute
|
|
16
|
+
access, no arbitrary calls — so a lesson file can't execute anything. Only the handful of
|
|
17
|
+
predicate functions below are callable.
|
|
18
|
+
- Everything is pure and evaluated against a `World` (duck-typed), so it's unit-testable with
|
|
19
|
+
a real `Topology` or a fake. `TopologyWorld` adapts the live canvas model.
|
|
20
|
+
|
|
21
|
+
Convention: `exists`/`count` take an element **type_key**; `linked`/`connected`/`contains`/
|
|
22
|
+
`property` take element **names** (as placed on the canvas).
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import operator
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
from .devices import REGISTRY
|
|
31
|
+
|
|
32
|
+
# ---- results -------------------------------------------------------------- #
|
|
33
|
+
MET = "met"
|
|
34
|
+
UNMET = "unmet"
|
|
35
|
+
PENDING = "pending" # behavioral, awaiting a run (Phase 2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Objective:
|
|
40
|
+
id: str
|
|
41
|
+
say: str # student-facing one-liner
|
|
42
|
+
kind: str = "structural" # structural | behavioral
|
|
43
|
+
check: str = "" # structural predicate expression
|
|
44
|
+
probe: str = "" # behavioral probe (opaque in Phase 1)
|
|
45
|
+
level: int | None = None # explicit ladder tier; None = derived from the predicate
|
|
46
|
+
stars: int = 0 # difficulty PASS: 0 = base experiment, 1+ = harder progressive passes
|
|
47
|
+
|
|
48
|
+
def is_behavioral(self) -> bool:
|
|
49
|
+
return self.kind == "behavioral"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stars_of(o) -> int:
|
|
53
|
+
"""Star rating of an objective given as either an Objective or a plain dict."""
|
|
54
|
+
v = o.get("stars", 0) if isinstance(o, dict) else getattr(o, "stars", 0)
|
|
55
|
+
try:
|
|
56
|
+
return max(0, int(v or 0))
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def objectives_for_pass(objectives, pass_level: int) -> list:
|
|
62
|
+
"""The objectives active at star-pass `pass_level` — everything rated at or below it. Pass 0 is
|
|
63
|
+
the base experiment; each higher pass switches on the next tier of harder (starred) steps, so the
|
|
64
|
+
student walks the experiment progressively rather than facing it all at once."""
|
|
65
|
+
return [o for o in objectives if _stars_of(o) <= pass_level]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def max_stars(objectives) -> int:
|
|
69
|
+
"""The deepest star-pass this set defines (0 = no harder passes, just the base)."""
|
|
70
|
+
return max((_stars_of(o) for o in objectives), default=0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class ObjectiveResult:
|
|
75
|
+
id: str
|
|
76
|
+
say: str
|
|
77
|
+
kind: str
|
|
78
|
+
status: str # met | unmet | pending
|
|
79
|
+
level: int = 1 # which rung of the progressive ladder this sits on
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def met(self) -> bool:
|
|
83
|
+
return self.status == MET
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---- the safe expression evaluator ---------------------------------------- #
|
|
87
|
+
_CMP = {ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Gt: operator.gt,
|
|
88
|
+
ast.GtE: operator.ge, ast.Lt: operator.lt, ast.LtE: operator.le}
|
|
89
|
+
|
|
90
|
+
# name-based predicates take element NAMES; type-based ones take element TYPE_KEYS. Type-based
|
|
91
|
+
# predicates are what missions should use, so an objective matches what the student built
|
|
92
|
+
# regardless of the (auto-generated) device names.
|
|
93
|
+
_PREDICATES = {"exists", "count", "linked", "connected", "contains", "property", "prop",
|
|
94
|
+
"link", "path", "contains_type", "through", "all_linked"}
|
|
95
|
+
_TYPE_ARG_FUNCS = {"exists", "count", "link", "path", "contains_type", "through",
|
|
96
|
+
"all_linked"} # args are type_keys
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PredicateError(ValueError):
|
|
100
|
+
"""A structural check that doesn't parse or uses an unknown function."""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def slot_match(dev_slot: str, spec_slot: str) -> bool:
|
|
104
|
+
"""Does a device tagged `dev_slot` fall under the scope `spec_slot`? An empty scope matches any
|
|
105
|
+
device (unscoped type predicate). Otherwise it matches the exact label OR anything NESTED beneath
|
|
106
|
+
it: composition labels are hierarchical with '_' segments (`pods0_lans1` is inside `pods0`), so a
|
|
107
|
+
`host@pods0` predicate reaches every host in pod 0's sub-networks. This is what makes recursive,
|
|
108
|
+
multi-level compositions gradable by the same `type@slot` oracle."""
|
|
109
|
+
dev_slot = dev_slot or ""
|
|
110
|
+
if not spec_slot:
|
|
111
|
+
return True
|
|
112
|
+
return dev_slot == spec_slot or dev_slot.startswith(spec_slot + "_")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---- the progressive ladder ------------------------------------------------ #
|
|
116
|
+
# Objectives are ordered basic → advanced so a student always has an obvious next move: place the
|
|
117
|
+
# elements, wire them, group them, then prove it live. The tier is DERIVED from the predicate, so
|
|
118
|
+
# every mission (existing and future) gets the ladder for free — no hand-tagging.
|
|
119
|
+
PLACEMENT, CONNECTION, CONTAINMENT, LIVE = 1, 2, 3, 4
|
|
120
|
+
|
|
121
|
+
_PRED_LEVEL = {
|
|
122
|
+
"exists": PLACEMENT, "count": PLACEMENT, "property": PLACEMENT, "prop": PLACEMENT,
|
|
123
|
+
"link": CONNECTION, "path": CONNECTION, "through": CONNECTION, "all_linked": CONNECTION,
|
|
124
|
+
"linked": CONNECTION, "connected": CONNECTION,
|
|
125
|
+
"contains": CONTAINMENT, "contains_type": CONTAINMENT,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
LEVEL_NAME = {PLACEMENT: "Place the elements", CONNECTION: "Make the connections",
|
|
129
|
+
# L3 carries both containment (inside a VPC) and isolation (shielded / no bypass) —
|
|
130
|
+
# the "get the structure right" rung, not merely grouping.
|
|
131
|
+
CONTAINMENT: "Group & isolate", LIVE: "Prove it live (Run / Check)"}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_level(expr: str) -> int:
|
|
135
|
+
"""The tier of a structural check — the HARDEST predicate it uses (a compound is as advanced as
|
|
136
|
+
its most advanced part)."""
|
|
137
|
+
try:
|
|
138
|
+
tree = ast.parse(expr or "", mode="eval")
|
|
139
|
+
except SyntaxError:
|
|
140
|
+
return PLACEMENT
|
|
141
|
+
levels = [_PRED_LEVEL.get(n.func.id, PLACEMENT)
|
|
142
|
+
for n in ast.walk(tree) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)]
|
|
143
|
+
return max(levels) if levels else PLACEMENT
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def level_of(objective) -> int:
|
|
147
|
+
"""Which rung of the ladder an objective sits on. An explicit `level:` (authored in the YAML)
|
|
148
|
+
wins — the derived tier is only a sensible default, and some checks are semantically more
|
|
149
|
+
advanced than their predicate suggests (an isolation check reads as `path`, but it's an
|
|
150
|
+
advanced idea)."""
|
|
151
|
+
explicit = getattr(objective, "level", None)
|
|
152
|
+
if explicit:
|
|
153
|
+
return int(explicit)
|
|
154
|
+
if getattr(objective, "kind", "structural") == "behavioral":
|
|
155
|
+
return LIVE
|
|
156
|
+
return check_level(getattr(objective, "check", ""))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def by_level(objectives) -> list:
|
|
160
|
+
"""Objectives ordered basic → advanced (stable within a tier, so authored order is preserved)."""
|
|
161
|
+
return sorted(objectives, key=level_of)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _arg(node) -> object:
|
|
165
|
+
"""A predicate argument is a bare identifier (element name/type), a literal, or a SLOT-scoped
|
|
166
|
+
type `type@slot`. We reuse Python's `@` (matmul) operator for scoping, so `switch@A` parses as
|
|
167
|
+
BinOp(Name, MatMult, Name) and we render it back to the string 'switch@A' for the World."""
|
|
168
|
+
if isinstance(node, ast.Constant):
|
|
169
|
+
return node.value
|
|
170
|
+
if isinstance(node, ast.Name):
|
|
171
|
+
return node.id
|
|
172
|
+
if (isinstance(node, ast.BinOp) and isinstance(node.op, ast.MatMult)
|
|
173
|
+
and isinstance(node.left, ast.Name) and isinstance(node.right, ast.Name)):
|
|
174
|
+
return f"{node.left.id}@{node.right.id}"
|
|
175
|
+
raise PredicateError(f"unsupported argument: {ast.dump(node)}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _eval(node, world) -> object:
|
|
179
|
+
if isinstance(node, ast.BoolOp):
|
|
180
|
+
vals = [_eval(v, world) for v in node.values]
|
|
181
|
+
return all(vals) if isinstance(node.op, ast.And) else any(vals)
|
|
182
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
|
|
183
|
+
return not _eval(node.operand, world)
|
|
184
|
+
if isinstance(node, ast.Compare):
|
|
185
|
+
if len(node.ops) != 1:
|
|
186
|
+
raise PredicateError("chained comparisons are not supported")
|
|
187
|
+
left = _eval(node.left, world)
|
|
188
|
+
right = _eval(node.comparators[0], world)
|
|
189
|
+
op = _CMP.get(type(node.ops[0]))
|
|
190
|
+
if op is None:
|
|
191
|
+
raise PredicateError("unsupported comparison operator")
|
|
192
|
+
return op(left, right)
|
|
193
|
+
if isinstance(node, ast.Call):
|
|
194
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in _PREDICATES:
|
|
195
|
+
raise PredicateError(f"unknown predicate: {ast.dump(node.func)}")
|
|
196
|
+
args = [_arg(a) for a in node.args]
|
|
197
|
+
return _call(node.func.id, args, world)
|
|
198
|
+
if isinstance(node, ast.Constant):
|
|
199
|
+
return node.value
|
|
200
|
+
if isinstance(node, ast.Name):
|
|
201
|
+
return node.id
|
|
202
|
+
raise PredicateError(f"unsupported expression: {ast.dump(node)}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _call(fn: str, args: list, world) -> object:
|
|
206
|
+
if fn == "exists":
|
|
207
|
+
return world.exists(str(args[0]))
|
|
208
|
+
if fn == "count":
|
|
209
|
+
return world.count(str(args[0]))
|
|
210
|
+
if fn == "linked":
|
|
211
|
+
return world.linked(str(args[0]), str(args[1]))
|
|
212
|
+
if fn == "connected":
|
|
213
|
+
return world.connected(str(args[0]), str(args[1]))
|
|
214
|
+
if fn == "contains":
|
|
215
|
+
return world.contains(str(args[0]), str(args[1]))
|
|
216
|
+
if fn in ("property", "prop"):
|
|
217
|
+
return world.prop(str(args[0]), str(args[1]))
|
|
218
|
+
if fn == "link": # a link between ANY typeA device and ANY typeB device
|
|
219
|
+
return world.link_types(str(args[0]), str(args[1]))
|
|
220
|
+
if fn == "path": # a path between some typeA and some typeB device
|
|
221
|
+
return world.path_types(str(args[0]), str(args[1]))
|
|
222
|
+
if fn == "contains_type": # a memberType device inside a boxType box
|
|
223
|
+
return world.contains_types(str(args[0]), str(args[1]))
|
|
224
|
+
if fn == "through": # every src->dst path crosses a gate (a chokepoint)
|
|
225
|
+
return world.through_types(str(args[0]), str(args[1]), str(args[2]))
|
|
226
|
+
if fn == "all_linked": # EVERY type_a device has a direct link to SOME type_b device
|
|
227
|
+
return world.all_linked_types(str(args[0]), str(args[1]))
|
|
228
|
+
raise PredicateError(f"unknown predicate: {fn}")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def parse_check(expr: str) -> ast.AST:
|
|
232
|
+
"""Parse+validate a structural predicate (raises PredicateError). Used by lesson
|
|
233
|
+
validation so a bad predicate fails at authoring time, not mid-mission."""
|
|
234
|
+
try:
|
|
235
|
+
tree = ast.parse(expr, mode="eval")
|
|
236
|
+
except SyntaxError as e:
|
|
237
|
+
raise PredicateError(f"syntax error in check {expr!r}: {e}") from e
|
|
238
|
+
for node in ast.walk(tree):
|
|
239
|
+
if isinstance(node, ast.Call):
|
|
240
|
+
if not isinstance(node.func, ast.Name) or node.func.id not in _PREDICATES:
|
|
241
|
+
raise PredicateError(f"unknown predicate in {expr!r}")
|
|
242
|
+
elif isinstance(node, (ast.Attribute, ast.Subscript, ast.Lambda, ast.comprehension)):
|
|
243
|
+
raise PredicateError(f"disallowed syntax in {expr!r}")
|
|
244
|
+
return tree.body
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def check_ok(expr: str) -> bool:
|
|
248
|
+
try:
|
|
249
|
+
parse_check(expr)
|
|
250
|
+
return True
|
|
251
|
+
except PredicateError:
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def evaluate_check(expr: str, world) -> bool:
|
|
256
|
+
"""Evaluate a structural predicate against a world → bool."""
|
|
257
|
+
return bool(_eval(parse_check(expr), world))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def evaluate(obj: Objective, world, runner=None) -> ObjectiveResult:
|
|
261
|
+
"""One objective → a result.
|
|
262
|
+
|
|
263
|
+
Structural objectives evaluate against the graph instantly. Behavioral objectives need a
|
|
264
|
+
live `runner` (GINI's runtime, the oracle): with one available they resolve to met/unmet via
|
|
265
|
+
the probe; without one they stay `pending` (Phase 1 behavior, and whenever nothing is running)."""
|
|
266
|
+
if obj.is_behavioral():
|
|
267
|
+
if runner is None or not getattr(runner, "available", lambda: False)():
|
|
268
|
+
return ObjectiveResult(obj.id, obj.say, obj.kind, PENDING, level_of(obj))
|
|
269
|
+
from . import probes as _probes
|
|
270
|
+
try:
|
|
271
|
+
met = _probes.evaluate(obj.probe, runner)
|
|
272
|
+
except _probes.ProbeError:
|
|
273
|
+
met = False
|
|
274
|
+
return ObjectiveResult(obj.id, obj.say, obj.kind, MET if met else UNMET, level_of(obj))
|
|
275
|
+
try:
|
|
276
|
+
met = evaluate_check(obj.check, world)
|
|
277
|
+
except PredicateError:
|
|
278
|
+
met = False
|
|
279
|
+
return ObjectiveResult(obj.id, obj.say, obj.kind, MET if met else UNMET, level_of(obj))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def evaluate_all(objectives, world, runner=None) -> list[ObjectiveResult]:
|
|
283
|
+
return [evaluate(o, world, runner) for o in objectives]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ---- the World adapter over a live Topology ------------------------------- #
|
|
287
|
+
class TopologyWorld:
|
|
288
|
+
"""Adapts a `domain.topology.Topology` to the predicate World interface. Duck-typed:
|
|
289
|
+
anything exposing `.devices` (values with .name/.type_key/.parent_id/.properties) and
|
|
290
|
+
`.links` (values with .source_id/.target_id) works, so tests can use a real Topology."""
|
|
291
|
+
|
|
292
|
+
def __init__(self, topology) -> None:
|
|
293
|
+
self.t = topology
|
|
294
|
+
|
|
295
|
+
def _by_name(self, name: str):
|
|
296
|
+
for d in self.t.devices.values():
|
|
297
|
+
if d.name == name:
|
|
298
|
+
return d
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _match(d, spec: str) -> bool:
|
|
303
|
+
"""A device matches `type` or a slot-scoped `type@slot` (a scaffold/bound-dependency tag)."""
|
|
304
|
+
type_key, _, slot = str(spec).partition("@")
|
|
305
|
+
return d.type_key == type_key and slot_match(getattr(d, "slot", ""), slot)
|
|
306
|
+
|
|
307
|
+
def exists(self, type_key: str) -> bool:
|
|
308
|
+
return any(self._match(d, type_key) for d in self.t.devices.values())
|
|
309
|
+
|
|
310
|
+
def count(self, type_key: str) -> int:
|
|
311
|
+
return sum(1 for d in self.t.devices.values() if self._match(d, type_key))
|
|
312
|
+
|
|
313
|
+
def _adjacency(self) -> dict:
|
|
314
|
+
adj: dict[str, set] = {}
|
|
315
|
+
for l in self.t.links.values():
|
|
316
|
+
adj.setdefault(l.source_id, set()).add(l.target_id)
|
|
317
|
+
adj.setdefault(l.target_id, set()).add(l.source_id)
|
|
318
|
+
return adj
|
|
319
|
+
|
|
320
|
+
def linked(self, a: str, b: str) -> bool:
|
|
321
|
+
da, db = self._by_name(a), self._by_name(b)
|
|
322
|
+
if da is None or db is None:
|
|
323
|
+
return False
|
|
324
|
+
return any((l.source_id, l.target_id) in ((da.id, db.id), (db.id, da.id))
|
|
325
|
+
for l in self.t.links.values())
|
|
326
|
+
|
|
327
|
+
def connected(self, a: str, b: str) -> bool:
|
|
328
|
+
"""A path exists between a and b over links (undirected BFS)."""
|
|
329
|
+
da, db = self._by_name(a), self._by_name(b)
|
|
330
|
+
if da is None or db is None:
|
|
331
|
+
return False
|
|
332
|
+
if da.id == db.id:
|
|
333
|
+
return True
|
|
334
|
+
adj = self._adjacency()
|
|
335
|
+
seen, frontier = {da.id}, [da.id]
|
|
336
|
+
while frontier:
|
|
337
|
+
nxt = []
|
|
338
|
+
for node in frontier:
|
|
339
|
+
for peer in adj.get(node, ()):
|
|
340
|
+
if peer == db.id:
|
|
341
|
+
return True
|
|
342
|
+
if peer not in seen:
|
|
343
|
+
seen.add(peer)
|
|
344
|
+
nxt.append(peer)
|
|
345
|
+
frontier = nxt
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
def contains(self, box: str, member: str) -> bool:
|
|
349
|
+
"""Is `member` inside the box named `box` (walking the parent_id chain, so nested
|
|
350
|
+
VPC→Subnet→member counts)?"""
|
|
351
|
+
boxdev, md = self._by_name(box), self._by_name(member)
|
|
352
|
+
if boxdev is None or md is None:
|
|
353
|
+
return False
|
|
354
|
+
cur = md
|
|
355
|
+
seen = set()
|
|
356
|
+
while cur is not None and cur.parent_id and cur.id not in seen:
|
|
357
|
+
seen.add(cur.id)
|
|
358
|
+
parent = self.t.devices.get(cur.parent_id)
|
|
359
|
+
if parent is not None and parent.id == boxdev.id:
|
|
360
|
+
return True
|
|
361
|
+
cur = parent
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
def prop(self, dev: str, key: str):
|
|
365
|
+
d = self._by_name(dev)
|
|
366
|
+
if d is None:
|
|
367
|
+
return None
|
|
368
|
+
props = getattr(d, "properties", {}) or {}
|
|
369
|
+
if key in props:
|
|
370
|
+
return props[key]
|
|
371
|
+
low = {k.lower(): v for k, v in props.items()} # forgiving on case
|
|
372
|
+
return low.get(key.lower())
|
|
373
|
+
|
|
374
|
+
# -- type-based (name-agnostic) predicates: match what the student built, not the names -- #
|
|
375
|
+
def link_types(self, type_a: str, type_b: str) -> bool:
|
|
376
|
+
"""A link directly connecting some device matching `type_a` to some matching `type_b`
|
|
377
|
+
(each may be slot-scoped as `type@slot`)."""
|
|
378
|
+
for l in self.t.links.values():
|
|
379
|
+
s = self.t.devices.get(l.source_id)
|
|
380
|
+
d = self.t.devices.get(l.target_id)
|
|
381
|
+
if s is None or d is None:
|
|
382
|
+
continue
|
|
383
|
+
if ((self._match(s, type_a) and self._match(d, type_b))
|
|
384
|
+
or (self._match(s, type_b) and self._match(d, type_a))):
|
|
385
|
+
return True
|
|
386
|
+
return False
|
|
387
|
+
|
|
388
|
+
def all_linked_types(self, type_a: str, type_b: str) -> bool:
|
|
389
|
+
"""EVERY device matching `type_a` has a direct link to SOME device matching `type_b` — the
|
|
390
|
+
universal that makes an open-N win condition N-independent ("every LAN is wired to the
|
|
391
|
+
router", any number of LANs). Vacuously true with no type_a devices; a `count(...)>=K` floor
|
|
392
|
+
guards emptiness separately. Each arg may be slot-scoped as `type@slot`."""
|
|
393
|
+
a_devs = [d for d in self.t.devices.values() if self._match(d, type_a)]
|
|
394
|
+
b_ids = {d.id for d in self.t.devices.values() if self._match(d, type_b)}
|
|
395
|
+
if not a_devs or not b_ids:
|
|
396
|
+
return not a_devs # no A → vacuously true; A but no B → false
|
|
397
|
+
adj = self._adjacency()
|
|
398
|
+
return all(any(peer in b_ids for peer in adj.get(da.id, ())) for da in a_devs)
|
|
399
|
+
|
|
400
|
+
def path_types(self, type_a: str, type_b: str) -> bool:
|
|
401
|
+
"""Is there a path (over links) between some device of type_a and some device of type_b?"""
|
|
402
|
+
dsts = {d.id for d in self.t.devices.values() if self._match(d, type_b)}
|
|
403
|
+
if not dsts:
|
|
404
|
+
return False
|
|
405
|
+
adj = self._adjacency()
|
|
406
|
+
for src in [d.id for d in self.t.devices.values() if self._match(d, type_a)]:
|
|
407
|
+
seen, frontier = {src}, [src]
|
|
408
|
+
while frontier:
|
|
409
|
+
nxt = []
|
|
410
|
+
for node in frontier:
|
|
411
|
+
for peer in adj.get(node, ()):
|
|
412
|
+
if peer in dsts and peer != src:
|
|
413
|
+
return True
|
|
414
|
+
if peer not in seen:
|
|
415
|
+
seen.add(peer)
|
|
416
|
+
nxt.append(peer)
|
|
417
|
+
frontier = nxt
|
|
418
|
+
return False
|
|
419
|
+
|
|
420
|
+
def through_types(self, gate_type: str, src_type: str, dst_type: str) -> bool:
|
|
421
|
+
"""Chokepoint: some src-type reaches some dst-type, and EVERY such path crosses a device of
|
|
422
|
+
`gate_type` — i.e. removing all gate-type devices disconnects the src tier from the dst tier.
|
|
423
|
+
This is the correct "traffic passes THROUGH the gate" semantic (rejects a parallel bypass),
|
|
424
|
+
as opposed to `path` (any route counts) or `link` (a direct cable)."""
|
|
425
|
+
if not self.path_types(src_type, dst_type):
|
|
426
|
+
return False # no traffic flows at all → not "through" anything
|
|
427
|
+
gates = {d.id for d in self.t.devices.values() if d.type_key == gate_type}
|
|
428
|
+
if not gates:
|
|
429
|
+
return False # the gate isn't even present
|
|
430
|
+
dsts = {d.id for d in self.t.devices.values()
|
|
431
|
+
if d.type_key == dst_type and d.id not in gates}
|
|
432
|
+
srcs = [d.id for d in self.t.devices.values()
|
|
433
|
+
if d.type_key == src_type and d.id not in gates]
|
|
434
|
+
if not dsts or not srcs:
|
|
435
|
+
return True # removing gates erases a tier → the gate was on the path
|
|
436
|
+
adj = self._adjacency()
|
|
437
|
+
for src in srcs: # can any src reach a dst WITHOUT crossing a gate?
|
|
438
|
+
seen, frontier = {src}, [src]
|
|
439
|
+
while frontier:
|
|
440
|
+
nxt = []
|
|
441
|
+
for node in frontier:
|
|
442
|
+
for peer in adj.get(node, ()):
|
|
443
|
+
if peer in gates:
|
|
444
|
+
continue # never route through a gate node
|
|
445
|
+
if peer in dsts and peer != src:
|
|
446
|
+
return False # a gate-free path exists → NOT a chokepoint
|
|
447
|
+
if peer not in seen:
|
|
448
|
+
seen.add(peer)
|
|
449
|
+
nxt.append(peer)
|
|
450
|
+
frontier = nxt
|
|
451
|
+
return True
|
|
452
|
+
|
|
453
|
+
def contains_types(self, box_type: str, member_type: str) -> bool:
|
|
454
|
+
"""Is some device of member_type inside a box of box_type (walking the parent chain)?"""
|
|
455
|
+
for d in self.t.devices.values():
|
|
456
|
+
if d.type_key != member_type:
|
|
457
|
+
continue
|
|
458
|
+
cur, seen = d, set()
|
|
459
|
+
while cur is not None and getattr(cur, "parent_id", None) and cur.id not in seen:
|
|
460
|
+
seen.add(cur.id)
|
|
461
|
+
parent = self.t.devices.get(cur.parent_id)
|
|
462
|
+
if parent is not None and parent.type_key == box_type:
|
|
463
|
+
return True
|
|
464
|
+
cur = parent
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def element_types_in_check(expr: str) -> list[str]:
|
|
469
|
+
"""The type_keys referenced by exists()/count() in a check — for lesson validation."""
|
|
470
|
+
out: list[str] = []
|
|
471
|
+
try:
|
|
472
|
+
tree = ast.parse(expr, mode="eval")
|
|
473
|
+
except SyntaxError:
|
|
474
|
+
return out
|
|
475
|
+
for node in ast.walk(tree):
|
|
476
|
+
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
|
477
|
+
and node.func.id in _TYPE_ARG_FUNCS):
|
|
478
|
+
for a in node.args: # every arg of a type-based predicate is a type_key
|
|
479
|
+
if isinstance(a, ast.Name):
|
|
480
|
+
out.append(a.id)
|
|
481
|
+
elif isinstance(a, ast.Constant) and isinstance(a.value, str):
|
|
482
|
+
out.append(a.value)
|
|
483
|
+
return out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def unknown_element_types(expr: str) -> list[str]:
|
|
487
|
+
"""type_keys in exists()/count() that aren't real GINI elements (validation)."""
|
|
488
|
+
return [t for t in element_types_in_check(expr) if t not in REGISTRY]
|
gini/domain/os_zoo.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""OS Zoo catalog — the historical/classic operating systems students can boot in GINI.
|
|
2
|
+
|
|
3
|
+
Pure data (no Qt, no Docker), so the palette, the compiler emission, and the Zoo Lab all read one
|
|
4
|
+
source of truth and it's unit-testable. Two tiers:
|
|
5
|
+
|
|
6
|
+
• FULL — freely redistributable; GINI builds/ships the image, boots out of the box.
|
|
7
|
+
• SKELETAL — proprietary (Windows, classic Mac); GINI ships only a shell with the right emulator
|
|
8
|
+
and a REFERENCE link. The student obtains the image/ROM legally and supplies its
|
|
9
|
+
path. GINI hosts nothing copyrighted (see OS_ZOO_DESIGN.md §8).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
FULL = "full"
|
|
16
|
+
SKELETAL = "skeletal"
|
|
17
|
+
|
|
18
|
+
# emulator backends
|
|
19
|
+
QEMU = "qemu"
|
|
20
|
+
DOSBOX = "dosbox-x"
|
|
21
|
+
BASILISK = "basilisk" # 68k classic Mac (needs a Mac ROM -> always skeletal)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ZooOS:
|
|
26
|
+
id: str # catalog id / ZOO_OS env value
|
|
27
|
+
label: str # palette + Zoo Lab title
|
|
28
|
+
tier: str # FULL | SKELETAL
|
|
29
|
+
emulator: str # QEMU | DOSBOX | BASILISK
|
|
30
|
+
arch: str # x86 | x86_64 | 68k
|
|
31
|
+
year: str # era, for the palette blurb
|
|
32
|
+
blurb: str # one line
|
|
33
|
+
source: str = "" # SKELETAL: where the student can legally obtain the image/ROM
|
|
34
|
+
nic: str = "e1000" # v2 networking default (per-OS; old guests need old NICs)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def full(self) -> bool:
|
|
38
|
+
return self.tier == FULL
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def byo(self) -> bool:
|
|
42
|
+
"""Skeletal = 'bring your own image': the student must supply the disk/ROM."""
|
|
43
|
+
return self.tier == SKELETAL
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
CATALOG: tuple[ZooOS, ...] = (
|
|
47
|
+
ZooOS("freedos", "FreeDOS", FULL, QEMU, "x86", "1998→",
|
|
48
|
+
"An open, still-maintained MS-DOS-compatible OS — the command-line PC of the DOS era.",
|
|
49
|
+
nic="ne2k_pci"),
|
|
50
|
+
ZooOS("kolibri", "KolibriOS", FULL, QEMU, "x86", "2004→",
|
|
51
|
+
"A tiny, blazing-fast GUI OS written entirely in assembly — the whole system boots from "
|
|
52
|
+
"a single 1.44 MB floppy in seconds.",
|
|
53
|
+
nic="rtl8139"),
|
|
54
|
+
ZooOS("menuet", "MenuetOS", FULL, QEMU, "x86", "2000→",
|
|
55
|
+
"The assembly GUI OS that KolibriOS forked from — the whole desktop, apps and all, on a "
|
|
56
|
+
"single 1.44 MB floppy. The 32-bit build is GPL. Boots in seconds.",
|
|
57
|
+
nic="rtl8139"),
|
|
58
|
+
ZooOS("win95", "Windows 95", SKELETAL, QEMU, "x86", "1995",
|
|
59
|
+
"The OS that put a Start button on the world. Bring your own installation media.",
|
|
60
|
+
source="https://archive.org (search Windows 95 — you must own a license)",
|
|
61
|
+
nic="ne2k_pci"),
|
|
62
|
+
ZooOS("mac7", "Mac System 7", SKELETAL, BASILISK, "68k", "1991",
|
|
63
|
+
"Classic Mac OS on a Motorola 68k. Needs a Mac disk image AND a Macintosh ROM you own.",
|
|
64
|
+
source="https://archive.org / macintoshgarden.org (image) + a Mac ROM you own"),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_BY_ID = {o.id: o for o in CATALOG}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get(os_id: str) -> ZooOS | None:
|
|
71
|
+
return _BY_ID.get(os_id)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def full_set() -> list[ZooOS]:
|
|
75
|
+
return [o for o in CATALOG if o.full]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def skeletal_set() -> list[ZooOS]:
|
|
79
|
+
return [o for o in CATALOG if o.byo]
|