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
gini/app/context.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Application context + typed event bus.
|
|
2
|
+
|
|
3
|
+
Replaces the legacy global `mainWidgets` / `options` dictionaries. One AppContext
|
|
4
|
+
owns the topology and settings; components talk to each other through the typed
|
|
5
|
+
signal bus rather than reaching into global state by string key.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import QObject, Signal
|
|
12
|
+
|
|
13
|
+
from ..domain import Topology
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EventBus(QObject):
|
|
17
|
+
"""Typed cross-component signals."""
|
|
18
|
+
topology_changed = Signal()
|
|
19
|
+
device_added = Signal(str) # device id
|
|
20
|
+
device_removed = Signal(str) # device id
|
|
21
|
+
device_changed = Signal(str) # device id (properties/position)
|
|
22
|
+
device_resized = Signal(str) # device id (size tier changed -> maybe live CPU update)
|
|
23
|
+
link_added = Signal(str) # link id
|
|
24
|
+
selection_changed = Signal(object) # device id or None
|
|
25
|
+
canvas_background_clicked = Signal() # left-click on empty canvas (exit sticky modes)
|
|
26
|
+
llm_reachable = Signal(str, bool) # (model, reachable) — async LLM health probe result
|
|
27
|
+
enrolment_changed = Signal(str, bool, int) # (student, course-server online, missions due)
|
|
28
|
+
boards_changed = Signal() # a real GINI32 board came online or went quiet
|
|
29
|
+
theme_changed = Signal(str) # theme name
|
|
30
|
+
log = Signal(str, str) # level, message
|
|
31
|
+
assistant_message = Signal(str, str) # role, text
|
|
32
|
+
run_state = Signal(bool, str) # ok, message (from the orchestrator worker thread)
|
|
33
|
+
device_activated = Signal(str) # device id (double-clicked -> open terminal/console)
|
|
34
|
+
machine_events = Signal(str) # xv6 device id — new teachable kernel events (proactive Coach)
|
|
35
|
+
device_delete_requested = Signal(str) # device id (right-click -> Delete)
|
|
36
|
+
warning_explain_requested = Signal(str) # device id (clicked its lint warning badge)
|
|
37
|
+
device_logs_requested = Signal(str) # device id (right-click -> View logs)
|
|
38
|
+
device_console_requested = Signal(str) # device id (right-click -> Open console in browser)
|
|
39
|
+
function_invoke_requested = Signal(str, str, str) # device id, method, body (Invoke a Function)
|
|
40
|
+
function_invoke_result = Signal(str, str) # device id, result text (back to inspector)
|
|
41
|
+
rider_ran = Signal(str, object) # rider device id, result dict {ok, raw, measurement, summary}
|
|
42
|
+
function_deploy_requested = Signal() # redeploy the faas runtime (AWS-style Deploy)
|
|
43
|
+
runtime_status = Signal(object) # {service: state} from the status poller
|
|
44
|
+
mission_changed = Signal(object) # Wizard objective (Mission) set/cleared -> guide the canvas
|
|
45
|
+
wizard_ghosts_requested = Signal(str) # device id -> resolve goal-relevant neighbours (LLM)
|
|
46
|
+
wizard_ghosts_ready = Signal(str, object) # device id, [(type_key, reason)] -> draw ghosts
|
|
47
|
+
fabric_metrics = Signal(object) # normalized cloud-fabric app metrics snapshot
|
|
48
|
+
k8s_metrics = Signal(object) # per-deployment kubernetes metrics snapshot
|
|
49
|
+
addressing_changed = Signal() # compiler-derived IP/MAC map refreshed
|
|
50
|
+
warnings_changed = Signal() # advisory topology-lint results refreshed
|
|
51
|
+
mission_flags_changed = Signal() # Mission move-legality flags refreshed -> red badges/glow
|
|
52
|
+
focus_requested = Signal(object) # device ids (or None = all) -> bring them into view
|
|
53
|
+
edges_restyled = Signal() # connector style (bent/straight) changed -> reroute edges
|
|
54
|
+
# --- AI tutor "present" channel (the stage the AI draws on) ---
|
|
55
|
+
present_spotlight = Signal(object) # list[device_id] to spotlight, or None to clear
|
|
56
|
+
present_highlight = Signal(object) # list[device_id] to ring, or None to clear
|
|
57
|
+
present_callout = Signal(str, str) # device_id, text
|
|
58
|
+
present_narrate = Signal(str) # narration text
|
|
59
|
+
present_packet = Signal(object) # list[device_id] path to animate
|
|
60
|
+
present_clear = Signal() # clear all overlays
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Settings:
|
|
65
|
+
theme: str = "dark"
|
|
66
|
+
grid: bool = True
|
|
67
|
+
connector_style: str = "orthogonal" # "orthogonal" (bent, rounded) | "straight"
|
|
68
|
+
snap_to_grid: bool = True
|
|
69
|
+
show_minimap: bool = True
|
|
70
|
+
autosave: bool = False
|
|
71
|
+
server: str = "localhost"
|
|
72
|
+
remote_port: int = 10000
|
|
73
|
+
local_port: int = 10001
|
|
74
|
+
# accessibility / motion
|
|
75
|
+
reduced_motion: bool = False
|
|
76
|
+
text_size: str = "Normal" # UI font scale: Normal | Large | Extra Large
|
|
77
|
+
high_contrast: bool = False
|
|
78
|
+
sound: bool = False
|
|
79
|
+
tutor_mode: bool = False
|
|
80
|
+
# onboarding: the Cue Cards feature tour
|
|
81
|
+
show_help_on_launch: bool = True
|
|
82
|
+
# self-hosted LLM (Ollama by default)
|
|
83
|
+
llm_enabled: bool = False
|
|
84
|
+
llm_url: str = "http://localhost:11434"
|
|
85
|
+
llm_model: str = "llama3.1"
|
|
86
|
+
llm_think: bool = False # ask reasoning models (e.g. gemma4:e2b) to think
|
|
87
|
+
llm_num_ctx: int = 8192 # context window (Ollama defaults to only 2048 + truncates)
|
|
88
|
+
# Reasoning 2.0: the deterministic Reasoning Twin audits mission-tutor turns for coverage
|
|
89
|
+
# (docs/REASONING_2.0_DESIGN.md). Off by default while phase A proves out.
|
|
90
|
+
twin_enabled: bool = False
|
|
91
|
+
# Build a missing backend image (gRouter / POX) automatically instead of printing the
|
|
92
|
+
# `docker build …` line and refusing to Run. Off by default: the first build takes a couple
|
|
93
|
+
# of minutes, and silently burning that on someone's first Run is a poor surprise.
|
|
94
|
+
autobuild_images: bool = False
|
|
95
|
+
# backend: run the lab on the LOCAL Docker daemon, or a remote brokered GINI server
|
|
96
|
+
# (a Kata-enabled Linux host reached over an authenticated API — see gini/server/).
|
|
97
|
+
backend: str = "local" # "local" | "gini-server"
|
|
98
|
+
gini_server_host: str = "" # the GINI server host (for "gini-server")
|
|
99
|
+
gini_server_port: int = 10000
|
|
100
|
+
gini_server_user: str = "" # username; password is entered at connect, never stored
|
|
101
|
+
# Teaching Center (the course server): released lessons, profile sync, submissions.
|
|
102
|
+
# Empty url = not enrolled -> Missions falls back to the local practice catalog.
|
|
103
|
+
tc_url: str = "" # e.g. http://localhost:8080
|
|
104
|
+
tc_course: str = "" # e.g. cs4480-fall26
|
|
105
|
+
tc_student: str = "" # the student's id in the course
|
|
106
|
+
tc_token: str = "" # one-time ENROLMENT token (spent when you claim the account)
|
|
107
|
+
# Passwords are never stored — a session token lives in the cache instead. This flag is the
|
|
108
|
+
# conscious override that lets a password go over plain HTTP to a remote host (demos only).
|
|
109
|
+
tc_allow_insecure: bool = False
|
|
110
|
+
# auto-internet: every container gets a default eth to the internet (Docker NAT).
|
|
111
|
+
# Off = "faithful mode": no internet unless an Internet element is drawn + wired.
|
|
112
|
+
auto_internet: bool = False
|
|
113
|
+
# GINI32 hardware: the lab Wi-Fi a board joins to reach this machine. It is the
|
|
114
|
+
# same network for every board in a class, so it is remembered once here and
|
|
115
|
+
# then written to each board over USB by Hardware > Set Up a Board. The password
|
|
116
|
+
# is stored because a board cannot be set up without it and there is nowhere else
|
|
117
|
+
# to keep it; it is the shared lab network, not a personal credential.
|
|
118
|
+
board_wifi_ssid: str = ""
|
|
119
|
+
board_wifi_password: str = ""
|
|
120
|
+
# Boards this laptop has set up over USB. Kept so the canvas can OFFER an id
|
|
121
|
+
# instead of asking the student to retype one from memory — a mistyped BoardID
|
|
122
|
+
# produces a board that is online and healthy but invisible to the topology,
|
|
123
|
+
# which is the single most confusing failure in the whole GINI32 story.
|
|
124
|
+
known_boards: list = field(default_factory=list)
|
|
125
|
+
# --- GINI32 hardware ---------------------------------------------------- #
|
|
126
|
+
# This install's identity to a board. A board records the id of the laptop that
|
|
127
|
+
# claimed it and then ignores every other laptop, which is what stops thirty
|
|
128
|
+
# students in one room from stealing each other's hardware. Minted on first use.
|
|
129
|
+
laptop_id: str = ""
|
|
130
|
+
# Boards this laptop has claimed: board name -> {mac, claimed_at}. Deliberately a
|
|
131
|
+
# SETTING, not part of a topology: opening a colleague's .gini file must not hand
|
|
132
|
+
# you their hardware.
|
|
133
|
+
claimed_boards: dict[str, dict] = field(default_factory=dict)
|
|
134
|
+
# per-type auto-name prefix overrides, e.g. {"host": "Mach_"} -> Mach_1, Mach_2, …
|
|
135
|
+
name_prefixes: dict[str, str] = field(default_factory=dict)
|
|
136
|
+
# per-type GINI $/hr rental price overrides for the cost dashboard, e.g. {"database": 20}
|
|
137
|
+
prices: dict[str, float] = field(default_factory=dict)
|
|
138
|
+
extra: dict[str, str] = field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AppContext:
|
|
142
|
+
def __init__(self) -> None:
|
|
143
|
+
self.bus = EventBus()
|
|
144
|
+
self.settings = Settings()
|
|
145
|
+
self.topology = Topology("untitled")
|
|
146
|
+
self.selected_id: str | None = None
|
|
147
|
+
self.addressing: dict[str, dict] = {} # device name -> {interfaces:[…]}
|
|
148
|
+
self.warnings: dict[str, list] = {} # device name -> [lint messages]
|
|
149
|
+
self.mission_flags: dict[str, str] = {} # device id -> reason (Mission off-task / bad-link)
|
|
150
|
+
self.mission = None # active Wizard objective (domain.missions.Mission)
|
|
151
|
+
self.teaching_center = None # agent.teaching_center.TeachingCenterClient | None
|
|
152
|
+
self.orchestrator = None # services.orchestrator.Orchestrator (probes exec here)
|
|
153
|
+
# live xv6 kernel state per Machine (domain.machine_state.MachineState) — the bridge the
|
|
154
|
+
# Machine Lab renders from and the Ask GINI agent reads for OS help. device_id -> state.
|
|
155
|
+
self.machine_states: dict = {}
|
|
156
|
+
# last run of each Source/Sink rider: device_id -> result dict (measurement etc.)
|
|
157
|
+
self.rider_results: dict = {}
|
|
158
|
+
self._rider_sessions = None # services.rider_session.RiderSessions (lazy)
|
|
159
|
+
# xv6 riders run over the console, not docker — MainWindow (which owns the live xv6 bridges)
|
|
160
|
+
# registers these hooks so toggle/is_running route to the serial path for qemu-serial riders.
|
|
161
|
+
self.xv6_rider_toggle = None # callable(rider_id) -> result dict
|
|
162
|
+
self.xv6_rider_running = None # callable(rider_id) -> bool
|
|
163
|
+
|
|
164
|
+
# convenience wrappers that emit the right events ----------------------- #
|
|
165
|
+
def add_device(self, type_key: str, x: float = 0.0, y: float = 0.0, **kw):
|
|
166
|
+
inst = self.topology.add_device(type_key, x=x, y=y, **kw)
|
|
167
|
+
self.bus.device_added.emit(inst.id)
|
|
168
|
+
self.bus.topology_changed.emit()
|
|
169
|
+
return inst
|
|
170
|
+
|
|
171
|
+
def add_link(self, source_id: str, target_id: str, label: str = ""):
|
|
172
|
+
# xv6 has no networking: hard-block xv6<->non-peripheral (and peripheral<->non-xv6) links.
|
|
173
|
+
from ..domain.connection_rules import link_blocked
|
|
174
|
+
s, t = self.topology.devices.get(source_id), self.topology.devices.get(target_id)
|
|
175
|
+
if s is not None and t is not None:
|
|
176
|
+
reason = link_blocked(s.type_key, t.type_key)
|
|
177
|
+
if reason:
|
|
178
|
+
self.log(reason, "info")
|
|
179
|
+
raise ValueError(reason)
|
|
180
|
+
link = self.topology.add_link(source_id, target_id, label)
|
|
181
|
+
self.bus.link_added.emit(link.id)
|
|
182
|
+
self.bus.topology_changed.emit()
|
|
183
|
+
return link
|
|
184
|
+
|
|
185
|
+
def connect(self, a_id: str, b_id: str, label: str = ""):
|
|
186
|
+
"""Wire two elements the right way: if either end is a Source/Sink rider, mount it on the
|
|
187
|
+
other with an ATTACH edge; otherwise a normal network link. The canvas calls this so the
|
|
188
|
+
teacher never has to know which kind an edge is — the grammar decides."""
|
|
189
|
+
from ..domain.connection_rules import is_rider
|
|
190
|
+
a = self.topology.devices.get(a_id)
|
|
191
|
+
b = self.topology.devices.get(b_id)
|
|
192
|
+
if a is not None and b is not None and (is_rider(a.type_key) or is_rider(b.type_key)):
|
|
193
|
+
rider_id, donor_id = (a_id, b_id) if is_rider(a.type_key) else (b_id, a_id)
|
|
194
|
+
return self.add_attach(rider_id, donor_id, label)
|
|
195
|
+
return self.add_link(a_id, b_id, label)
|
|
196
|
+
|
|
197
|
+
def add_attach(self, rider_id: str, donor_id: str, label: str = ""):
|
|
198
|
+
"""Mount a Source/Sink rider onto its donor (a dotted, non-network edge)."""
|
|
199
|
+
from ..domain.connection_rules import attach_blocked
|
|
200
|
+
r, d = self.topology.devices.get(rider_id), self.topology.devices.get(donor_id)
|
|
201
|
+
if r is not None and d is not None:
|
|
202
|
+
reason = attach_blocked(r.type_key, d.type_key)
|
|
203
|
+
if reason:
|
|
204
|
+
self.log(reason, "info")
|
|
205
|
+
raise ValueError(reason)
|
|
206
|
+
link = self.topology.add_attach(rider_id, donor_id, label)
|
|
207
|
+
self.bus.link_added.emit(link.id)
|
|
208
|
+
self.bus.topology_changed.emit()
|
|
209
|
+
return link
|
|
210
|
+
|
|
211
|
+
def run_rider(self, rider_id: str) -> dict:
|
|
212
|
+
"""Execute a Source/Sink on its donor, stream the raw output to the console, remember the
|
|
213
|
+
measurement, and emit `rider_ran`. Safe to call from a worker thread (it only touches the
|
|
214
|
+
bus, whose signals marshal to the GUI). Returns the result dict."""
|
|
215
|
+
from ..domain.connection_rules import is_rider
|
|
216
|
+
dev = self.topology.devices.get(rider_id)
|
|
217
|
+
if dev is None or not is_rider(dev.type_key):
|
|
218
|
+
return {"ok": False, "error": "not a Source/Sink"}
|
|
219
|
+
if self.orchestrator is None:
|
|
220
|
+
res = {"ok": False, "error": "Press Run to start the topology, then run this."}
|
|
221
|
+
else:
|
|
222
|
+
from ..services.rider_runner import RiderRunner
|
|
223
|
+
runner = RiderRunner(self.orchestrator)
|
|
224
|
+
if not runner.available():
|
|
225
|
+
res = {"ok": False, "error": "The topology isn't running — press Run first."}
|
|
226
|
+
else:
|
|
227
|
+
res = runner.run(self.topology, rider_id)
|
|
228
|
+
|
|
229
|
+
# ONE concise line in the shared console (the full raw stream + measurement go to the
|
|
230
|
+
# inspector's Live tab, scoped to this node, so the console stays readable).
|
|
231
|
+
if res.get("ok"):
|
|
232
|
+
self.rider_results[rider_id] = res
|
|
233
|
+
tail = res.get("summary", "")
|
|
234
|
+
if res.get("inferred_target"):
|
|
235
|
+
tail += f" (→ {res['inferred_target']})"
|
|
236
|
+
self.log(f"{dev.name} on {res.get('donor', '?')} → {tail}", "info")
|
|
237
|
+
else:
|
|
238
|
+
self.log(f"{dev.name}: {res.get('error', 'run failed')}", "info")
|
|
239
|
+
self.bus.rider_ran.emit(rider_id, res)
|
|
240
|
+
return res
|
|
241
|
+
|
|
242
|
+
# -- continuous Source/Sink sessions (double-click toggles start/stop) --- #
|
|
243
|
+
def _sessions(self):
|
|
244
|
+
if self._rider_sessions is None and self.orchestrator is not None:
|
|
245
|
+
from ..services.rider_session import RiderSessions
|
|
246
|
+
self._rider_sessions = RiderSessions(self.orchestrator)
|
|
247
|
+
return self._rider_sessions
|
|
248
|
+
|
|
249
|
+
def _is_xv6_rider(self, rider_id: str) -> bool:
|
|
250
|
+
dev = self.topology.devices.get(rider_id)
|
|
251
|
+
return bool(dev and getattr(dev.type, "driver", "") == "qemu-serial")
|
|
252
|
+
|
|
253
|
+
def is_rider_running(self, rider_id: str) -> bool:
|
|
254
|
+
if self._is_xv6_rider(rider_id):
|
|
255
|
+
return bool(self.xv6_rider_running and self.xv6_rider_running(rider_id))
|
|
256
|
+
s = self._rider_sessions
|
|
257
|
+
return bool(s and s.is_running(rider_id))
|
|
258
|
+
|
|
259
|
+
def toggle_rider(self, rider_id: str) -> dict:
|
|
260
|
+
"""Double-click behaviour: start the rider if idle, stop it if running. xv6 riders route to
|
|
261
|
+
the console (serial) path; everything else to the docker session path."""
|
|
262
|
+
if self._is_xv6_rider(rider_id):
|
|
263
|
+
if self.xv6_rider_toggle is not None:
|
|
264
|
+
return self.xv6_rider_toggle(rider_id)
|
|
265
|
+
return {"ok": False, "error": "Run the xv6 Machine first."}
|
|
266
|
+
if self.is_rider_running(rider_id):
|
|
267
|
+
return self.stop_rider(rider_id)
|
|
268
|
+
return self.start_rider(rider_id)
|
|
269
|
+
|
|
270
|
+
def start_rider(self, rider_id: str) -> dict:
|
|
271
|
+
from ..domain.connection_rules import is_rider
|
|
272
|
+
dev = self.topology.devices.get(rider_id)
|
|
273
|
+
if dev is None or not is_rider(dev.type_key):
|
|
274
|
+
return {"ok": False, "error": "not a Source/Sink"}
|
|
275
|
+
sess = self._sessions()
|
|
276
|
+
if sess is None or not sess.available():
|
|
277
|
+
res = {"ok": False, "error": "Press Run to start the topology, then start this."}
|
|
278
|
+
self.log(f"{dev.name}: {res['error']}", "info")
|
|
279
|
+
self.bus.rider_ran.emit(rider_id, {"ok": False, "running": False, **res})
|
|
280
|
+
return res
|
|
281
|
+
res = sess.start(self.topology, rider_id, self._on_rider_update)
|
|
282
|
+
if res.get("ok"):
|
|
283
|
+
tail = f" (→ {res['inferred_target']})" if res.get("inferred_target") else ""
|
|
284
|
+
self.log(f"{dev.name} started on {res.get('donor', '?')}{tail}", "info")
|
|
285
|
+
else:
|
|
286
|
+
self.log(f"{dev.name}: {res.get('error', 'could not start')}", "info")
|
|
287
|
+
self.bus.rider_ran.emit(rider_id, {"ok": False, "running": False, **res})
|
|
288
|
+
return res
|
|
289
|
+
|
|
290
|
+
def stop_rider(self, rider_id: str) -> dict:
|
|
291
|
+
sess = self._rider_sessions
|
|
292
|
+
dev = self.topology.devices.get(rider_id)
|
|
293
|
+
if sess is not None:
|
|
294
|
+
sess.stop(rider_id)
|
|
295
|
+
if dev is not None:
|
|
296
|
+
self.log(f"{dev.name} stopped", "info")
|
|
297
|
+
return {"ok": True, "running": False}
|
|
298
|
+
|
|
299
|
+
def stop_all_riders(self) -> None:
|
|
300
|
+
if self._rider_sessions is not None:
|
|
301
|
+
self._rider_sessions.stop_all()
|
|
302
|
+
|
|
303
|
+
def _on_rider_update(self, rider_id: str, snapshot: dict) -> None:
|
|
304
|
+
"""Called from the session reader thread on each new line (and at stop). Store + emit; the
|
|
305
|
+
Qt signal marshals to the GUI thread, so touching the bus here is safe."""
|
|
306
|
+
self.rider_results[rider_id] = snapshot
|
|
307
|
+
self.bus.rider_ran.emit(rider_id, snapshot)
|
|
308
|
+
|
|
309
|
+
def remove_device(self, device_id: str) -> None:
|
|
310
|
+
if self.is_rider_running(device_id): # never orphan a running rider on delete
|
|
311
|
+
self.stop_rider(device_id)
|
|
312
|
+
self.topology.remove_device(device_id)
|
|
313
|
+
if self.selected_id == device_id:
|
|
314
|
+
self.select(None)
|
|
315
|
+
self.bus.device_removed.emit(device_id)
|
|
316
|
+
self.bus.topology_changed.emit()
|
|
317
|
+
|
|
318
|
+
def clear_topology(self) -> int:
|
|
319
|
+
"""Wipe the board (used by staged missions, which must start from an exactly-known canvas).
|
|
320
|
+
Goes through remove_device so every view tears its node down the normal way. Returns how
|
|
321
|
+
many devices were removed."""
|
|
322
|
+
ids = list(self.topology.devices)
|
|
323
|
+
for did in ids:
|
|
324
|
+
self.remove_device(did)
|
|
325
|
+
self.topology.manual_addressing = False # a fresh board is auto-addressed again
|
|
326
|
+
self.addressing.clear()
|
|
327
|
+
self.warnings.clear()
|
|
328
|
+
self.mission_flags.clear()
|
|
329
|
+
return len(ids)
|
|
330
|
+
|
|
331
|
+
def select(self, device_id: str | None) -> None:
|
|
332
|
+
self.selected_id = device_id
|
|
333
|
+
self.bus.selection_changed.emit(device_id)
|
|
334
|
+
|
|
335
|
+
# -- Teaching Center ----------------------------------------------------- #
|
|
336
|
+
def connect_teaching_center(self):
|
|
337
|
+
"""(Re)build the Teaching Center client from Settings. Returns the client, or None when the
|
|
338
|
+
student isn't enrolled (no URL) — in which case Missions shows the local practice catalog.
|
|
339
|
+
Never raises: an unreachable Center degrades to the offline cache."""
|
|
340
|
+
s = self.settings
|
|
341
|
+
if not (s.tc_url and s.tc_course and s.tc_student):
|
|
342
|
+
self.teaching_center = None
|
|
343
|
+
# Half-configured is a trap (the Settings placeholders LOOK like values), so say which
|
|
344
|
+
# fields are actually missing instead of silently falling back to the local catalog.
|
|
345
|
+
given = any((s.tc_url, s.tc_course, s.tc_student, s.tc_token))
|
|
346
|
+
if given:
|
|
347
|
+
missing = [n for n, v in (("course server", s.tc_url), ("course", s.tc_course),
|
|
348
|
+
("student id", s.tc_student)) if not v]
|
|
349
|
+
self.log("Teaching Center not connected — still missing: "
|
|
350
|
+
+ ", ".join(missing) + " (Settings → Teaching Center).", "info")
|
|
351
|
+
return None
|
|
352
|
+
try:
|
|
353
|
+
from ..agent.teaching_center import TeachingCenterClient
|
|
354
|
+
self.teaching_center = TeachingCenterClient(
|
|
355
|
+
s.tc_url, course=s.tc_course, student_id=s.tc_student, token=s.tc_token,
|
|
356
|
+
allow_insecure=bool(getattr(s, "tc_allow_insecure", False)))
|
|
357
|
+
except Exception as e: # noqa: BLE001
|
|
358
|
+
self.log(f"Teaching Center: {e}", "info")
|
|
359
|
+
self.teaching_center = None
|
|
360
|
+
return self.teaching_center
|
|
361
|
+
|
|
362
|
+
def set_mission(self, mission) -> None:
|
|
363
|
+
"""Set (or clear, with None) the Wizard objective and notify the canvas/palette."""
|
|
364
|
+
self.mission = mission
|
|
365
|
+
self.bus.mission_changed.emit(mission)
|
|
366
|
+
|
|
367
|
+
def log(self, message: str, level: str = "info") -> None:
|
|
368
|
+
self.bus.log.emit(level, message)
|
gini/app/paths.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""GINI's home on disk — ``~/.gini`` — for settings and the user's saved projects.
|
|
2
|
+
|
|
3
|
+
Everything user-level lives under one directory so save/restore is predictable:
|
|
4
|
+
|
|
5
|
+
~/.gini/
|
|
6
|
+
config.json persisted settings (theme, LLM, …)
|
|
7
|
+
projects/ default location for saved .gini topologies
|
|
8
|
+
|
|
9
|
+
Override the location with the ``GINI_HOME_DIR`` environment variable.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def gini_home() -> Path:
|
|
19
|
+
return Path(os.environ.get("GINI_HOME_DIR") or (Path.home() / ".gini")).expanduser()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def projects_dir() -> Path:
|
|
23
|
+
return gini_home() / "projects"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def captures_dir() -> Path:
|
|
27
|
+
"""Host directory for packet captures. Bind-mounted into each gRouter container at
|
|
28
|
+
``/captures``, so a ``tap`` inline VNF's ``.pcap`` lands here on the host machine and
|
|
29
|
+
opens directly in Wireshark. Survives topology teardown (the compose workdir does not)."""
|
|
30
|
+
return gini_home() / "captures"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def oszoo_cache_dir() -> Path:
|
|
34
|
+
"""Host directory for OS Zoo guest images. Bind-mounted into each OS Zoo container at
|
|
35
|
+
``/zoo/cache``, so a historical OS's ISO/disk downloads once and is reused across runs
|
|
36
|
+
(an anonymous volume would be discarded on recreate, forcing a re-download every Run).
|
|
37
|
+
Survives topology teardown."""
|
|
38
|
+
return gini_home() / "oszoo-cache"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scripts_dir() -> Path:
|
|
42
|
+
"""Host directory for student-written router modules. Bind-mounted (read-only) into each
|
|
43
|
+
gRouter container at ``/scripts``, so a Lua data-plane VNF edited on the host machine loads
|
|
44
|
+
with ``gpipe add lua /scripts/<name>.lua`` from the router console (Chapter on designing and
|
|
45
|
+
implementing protocols). Survives topology teardown."""
|
|
46
|
+
return gini_home() / "scripts"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def config_path() -> Path:
|
|
50
|
+
return gini_home() / "config.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ensure_dirs() -> None:
|
|
54
|
+
projects_dir().mkdir(parents=True, exist_ok=True)
|
|
55
|
+
captures_dir().mkdir(parents=True, exist_ok=True)
|
|
56
|
+
scripts_dir().mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config() -> dict:
|
|
60
|
+
try:
|
|
61
|
+
return json.loads(config_path().read_text())
|
|
62
|
+
except Exception: # missing or malformed -> defaults
|
|
63
|
+
return {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_config(data: dict) -> None:
|
|
67
|
+
ensure_dirs()
|
|
68
|
+
try:
|
|
69
|
+
config_path().write_text(json.dumps(data, indent=2))
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# -- recent projects (last-opened + MRU list), kept out of config.json so a settings
|
|
75
|
+
# write never clobbers them --------------------------------------------------- #
|
|
76
|
+
def recents_path() -> Path:
|
|
77
|
+
return gini_home() / "recents.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_recents() -> dict:
|
|
81
|
+
try:
|
|
82
|
+
data = json.loads(recents_path().read_text())
|
|
83
|
+
return data if isinstance(data, dict) else {}
|
|
84
|
+
except Exception:
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_recents(data: dict) -> None:
|
|
89
|
+
ensure_dirs()
|
|
90
|
+
try:
|
|
91
|
+
recents_path().write_text(json.dumps(data, indent=2))
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def remember_project(path: str, *, limit: int = 8) -> None:
|
|
97
|
+
"""Record `path` as the last-opened project and push it to the front of the MRU list."""
|
|
98
|
+
data = load_recents()
|
|
99
|
+
mru = [p for p in data.get("list", []) if p != path]
|
|
100
|
+
mru.insert(0, path)
|
|
101
|
+
save_recents({"last": path, "list": mru[:limit]})
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# settings fields that are persisted to / loaded from config.json
|
|
105
|
+
PERSISTED_KEYS = (
|
|
106
|
+
"theme", "reduced_motion", "text_size",
|
|
107
|
+
"llm_enabled", "llm_url", "llm_model", "llm_think",
|
|
108
|
+
"auto_internet", "name_prefixes", "prices", "autobuild_images",
|
|
109
|
+
"connector_style", # bent ↔ straight, toggled from the toolbar
|
|
110
|
+
"backend", "gini_server_host", "gini_server_port", "gini_server_user",
|
|
111
|
+
"show_help_on_launch",
|
|
112
|
+
"tc_url", "tc_course", "tc_student", "tc_token", # Teaching Center enrolment
|
|
113
|
+
"tc_allow_insecure", # the conscious plaintext-password override
|
|
114
|
+
# GINI32 hardware. `laptop_id` must be stable or every claimed board orphans at
|
|
115
|
+
# once; `claimed_boards` is a property of this laptop, never of a topology, so a
|
|
116
|
+
# colleague's .gini file cannot hand you their hardware.
|
|
117
|
+
"laptop_id", "claimed_boards",
|
|
118
|
+
# The lab Wi-Fi written to a board over USB. Remembered so setting up the second
|
|
119
|
+
# and subsequent boards is one click — it is the same network for a whole class.
|
|
120
|
+
"board_wifi_ssid", "board_wifi_password", "known_boards",
|
|
121
|
+
)
|
gini/data/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# `gini/data`
|
|
2
|
+
|
|
3
|
+
Generated data assets shipped with GINI.
|
|
4
|
+
|
|
5
|
+
## `kb_index.json` — Ask GINI semantic index (L2 recall)
|
|
6
|
+
|
|
7
|
+
Precomputed embeddings of the knowledge base (concept notes + recipes + element
|
|
8
|
+
descriptions), used by `agent/embed.py` for semantic recall when lexical + LLM-expansion
|
|
9
|
+
retrieval comes back empty.
|
|
10
|
+
|
|
11
|
+
**This file is generated, not hand-edited.** Regenerate it whenever you change the concept
|
|
12
|
+
notes, recipes, or element catalog:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
ollama pull all-minilm
|
|
16
|
+
GINI_LLM_URL=http://localhost:11434 python scripts/build_kb_index.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If `kb_index.json` is absent, Ask GINI still works — L2 simply disables and retrieval falls
|
|
20
|
+
back to lexical (L0) + LLM keyword-expansion (L1). `tests/test_kb_index_sync.py` fails if the
|
|
21
|
+
shipped index drifts from the live KB, as a reminder to rebuild.
|
gini/domain/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Pure-Python domain model — no Qt dependency."""
|
|
2
|
+
from . import devices, topology
|
|
3
|
+
from .devices import Category, DeviceType, REGISTRY, all_devices, by_category, get
|
|
4
|
+
from .topology import DeviceInstance, Link, Topology
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"devices", "topology", "Category", "DeviceType", "REGISTRY",
|
|
8
|
+
"all_devices", "by_category", "get", "DeviceInstance", "Link", "Topology",
|
|
9
|
+
]
|