world-engine-client 0.3.0__tar.gz → 0.4.0__tar.gz

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 (20) hide show
  1. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/PKG-INFO +32 -7
  2. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/README.md +27 -6
  3. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/pyproject.toml +4 -1
  4. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/__init__.py +3 -1
  5. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/env.py +106 -28
  6. world_engine_client-0.4.0/src/world_engine_client/render.py +373 -0
  7. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/rotations.py +2 -3
  8. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/conftest.py +18 -23
  9. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/test_env_transport.py +1 -45
  10. world_engine_client-0.4.0/tests/test_render.py +175 -0
  11. world_engine_client-0.3.0/src/world_engine_client/wire.py +0 -66
  12. world_engine_client-0.3.0/tests/test_wire.py +0 -140
  13. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.github/workflows/publish.yml +0 -0
  14. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.github/workflows/test.yml +0 -0
  15. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.gitignore +0 -0
  16. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/LICENSE +0 -0
  17. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/errors.py +0 -0
  18. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/gym_adapter.py +0 -0
  19. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/spaces.py +0 -0
  20. {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/test_spaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: world-engine-client
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Remote Gym-style Python client for a World Engine simulation server: connect(token) -> reset/step over HTTP.
5
5
  Project-URL: Homepage, https://github.com/Grounded-Intelligence-Initiative/world-engine-client-python
6
6
  Project-URL: Repository, https://github.com/Grounded-Intelligence-Initiative/world-engine-client-python
@@ -22,6 +22,10 @@ Provides-Extra: gym
22
22
  Requires-Dist: gymnasium>=1.0; extra == 'gym'
23
23
  Provides-Extra: img
24
24
  Requires-Dist: pillow>=10; extra == 'img'
25
+ Provides-Extra: render
26
+ Requires-Dist: pillow>=10; extra == 'render'
27
+ Requires-Dist: playwright>=1.40; extra == 'render'
28
+ Requires-Dist: websockets>=12; extra == 'render'
25
29
  Description-Content-Type: text/markdown
26
30
 
27
31
  # world-engine-client
@@ -84,11 +88,32 @@ cookies and editor write credentials are not Python control credentials.
84
88
 
85
89
  Protocol 3 is the default. Direct connections remain reset-required and state-only
86
90
  unless requested otherwise. `proto=2` preserves state-only compatibility and
87
- rejects image/resume requests. Image-enabled operation requires a real GPU renderer
88
- on the server. `images=True` captures all configured cameras; `cameras=['wrist']`
89
- selects a subset. RGB arrays appear in `obs['rgb']`; `render()` returns the latest
90
- camera without stepping. `as_gymnasium()` supplies an optional Gymnasium Env.
91
- Observations identify the instance, episode, step, and simulation time in `info`.
91
+ rejects image/resume requests. `images=True` captures all configured cameras;
92
+ `cameras=['wrist']` selects a subset. RGB arrays appear in `obs['rgb']`; `render()`
93
+ returns the latest camera without stepping. `as_gymnasium()` supplies an optional
94
+ Gymnasium Env.
95
+
96
+ ### Camera images
97
+
98
+ Camera images are drawn on your computer, never on the server: the server only
99
+ simulates physics and sends the poses with each observation, and a headless Chrome
100
+ here runs the same scene the browser viewer shows. Nothing is throttled when no
101
+ browser tab is open, and the images never travel over the network. This needs the
102
+ `render` extra:
103
+
104
+ ```bash
105
+ pip install 'world-engine-client[render]'
106
+ python -m playwright install chromium # skip if Google Chrome is installed
107
+ ```
108
+
109
+ Scene assets are fetched once per session and cached by content hash in a
110
+ temporary directory that `close()` deletes; pass
111
+ `render_options={"cache_dir": "..."}` to keep them across sessions. Chrome must
112
+ get hardware WebGL (check `chrome://gpu`). Without it the renderer refuses to
113
+ start, because software rendering manages only a few frames per second;
114
+ `render_options={"allow_software": True}` accepts that, and the
115
+ `WE_RENDER_CHROME_ARGS` environment variable overrides Chrome's flags.
116
+ `env.last_timings` splits each step into server, transport and render time.
92
117
 
93
118
  Heartbeats run every 30 seconds with a 90-second controller lease. Expiry freezes
94
119
  the simulation until explicit takeover. A lost action response is never retried:
@@ -166,7 +191,7 @@ R = rot6d_to_matrix(demo["action_dict/abs_rot_6d"][t])
166
191
  ## Development
167
192
 
168
193
  ```bash
169
- pip install -e '.[img,gym]' pytest && python -m pytest
194
+ pip install -e '.[img,gym,render]' pytest && python -m pytest
170
195
  ```
171
196
 
172
197
  `tests/` covers the client alone against a stub server: binary decoding, the
@@ -58,11 +58,32 @@ cookies and editor write credentials are not Python control credentials.
58
58
 
59
59
  Protocol 3 is the default. Direct connections remain reset-required and state-only
60
60
  unless requested otherwise. `proto=2` preserves state-only compatibility and
61
- rejects image/resume requests. Image-enabled operation requires a real GPU renderer
62
- on the server. `images=True` captures all configured cameras; `cameras=['wrist']`
63
- selects a subset. RGB arrays appear in `obs['rgb']`; `render()` returns the latest
64
- camera without stepping. `as_gymnasium()` supplies an optional Gymnasium Env.
65
- Observations identify the instance, episode, step, and simulation time in `info`.
61
+ rejects image/resume requests. `images=True` captures all configured cameras;
62
+ `cameras=['wrist']` selects a subset. RGB arrays appear in `obs['rgb']`; `render()`
63
+ returns the latest camera without stepping. `as_gymnasium()` supplies an optional
64
+ Gymnasium Env.
65
+
66
+ ### Camera images
67
+
68
+ Camera images are drawn on your computer, never on the server: the server only
69
+ simulates physics and sends the poses with each observation, and a headless Chrome
70
+ here runs the same scene the browser viewer shows. Nothing is throttled when no
71
+ browser tab is open, and the images never travel over the network. This needs the
72
+ `render` extra:
73
+
74
+ ```bash
75
+ pip install 'world-engine-client[render]'
76
+ python -m playwright install chromium # skip if Google Chrome is installed
77
+ ```
78
+
79
+ Scene assets are fetched once per session and cached by content hash in a
80
+ temporary directory that `close()` deletes; pass
81
+ `render_options={"cache_dir": "..."}` to keep them across sessions. Chrome must
82
+ get hardware WebGL (check `chrome://gpu`). Without it the renderer refuses to
83
+ start, because software rendering manages only a few frames per second;
84
+ `render_options={"allow_software": True}` accepts that, and the
85
+ `WE_RENDER_CHROME_ARGS` environment variable overrides Chrome's flags.
86
+ `env.last_timings` splits each step into server, transport and render time.
66
87
 
67
88
  Heartbeats run every 30 seconds with a 90-second controller lease. Expiry freezes
68
89
  the simulation until explicit takeover. A lost action response is never retried:
@@ -140,7 +161,7 @@ R = rot6d_to_matrix(demo["action_dict/abs_rot_6d"][t])
140
161
  ## Development
141
162
 
142
163
  ```bash
143
- pip install -e '.[img,gym]' pytest && python -m pytest
164
+ pip install -e '.[img,gym,render]' pytest && python -m pytest
144
165
  ```
145
166
 
146
167
  `tests/` covers the client alone against a stub server: binary decoding, the
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "world-engine-client"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Remote Gym-style Python client for a World Engine simulation server: connect(token) -> reset/step over HTTP."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -28,6 +28,9 @@ dependencies = [
28
28
  [project.optional-dependencies]
29
29
  img = ["Pillow>=10"]
30
30
  gym = ["gymnasium>=1.0"]
31
+ # Camera images, rendered on this machine; also run
32
+ # `python -m playwright install chromium` unless Chrome is installed.
33
+ render = ["Pillow>=10", "playwright>=1.40", "websockets>=12"]
31
34
 
32
35
  [dependency-groups]
33
36
  dev = ["pytest>=8", "ruff", "Pillow>=10", "gymnasium>=1.0"]
@@ -30,6 +30,7 @@ from .errors import (
30
30
  TokenError,
31
31
  WorldEngineClientError,
32
32
  )
33
+ from .render import RendererError
33
34
  from .rotations import (
34
35
  axisangle_to_matrix,
35
36
  matrix_to_axisangle,
@@ -49,9 +50,10 @@ __all__ = [
49
50
  "BadConfigError",
50
51
  "RecoveryRequiredError",
51
52
  "RecordingPendingError",
53
+ "RendererError",
52
54
  "matrix_to_rot6d",
53
55
  "rot6d_to_matrix",
54
56
  "axisangle_to_matrix",
55
57
  "matrix_to_axisangle",
56
58
  ]
57
- __version__ = "0.3.0"
59
+ __version__ = "0.4.0"
@@ -3,7 +3,8 @@
3
3
  Synchronous by design — an inference loop is synchronous. Transport is plain
4
4
  HTTP POST (persistent stdlib http.client): one round-trip per step, which the lockstep
5
5
  semantics require anyway. Obs arrays come back as JSON lists and are converted
6
- to numpy here; protocol 3 carries synchronized JPEG camera observations.
6
+ to numpy here. Camera images are rendered on this machine (see render.py): the
7
+ server sends the poses they are drawn from with every observation.
7
8
 
8
9
  Proto 2 (server ≥ the editor-teleop release): the default control mode is
9
10
  ``osc_pose_delta`` — a 7-dim action ``[dpos(3), drot_axisangle(3), gripper]``
@@ -26,7 +27,8 @@ import urllib.parse
26
27
  import numpy as np
27
28
 
28
29
  from .errors import ProtocolError, RecoveryRequiredError, WorldEngineClientError, raise_for
29
- from .wire import CONTENT_TYPE, MAX_RESPONSE, decode
30
+
31
+ MAX_RESPONSE = 64 * 1024 * 1024
30
32
 
31
33
  _PROTO = 3
32
34
 
@@ -45,6 +47,8 @@ def connect(
45
47
  expected_instance_id: str | None = None,
46
48
  proto: int = _PROTO,
47
49
  access_provider=None,
50
+ render: str = "auto",
51
+ render_options: dict | None = None,
48
52
  ) -> RemoteEnv:
49
53
  """Bind to the server as THE controller and return a ready RemoteEnv.
50
54
 
@@ -52,9 +56,20 @@ def connect(
52
56
  shown in the editor Inspector's Python API card during a Simulate session).
53
57
  ``takeover=True`` revokes a controller that died without releasing.
54
58
  ``control_mode`` / ``obs_keys`` negotiate the space at connect time (see
55
- ``env.metadata['supported_control_modes']`` for what the server offers)."""
59
+ ``env.metadata['supported_control_modes']`` for what the server offers).
60
+
61
+ Camera images (``images=True`` or ``cameras=[...]``) are rendered on this
62
+ machine in headless Chrome; the server only simulates. That needs the
63
+ ``render`` extra. ``render`` accepts ``"auto"`` or ``"local"`` (the same
64
+ thing; kept for callers that pass it). ``render_options`` go to
65
+ :class:`world_engine_client.render.LocalRenderer` (``cache_dir``,
66
+ ``allow_software``, ``chrome_args``)."""
67
+ if render not in ("auto", "local"):
68
+ raise ValueError("render must be 'auto' or 'local': servers no longer render camera images")
56
69
  env = RemoteEnv(url.rstrip("/"), token, timeout=timeout)
57
70
  env._access_provider = access_provider
71
+ env._render_request = render
72
+ env._render_options = dict(render_options or {})
58
73
  try:
59
74
  env._connect(
60
75
  takeover=takeover,
@@ -66,8 +81,8 @@ def connect(
66
81
  expected_instance_id=expected_instance_id,
67
82
  proto=proto,
68
83
  )
69
- except Exception:
70
- env._http.close()
84
+ except BaseException:
85
+ env.close()
71
86
  raise
72
87
  return env
73
88
 
@@ -105,6 +120,9 @@ class RemoteEnv:
105
120
  self.action_dim: int = 7
106
121
  self.observation_keys: list[str] = []
107
122
  self.control_mode: str | None = None
123
+ self._render_request = "server"
124
+ self._render_options: dict = {}
125
+ self.renderer = None # LocalRenderer when images are rendered on this machine
108
126
 
109
127
  # ----- Gym API ------------------------------------------------------------ #
110
128
  def reset(self, *, seed: int | None = None, options: dict | None = None):
@@ -204,6 +222,9 @@ class RemoteEnv:
204
222
  self._stop.set()
205
223
  if self._heartbeat_thread is not None:
206
224
  self._heartbeat_thread.join(timeout=self._timeout + 1)
225
+ if self.renderer is not None:
226
+ self.renderer.close()
227
+ self.renderer = None
207
228
  if self._session_id is None:
208
229
  self._http.close()
209
230
  return
@@ -233,11 +254,16 @@ class RemoteEnv:
233
254
  expected_instance_id=None,
234
255
  proto=_PROTO,
235
256
  ) -> None:
236
- if images or cameras:
237
- try:
238
- import PIL # noqa: F401
239
- except ImportError as exc:
240
- raise ImportError("Install world-engine-client[img] for images") from exc
257
+ local = bool(images or cameras)
258
+ if local:
259
+ from . import render as local_render
260
+
261
+ if not local_render.available():
262
+ raise local_render.RendererError(
263
+ "camera images are rendered on this machine and need the render extra",
264
+ code="renderer_unavailable",
265
+ fixes=local_render._INSTALL_FIXES,
266
+ )
241
267
  body: dict = {
242
268
  "proto": proto,
243
269
  "takeover": takeover,
@@ -250,6 +276,8 @@ class RemoteEnv:
250
276
  body["control_mode"] = control_mode
251
277
  if obs_keys is not None:
252
278
  body["obs_keys"] = list(obs_keys)
279
+ if local:
280
+ body["render"] = "client"
253
281
  reply = self._post("/control/connect", body, with_session=False)
254
282
  if reply.get("proto") != proto:
255
283
  raise ProtocolError("server did not honor the requested protocol")
@@ -259,6 +287,17 @@ class RemoteEnv:
259
287
  if proto == 3:
260
288
  self._episode_id = reply.get("episode_id")
261
289
  self._refresh_env(reply.get("env"))
290
+ bundle = reply.get("render") if local else None
291
+ if local and (not bundle or bundle.get("protocol") != 1):
292
+ raise ProtocolError(
293
+ "this server predates client rendering; upgrade it, or connect with images=False"
294
+ )
295
+ if local:
296
+ from .render import LocalRenderer
297
+
298
+ self.renderer = LocalRenderer(
299
+ self._open_render, bundle["scene_init"], bundle["digests"], **self._render_options
300
+ )
262
301
  if proto == 3:
263
302
  self._heartbeat_thread = threading.Thread(target=self._heartbeat, daemon=True)
264
303
  self._heartbeat_thread.start()
@@ -303,19 +342,7 @@ class RemoteEnv:
303
342
  if len(data) > MAX_RESPONSE:
304
343
  raise ProtocolError("response exceeds size limit")
305
344
  received = time.perf_counter()
306
- if response.getheader("Content-Type", "").split(";")[0] == CONTENT_TYPE:
307
- reply = decode(data, self.metadata.get("cameras", {}))
308
- decoded = time.perf_counter()
309
- server_ms = reply.get("info", {}).get("timings", {}).get("server_ms", 0)
310
- self.last_timings = {
311
- **reply.get("info", {}).get("timings", {}),
312
- "decode_ms": (decoded - received) * 1000,
313
- "transport_ms": max(0, (received - started) * 1000 - server_ms),
314
- "total_ms": (decoded - started) * 1000,
315
- "response_bytes": len(data),
316
- }
317
- else:
318
- reply = json.loads(data)
345
+ reply = json.loads(data)
319
346
  if not isinstance(reply, dict) or "ok" not in reply:
320
347
  raise ProtocolError("invalid response envelope")
321
348
  except (OSError, http.client.HTTPException, ValueError, ProtocolError) as exc:
@@ -328,11 +355,8 @@ class RemoteEnv:
328
355
  raise ProtocolError(f"request failed at {path}: {exc}") from exc
329
356
  if reply.get("ok") and path in ("/control/reset", "/control/step", "/control/observe"):
330
357
  try:
331
- if (
332
- self.metadata.get("cameras")
333
- and response.getheader("Content-Type", "").split(";")[0] != CONTENT_TYPE
334
- ):
335
- raise ValueError("image observation silently downgraded to JSON")
358
+ if self.renderer is not None and "render_state" not in reply:
359
+ raise ValueError("observation without the poses to render")
336
360
  if not isinstance(reply["obs"], dict) or not isinstance(reply["info"], dict):
337
361
  raise ValueError("invalid observation or info")
338
362
  for key, spec in (
@@ -362,7 +386,45 @@ class RemoteEnv:
362
386
  if reply.get("code") == "recovery_required":
363
387
  self._uncertain = True
364
388
  raise_for(reply)
389
+ if path not in ("/control/reset", "/control/step", "/control/observe"):
365
390
  return reply
391
+ server_ms = reply["info"].get("timings", {}).get("server_ms", 0)
392
+ timings = {
393
+ "server_ms": server_ms,
394
+ "transport_ms": max(0, (received - started) * 1000 - server_ms),
395
+ "response_bytes": len(data),
396
+ }
397
+ if self.renderer is not None:
398
+ # Outside the lock: the heartbeat must not wait for a frame. A
399
+ # render failure leaves physics as stepped; observe() renders again.
400
+ rendering = time.perf_counter()
401
+ reply["obs"]["rgb"] = self.renderer.render(reply["render_state"])
402
+ timings["render_ms"] = (time.perf_counter() - rendering) * 1000
403
+ timings["total_ms"] = (time.perf_counter() - started) * 1000
404
+ self.last_timings = timings
405
+ return reply
406
+
407
+ def _open_render(self, path: str):
408
+ """GET <server>/render<path> with this session's credentials, on its own
409
+ connection (the renderer runs on another thread)."""
410
+ parsed = urllib.parse.urlsplit(self._base)
411
+ transport = (
412
+ http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
413
+ )
414
+ conn = transport(parsed.hostname, parsed.port, timeout=max(self._timeout, 120))
415
+ headers = {"X-We-Control-Token": self._token}
416
+ if self._access_provider:
417
+ headers["Authorization"] = "Bearer " + self._access_provider()
418
+ conn.request("GET", self._prefix + "/render" + path, headers=headers)
419
+ response = conn.getresponse()
420
+ if response.status == 404:
421
+ conn.close()
422
+ raise FileNotFoundError(path)
423
+ if response.status != 200:
424
+ detail = response.read(500).decode("utf-8", "replace")
425
+ conn.close()
426
+ raise OSError(f"HTTP {response.status} for {path}: {detail}")
427
+ return _Owned(response, conn)
366
428
 
367
429
  @staticmethod
368
430
  def _np_obs(obs: dict) -> dict:
@@ -377,3 +439,19 @@ class RemoteEnv:
377
439
  else:
378
440
  out[k] = v
379
441
  return out
442
+
443
+
444
+ class _Owned:
445
+ """A response that closes its connection when the caller is done."""
446
+
447
+ def __init__(self, response, conn):
448
+ self._response, self._conn = response, conn
449
+
450
+ def read(self, *a):
451
+ return self._response.read(*a)
452
+
453
+ def __enter__(self):
454
+ return self
455
+
456
+ def __exit__(self, *exc):
457
+ self._conn.close()
@@ -0,0 +1,373 @@
1
+ """Render camera observations on this machine (``render="local"``).
2
+
3
+ The server only simulates physics. It sends the scene description once at
4
+ connect and the poses with every observation; a headless Chrome started here
5
+ runs the same capture page the server-side renderer runs, so the images match.
6
+ Headless Chrome is never a "background tab", so nothing throttles it.
7
+
8
+ The capture page, its scripts and the scene assets are fetched from the
9
+ server's ``/render/`` prefix with this session's credentials. Assets are cached
10
+ by content digest in a temporary directory that ``close()`` deletes, or in
11
+ ``cache_dir`` to keep them across sessions.
12
+
13
+ Needs the ``render`` extra (Playwright, websockets, Pillow) and a Chrome or
14
+ Chromium: ``pip install 'world-engine-client[render]'`` then
15
+ ``python -m playwright install chromium`` (or use an installed Chrome).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import hashlib
22
+ import io
23
+ import json
24
+ import mimetypes
25
+ import os
26
+ import shlex
27
+ import shutil
28
+ import sys
29
+ import tempfile
30
+ import threading
31
+ import warnings
32
+ from collections.abc import Callable
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+
37
+ from .errors import WorldEngineClientError
38
+
39
+ # Hardware WebGL in headless Chrome, per platform. The Linux flags are the ones
40
+ # the server renderer is verified with (NVIDIA through Vulkan ANGLE); macOS uses
41
+ # Metal; Windows uses Chrome's default (D3D11). WE_RENDER_CHROME_ARGS overrides.
42
+ if sys.platform == "darwin":
43
+ _CHROME_ARGS = ["--headless=new", "--use-angle=metal", "--ignore-gpu-blocklist"]
44
+ elif sys.platform.startswith("linux"):
45
+ _CHROME_ARGS = [
46
+ "--headless=new",
47
+ "--ozone-platform=headless",
48
+ "--use-angle=vulkan",
49
+ "--enable-features=Vulkan",
50
+ "--use-gl=angle",
51
+ "--ignore-gpu-blocklist",
52
+ "--no-sandbox",
53
+ ]
54
+ else:
55
+ _CHROME_ARGS = ["--headless=new", "--ignore-gpu-blocklist"]
56
+
57
+ _INSTALL_FIXES = [
58
+ "pip install 'world-engine-client[render]' && python -m playwright install chromium",
59
+ "or pass images=False for a state-only session",
60
+ ]
61
+
62
+
63
+ class RendererError(WorldEngineClientError):
64
+ """Local rendering is unavailable or failed. The physics step itself is
65
+ unaffected: ``env.observe()`` renders the current state again."""
66
+
67
+
68
+ def available() -> bool:
69
+ """True when the ``render`` extra is installed (Chrome is checked on start)."""
70
+ import importlib.util
71
+
72
+ return all(importlib.util.find_spec(m) is not None for m in ("playwright", "websockets", "PIL"))
73
+
74
+
75
+ Fetch = Callable[[str], "io.BufferedIOBase"]
76
+
77
+
78
+ class LocalRenderer:
79
+ """Headless Chrome running the capture page; ``render(state)`` -> images.
80
+
81
+ ``fetch(path)`` opens ``<server>/render<path>`` with the session's
82
+ credentials and returns a readable response."""
83
+
84
+ def __init__(
85
+ self,
86
+ fetch: Fetch,
87
+ scene_init: dict,
88
+ digests: dict[str, str],
89
+ *,
90
+ cache_dir: str | os.PathLike | None = None,
91
+ allow_software: bool = False,
92
+ chrome_args: list[str] | None = None,
93
+ timeout: float = 300.0,
94
+ ):
95
+ if not available():
96
+ raise RendererError(
97
+ "local rendering needs the render extra",
98
+ code="renderer_unavailable",
99
+ fixes=_INSTALL_FIXES,
100
+ )
101
+ self._fetch = fetch
102
+ self._scene_init = scene_init
103
+ self._digests = dict(digests)
104
+ self._own_cache = cache_dir is None
105
+ self._cache = Path(cache_dir or tempfile.mkdtemp(prefix="we_render_"))
106
+ self._cache.mkdir(parents=True, exist_ok=True)
107
+ self._allow_software = allow_software
108
+ override = os.environ.get("WE_RENDER_CHROME_ARGS")
109
+ self._args = chrome_args or (shlex.split(override) if override else _CHROME_ARGS)
110
+ self._timeout = timeout
111
+ self.gpu = "(unknown)"
112
+ self.software = False
113
+ self.lighting: str | None = None
114
+ self._frame_id = 0
115
+ self._pending: dict[int, asyncio.Future] = {}
116
+ self._ws = None
117
+ self._ready = None
118
+ self._page_error: dict | None = None
119
+ self._console: list[str] = []
120
+ self._http = None
121
+ self._download_lock = threading.Lock()
122
+ self._loop = asyncio.new_event_loop()
123
+ self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
124
+ self._thread.start()
125
+ try:
126
+ self._call(self._start(), timeout + 30)
127
+ except BaseException:
128
+ self.close()
129
+ raise
130
+
131
+ # ----- public ------------------------------------------------------------- #
132
+ def render(self, state: dict) -> dict[str, np.ndarray]:
133
+ """Pose the scene as ``state`` (a ``render_state`` from the server) and
134
+ return ``{camera name: (H, W, 3) uint8}``."""
135
+ if self._ws is None:
136
+ raise RendererError("renderer is closed", code="renderer_unavailable")
137
+ header, payload = self._call(self._render(dict(state)), self._timeout)
138
+ from PIL import Image
139
+
140
+ images = {}
141
+ for spec in header.get("images", []):
142
+ chunk = payload[spec["offset"] : spec["offset"] + spec["length"]]
143
+ with Image.open(io.BytesIO(chunk)) as img:
144
+ images[spec["name"]] = np.asarray(img.convert("RGB"), dtype=np.uint8).copy()
145
+ self.last_timings = header.get("timings", {})
146
+ return images
147
+
148
+ def close(self) -> None:
149
+ if self._loop.is_running():
150
+ try:
151
+ self._call(self._stop(), 30)
152
+ except Exception:
153
+ pass
154
+ self._loop.call_soon_threadsafe(self._loop.stop)
155
+ self._thread.join(timeout=10)
156
+ if self._own_cache:
157
+ shutil.rmtree(self._cache, ignore_errors=True)
158
+
159
+ def __enter__(self) -> LocalRenderer:
160
+ return self
161
+
162
+ def __exit__(self, *exc) -> None:
163
+ self.close()
164
+
165
+ # ----- loop thread ---------------------------------------------------------- #
166
+ def _call(self, coro, timeout):
167
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
168
+ try:
169
+ return future.result(timeout)
170
+ except TimeoutError as exc:
171
+ future.cancel()
172
+ raise RendererError(
173
+ "local renderer timed out: " + " | ".join(self._console[-5:]),
174
+ code="renderer_unavailable",
175
+ ) from exc
176
+
177
+ async def _start(self):
178
+ import websockets
179
+ from playwright.async_api import async_playwright
180
+
181
+ self._ready = asyncio.Event()
182
+
183
+ async def handler(ws):
184
+ self._ws = ws
185
+ async for raw in ws:
186
+ if isinstance(raw, (bytes, bytearray)):
187
+ buf = bytes(raw)
188
+ size = int.from_bytes(buf[:4], "little")
189
+ header = json.loads(buf[4 : 4 + size].decode("utf-8"))
190
+ fut = self._pending.pop(header.get("frame_id"), None)
191
+ if fut is not None and not fut.done():
192
+ fut.set_result((header, buf[4 + size :]))
193
+ continue
194
+ msg = json.loads(raw)
195
+ if msg.get("type") == "ready":
196
+ self.gpu = msg.get("gpu", self.gpu)
197
+ self.software = bool(msg.get("software"))
198
+ self.lighting = msg.get("lighting")
199
+ self._ready.set()
200
+ elif msg.get("type") == "error":
201
+ self._page_error = msg
202
+ self.gpu = msg.get("gpu", self.gpu)
203
+ self._ready.set()
204
+
205
+ self._server = await websockets.serve(handler, "127.0.0.1", 0, max_size=None)
206
+ port = self._server.sockets[0].getsockname()[1]
207
+ self._pw = await async_playwright().start()
208
+ self._browser = await self._launch()
209
+ self._page = await self._browser.new_page()
210
+ self._page.on(
211
+ "console",
212
+ lambda m: m.type in ("warning", "error") and self._console.append(m.text[:300]),
213
+ )
214
+ self._page.on("pageerror", lambda e: self._console.append(f"pageerror: {e}"))
215
+ # A real loopback HTTP server serves the page: Chrome's local network
216
+ # access checks block a page that is not itself on loopback (including
217
+ # one answered by request interception) from opening ws://127.0.0.1.
218
+ self._http = _ProxyServer(self)
219
+ origin = f"http://127.0.0.1:{self._http.port}"
220
+ query = f"captureOnly=1&ws=ws://127.0.0.1:{port}/"
221
+ if self._allow_software:
222
+ query += "&allowSoftware=1"
223
+ await self._page.goto(f"{origin}/index.html?{query}", wait_until="load")
224
+ for _ in range(int(self._timeout * 10)):
225
+ if self._ws is not None:
226
+ break
227
+ await asyncio.sleep(0.1)
228
+ if self._ws is None:
229
+ raise RendererError(
230
+ "the capture page did not start: " + " | ".join(self._console[-5:]),
231
+ code="renderer_unavailable",
232
+ )
233
+ await self._ws.send(json.dumps(self._scene_init))
234
+ try:
235
+ await asyncio.wait_for(self._ready.wait(), self._timeout)
236
+ except TimeoutError as exc:
237
+ raise RendererError(
238
+ "scene loading timed out: " + " | ".join(self._console[-5:]),
239
+ code="renderer_unavailable",
240
+ ) from exc
241
+ if self._page_error is not None:
242
+ raise RendererError(
243
+ f"Chrome has no hardware WebGL here ({self.gpu}); software rendering "
244
+ "is very slow, so it is off by default",
245
+ code="software_renderer",
246
+ fixes=[
247
+ "check chrome://gpu, or set WE_RENDER_CHROME_ARGS for your GPU",
248
+ "pass render_options={'allow_software': True} to accept slow rendering",
249
+ "or pass images=False for a state-only session",
250
+ ],
251
+ )
252
+ if self.software:
253
+ warnings.warn(
254
+ f"rendering camera images in software ({self.gpu}); expect a few frames per second",
255
+ RuntimeWarning,
256
+ stacklevel=2,
257
+ )
258
+
259
+ async def _launch(self):
260
+ errors = []
261
+ for channel in ("chrome", None): # an installed Chrome, else Playwright's
262
+ try:
263
+ return await self._pw.chromium.launch(
264
+ channel=channel,
265
+ headless=True,
266
+ args=self._args,
267
+ ignore_default_args=["--disable-gpu", "--headless"],
268
+ )
269
+ except Exception as exc: # noqa: BLE001 - try the next browser
270
+ errors.append(str(exc).splitlines()[0])
271
+ raise RendererError(
272
+ "no Chrome or Chromium to render with: " + " | ".join(errors),
273
+ code="renderer_unavailable",
274
+ fixes=_INSTALL_FIXES,
275
+ )
276
+
277
+ async def _render(self, state):
278
+ self._frame_id += 1
279
+ state["frame_id"] = self._frame_id
280
+ fut = self._loop.create_future()
281
+ self._pending[self._frame_id] = fut
282
+ await self._ws.send(json.dumps(state))
283
+ return await fut
284
+
285
+ def _provide(self, path: str) -> tuple[Path | bytes, str]:
286
+ """The body for ``path`` (a cached file or bytes) and its content type."""
287
+ ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
288
+ if path.endswith(".js"):
289
+ ctype = "text/javascript"
290
+ digest = self._digests.get(path)
291
+ if digest is None:
292
+ return self._read(path), ctype
293
+ cached = self._cache / digest
294
+ with self._download_lock:
295
+ if not cached.is_file():
296
+ self._download(path, digest, cached)
297
+ return cached, ctype
298
+
299
+ def _read(self, path: str) -> bytes:
300
+ with self._fetch(path) as response:
301
+ return response.read()
302
+
303
+ def _download(self, path: str, digest: str, target: Path) -> None:
304
+ partial = target.with_suffix(".part")
305
+ h = hashlib.sha256()
306
+ with self._fetch(path) as response, open(partial, "wb") as out:
307
+ for chunk in iter(lambda: response.read(1 << 20), b""):
308
+ h.update(chunk)
309
+ out.write(chunk)
310
+ if h.hexdigest() != digest:
311
+ partial.unlink(missing_ok=True)
312
+ raise RendererError(f"asset {path} does not match its digest", code="asset_mismatch")
313
+ partial.replace(target)
314
+
315
+ async def _stop(self):
316
+ for name in ("_browser", "_pw"):
317
+ obj = getattr(self, name, None)
318
+ if obj is not None:
319
+ try:
320
+ await (obj.close() if name == "_browser" else obj.stop())
321
+ except Exception:
322
+ pass
323
+ server = getattr(self, "_server", None)
324
+ if server is not None:
325
+ server.close()
326
+ await server.wait_closed()
327
+ if self._http is not None:
328
+ self._http.stop()
329
+ self._http = None
330
+ self._ws = None
331
+
332
+
333
+ class _ProxyServer:
334
+ """Loopback HTTP server for the capture page: each GET is answered from
335
+ the digest cache or fetched from the server's /render/ prefix."""
336
+
337
+ def __init__(self, renderer: LocalRenderer):
338
+ import http.server
339
+
340
+ class Handler(http.server.BaseHTTPRequestHandler):
341
+ def do_GET(self): # noqa: N802 - http.server API
342
+ path = self.path.split("?", 1)[0]
343
+ try:
344
+ body, ctype = renderer._provide(path)
345
+ except FileNotFoundError:
346
+ self.send_error(404)
347
+ return
348
+ except Exception as exc: # noqa: BLE001 - reported in the console tail
349
+ renderer._console.append(f"fetch {path}: {exc}")
350
+ self.send_error(502)
351
+ return
352
+ size = body.stat().st_size if isinstance(body, Path) else len(body)
353
+ self.send_response(200)
354
+ self.send_header("Content-Type", ctype)
355
+ self.send_header("Content-Length", str(size))
356
+ self.end_headers()
357
+ if isinstance(body, Path):
358
+ with open(body, "rb") as f:
359
+ shutil.copyfileobj(f, self.wfile, 1 << 20)
360
+ else:
361
+ self.wfile.write(body)
362
+
363
+ def log_message(self, *args):
364
+ pass
365
+
366
+ self._httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
367
+ self._httpd.daemon_threads = True
368
+ self.port = self._httpd.server_address[1]
369
+ threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
370
+
371
+ def stop(self) -> None:
372
+ self._httpd.shutdown()
373
+ self._httpd.server_close()
@@ -56,8 +56,7 @@ def matrix_to_axisangle(m: np.ndarray) -> np.ndarray:
56
56
  if m[0, 2] + m[2, 0] < 0:
57
57
  axis[2] = -axis[2]
58
58
  return axis * angle
59
- axis = (
60
- np.array([m[2, 1] - m[1, 2], m[0, 2] - m[2, 0], m[1, 0] - m[0, 1]])
61
- / (2.0 * np.sin(angle))
59
+ axis = np.array([m[2, 1] - m[1, 2], m[0, 2] - m[2, 0], m[1, 0] - m[0, 1]]) / (
60
+ 2.0 * np.sin(angle)
62
61
  )
63
62
  return axis * angle
@@ -4,15 +4,13 @@ These tests exercise the client alone. The wire contract against a real engine
4
4
  is covered in the server repository (tests/test_remote_*.py).
5
5
  """
6
6
 
7
- import io
8
7
  import json
9
8
  import socket
10
9
  import threading
11
- from http.server import BaseHTTPRequestHandler, HTTPServer
10
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
11
 
13
12
  import pytest
14
13
 
15
- CONTENT_TYPE = "application/vnd.world-engine.observation-v3"
16
14
  DROP = object() # responder return value: close the socket without replying
17
15
 
18
16
  ENV = {
@@ -33,25 +31,6 @@ ENV = {
33
31
  OBS = {"robot0_eef_pos": [0.1, 0.2, 0.3], "object_ids": ["cube"]}
34
32
 
35
33
 
36
- def jpeg(width, height, color=(200, 30, 30)):
37
- from PIL import Image
38
-
39
- buf = io.BytesIO()
40
- Image.new("RGB", (width, height), color).save(buf, format="JPEG", quality=90)
41
- return buf.getvalue()
42
-
43
-
44
- def pack(header, chunks, specs=None):
45
- """Protocol-3 packet: u32 length, JSON envelope, concatenated JPEGs."""
46
- if specs is None:
47
- specs, offset = [], 0
48
- for name, w, h, data in chunks:
49
- specs.append({"name": name, "w": w, "h": h, "offset": offset, "length": len(data)})
50
- offset += len(data)
51
- meta = json.dumps({"wire_version": 3, **header, "images": specs}).encode()
52
- return len(meta).to_bytes(4, "little") + meta + b"".join(c[3] for c in chunks)
53
-
54
-
55
34
  class ControlStub:
56
35
  """Records every request; ``responders[path]`` overrides the default reply."""
57
36
 
@@ -60,6 +39,7 @@ class ControlStub:
60
39
  self.responders = {}
61
40
  self.step_id = 0
62
41
  self.episode = 0
42
+ self.files = {} # GET path -> bytes (the /render/ prefix)
63
43
 
64
44
  def default(self, path, body):
65
45
  path = path[path.index("/control/") :] # tolerate a gateway prefix
@@ -126,10 +106,25 @@ def stub():
126
106
  self.end_headers()
127
107
  self.wfile.write(data)
128
108
 
109
+ def do_GET(self):
110
+ state.calls.append((self.path, None, dict(self.headers)))
111
+ data = state.files.get(self.path)
112
+ if data is None:
113
+ self.send_response(404)
114
+ self.send_header("Content-Length", "0")
115
+ self.end_headers()
116
+ return
117
+ self.send_response(200)
118
+ self.send_header("Content-Type", "application/octet-stream")
119
+ self.send_header("Content-Length", str(len(data)))
120
+ self.end_headers()
121
+ self.wfile.write(data)
122
+
129
123
  def log_message(self, *args):
130
124
  pass
131
125
 
132
- server = HTTPServer(("127.0.0.1", 0), Handler)
126
+ server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
127
+ server.daemon_threads = True
133
128
  threading.Thread(target=server.serve_forever, daemon=True).start()
134
129
  state.url = f"http://127.0.0.1:{server.server_address[1]}"
135
130
  yield state
@@ -4,13 +4,11 @@ The rule under test: a request whose outcome is unknown is never retried, and
4
4
  step/reset failures block further actions until an explicit reset or reconnect.
5
5
  """
6
6
 
7
- import copy
8
- import sys
9
7
  import threading
10
8
 
11
9
  import numpy as np
12
10
  import pytest
13
- from conftest import CONTENT_TYPE, DROP, ENV, OBS, jpeg, pack
11
+ from conftest import DROP, ENV, OBS
14
12
 
15
13
  import world_engine_client as wec
16
14
  from world_engine_client import env as env_module
@@ -286,48 +284,6 @@ def test_render_requires_an_image_observation(env):
286
284
  env.render()
287
285
 
288
286
 
289
- def image_env(stub):
290
- with_cameras = {**copy.deepcopy(ENV), "cameras": CAMERAS, "available_cameras": CAMERAS}
291
- stub.responders["/control/connect"] = lambda body: {
292
- "ok": True,
293
- "session_id": "sess",
294
- "proto": 3,
295
- "instance_id": "inst",
296
- "episode_id": "e0",
297
- "env": with_cameras,
298
- }
299
- return wec.connect("secret", url=stub.url, images=True)
300
-
301
-
302
- def test_binary_image_reply_decodes_and_reports_timings(stub):
303
- env = image_env(stub)
304
- assert stub.calls[0][1]["images"] is True
305
- header = {"ok": True, "obs": OBS, "info": {"episode_id": "e0", "timings": {"server_ms": 1}}}
306
- packet = pack(header, [("front", 8, 6, jpeg(8, 6))])
307
- stub.responders["/control/observe"] = lambda body: (CONTENT_TYPE, packet)
308
- obs, info = env.observe()
309
- assert obs["rgb"]["front"].shape == (6, 8, 3)
310
- assert env.render() is obs["rgb"]["front"] and env.render("front") is obs["rgb"]["front"]
311
- assert {"decode_ms", "transport_ms", "total_ms", "server_ms"} <= set(env.last_timings)
312
- assert env.last_timings["response_bytes"] == len(packet)
313
- assert env.last_timings["transport_ms"] >= 0
314
- env.close()
315
-
316
-
317
- def test_image_session_rejects_a_json_downgrade(stub):
318
- env = image_env(stub)
319
- with pytest.raises(wec.ProtocolError, match="malformed observation"):
320
- env.observe()
321
- env.close()
322
-
323
-
324
- def test_images_without_pillow_fail_before_any_request(stub, monkeypatch):
325
- monkeypatch.setitem(sys.modules, "PIL", None)
326
- with pytest.raises(ImportError, match=r"world-engine-client\[img\]"):
327
- wec.connect("secret", url=stub.url, cameras=["front"])
328
- assert stub.calls == []
329
-
330
-
331
287
  class Ticks:
332
288
  """Stands in for the stop event: ``wait`` returns immediately, n times."""
333
289
 
@@ -0,0 +1,175 @@
1
+ """Client rendering without a browser: the extra requirement, the render_state
2
+ hand-off, credentials on asset requests and the digest cache."""
3
+
4
+ import copy
5
+ import hashlib
6
+
7
+ import numpy as np
8
+ import pytest
9
+ from conftest import ENV, OBS
10
+
11
+ import world_engine_client as wec
12
+ from world_engine_client import render as render_module
13
+
14
+ CAMERAS = {"front": {"width": 8, "height": 6}}
15
+ STATE = {"type": "state", "frame_id": 0, "bodies": [], "robot": {}, "cameras": []}
16
+ BUNDLE = {
17
+ "protocol": 1,
18
+ "scene_init": {"type": "scene_init", "cameras": []},
19
+ "digests": {"/__asset/0.ply": "ab" * 32},
20
+ }
21
+
22
+
23
+ class FakeRenderer:
24
+ instances = []
25
+
26
+ def __init__(self, fetch, scene_init, digests, **options):
27
+ self.fetch, self.scene_init, self.digests = fetch, scene_init, digests
28
+ self.options = options
29
+ self.states, self.closed, self.fail = [], False, False
30
+ FakeRenderer.instances.append(self)
31
+
32
+ def render(self, state):
33
+ if self.fail:
34
+ raise render_module.RendererError("GPU lost", code="renderer_unavailable")
35
+ self.states.append(state)
36
+ return {"front": np.full((6, 8, 3), len(self.states), np.uint8)}
37
+
38
+ def close(self):
39
+ self.closed = True
40
+
41
+
42
+ @pytest.fixture
43
+ def client_render(stub, monkeypatch):
44
+ """A stub that offers client rendering; LocalRenderer replaced by a fake."""
45
+ FakeRenderer.instances = []
46
+ monkeypatch.setattr(render_module, "available", lambda: True)
47
+ monkeypatch.setattr(render_module, "LocalRenderer", FakeRenderer)
48
+ env = {**copy.deepcopy(ENV), "cameras": CAMERAS, "render": "client"}
49
+ stub.responders["/control/connect"] = lambda body: {
50
+ "ok": True,
51
+ "session_id": "sess",
52
+ "proto": 3,
53
+ "instance_id": "inst",
54
+ "episode_id": "e0",
55
+ "env": env,
56
+ **({"render": BUNDLE} if body.get("render") == "client" else {}),
57
+ }
58
+ for path in ("/control/reset", "/control/observe"):
59
+ stub.responders[path] = lambda body: {
60
+ "ok": True,
61
+ "obs": dict(OBS),
62
+ "info": {"episode_id": "e1", "step_id": 0},
63
+ "render_state": STATE,
64
+ }
65
+ stub.responders["/control/step"] = lambda body: {
66
+ "ok": True,
67
+ "obs": dict(OBS),
68
+ "info": {"episode_id": "e1", "step_id": 1},
69
+ "reward": 0.0,
70
+ "terminated": False,
71
+ "truncated": False,
72
+ "render_state": STATE,
73
+ }
74
+ return stub
75
+
76
+
77
+ def test_images_are_rendered_locally(client_render):
78
+ env = wec.connect("secret", url=client_render.url, images=True, render_options={"x": 1})
79
+ connect = client_render.calls[0][1]
80
+ assert connect["render"] == "client" and connect["images"] is True
81
+ fake = FakeRenderer.instances[0]
82
+ assert fake.scene_init == BUNDLE["scene_init"] and fake.options == {"x": 1}
83
+ obs, _ = env.reset(seed=0)
84
+ assert obs["rgb"]["front"].shape == (6, 8, 3) and fake.states == [STATE]
85
+ assert env.render() is obs["rgb"]["front"]
86
+ env.close()
87
+ assert fake.closed and client_render.paths()[-1] == "/control/release"
88
+
89
+
90
+ @pytest.mark.parametrize("kw", [{"images": True}, {"cameras": ["front"]}])
91
+ def test_images_without_the_extra_fail_before_any_request(stub, monkeypatch, kw):
92
+ monkeypatch.setattr(render_module, "available", lambda: False)
93
+ with pytest.raises(wec.RendererError, match=r"render\]"):
94
+ wec.connect("secret", url=stub.url, **kw)
95
+ assert stub.calls == []
96
+
97
+
98
+ @pytest.mark.parametrize("mode", ["server", "browser"])
99
+ def test_servers_never_render(stub, mode):
100
+ with pytest.raises(ValueError, match="no longer render"):
101
+ wec.connect("secret", url=stub.url, images=True, render=mode)
102
+ assert stub.calls == []
103
+
104
+
105
+ def test_a_server_without_client_rendering_is_released(client_render):
106
+ client_render.responders["/control/connect"] = lambda body: {
107
+ "ok": True,
108
+ "session_id": "sess",
109
+ "proto": 3,
110
+ "instance_id": "inst",
111
+ "episode_id": "e0",
112
+ "env": {**copy.deepcopy(ENV), "cameras": CAMERAS},
113
+ }
114
+ with pytest.raises(wec.ProtocolError, match="predates client rendering"):
115
+ wec.connect("secret", url=client_render.url, images=True)
116
+ assert client_render.paths()[-1] == "/control/release"
117
+
118
+
119
+ def test_a_render_failure_leaves_physics_and_observe_renders_again(client_render):
120
+ env = wec.connect("secret", url=client_render.url, images=True)
121
+ env.reset(seed=0)
122
+ fake = FakeRenderer.instances[0]
123
+ fake.fail = True
124
+ with pytest.raises(wec.RendererError, match="GPU lost"):
125
+ env.observe()
126
+ fake.fail = False
127
+ obs, _ = env.observe()
128
+ assert obs["rgb"]["front"][0, 0, 0] == 2
129
+ obs, *_ = env.step([0, 0, 0, 0]) # the failed render blocked no action
130
+ assert obs["rgb"]["front"][0, 0, 0] == 3
131
+ env.close()
132
+
133
+
134
+ def test_an_observation_without_poses_is_a_protocol_error(client_render):
135
+ env = wec.connect("secret", url=client_render.url, images=True)
136
+ client_render.responders["/control/observe"] = lambda body: {
137
+ "ok": True,
138
+ "obs": dict(OBS),
139
+ "info": {"episode_id": "e1", "step_id": 0},
140
+ }
141
+ with pytest.raises(wec.ProtocolError, match="malformed observation"):
142
+ env.observe()
143
+ env.close()
144
+
145
+
146
+ def test_asset_requests_carry_the_session_credentials(client_render):
147
+ env = wec.connect(
148
+ "secret", url=client_render.url, images=True, access_provider=lambda: "scoped"
149
+ )
150
+ client_render.files["/render/index.html"] = b"<html>"
151
+ with env._open_render("/index.html") as response:
152
+ assert response.read() == b"<html>"
153
+ path, _, headers = client_render.calls[-1]
154
+ assert path == "/render/index.html"
155
+ assert headers["X-We-Control-Token"] == "secret"
156
+ assert headers["Authorization"] == "Bearer scoped"
157
+ with pytest.raises(FileNotFoundError):
158
+ env._open_render("/missing.js")
159
+ env.close()
160
+
161
+
162
+ def test_downloads_are_verified_against_their_digest(tmp_path, stub):
163
+ renderer = object.__new__(render_module.LocalRenderer) # no browser needed
164
+ env = wec.connect("secret", url=stub.url)
165
+ renderer._fetch = env._open_render
166
+ data = b"splat bytes"
167
+ stub.files["/render/__asset/0.ply"] = data
168
+ good = tmp_path / hashlib.sha256(data).hexdigest()
169
+ renderer._download("/__asset/0.ply", good.name, good)
170
+ assert good.read_bytes() == data
171
+ bad = tmp_path / ("0" * 64)
172
+ with pytest.raises(wec.RendererError, match="digest"):
173
+ renderer._download("/__asset/0.ply", bad.name, bad)
174
+ assert not bad.exists() and not bad.with_suffix(".part").exists()
175
+ env.close()
@@ -1,66 +0,0 @@
1
- """Strict protocol-3 binary decoder; Pillow is imported only for images."""
2
-
3
- import io
4
- import json
5
-
6
- import numpy as np
7
-
8
- from .errors import ProtocolError
9
-
10
- CONTENT_TYPE = "application/vnd.world-engine.observation-v3"
11
- MAX_RESPONSE = 64 * 1024 * 1024
12
-
13
-
14
- def decode(data: bytes, cameras: dict) -> dict:
15
- try:
16
- if len(data) < 4 or len(data) > MAX_RESPONSE:
17
- raise ValueError("invalid packet length")
18
- length = int.from_bytes(data[:4], "little")
19
- if not 0 < length <= min(len(data) - 4, 1024 * 1024):
20
- raise ValueError("invalid metadata length")
21
- header = json.loads(data[4 : 4 + length])
22
- if type(header["wire_version"]) is not int or header["wire_version"] != 3:
23
- raise ValueError("unsupported observation version")
24
- payload = data[4 + length :]
25
- seen, offset = set(), 0
26
- for spec in header["images"]:
27
- name = spec["name"]
28
- if name in seen or name not in cameras:
29
- raise ValueError("unexpected or duplicate camera")
30
- seen.add(name)
31
- n = spec["length"]
32
- if (
33
- type(n) is not int
34
- or type(spec["offset"]) is not int
35
- or type(spec["w"]) is not int
36
- or type(spec["h"]) is not int
37
- or n <= 0
38
- or spec["offset"] != offset
39
- or offset + n > len(payload)
40
- ):
41
- raise ValueError("invalid image offset/length")
42
- expected = cameras[name]
43
- if (spec["w"], spec["h"]) != (expected["width"], expected["height"]):
44
- raise ValueError("camera dimensions changed")
45
- offset += n
46
- if seen != set(cameras) or offset != len(payload):
47
- raise ValueError("missing images or trailing data")
48
- try:
49
- from PIL import Image
50
- except ImportError as exc:
51
- raise ImportError(
52
- "RGB observations require pip install 'world-engine-client[img]'"
53
- ) from exc
54
- rgb = {}
55
- for spec in header["images"]:
56
- chunk = payload[spec["offset"] : spec["offset"] + spec["length"]]
57
- if not chunk.endswith(b"\xff\xd9"):
58
- raise ValueError("truncated JPEG")
59
- with Image.open(io.BytesIO(chunk)) as img:
60
- if img.format != "JPEG" or img.size != (spec["w"], spec["h"]):
61
- raise ValueError("decoded image dimensions or format mismatch")
62
- rgb[spec["name"]] = np.asarray(img.convert("RGB"), dtype=np.uint8).copy()
63
- header["obs"]["rgb"] = rgb
64
- return header
65
- except (KeyError, TypeError, ValueError, OSError) as exc:
66
- raise ProtocolError(f"malformed observation: {exc}") from exc
@@ -1,140 +0,0 @@
1
- """wire.decode accepts exactly the documented packet and nothing else."""
2
-
3
- import json
4
- import sys
5
-
6
- import numpy as np
7
- import pytest
8
- from conftest import jpeg, pack
9
-
10
- from world_engine_client import ProtocolError, wire
11
-
12
- CAMERAS = {"front": {"width": 8, "height": 6}, "wrist": {"width": 4, "height": 4}}
13
- HEADER = {"obs": {"robot0_eef_pos": [0, 0, 0]}, "info": {"step_id": 1}}
14
-
15
-
16
- def chunks():
17
- return [("front", 8, 6, jpeg(8, 6)), ("wrist", 4, 4, jpeg(4, 4, (10, 10, 240)))]
18
-
19
-
20
- def test_round_trip_returns_rgb_uint8_per_camera():
21
- reply = wire.decode(pack(HEADER, chunks()), CAMERAS)
22
- rgb = reply["obs"]["rgb"]
23
- assert list(rgb) == ["front", "wrist"]
24
- assert rgb["front"].shape == (6, 8, 3) and rgb["front"].dtype == np.uint8
25
- assert rgb["wrist"].shape == (4, 4, 3)
26
- # Channel order survives: the first camera is red, the second blue.
27
- assert rgb["front"][0, 0, 0] > 150 > rgb["front"][0, 0, 2]
28
- assert rgb["wrist"][0, 0, 2] > 150 > rgb["wrist"][0, 0, 0]
29
- assert reply["info"] == {"step_id": 1}
30
-
31
-
32
- def raw(header: bytes, payload: bytes = b"") -> bytes:
33
- return len(header).to_bytes(4, "little") + header + payload
34
-
35
-
36
- def respec(**change):
37
- """A two-camera packet whose first descriptor is altered."""
38
- parts = chunks()
39
- specs, offset = [], 0
40
- for name, w, h, data in parts:
41
- specs.append({"name": name, "w": w, "h": h, "offset": offset, "length": len(data)})
42
- offset += len(data)
43
- specs[0].update(change)
44
- return pack(HEADER, parts, specs)
45
-
46
-
47
- @pytest.mark.parametrize(
48
- "packet",
49
- [
50
- b"",
51
- b"\x01\x00",
52
- raw(b""), # zero metadata length
53
- (2 * 1024 * 1024).to_bytes(4, "little") + b"{}", # metadata longer than the packet
54
- raw(b"{not json"),
55
- raw(json.dumps({"wire_version": 3, "obs": {}}).encode()), # no images key
56
- raw(json.dumps({"wire_version": 3, "images": []}).encode()), # no obs, missing cameras
57
- raw(json.dumps([1, 2]).encode()),
58
- ],
59
- ids=["empty", "short", "zero-meta", "meta-overrun", "bad-json", "no-images", "no-obs", "list"],
60
- )
61
- def test_malformed_envelopes_are_rejected(packet):
62
- with pytest.raises(ProtocolError, match="malformed observation"):
63
- wire.decode(packet, CAMERAS)
64
-
65
-
66
- @pytest.mark.parametrize("version", [2, 4, "3", 3.0, True, None])
67
- def test_only_integer_wire_version_3_is_accepted(version):
68
- meta = json.dumps({"wire_version": version, "obs": {}, "images": []}).encode()
69
- with pytest.raises(ProtocolError):
70
- wire.decode(raw(meta), {})
71
-
72
-
73
- @pytest.mark.parametrize(
74
- "change",
75
- [
76
- {"name": "wrist"}, # duplicate
77
- {"name": "side"}, # not negotiated
78
- {"length": 0},
79
- {"length": -5},
80
- {"length": "12"},
81
- {"length": 12.0},
82
- {"offset": 1},
83
- {"offset": "0"},
84
- {"w": 8.0},
85
- {"h": "6"},
86
- {"w": 16}, # disagrees with the negotiated dimensions
87
- {"length": 10**9}, # beyond the payload
88
- ],
89
- ids=lambda c: "-".join(f"{k}={v!r}" for k, v in c.items()),
90
- )
91
- def test_bad_image_descriptors_are_rejected(change):
92
- with pytest.raises(ProtocolError):
93
- wire.decode(respec(**change), CAMERAS)
94
-
95
-
96
- def test_missing_camera_and_trailing_bytes_are_rejected():
97
- with pytest.raises(ProtocolError, match="missing images"):
98
- wire.decode(pack(HEADER, chunks()[:1]), CAMERAS)
99
- with pytest.raises(ProtocolError, match="trailing"):
100
- wire.decode(pack(HEADER, chunks()) + b"\x00", CAMERAS)
101
-
102
-
103
- def test_non_contiguous_offsets_are_rejected():
104
- parts = chunks()
105
- specs = [
106
- {"name": "front", "w": 8, "h": 6, "offset": 0, "length": len(parts[0][3])},
107
- {"name": "wrist", "w": 4, "h": 4, "offset": len(parts[0][3]) + 1, "length": 1},
108
- ]
109
- with pytest.raises(ProtocolError, match="offset"):
110
- wire.decode(pack(HEADER, parts, specs), CAMERAS)
111
-
112
-
113
- def test_truncated_and_non_jpeg_images_are_rejected():
114
- one = {"front": CAMERAS["front"]}
115
- good = jpeg(8, 6)
116
- with pytest.raises(ProtocolError, match="truncated JPEG"):
117
- wire.decode(pack(HEADER, [("front", 8, 6, good[:-2])]), one)
118
- with pytest.raises(ProtocolError):
119
- wire.decode(pack(HEADER, [("front", 8, 6, b"not an image" + b"\xff\xd9")]), one)
120
-
121
-
122
- def test_decoded_size_must_match_descriptor():
123
- # The descriptor and the negotiated camera agree, but the JPEG itself is 4x4.
124
- packet = pack(HEADER, [("front", 8, 6, jpeg(4, 4))])
125
- with pytest.raises(ProtocolError, match="dimensions or format"):
126
- wire.decode(packet, {"front": CAMERAS["front"]})
127
-
128
-
129
- def test_response_size_limit(monkeypatch):
130
- packet = pack(HEADER, chunks())
131
- monkeypatch.setattr(wire, "MAX_RESPONSE", len(packet) - 1)
132
- with pytest.raises(ProtocolError, match="packet length"):
133
- wire.decode(packet, CAMERAS)
134
-
135
-
136
- def test_missing_pillow_is_an_actionable_import_error(monkeypatch):
137
- packet = pack(HEADER, chunks())
138
- monkeypatch.setitem(sys.modules, "PIL", None)
139
- with pytest.raises(ImportError, match=r"world-engine-client\[img\]"):
140
- wire.decode(packet, CAMERAS)