lang2sim 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. lang2sim/__init__.py +47 -0
  2. lang2sim/agent/__init__.py +5 -0
  3. lang2sim/agent/nemotron_client.py +109 -0
  4. lang2sim/agent/react_loop.py +848 -0
  5. lang2sim/agent/tools.py +998 -0
  6. lang2sim/cli/app.py +183 -0
  7. lang2sim/cli/screens/__init__.py +8 -0
  8. lang2sim/cli/screens/command_palette_modal.py +146 -0
  9. lang2sim/cli/screens/landing_screen.py +96 -0
  10. lang2sim/cli/screens/sessions_modal.py +236 -0
  11. lang2sim/cli/screens/workspace_screen.py +709 -0
  12. lang2sim/cli/widgets/__init__.py +16 -0
  13. lang2sim/cli/widgets/action_pill.py +67 -0
  14. lang2sim/cli/widgets/command_card.py +265 -0
  15. lang2sim/cli/widgets/plan_card.py +106 -0
  16. lang2sim/cli/widgets/prompt_card.py +96 -0
  17. lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
  18. lang2sim/cli/widgets/thought_box.py +64 -0
  19. lang2sim/compiler/__init__.py +15 -0
  20. lang2sim/compiler/sdf_compiler.py +65 -0
  21. lang2sim/compiler/sir_generator.py +46 -0
  22. lang2sim/compiler/sir_validator.py +87 -0
  23. lang2sim/core/__init__.py +50 -0
  24. lang2sim/core/config.py +116 -0
  25. lang2sim/core/models.py +279 -0
  26. lang2sim/core/session_manager.py +189 -0
  27. lang2sim/perturbation/__init__.py +50 -0
  28. lang2sim/perturbation/_compat.py +19 -0
  29. lang2sim/perturbation/gazebo_bridge.py +520 -0
  30. lang2sim/perturbation/mcp_server.py +129 -0
  31. lang2sim/perturbation/routers/__init__.py +19 -0
  32. lang2sim/perturbation/routers/world.py +110 -0
  33. lang2sim/perturbation/sdf_snippets.py +112 -0
  34. lang2sim/runtime/__init__.py +7 -0
  35. lang2sim/runtime/control_api/__init__.py +7 -0
  36. lang2sim/runtime/control_api/client.py +187 -0
  37. lang2sim/runtime/control_api/server.py +504 -0
  38. lang2sim/runtime/display.py +141 -0
  39. lang2sim/runtime/docker_runner.py +286 -0
  40. lang2sim/runtime/doctor.py +220 -0
  41. lang2sim/runtime/hardware_probe.py +144 -0
  42. lang2sim/runtime/health_monitor.py +66 -0
  43. lang2sim/runtime/launch_builder.py +137 -0
  44. lang2sim/runtime/nav2_dispatcher.py +215 -0
  45. lang2sim/runtime/platform.py +137 -0
  46. lang2sim/runtime/ros_bridge.py +183 -0
  47. lang2sim/runtime/run_folder.py +86 -0
  48. lang2sim/runtime/self_healing.py +387 -0
  49. lang2sim/runtime/session.py +136 -0
  50. lang2sim/runtime/world_check.py +197 -0
  51. lang2sim/telemetry/__init__.py +85 -0
  52. lang2sim/telemetry/nqi_scorer.py +645 -0
  53. lang2sim/telemetry/ros_subscribers.py +489 -0
  54. lang2sim/telemetry/trajectory.py +258 -0
  55. lang2sim/telemetry/vision.py +477 -0
  56. lang2sim/world_gen/__init__.py +16 -0
  57. lang2sim/world_gen/catalog_loader.py +83 -0
  58. lang2sim/world_gen/map_slicer.py +131 -0
  59. lang2sim/world_gen/shapely_solver.py +256 -0
  60. lang2sim-2.0.0.dist-info/METADATA +53 -0
  61. lang2sim-2.0.0.dist-info/RECORD +65 -0
  62. lang2sim-2.0.0.dist-info/WHEEL +5 -0
  63. lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
  64. lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. lang2sim-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,66 @@
1
+ """
2
+ Health monitor: healthy / degraded / crashed, with the reasons.
3
+ File: lang2sim/runtime/health_monitor.py
4
+
5
+ Pure function over what the API server already knows (process status, the ROS
6
+ snapshot, readiness), plus a little memory so a slow simulation is only
7
+ reported once RTF has stayed low for a while. Feeds /v1/telemetry's
8
+ health_status, /v1/sim/health and the self-healer.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import Dict, List, Literal, Optional
15
+
16
+ from lang2sim.runtime.ros_bridge import RosSnapshot, readiness
17
+
18
+ Health = Literal["healthy", "degraded", "crashed"]
19
+
20
+ LOW_RTF = 0.2 # below this the sim is too slow to be useful
21
+ LOW_RTF_FOR_S = 15.0 # ...for this long before we call it degraded
22
+ STALL_S = 5.0 # clock not advancing this long while the process runs = stalled
23
+
24
+
25
+ @dataclass
26
+ class HealthReport:
27
+ health: Health
28
+ issues: List[str] = field(default_factory=list)
29
+ rtf: Optional[float] = None
30
+ sim_time: float = 0.0
31
+ readiness: Dict[str, bool] = field(default_factory=dict)
32
+
33
+
34
+ class HealthMonitor:
35
+ def __init__(self, low_rtf: float = LOW_RTF, low_rtf_for_s: float = LOW_RTF_FOR_S) -> None:
36
+ self.low_rtf, self.low_rtf_for_s = low_rtf, low_rtf_for_s
37
+ self._low_since: Optional[float] = None
38
+
39
+ def reset(self) -> None:
40
+ self._low_since = None
41
+
42
+ def evaluate(self, process_status: str, snap: Optional[RosSnapshot]) -> HealthReport:
43
+ """process_status: SimProcess.status() value (idle/launching/running/degraded/stopped/error)."""
44
+ if process_status == "error":
45
+ return HealthReport("crashed", ["simulation process exited with an error"])
46
+ if process_status in ("idle", "stopped") or snap is None:
47
+ self.reset()
48
+ return HealthReport("healthy")
49
+
50
+ checks = readiness(snap)
51
+ issues: List[str] = []
52
+ if process_status == "launching":
53
+ return HealthReport("healthy", [], snap.rtf, snap.sim_time, checks)
54
+
55
+ # Running (or degraded): every check should hold.
56
+ issues += [f"{name} lost" for name, ok in checks.items() if not ok]
57
+ if snap.clock_wall is not None and snap.now - snap.clock_wall > STALL_S:
58
+ issues.append(f"simulation clock stalled for {snap.now - snap.clock_wall:.0f} s")
59
+ if snap.rtf is not None and snap.rtf < self.low_rtf:
60
+ self._low_since = self._low_since or snap.now
61
+ if snap.now - self._low_since >= self.low_rtf_for_s:
62
+ issues.append(f"real-time factor {snap.rtf:.2f} below {self.low_rtf} for "
63
+ f"{snap.now - self._low_since:.0f} s")
64
+ else:
65
+ self._low_since = None
66
+ return HealthReport("degraded" if issues else "healthy", issues, snap.rtf, snap.sim_time, checks)
@@ -0,0 +1,137 @@
1
+ """
2
+ Launch generator: one self-contained sim_launch.py per run.
3
+ File: lang2sim/runtime/launch_builder.py
4
+
5
+ Placed in runtime/ rather than compiler/ because the container imports it and
6
+ compiler/__init__.py pulls in jinja2/shapely, which the sim image does not ship.
7
+
8
+ The generated file is plain ROS 2 Python launch code with this run's settings
9
+ baked in as a dict literal at the top, so it can be read, diffed and re-run by
10
+ hand: ros2 launch <run_dir>/sim_launch.py
11
+
12
+ It starts: Gazebo server (+ optional GUI that may be closed without stopping the
13
+ sim), robot_state_publisher, the TurtleBot3 Waffle spawned at the manifest's
14
+ pose, ros_gz_bridge (+ optional camera bridge), Nav2 with this run's map and AMCL
15
+ seeded at the spawn pose, and optional RViz2.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import pprint
21
+ from dataclasses import asdict, dataclass, field
22
+ from pathlib import Path
23
+ from typing import List
24
+
25
+ LAUNCH_FILE = "sim_launch.py"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LaunchConfig:
30
+ run_dir: str # container path; logs and generated params go here
31
+ world_sdf: str # container path, already checked/normalized
32
+ world_name: str
33
+ map_yaml: str
34
+ x: float = 0.0
35
+ y: float = 0.0
36
+ yaw: float = 0.0
37
+ gui: bool = True
38
+ rviz: bool = True
39
+ camera: bool = True # image bridge; heavy under software rendering
40
+ nav2_params: str = "" # "" -> TurtleBot3 Waffle params
41
+ nav2_delay_s: float = 5.0
42
+ resource_paths: List[str] = field(default_factory=list) # extra GZ_SIM_RESOURCE_PATH entries
43
+ robot: str = "waffle"
44
+
45
+
46
+ _TEMPLATE = '''\
47
+ """Generated by lang2sim (lang2sim/runtime/launch_builder.py). Do not edit; regenerate instead."""
48
+ import math
49
+ import os
50
+
51
+ import yaml
52
+ from ament_index_python.packages import get_package_share_directory
53
+ from launch import LaunchDescription
54
+ from launch.actions import AppendEnvironmentVariable, IncludeLaunchDescription, TimerAction
55
+ from launch.launch_description_sources import PythonLaunchDescriptionSource
56
+ from launch_ros.actions import Node
57
+
58
+ CONFIG = __CONFIG__
59
+
60
+
61
+ def _nav2_params(tb3_nav):
62
+ """Base Nav2 params with AMCL seeded at the spawn pose, written into the run folder."""
63
+ base = CONFIG["nav2_params"] or os.path.join(tb3_nav, "param", CONFIG["robot"] + ".yaml")
64
+ with open(base) as f:
65
+ params = yaml.safe_load(f)
66
+ amcl = params.setdefault("amcl", {}).setdefault("ros__parameters", {})
67
+ amcl["set_initial_pose"] = True
68
+ amcl["initial_pose"] = {"x": float(CONFIG["x"]), "y": float(CONFIG["y"]), "z": 0.0,
69
+ "yaw": float(CONFIG["yaw"])}
70
+ out = os.path.join(CONFIG["run_dir"], "nav2_params.l2s.yaml")
71
+ with open(out, "w") as f:
72
+ yaml.safe_dump(params, f, sort_keys=False)
73
+ return out
74
+
75
+
76
+ def generate_launch_description():
77
+ tb3_gz = get_package_share_directory("turtlebot3_gazebo")
78
+ tb3_nav = get_package_share_directory("turtlebot3_navigation2")
79
+ ros_gz_sim = get_package_share_directory("ros_gz_sim")
80
+ nav2_bringup = get_package_share_directory("nav2_bringup")
81
+ model = "turtlebot3_" + CONFIG["robot"]
82
+
83
+ with open(os.path.join(tb3_gz, "urdf", model + ".urdf")) as f:
84
+ robot_description = f.read()
85
+
86
+ actions = [AppendEnvironmentVariable("GZ_SIM_RESOURCE_PATH", p)
87
+ for p in [os.path.join(tb3_gz, "models"), *CONFIG["resource_paths"]]]
88
+
89
+ actions.append(IncludeLaunchDescription(
90
+ PythonLaunchDescriptionSource(os.path.join(ros_gz_sim, "launch", "gz_sim.launch.py")),
91
+ launch_arguments={"gz_args": "-r -s -v2 " + CONFIG["world_sdf"], "on_exit_shutdown": "true"}.items()))
92
+ if CONFIG["gui"]:
93
+ actions.append(IncludeLaunchDescription(
94
+ PythonLaunchDescriptionSource(os.path.join(ros_gz_sim, "launch", "gz_sim.launch.py")),
95
+ launch_arguments={"gz_args": "-g -v2", "on_exit_shutdown": "false"}.items()))
96
+
97
+ actions.append(Node(
98
+ package="robot_state_publisher", executable="robot_state_publisher", name="robot_state_publisher",
99
+ output="screen", parameters=[{"use_sim_time": True, "robot_description": robot_description}]))
100
+ actions.append(Node(
101
+ package="ros_gz_sim", executable="create", output="screen",
102
+ arguments=["-world", CONFIG["world_name"], "-name", CONFIG["robot"],
103
+ "-file", os.path.join(tb3_gz, "models", model, "model.sdf"),
104
+ "-x", str(CONFIG["x"]), "-y", str(CONFIG["y"]), "-z", "0.01", "-Y", str(CONFIG["yaw"])]))
105
+ actions.append(Node(
106
+ package="ros_gz_bridge", executable="parameter_bridge", name="ros_gz_bridge", output="screen",
107
+ parameters=[{"config_file": os.path.join(tb3_gz, "params", model + "_bridge.yaml"),
108
+ "use_sim_time": True}]))
109
+ if CONFIG["camera"]:
110
+ actions.append(Node(package="ros_gz_image", executable="image_bridge", name="ros_gz_image_bridge",
111
+ arguments=["/camera/image_raw"], output="screen"))
112
+
113
+ actions.append(TimerAction(period=float(CONFIG["nav2_delay_s"]), actions=[IncludeLaunchDescription(
114
+ PythonLaunchDescriptionSource(os.path.join(nav2_bringup, "launch", "bringup_launch.py")),
115
+ launch_arguments={"map": CONFIG["map_yaml"], "use_sim_time": "true", "autostart": "true",
116
+ "params_file": _nav2_params(tb3_nav)}.items())]))
117
+
118
+ if CONFIG["rviz"]:
119
+ actions.append(Node(package="rviz2", executable="rviz2", name="rviz2", output="log",
120
+ arguments=["-d", os.path.join(tb3_nav, "rviz", "tb3_navigation2.rviz")],
121
+ parameters=[{"use_sim_time": True}]))
122
+ return LaunchDescription(actions)
123
+ '''
124
+
125
+
126
+ def render(cfg: LaunchConfig) -> str:
127
+ """Python source of the launch file for this config."""
128
+ if not cfg.world_name:
129
+ raise ValueError("world_name is required (the robot is spawned into it by name)")
130
+ literal = pprint.pformat(asdict(cfg), indent=4, sort_dicts=True, width=100)
131
+ return _TEMPLATE.replace("__CONFIG__", literal)
132
+
133
+
134
+ def write(cfg: LaunchConfig, out_dir: Path | None = None) -> Path:
135
+ path = Path(out_dir or cfg.run_dir) / LAUNCH_FILE
136
+ path.write_text(render(cfg), encoding="utf-8")
137
+ return path
@@ -0,0 +1,215 @@
1
+ """
2
+ Nav2 dispatcher: initial pose, goals, waypoint missions, cancel, live status.
3
+ File: lang2sim/runtime/nav2_dispatcher.py
4
+
5
+ Runs inside the container, in the API server. One worker thread owns one
6
+ nav2_simple_commander.BasicNavigator (it spins itself, so it must never be
7
+ touched from another thread); API handlers only enqueue commands and read a
8
+ lock-protected NavStatus.
9
+
10
+ Waypoint missions are driven leg by leg with goToPose, so every leg reports
11
+ distance remaining, ETA and recoveries. A new goal preempts the current task.
12
+
13
+ BasicNavigator.waitUntilNav2Active() is deliberately not used: until AMCL
14
+ answers, it republishes its default (0, 0) pose, which would teleport the
15
+ localization estimate. The server only dispatches once readiness says Nav2 is
16
+ active and the robot is localized.
17
+
18
+ The navigator is injected via `navigator_factory` / `make_pose` so the logic is
19
+ unit-tested on the host without ROS.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import math
25
+ import queue
26
+ import threading
27
+ import time
28
+ from dataclasses import dataclass
29
+ from typing import Any, Callable, List, Optional, Sequence
30
+
31
+ from lang2sim.core.models import NavStatus, Pose2D
32
+
33
+ POLL_S = 0.1
34
+
35
+
36
+ def _ros_navigator() -> Any:
37
+ from nav2_simple_commander.robot_navigator import BasicNavigator # type: ignore[import-not-found]
38
+ from rclpy.parameter import Parameter # type: ignore[import-not-found]
39
+
40
+ nav = BasicNavigator("l2s_nav_dispatcher")
41
+ nav.set_parameters([Parameter("use_sim_time", value=True)])
42
+ return nav
43
+
44
+
45
+ def _ros_pose(nav: Any, p: Pose2D) -> Any:
46
+ from geometry_msgs.msg import PoseStamped # type: ignore[import-not-found]
47
+
48
+ ps = PoseStamped()
49
+ ps.header.frame_id = "map"
50
+ ps.header.stamp = nav.get_clock().now().to_msg()
51
+ ps.pose.position.x, ps.pose.position.y = p.x, p.y
52
+ ps.pose.orientation.z, ps.pose.orientation.w = math.sin(p.yaw / 2), math.cos(p.yaw / 2)
53
+ return ps
54
+
55
+
56
+ def _seconds(duration: Any) -> float:
57
+ """builtin_interfaces/Duration (or a float in tests) -> seconds."""
58
+ if duration is None:
59
+ return 0.0
60
+ if isinstance(duration, (int, float)):
61
+ return float(duration)
62
+ return duration.sec + duration.nanosec * 1e-9
63
+
64
+
65
+ @dataclass
66
+ class _Task:
67
+ kind: str # "goal" | "waypoints" | "initial_pose"
68
+ poses: List[Pose2D]
69
+ timeout_s: float = 120.0
70
+
71
+
72
+ class NavDispatcher:
73
+ def __init__(self, navigator_factory: Callable[[], Any] = _ros_navigator,
74
+ make_pose: Callable[[Any, Pose2D], Any] = _ros_pose) -> None:
75
+ self._factory = navigator_factory
76
+ self._make_pose = make_pose
77
+ self._lock = threading.Lock()
78
+ self._status = NavStatus()
79
+ self._queue: "queue.Queue[_Task]" = queue.Queue()
80
+ self._cancel = threading.Event()
81
+ self._stop = threading.Event()
82
+ self._thread: Optional[threading.Thread] = None
83
+ self._nav: Any = None
84
+ self._last: Optional[_Task] = None
85
+
86
+ # --- API (any thread) ---------------------------------------------------
87
+ def status(self) -> NavStatus:
88
+ with self._lock:
89
+ return self._status.model_copy()
90
+
91
+ def set_initial_pose(self, pose: Pose2D) -> None:
92
+ self._submit(_Task("initial_pose", [pose]))
93
+
94
+ def go_to(self, goal: Pose2D, timeout_s: float = 120.0) -> NavStatus:
95
+ return self._submit(_Task("goal", [goal], timeout_s))
96
+
97
+ def follow(self, waypoints: Sequence[Pose2D], timeout_s: float = 180.0) -> NavStatus:
98
+ if not waypoints:
99
+ raise ValueError("at least one waypoint is required")
100
+ return self._submit(_Task("waypoints", list(waypoints), timeout_s))
101
+
102
+ def remaining(self) -> List[Pose2D]:
103
+ """Poses of the current/last task from the leg in progress onwards (for re-planning)."""
104
+ with self._lock:
105
+ task, idx = self._last, self._status.waypoint_index
106
+ return list(task.poses[idx:]) if task else []
107
+
108
+ def resume_with(self, first: Pose2D, timeout_s: Optional[float] = None) -> NavStatus:
109
+ """Replace the current leg's target with `first`, keep the rest of the mission."""
110
+ rest = self.remaining()[1:]
111
+ timeout = timeout_s if timeout_s is not None else (self._last.timeout_s if self._last else 120.0)
112
+ if rest:
113
+ return self.follow([first, *rest], timeout)
114
+ return self.go_to(first, timeout)
115
+
116
+ def cancel(self) -> NavStatus:
117
+ self._drain()
118
+ if self.status().is_active:
119
+ self._cancel.set()
120
+ return self.status()
121
+
122
+ def shutdown(self) -> None:
123
+ self._stop.set()
124
+ self._cancel.set()
125
+ if self._thread:
126
+ self._thread.join(timeout=5)
127
+
128
+ # --- internals -------------------------------------------------------------
129
+ def _submit(self, task: _Task) -> NavStatus:
130
+ self._ensure_worker()
131
+ if task.kind != "initial_pose":
132
+ self._drain() # newest command wins
133
+ if self.status().is_active:
134
+ self._cancel.set() # preempt the running task
135
+ with self._lock: # report the new task immediately
136
+ self._status = NavStatus(is_active=True, status="navigating", current_task=task.kind,
137
+ target_pose=task.poses[0], waypoints_total=len(task.poses))
138
+ self._queue.put(task)
139
+ return self.status()
140
+
141
+ def _drain(self) -> None:
142
+ try:
143
+ while True:
144
+ self._queue.get_nowait()
145
+ except queue.Empty:
146
+ pass
147
+
148
+ def _ensure_worker(self) -> None:
149
+ if self._thread and self._thread.is_alive():
150
+ return
151
+ self._thread = threading.Thread(target=self._worker, name="l2s-nav", daemon=True)
152
+ self._thread.start()
153
+
154
+ def _set(self, **kw: Any) -> None:
155
+ with self._lock:
156
+ self._status = self._status.model_copy(update=kw)
157
+
158
+ def _worker(self) -> None:
159
+ self._nav = self._factory()
160
+ while not self._stop.is_set():
161
+ try:
162
+ task = self._queue.get(timeout=0.2)
163
+ except queue.Empty:
164
+ continue
165
+ if task.kind == "initial_pose":
166
+ self._nav.setInitialPose(self._make_pose(self._nav, task.poses[0]))
167
+ continue
168
+ self._cancel.clear()
169
+ self._last = task
170
+ self._run(task)
171
+
172
+ def _run(self, task: _Task) -> None:
173
+ total = len(task.poses)
174
+ for i, pose in enumerate(task.poses):
175
+ self._set(is_active=True, status="navigating", current_task=task.kind, target_pose=pose,
176
+ waypoint_index=i, waypoints_total=total, distance_remaining=0.0,
177
+ navigation_time=0.0, eta_seconds=None, recoveries=0, message=None)
178
+ outcome, msg = self._leg(pose, task.timeout_s)
179
+ if outcome != "succeeded":
180
+ if outcome == "canceled" and not self._queue.empty():
181
+ return # preempted: the next task's status is already showing
182
+ self._set(is_active=False, status=outcome, message=msg)
183
+ return
184
+ self._set(is_active=False, status="succeeded", distance_remaining=0.0, eta_seconds=0.0,
185
+ message=None)
186
+
187
+ def _leg(self, pose: Pose2D, timeout_s: float):
188
+ nav = self._nav
189
+ if not nav.goToPose(self._make_pose(nav, pose)):
190
+ return "failed", "goal rejected by Nav2 (bt_navigator not active?)"
191
+ timed_out = False
192
+ while not nav.isTaskComplete():
193
+ fb = nav.getFeedback()
194
+ if fb is not None:
195
+ t = _seconds(getattr(fb, "navigation_time", None))
196
+ self._set(distance_remaining=round(float(getattr(fb, "distance_remaining", 0.0)), 3),
197
+ navigation_time=round(t, 2),
198
+ eta_seconds=round(_seconds(getattr(fb, "estimated_time_remaining", None)), 1),
199
+ recoveries=int(getattr(fb, "number_of_recoveries", 0)))
200
+ if t > timeout_s and not timed_out:
201
+ timed_out = True
202
+ nav.cancelTask()
203
+ if self._cancel.is_set():
204
+ nav.cancelTask()
205
+ self._cancel.clear()
206
+ return "canceled", "canceled by request"
207
+ time.sleep(POLL_S)
208
+ name = getattr(nav.getResult(), "name", str(nav.getResult())).upper()
209
+ if timed_out:
210
+ return "failed", f"timed out after {timeout_s:.0f} s (sim time)"
211
+ if "SUCCEEDED" in name:
212
+ return "succeeded", None
213
+ if "CANCELED" in name:
214
+ return "canceled", "canceled by Nav2"
215
+ return "failed", f"Nav2 result {name}"
@@ -0,0 +1,137 @@
1
+ """
2
+ Host platform + Docker detection.
3
+ File: lang2sim/runtime/platform.py
4
+
5
+ Answers: which OS family are we on (linux / wsl / macos / windows), is there a
6
+ display, and can we reach a Docker daemon (and does it have the NVIDIA runtime).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import platform as _platform
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Dict, List, Literal, Optional
19
+
20
+ HostOS = Literal["linux", "wsl", "macos", "windows"]
21
+ Arch = Literal["amd64", "arm64", "unknown"]
22
+
23
+ _ARCH_ALIASES = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class DockerInfo:
28
+ cli_path: Optional[str] = None
29
+ server_reachable: bool = False
30
+ server_version: Optional[str] = None
31
+ operating_system: Optional[str] = None # "Docker Desktop", "Ubuntu 24.04 LTS", ...
32
+ arch: Arch = "unknown"
33
+ runtimes: List[str] = field(default_factory=list)
34
+ mem_total_mb: Optional[int] = None # memory the daemon's VM/host gives containers
35
+ cpus: Optional[int] = None
36
+ error: Optional[str] = None
37
+
38
+ @property
39
+ def installed(self) -> bool:
40
+ return self.cli_path is not None
41
+
42
+ @property
43
+ def is_desktop(self) -> bool:
44
+ return bool(self.operating_system and "Docker Desktop" in self.operating_system)
45
+
46
+ @property
47
+ def has_nvidia_runtime(self) -> bool:
48
+ return "nvidia" in self.runtimes
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class HostPlatform:
53
+ os: HostOS
54
+ arch: Arch
55
+ env: Dict[str, str] = field(default_factory=dict) # DISPLAY / WAYLAND_DISPLAY / XAUTHORITY
56
+ has_wslg: bool = False
57
+ has_x11_socket_dir: bool = False
58
+ docker: DockerInfo = field(default_factory=DockerInfo)
59
+
60
+ @property
61
+ def display(self) -> Optional[str]:
62
+ return self.env.get("DISPLAY") or None
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Pure helpers
67
+ # ---------------------------------------------------------------------------
68
+
69
+ def normalize_arch(machine: str) -> Arch:
70
+ return _ARCH_ALIASES.get(machine.lower(), "unknown") # type: ignore[return-value]
71
+
72
+
73
+ def classify_os(system: str, release: str, wsl_interop: bool = False) -> HostOS:
74
+ s = system.lower()
75
+ if s == "darwin":
76
+ return "macos"
77
+ if s == "windows":
78
+ return "windows"
79
+ if s == "linux" and (wsl_interop or "microsoft" in release.lower()):
80
+ return "wsl"
81
+ return "linux"
82
+
83
+
84
+ def parse_docker_info(info_json: str, cli_path: Optional[str] = None) -> DockerInfo:
85
+ """Parse `docker info --format '{{json .}}'`."""
86
+ try:
87
+ d = json.loads(info_json)
88
+ except json.JSONDecodeError as e:
89
+ return DockerInfo(cli_path=cli_path, error=f"bad docker info output: {e}")
90
+ if d.get("ServerErrors"):
91
+ return DockerInfo(cli_path=cli_path, error="; ".join(d["ServerErrors"]))
92
+ return DockerInfo(
93
+ cli_path=cli_path,
94
+ server_reachable=True,
95
+ server_version=d.get("ServerVersion"),
96
+ operating_system=d.get("OperatingSystem"),
97
+ arch=normalize_arch(d.get("Architecture", "")),
98
+ runtimes=sorted((d.get("Runtimes") or {}).keys()),
99
+ mem_total_mb=int(d["MemTotal"]) // (1024 * 1024) if d.get("MemTotal") else None,
100
+ cpus=d.get("NCPU"),
101
+ )
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Probes
106
+ # ---------------------------------------------------------------------------
107
+
108
+ def detect_docker(docker: str = "docker", timeout: float = 10.0) -> DockerInfo:
109
+ cli = shutil.which(docker)
110
+ if not cli:
111
+ return DockerInfo(error="docker CLI not found on PATH")
112
+ try:
113
+ out = subprocess.run([cli, "info", "--format", "{{json .}}"],
114
+ capture_output=True, text=True, timeout=timeout, check=False)
115
+ except subprocess.TimeoutExpired:
116
+ return DockerInfo(cli_path=cli, error="`docker info` timed out (daemon starting?)")
117
+ except OSError as e:
118
+ return DockerInfo(cli_path=cli, error=str(e))
119
+ if out.returncode != 0 or not out.stdout.strip().startswith("{"):
120
+ msg = (out.stderr or out.stdout).strip().splitlines()
121
+ return DockerInfo(cli_path=cli, error=msg[-1] if msg else "docker daemon not reachable")
122
+ return parse_docker_info(out.stdout, cli)
123
+
124
+
125
+ def detect(docker: str = "docker", probe_docker: bool = True) -> HostPlatform:
126
+ host_os = classify_os(_platform.system(), _platform.release(),
127
+ wsl_interop=os.path.exists("/proc/sys/fs/binfmt_misc/WSLInterop"))
128
+ env = {k: os.environ[k] for k in ("DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR")
129
+ if os.environ.get(k)}
130
+ return HostPlatform(
131
+ os=host_os,
132
+ arch=normalize_arch(_platform.machine()),
133
+ env=env,
134
+ has_wslg=Path("/mnt/wslg").is_dir(),
135
+ has_x11_socket_dir=Path("/tmp/.X11-unix").is_dir(),
136
+ docker=detect_docker(docker) if probe_docker else DockerInfo(),
137
+ )