burtenshaw HF Staff commited on
Commit
5ee0abc
·
verified ·
1 Parent(s): 2fe2e19

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +4 -0
  2. .gitignore +44 -0
  3. Dockerfile +68 -0
  4. README.md +630 -5
  5. __init__.py +12 -0
  6. assets/unity_3dball.gif +3 -0
  7. assets/unity_pushblock.gif +3 -0
  8. client.py +428 -0
  9. envs/unity_env/.gitignore +44 -0
  10. envs/unity_env/README.md +619 -0
  11. envs/unity_env/__init__.py +12 -0
  12. envs/unity_env/assets/unity_3dball.gif +3 -0
  13. envs/unity_env/assets/unity_pushblock.gif +3 -0
  14. envs/unity_env/client.py +428 -0
  15. envs/unity_env/models.py +164 -0
  16. envs/unity_env/openenv.yaml +6 -0
  17. envs/unity_env/pyproject.toml +45 -0
  18. envs/unity_env/server/Dockerfile +66 -0
  19. envs/unity_env/server/__init__.py +11 -0
  20. envs/unity_env/server/app.py +84 -0
  21. envs/unity_env/server/unity_environment.py +554 -0
  22. models.py +164 -0
  23. openenv.yaml +6 -0
  24. pyproject.toml +45 -0
  25. server/Dockerfile +66 -0
  26. server/__init__.py +11 -0
  27. server/app.py +84 -0
  28. server/unity_environment.py +554 -0
  29. src/__init__.py +7 -0
  30. src/openenv/__init__.py +23 -0
  31. src/openenv/auto/__init__.py +39 -0
  32. src/openenv/auto/_discovery.py +584 -0
  33. src/openenv/auto/auto_action.py +276 -0
  34. src/openenv/auto/auto_env.py +896 -0
  35. src/openenv/cli/__init__.py +9 -0
  36. src/openenv/cli/__main__.py +62 -0
  37. src/openenv/cli/_cli_utils.py +79 -0
  38. src/openenv/cli/_validation.py +162 -0
  39. src/openenv/cli/commands/__init__.py +11 -0
  40. src/openenv/cli/commands/build.py +461 -0
  41. src/openenv/cli/commands/fork.py +197 -0
  42. src/openenv/cli/commands/init.py +500 -0
  43. src/openenv/cli/commands/push.py +718 -0
  44. src/openenv/cli/commands/serve.py +94 -0
  45. src/openenv/cli/commands/validate.py +108 -0
  46. src/openenv/cli/templates/__init__.py +7 -0
  47. src/openenv/cli/templates/openenv_env/.dockerignore +15 -0
  48. src/openenv/cli/templates/openenv_env/README.md +255 -0
  49. src/openenv/cli/templates/openenv_env/__init__.py +16 -0
  50. src/openenv/cli/templates/openenv_env/client.py +99 -0
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/unity_3dball.gif filter=lfs diff=lfs merge=lfs -text
37
+ assets/unity_pushblock.gif filter=lfs diff=lfs merge=lfs -text
38
+ envs/unity_env/assets/unity_3dball.gif filter=lfs diff=lfs merge=lfs -text
39
+ envs/unity_env/assets/unity_pushblock.gif filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unity ML-Agents cache (binaries are downloaded here)
2
+ .mlagents-cache/
3
+
4
+ # Python cache
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+
28
+ # Virtual environments
29
+ .venv/
30
+ venv/
31
+ ENV/
32
+
33
+ # IDE
34
+ .idea/
35
+ .vscode/
36
+ *.swp
37
+ *.swo
38
+
39
+ # OS
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # ml-agents source (when developing locally)
44
+ ml-agents/
Dockerfile ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build for Unity ML-Agents environment
8
+ # Uses pip for package installation (no virtual environment)
9
+ # Note: Using Python 3.10.12 specifically because ml-agents requires >=3.10.1,<=3.10.12
10
+ # Note: Unity binaries are x86_64 only, so we force linux/amd64 platform
11
+
12
+ FROM --platform=linux/amd64 python:3.10.12-slim AS builder
13
+
14
+ WORKDIR /app
15
+
16
+ # Install build dependencies
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ build-essential \
19
+ git \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Copy environment code
23
+ COPY . /app/env
24
+
25
+ WORKDIR /app/env
26
+
27
+ # Install dependencies using pip
28
+ # Note: mlagents packages are installed from git source via pyproject.toml
29
+ RUN pip install --upgrade pip && \
30
+ pip install --no-cache-dir -e .
31
+
32
+ # Final runtime stage
33
+ FROM --platform=linux/amd64 python:3.10.12-slim
34
+
35
+ WORKDIR /app
36
+
37
+ # Install runtime dependencies (curl for healthcheck)
38
+ RUN apt-get update && apt-get install -y --no-install-recommends \
39
+ curl \
40
+ && rm -rf /var/lib/apt/lists/*
41
+
42
+ # Copy installed packages from builder
43
+ COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
44
+ COPY --from=builder /usr/local/bin /usr/local/bin
45
+
46
+ # Copy the environment code
47
+ COPY . /app/env
48
+
49
+ # Create cache directory for Unity binaries
50
+ RUN mkdir -p /root/.mlagents-cache
51
+
52
+ # Set PYTHONPATH so imports work correctly
53
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
54
+
55
+ # Expose port
56
+ EXPOSE 8000
57
+
58
+ # Health check
59
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
60
+ CMD curl -f http://localhost:8000/health || exit 1
61
+
62
+ # Note: Longer start period (60s) because Unity environment download may take time on first run
63
+
64
+ # Run the FastAPI server
65
+ # Note: workers=1 because Unity environments are not thread-safe
66
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
67
+
68
+ ENV ENABLE_WEB_INTERFACE=true
README.md CHANGED
@@ -1,10 +1,635 @@
1
  ---
2
- title: Unity Env-v2-1-0
3
- emoji: 🏆
4
- colorFrom: indigo
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Unity Environment Server
3
+ emoji: 🌐
4
+ colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
  pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - Unity
13
+ - MlAgents
14
+ - MlAgentsUnity
15
+ - MlAgentsEnv
16
  ---
17
 
18
+ ## Hugging Face Space Deployment
19
+
20
+ This Space is built from OpenEnv environment `unity_env`.
21
+
22
+ - Space URL: `https://huggingface.co/spaces/openenv/unity_env-v2-1-0`
23
+ - OpenEnv pinned ref: `v2.1.0`
24
+ - Hub tag: `openenv`
25
+
26
+ ### Connecting from Code
27
+
28
+ ```python
29
+ from envs.unity_env import Env
30
+
31
+ env = Env(base_url="https://huggingface.co/spaces/openenv/unity_env-v2-1-0")
32
+ ```
33
+
34
+ <!--
35
+ Copyright (c) Meta Platforms, Inc. and affiliates.
36
+ All rights reserved.
37
+ This source code is licensed under the BSD-style license found in the
38
+ LICENSE file in the root directory of this source tree.
39
+ -->
40
+
41
+
42
+ # Unity ML-Agents Environment
43
+
44
+ OpenEnv wrapper for [Unity ML-Agents](https://github.com/Unity-Technologies/ml-agents) environments. This environment provides access to Unity's reinforcement learning environments through a standardized HTTP/WebSocket interface.
45
+
46
+ <div align="center">
47
+ <img src="assets/unity_pushblock.gif" alt="PushBlock" width="400"/>
48
+ <img src="assets/unity_3dball.gif" alt="3DBall" width="400"/>
49
+ </div>
50
+
51
+ ## Supported Environments
52
+
53
+ | Environment | Action Type | Description |
54
+ |------------|-------------|-------------|
55
+ | **PushBlock** | Discrete (7) | Push a block to a goal position |
56
+ | **3DBall** | Continuous (2) | Balance a ball on a platform |
57
+ | **3DBallHard** | Continuous (2) | Harder version of 3DBall |
58
+ | **GridWorld** | Discrete (5) | Navigate a grid to find goals |
59
+ | **Basic** | Discrete (3) | Simple left/right movement |
60
+
61
+ More environments may be available depending on the ML-Agents registry version.
62
+
63
+ ## Installation
64
+
65
+ ### Option 1: Non-Docker Installation (Local Development)
66
+
67
+ #### Prerequisites
68
+
69
+ - Python 3.10+
70
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
71
+
72
+ #### Install from OpenEnv Repository
73
+
74
+ ```bash
75
+ # Clone the OpenEnv repository (if not already done)
76
+ git clone https://github.com/your-org/OpenEnv.git
77
+ cd OpenEnv
78
+
79
+ # Install the unity_env package with dependencies
80
+ cd envs/unity_env
81
+ uv pip install -e .
82
+
83
+ # Or with pip
84
+ pip install -e .
85
+ ```
86
+
87
+ #### Install Dependencies Only
88
+
89
+ ```bash
90
+ cd envs/unity_env
91
+
92
+ # Using uv (recommended)
93
+ uv sync
94
+
95
+ # Or using pip
96
+ pip install -r requirements.txt # if available
97
+ pip install mlagents-envs numpy pillow fastapi uvicorn pydantic
98
+ ```
99
+
100
+ #### Verify Installation
101
+
102
+ ```bash
103
+ # Test the installation
104
+ cd envs/unity_env
105
+ python -c "from client import UnityEnv; print('Installation successful!')"
106
+ ```
107
+
108
+ **Note:** The first run will download Unity environment binaries (~500MB). These are cached in `~/.mlagents-cache/` for future use.
109
+
110
+ ### Option 2: Docker Installation
111
+
112
+ #### Prerequisites
113
+
114
+ - Docker installed and running
115
+ - Python 3.10+ (for running the client)
116
+
117
+ #### Build the Docker Image
118
+
119
+ ```bash
120
+ cd envs/unity_env
121
+
122
+ # Build the Docker image
123
+ docker build -f server/Dockerfile -t unity-env:latest .
124
+
125
+ # Verify the build
126
+ docker images | grep unity-env
127
+ ```
128
+
129
+ **Note for Apple Silicon (M1/M2/M3/M4) users:** Docker mode is **not supported** on Apple Silicon because Unity's Mono runtime crashes under x86_64 emulation. Use **direct mode** (`--direct`) or **server mode** (`--url`) instead, which run native macOS binaries. See [Troubleshooting](#docker-mode-fails-on-apple-silicon-m1m2m3m4) for details.
130
+
131
+ #### Run the Docker Container
132
+
133
+ ```bash
134
+ # Run with default settings (graphics enabled, 800x600)
135
+ docker run -p 8000:8000 unity-env:latest
136
+
137
+ # Run with custom settings
138
+ docker run -p 8000:8000 \
139
+ -e UNITY_NO_GRAPHICS=0 \
140
+ -e UNITY_WIDTH=1280 \
141
+ -e UNITY_HEIGHT=720 \
142
+ -e UNITY_TIME_SCALE=1.0 \
143
+ unity-env:latest
144
+
145
+ # Run in headless mode (faster for training)
146
+ docker run -p 8000:8000 \
147
+ -e UNITY_NO_GRAPHICS=1 \
148
+ -e UNITY_TIME_SCALE=20 \
149
+ unity-env:latest
150
+
151
+ # Run with persistent cache (avoid re-downloading binaries)
152
+ docker run -p 8000:8000 \
153
+ -v ~/.mlagents-cache:/root/.mlagents-cache \
154
+ unity-env:latest
155
+ ```
156
+
157
+ #### Install Client Dependencies
158
+
159
+ To connect to the Docker container, install the client on your host machine:
160
+
161
+ ```bash
162
+ cd envs/unity_env
163
+ pip install requests websockets
164
+ ```
165
+
166
+ ## Quick Start
167
+
168
+ ### Option 1: Direct Mode (Fastest for Testing)
169
+
170
+ Run the Unity environment directly without a server:
171
+
172
+ ```bash
173
+ # From the OpenEnv repository root:
174
+
175
+ # Run with graphics (default: 1280x720)
176
+ python examples/unity_simple.py --direct
177
+
178
+ # Run with custom window size
179
+ python examples/unity_simple.py --direct --width 800 --height 600
180
+
181
+ # Run headless (faster for training)
182
+ python examples/unity_simple.py --direct --no-graphics --time-scale 20
183
+
184
+ # Run 3DBall environment
185
+ python examples/unity_simple.py --direct --env 3DBall --episodes 5
186
+ ```
187
+
188
+ ### Option 2: Server Mode
189
+
190
+ Start the server and connect with a client:
191
+
192
+ ```bash
193
+ # Terminal 1: Start the server (graphics enabled by default)
194
+ cd envs/unity_env
195
+ uv run uvicorn server.app:app --host 0.0.0.0 --port 8000
196
+
197
+ # Terminal 2: Run the example client (from repo root)
198
+ python examples/unity_simple.py --url http://localhost:8000
199
+ python examples/unity_simple.py --url http://localhost:8000 --env 3DBall --episodes 5
200
+ ```
201
+
202
+ ### Option 3: Docker Mode
203
+
204
+ Run via Docker container (auto-starts and connects):
205
+
206
+ ```bash
207
+ # Build the Docker image first
208
+ cd envs/unity_env
209
+ docker build -f server/Dockerfile -t unity-env:latest .
210
+
211
+ # Run examples from repo root:
212
+ cd ../..
213
+
214
+ # Run with default settings
215
+ python examples/unity_simple.py --docker
216
+
217
+ # Run with custom window size
218
+ python examples/unity_simple.py --docker --width 1280 --height 720
219
+
220
+ # Run headless (faster for training)
221
+ python examples/unity_simple.py --docker --no-graphics --time-scale 20
222
+
223
+ # Run 3DBall for 10 episodes
224
+ python examples/unity_simple.py --docker --env 3DBall --episodes 10
225
+
226
+ # Use a custom Docker image
227
+ python examples/unity_simple.py --docker --docker-image my-unity-env:v1
228
+ ```
229
+
230
+ ## Example Scripts
231
+
232
+ ### Basic Usage Examples
233
+
234
+ #### 1. Direct Mode - Quick Testing
235
+
236
+ ```bash
237
+ # Run PushBlock with graphics (default)
238
+ python examples/unity_simple.py --direct
239
+
240
+ # Output:
241
+ # ============================================================
242
+ # Unity ML-Agents Environment - Direct Mode
243
+ # ============================================================
244
+ # Environment: PushBlock
245
+ # Episodes: 3
246
+ # Max steps: 500
247
+ # Window size: 1280x720
248
+ # Graphics: Enabled
249
+ # ...
250
+ ```
251
+
252
+ #### 2. Direct Mode - Training Configuration
253
+
254
+ ```bash
255
+ # Headless mode with fast simulation (20x speed)
256
+ python examples/unity_simple.py --direct --no-graphics --time-scale 20 --episodes 10 --max-steps 1000
257
+
258
+ # This is ideal for training - no graphics overhead, faster simulation
259
+ ```
260
+
261
+ #### 3. Direct Mode - 3DBall with Custom Window
262
+
263
+ ```bash
264
+ # Run 3DBall (continuous actions) with larger window
265
+ python examples/unity_simple.py --direct --env 3DBall --width 1280 --height 720 --episodes 5
266
+ ```
267
+
268
+ #### 4. Docker Mode - Production-like Testing
269
+
270
+ ```bash
271
+ # Build the image first
272
+ cd envs/unity_env
273
+ docker build -f server/Dockerfile -t unity-env:latest .
274
+
275
+ # Run via Docker with graphics
276
+ python examples/unity_simple.py --docker --width 1280 --height 720
277
+
278
+ # Run via Docker in headless mode for training
279
+ python examples/unity_simple.py --docker --no-graphics --time-scale 20 --episodes 20
280
+ ```
281
+
282
+ #### 5. Server Mode - Separate Server and Client
283
+
284
+ ```bash
285
+ # Terminal 1: Start server with specific settings
286
+ UNITY_WIDTH=1280 UNITY_HEIGHT=720 uv run uvicorn server.app:app --port 8000
287
+
288
+ # Terminal 2: Connect and run episodes
289
+ python examples/unity_simple.py --url http://localhost:8000 --env PushBlock --episodes 5
290
+ python examples/unity_simple.py --url http://localhost:8000 --env 3DBall --episodes 5
291
+ ```
292
+
293
+ #### 6. Alternating Environments
294
+
295
+ ```bash
296
+ # Run alternating episodes between PushBlock and 3DBall
297
+ python examples/unity_simple.py --direct --env both --episodes 6
298
+ # Episodes 1,3,5 = PushBlock; Episodes 2,4,6 = 3DBall
299
+ ```
300
+
301
+ ### Command Line Options
302
+
303
+ | Option | Default | Description |
304
+ |--------|---------|-------------|
305
+ | `--direct` | - | Run environment directly (no server) |
306
+ | `--docker` | - | Run via Docker container |
307
+ | `--url` | localhost:8000 | Server URL for server mode |
308
+ | `--docker-image` | unity-env:latest | Docker image name |
309
+ | `--env` | PushBlock | Environment: PushBlock, 3DBall, both |
310
+ | `--episodes` | 3 | Number of episodes |
311
+ | `--max-steps` | 500 | Max steps per episode |
312
+ | `--width` | 1280 | Window width in pixels |
313
+ | `--height` | 720 | Window height in pixels |
314
+ | `--no-graphics` | - | Headless mode (faster) |
315
+ | `--time-scale` | 1.0 | Simulation speed multiplier |
316
+ | `--quality-level` | 5 | Graphics quality 0-5 |
317
+ | `--quiet` | - | Reduce output verbosity |
318
+
319
+ ## Python Client Usage
320
+
321
+ ### Connect to Server
322
+
323
+ ```python
324
+ from envs.unity_env import UnityEnv, UnityAction
325
+
326
+ # Connect to the server
327
+ with UnityEnv(base_url="http://localhost:8000") as client:
328
+ # Reset to PushBlock environment
329
+ result = client.reset(env_id="PushBlock")
330
+ print(f"Observation dims: {len(result.observation.vector_observations)}")
331
+
332
+ # Take actions
333
+ for _ in range(100):
334
+ # PushBlock actions: 0=noop, 1=forward, 2=backward,
335
+ # 3=rotate_left, 4=rotate_right, 5=strafe_left, 6=strafe_right
336
+ action = UnityAction(discrete_actions=[1]) # Move forward
337
+ result = client.step(action)
338
+ print(f"Reward: {result.reward}, Done: {result.done}")
339
+
340
+ if result.done:
341
+ result = client.reset()
342
+ ```
343
+
344
+ ### Connect via Docker
345
+
346
+ ```python
347
+ from envs.unity_env import UnityEnv, UnityAction
348
+
349
+ # Automatically start Docker container and connect
350
+ client = UnityEnv.from_docker_image(
351
+ "unity-env:latest",
352
+ environment={
353
+ "UNITY_NO_GRAPHICS": "0",
354
+ "UNITY_WIDTH": "1280",
355
+ "UNITY_HEIGHT": "720",
356
+ }
357
+ )
358
+
359
+ try:
360
+ result = client.reset(env_id="PushBlock")
361
+ for _ in range(100):
362
+ action = UnityAction(discrete_actions=[1])
363
+ result = client.step(action)
364
+ finally:
365
+ client.close()
366
+ ```
367
+
368
+ ### Switch Environments Dynamically
369
+
370
+ ```python
371
+ # Start with PushBlock
372
+ result = client.reset(env_id="PushBlock")
373
+ # ... train on PushBlock ...
374
+
375
+ # Switch to 3DBall (continuous actions)
376
+ result = client.reset(env_id="3DBall")
377
+ action = UnityAction(continuous_actions=[0.5, -0.3])
378
+ result = client.step(action)
379
+ ```
380
+
381
+ ### Direct Mode (Embedded Server)
382
+
383
+ ```python
384
+ from envs.unity_env.client import UnityEnv
385
+ from envs.unity_env.models import UnityAction
386
+
387
+ # Create client with embedded local server (no separate server needed)
388
+ client = UnityEnv.from_direct(
389
+ env_id="PushBlock",
390
+ no_graphics=False, # Show graphics window
391
+ width=1280,
392
+ height=720,
393
+ time_scale=1.0,
394
+ )
395
+
396
+ try:
397
+ result = client.reset()
398
+ print(f"Observation: {len(result.observation.vector_observations)} dimensions")
399
+
400
+ for step in range(100):
401
+ action = UnityAction(discrete_actions=[1]) # Move forward
402
+ result = client.step(action)
403
+ print(f"Step {step}: reward={result.reward}, done={result.done}")
404
+
405
+ if result.done:
406
+ result = client.reset()
407
+ finally:
408
+ client.close()
409
+ ```
410
+
411
+ ## Action Spaces
412
+
413
+ ### PushBlock (Discrete)
414
+
415
+ 7 discrete actions:
416
+ - `0`: No operation
417
+ - `1`: Move forward
418
+ - `2`: Move backward
419
+ - `3`: Rotate left
420
+ - `4`: Rotate right
421
+ - `5`: Strafe left
422
+ - `6`: Strafe right
423
+
424
+ ```python
425
+ action = UnityAction(discrete_actions=[1]) # Move forward
426
+ ```
427
+
428
+ ### 3DBall (Continuous)
429
+
430
+ 2 continuous actions in range [-1, 1]:
431
+ - Action 0: X-axis rotation
432
+ - Action 1: Z-axis rotation
433
+
434
+ ```python
435
+ action = UnityAction(continuous_actions=[0.5, -0.3])
436
+ ```
437
+
438
+ ## Observations
439
+
440
+ All environments provide vector observations. The size depends on the environment:
441
+
442
+ - **PushBlock**: 70 dimensions (14 ray-casts detecting walls/goals/blocks)
443
+ - **3DBall**: 8 dimensions (rotation and ball position/velocity)
444
+ - **GridWorld**: Visual observations (grid view)
445
+
446
+ ```python
447
+ result = client.reset()
448
+ obs = result.observation
449
+
450
+ # Access observations
451
+ print(f"Vector obs: {obs.vector_observations}")
452
+ print(f"Behavior: {obs.behavior_name}")
453
+ print(f"Action spec: {obs.action_spec_info}")
454
+ ```
455
+
456
+ ### Visual Observations (Optional)
457
+
458
+ Some environments support visual observations. Enable with `include_visual=True`:
459
+
460
+ ```python
461
+ result = client.reset(include_visual=True)
462
+ if result.observation.visual_observations:
463
+ # Base64-encoded PNG images
464
+ for img_b64 in result.observation.visual_observations:
465
+ # Decode and use the image
466
+ import base64
467
+ img_bytes = base64.b64decode(img_b64)
468
+ ```
469
+
470
+ ## Configuration
471
+
472
+ ### Direct Mode Arguments
473
+
474
+ When using `UnityEnv.from_direct()` to run with an embedded server:
475
+
476
+ ```python
477
+ from envs.unity_env.client import UnityEnv
478
+
479
+ client = UnityEnv.from_direct(
480
+ env_id="PushBlock", # Unity environment to load
481
+ no_graphics=False, # False = show graphics window
482
+ width=1280, # Window width in pixels
483
+ height=720, # Window height in pixels
484
+ time_scale=1.0, # Simulation speed (20.0 for fast training)
485
+ quality_level=5, # Graphics quality 0-5
486
+ port=8765, # Port for embedded server
487
+ )
488
+ ```
489
+
490
+ ### Environment Variables
491
+
492
+ For Docker deployment, configure via environment variables:
493
+
494
+ | Variable | Default | Description |
495
+ |----------|---------|-------------|
496
+ | `UNITY_ENV_ID` | PushBlock | Default Unity environment |
497
+ | `UNITY_NO_GRAPHICS` | 0 | Set to 1 for headless mode |
498
+ | `UNITY_WIDTH` | 1280 | Window width in pixels |
499
+ | `UNITY_HEIGHT` | 720 | Window height in pixels |
500
+ | `UNITY_TIME_SCALE` | 1.0 | Simulation speed multiplier |
501
+ | `UNITY_QUALITY_LEVEL` | 5 | Graphics quality 0-5 |
502
+ | `UNITY_CACHE_DIR` | ~/.mlagents-cache | Binary cache directory |
503
+
504
+ ## Environment State
505
+
506
+ Access detailed environment information:
507
+
508
+ ```python
509
+ state = client.state()
510
+ print(f"Environment: {state.env_id}")
511
+ print(f"Episode ID: {state.episode_id}")
512
+ print(f"Step count: {state.step_count}")
513
+ print(f"Available envs: {state.available_envs}")
514
+ print(f"Action spec: {state.action_spec}")
515
+ print(f"Observation spec: {state.observation_spec}")
516
+ ```
517
+
518
+ ## Troubleshooting
519
+
520
+ ### Docker Mode Fails on Apple Silicon (M1/M2/M3/M4)
521
+
522
+ **Symptom:** When running with `--docker` on Apple Silicon Macs, you see an error like:
523
+
524
+ ```
525
+ Error running with Docker: Server error: The Unity environment took too long to respond...
526
+ ```
527
+
528
+ Or in Docker logs:
529
+
530
+ ```
531
+ * Assertion: should not be reached at tramp-amd64.c:605
532
+ Environment shut down with return code -6 (SIGABRT)
533
+ ```
534
+
535
+ **Cause:** Unity ML-Agents binaries are x86_64 (Intel) only. When Docker runs the x86_64 Linux container on Apple Silicon, it uses QEMU emulation. The Mono runtime inside Unity has architecture-specific code that crashes under emulation.
536
+
537
+ **Solutions:**
538
+
539
+ 1. **Use Direct Mode** (recommended for macOS):
540
+ ```bash
541
+ python examples/unity_simple.py --direct --no-graphics
542
+ ```
543
+ Direct mode downloads native macOS binaries which work on Apple Silicon.
544
+
545
+ 2. **Use Server Mode** with a local server:
546
+ ```bash
547
+ # Terminal 1: Start server (uses native macOS binaries)
548
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
549
+
550
+ # Terminal 2: Run client
551
+ python examples/unity_simple.py --url http://localhost:8000
552
+ ```
553
+
554
+ 3. **Use an x86_64 Linux machine** for Docker mode:
555
+ The Docker image works correctly on native x86_64 Linux machines (cloud VMs, dedicated servers, etc.).
556
+
557
+ ### First Run is Slow
558
+
559
+ The first run downloads Unity binaries (~500MB). This is normal and only happens once. Binaries are cached in `~/.mlagents-cache/`.
560
+
561
+ ### Graphics Not Showing
562
+
563
+ - Ensure `--no-graphics` is NOT set
564
+ - On Linux, ensure X11 is available
565
+ - For Docker, you may need to set up X11 forwarding
566
+
567
+ ### Docker Container Fails to Start
568
+
569
+ ```bash
570
+ # Check Docker logs
571
+ docker logs <container_id>
572
+
573
+ # Ensure the image is built
574
+ docker images | grep unity-env
575
+
576
+ # Rebuild if necessary
577
+ cd envs/unity_env
578
+ docker build -f server/Dockerfile -t unity-env:latest .
579
+ ```
580
+
581
+ ### Import Errors
582
+
583
+ ```bash
584
+ # Ensure you're in the correct directory
585
+ cd envs/unity_env
586
+
587
+ # Install dependencies
588
+ uv sync
589
+ # or
590
+ pip install -e .
591
+ ```
592
+
593
+ ### mlagents-envs Installation Issues
594
+
595
+ The `mlagents-envs` and `mlagents` packages are installed from source by default (via the GitHub repository). If you encounter issues or want to install manually:
596
+
597
+ ```bash
598
+ # Clone the ml-agents repository
599
+ git clone https://github.com/Unity-Technologies/ml-agents.git
600
+ cd ml-agents
601
+
602
+ # Install mlagents-envs from source
603
+ pip install -e ./ml-agents-envs
604
+
605
+ # Install the full ml-agents package
606
+ pip install -e ./ml-agents
607
+ ```
608
+
609
+ This approach is useful when:
610
+ - You need to modify the mlagents source code
611
+ - You want to use a specific branch or commit
612
+ - The git dependency in pyproject.toml is causing issues
613
+
614
+ ## Caveats
615
+
616
+ 1. **First Run Download**: Unity binaries (~500MB) are downloaded on first use
617
+ 2. **Platform-Specific**: Binaries are platform-specific (macOS, Linux, Windows)
618
+ 3. **Apple Silicon + Docker**: Docker mode does not work on Apple Silicon Macs due to x86_64 emulation issues with Unity's Mono runtime. Use direct mode or server mode instead.
619
+ 4. **Single Worker**: Unity environments are not thread-safe; use `workers=1`
620
+ 5. **Graphics Mode**: Some features require X11/display for graphics mode
621
+ 6. **Multi-Agent**: Currently uses first agent only; full multi-agent support planned
622
+
623
+ ## Dependencies
624
+
625
+ - `mlagents-envs` (installed from source via git)
626
+ - `mlagents` (installed from source via git)
627
+ - `numpy>=1.20.0`
628
+ - `pillow>=9.0.0` (for visual observations)
629
+ - `openenv-core[core]>=0.2.0`
630
+
631
+ ## References
632
+
633
+ - [Unity ML-Agents Documentation](https://docs.unity3d.com/Packages/com.unity.ml-agents@4.0/manual/index.html)
634
+ - [ML-Agents GitHub](https://github.com/Unity-Technologies/ml-agents)
635
+ - [Example Environments](https://docs.unity3d.com/Packages/com.unity.ml-agents@4.0/manual/Learning-Environment-Examples.html)
__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Unity ML-Agents Environment for OpenEnv."""
8
+
9
+ from .client import UnityEnv
10
+ from .models import UnityAction, UnityObservation, UnityState
11
+
12
+ __all__ = ["UnityAction", "UnityObservation", "UnityState", "UnityEnv"]
assets/unity_3dball.gif ADDED

Git LFS Details

  • SHA256: ee94fedb0f70a57752657c988f5c612dbe6145501fa1f896cd3c73d2de029c8d
  • Pointer size: 132 Bytes
  • Size of remote file: 6.59 MB
assets/unity_pushblock.gif ADDED

Git LFS Details

  • SHA256: 1a20ea5a7d77c753f7d832948222a4ff06f45d96c5b39006217e44cd1b803e4e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.05 MB
client.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Unity ML-Agents Environment Client.
9
+
10
+ This module provides the client for connecting to a Unity ML-Agents
11
+ Environment server via WebSocket for persistent sessions.
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Support multiple import scenarios
17
+ try:
18
+ # In-repo imports (when running from OpenEnv repository root)
19
+ from openenv.core.client_types import StepResult
20
+ from openenv.core.env_client import EnvClient
21
+
22
+ from .models import UnityAction, UnityObservation, UnityState
23
+ except ImportError:
24
+ # openenv from pip
25
+ from openenv.core.client_types import StepResult
26
+ from openenv.core.env_client import EnvClient
27
+
28
+ try:
29
+ # Direct execution from envs/unity_env/ directory
30
+ from models import UnityAction, UnityObservation, UnityState
31
+ except ImportError:
32
+ try:
33
+ # Package installed as unity_env
34
+ from unity_env.models import UnityAction, UnityObservation, UnityState
35
+ except ImportError:
36
+ # Running from OpenEnv root with envs prefix
37
+ from envs.unity_env.models import UnityAction, UnityObservation, UnityState
38
+
39
+
40
+ class UnityEnv(EnvClient[UnityAction, UnityObservation, UnityState]):
41
+ """
42
+ Client for Unity ML-Agents environments.
43
+
44
+ This client maintains a persistent WebSocket connection to the environment
45
+ server, enabling efficient multi-step interactions with lower latency.
46
+ Each client instance has its own dedicated environment session on the server.
47
+
48
+ Note: Unity environments can take 30-60+ seconds to initialize on first reset
49
+ (downloading binaries, starting Unity process). The client is configured with
50
+ longer ping timeouts to handle this.
51
+
52
+ Supported Unity Environments:
53
+ - PushBlock: Push a block to a goal (discrete actions: 7)
54
+ - 3DBall: Balance a ball on a platform (continuous actions: 2)
55
+ - 3DBallHard: Harder version of 3DBall
56
+ - GridWorld: Navigate a grid to find goals
57
+ - Basic: Simple movement task
58
+ - And more from the ML-Agents registry
59
+
60
+ Example:
61
+ >>> # Connect to a running server
62
+ >>> with UnityEnv(base_url="http://localhost:8000") as client:
63
+ ... result = client.reset()
64
+ ... print(f"Vector obs: {len(result.observation.vector_observations)} dims")
65
+ ...
66
+ ... # Take action (PushBlock: 1=forward)
67
+ ... result = client.step(UnityAction(discrete_actions=[1]))
68
+ ... print(f"Reward: {result.reward}")
69
+
70
+ Example with Docker:
71
+ >>> # Automatically start container and connect
72
+ >>> client = UnityEnv.from_docker_image("unity-env:latest")
73
+ >>> try:
74
+ ... result = client.reset(env_id="3DBall")
75
+ ... result = client.step(UnityAction(continuous_actions=[0.5, -0.3]))
76
+ ... finally:
77
+ ... client.close()
78
+
79
+ Example switching environments:
80
+ >>> client = UnityEnv(base_url="http://localhost:8000")
81
+ >>> # Start with PushBlock
82
+ >>> result = client.reset(env_id="PushBlock")
83
+ >>> # ... train on PushBlock ...
84
+ >>> # Switch to 3DBall
85
+ >>> result = client.reset(env_id="3DBall")
86
+ >>> # ... train on 3DBall ...
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ base_url: str,
92
+ connect_timeout_s: float = 10.0,
93
+ message_timeout_s: float = 180.0, # 3 minutes for slow Unity initialization
94
+ provider: Optional[Any] = None,
95
+ ):
96
+ """
97
+ Initialize Unity environment client.
98
+
99
+ Uses longer default timeouts than the base EnvClient because Unity
100
+ environments can take 30-60+ seconds to initialize on first reset.
101
+
102
+ Args:
103
+ base_url: Base URL of the environment server (http:// or ws://).
104
+ connect_timeout_s: Timeout for establishing WebSocket connection
105
+ message_timeout_s: Timeout for receiving responses (default 3 min for Unity)
106
+ provider: Optional container/runtime provider for lifecycle management.
107
+ """
108
+ super().__init__(
109
+ base_url=base_url,
110
+ connect_timeout_s=connect_timeout_s,
111
+ message_timeout_s=message_timeout_s,
112
+ provider=provider,
113
+ )
114
+
115
+ def connect(self) -> "UnityEnv":
116
+ """
117
+ Establish WebSocket connection to the server.
118
+
119
+ Overrides the default connection to use longer ping timeouts,
120
+ since Unity environments can take 30-60+ seconds to initialize.
121
+
122
+ Returns:
123
+ self for method chaining
124
+
125
+ Raises:
126
+ ConnectionError: If connection cannot be established
127
+ """
128
+ from websockets.sync.client import connect as ws_connect
129
+
130
+ if self._ws is not None:
131
+ return self
132
+
133
+ try:
134
+ # Use longer ping_timeout for Unity (60s) since environment
135
+ # initialization can block the server for a while
136
+ self._ws = ws_connect(
137
+ self._ws_url,
138
+ open_timeout=self._connect_timeout,
139
+ ping_timeout=120, # 2 minutes for slow Unity initialization
140
+ ping_interval=30, # Send pings every 30 seconds
141
+ close_timeout=30,
142
+ )
143
+ except Exception as e:
144
+ raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
145
+
146
+ return self
147
+
148
+ def _step_payload(self, action: UnityAction) -> Dict:
149
+ """
150
+ Convert UnityAction to JSON payload for step request.
151
+
152
+ Args:
153
+ action: UnityAction instance
154
+
155
+ Returns:
156
+ Dictionary representation suitable for JSON encoding
157
+ """
158
+ payload: Dict[str, Any] = {}
159
+
160
+ if action.discrete_actions is not None:
161
+ payload["discrete_actions"] = action.discrete_actions
162
+
163
+ if action.continuous_actions is not None:
164
+ payload["continuous_actions"] = action.continuous_actions
165
+
166
+ if action.metadata:
167
+ payload["metadata"] = action.metadata
168
+
169
+ return payload
170
+
171
+ def _parse_result(self, payload: Dict) -> StepResult[UnityObservation]:
172
+ """
173
+ Parse server response into StepResult[UnityObservation].
174
+
175
+ Args:
176
+ payload: JSON response from server
177
+
178
+ Returns:
179
+ StepResult with UnityObservation
180
+ """
181
+ obs_data = payload.get("observation", {})
182
+
183
+ observation = UnityObservation(
184
+ vector_observations=obs_data.get("vector_observations", []),
185
+ visual_observations=obs_data.get("visual_observations"),
186
+ behavior_name=obs_data.get("behavior_name", ""),
187
+ action_spec_info=obs_data.get("action_spec_info", {}),
188
+ observation_spec_info=obs_data.get("observation_spec_info", {}),
189
+ done=payload.get("done", False),
190
+ reward=payload.get("reward"),
191
+ metadata=obs_data.get("metadata", {}),
192
+ )
193
+
194
+ return StepResult(
195
+ observation=observation,
196
+ reward=payload.get("reward"),
197
+ done=payload.get("done", False),
198
+ )
199
+
200
+ def _parse_state(self, payload: Dict) -> UnityState:
201
+ """
202
+ Parse server response into UnityState object.
203
+
204
+ Args:
205
+ payload: JSON response from /state endpoint
206
+
207
+ Returns:
208
+ UnityState object with environment information
209
+ """
210
+ return UnityState(
211
+ episode_id=payload.get("episode_id"),
212
+ step_count=payload.get("step_count", 0),
213
+ env_id=payload.get("env_id", ""),
214
+ behavior_name=payload.get("behavior_name", ""),
215
+ action_spec=payload.get("action_spec", {}),
216
+ observation_spec=payload.get("observation_spec", {}),
217
+ available_envs=payload.get("available_envs", []),
218
+ )
219
+
220
+ def reset(
221
+ self,
222
+ env_id: Optional[str] = None,
223
+ include_visual: bool = False,
224
+ **kwargs,
225
+ ) -> StepResult[UnityObservation]:
226
+ """
227
+ Reset the environment.
228
+
229
+ Args:
230
+ env_id: Optionally switch to a different Unity environment.
231
+ Available: PushBlock, 3DBall, 3DBallHard, GridWorld, Basic
232
+ include_visual: If True, include visual observations in response.
233
+ **kwargs: Additional arguments passed to server.
234
+
235
+ Returns:
236
+ StepResult with initial observation.
237
+ """
238
+ reset_kwargs = dict(kwargs)
239
+ if env_id is not None:
240
+ reset_kwargs["env_id"] = env_id
241
+ reset_kwargs["include_visual"] = include_visual
242
+
243
+ return super().reset(**reset_kwargs)
244
+
245
+ @staticmethod
246
+ def available_environments() -> List[str]:
247
+ """
248
+ List commonly available Unity environments.
249
+
250
+ Note: The actual list may vary based on the ML-Agents registry version.
251
+ Use state.available_envs after connecting for the authoritative list.
252
+
253
+ Returns:
254
+ List of environment identifiers.
255
+ """
256
+ return [
257
+ "PushBlock",
258
+ "3DBall",
259
+ "3DBallHard",
260
+ "GridWorld",
261
+ "Basic",
262
+ "VisualPushBlock",
263
+ ]
264
+
265
+ @classmethod
266
+ def from_direct(
267
+ cls,
268
+ env_id: str = "PushBlock",
269
+ no_graphics: bool = False,
270
+ width: int = 1280,
271
+ height: int = 720,
272
+ time_scale: float = 1.0,
273
+ quality_level: int = 5,
274
+ port: int = 8765,
275
+ ) -> "UnityEnv":
276
+ """
277
+ Create a Unity environment client with an embedded local server.
278
+
279
+ This method starts a local uvicorn server in a subprocess and returns
280
+ a client connected to it. This provides the convenience of direct mode
281
+ while maintaining the client-server separation.
282
+
283
+ Note: The first call will download Unity binaries (~500MB) which may
284
+ take several minutes. Binaries are cached for subsequent runs.
285
+
286
+ Args:
287
+ env_id: Default Unity environment to use (PushBlock, 3DBall, etc.)
288
+ no_graphics: If True, run Unity in headless mode (faster for training)
289
+ width: Window width in pixels (default: 1280)
290
+ height: Window height in pixels (default: 720)
291
+ time_scale: Simulation speed multiplier (default: 1.0, use 20.0 for fast training)
292
+ quality_level: Graphics quality 0-5 (default: 5)
293
+ port: Port for the local server (default: 8765)
294
+
295
+ Returns:
296
+ UnityEnv client connected to the local server
297
+
298
+ Example:
299
+ >>> # Quick start with direct mode
300
+ >>> client = UnityEnv.from_direct(no_graphics=True, time_scale=20)
301
+ >>> try:
302
+ ... result = client.reset(env_id="PushBlock")
303
+ ... for _ in range(100):
304
+ ... result = client.step(UnityAction(discrete_actions=[1]))
305
+ ... finally:
306
+ ... client.close()
307
+
308
+ >>> # With custom settings
309
+ >>> client = UnityEnv.from_direct(
310
+ ... env_id="3DBall",
311
+ ... no_graphics=True,
312
+ ... time_scale=20,
313
+ ... port=9000
314
+ ... )
315
+ """
316
+ import os
317
+ import subprocess
318
+ import sys
319
+ import time
320
+
321
+ import requests
322
+
323
+ # Find the project root and server module
324
+ # Try to locate the server module
325
+ try:
326
+ from pathlib import Path
327
+
328
+ # Get the directory containing this file
329
+ client_dir = Path(__file__).parent
330
+ server_app = "envs.unity_env.server.app:app"
331
+ cwd = client_dir.parent.parent # OpenEnv root
332
+
333
+ # Check if we're in the envs/unity_env directory structure
334
+ if not (cwd / "envs" / "unity_env" / "server" / "app.py").exists():
335
+ # Try alternative paths
336
+ if (client_dir / "server" / "app.py").exists():
337
+ server_app = "server.app:app"
338
+ cwd = client_dir
339
+ except Exception:
340
+ server_app = "envs.unity_env.server.app:app"
341
+ cwd = None
342
+
343
+ # Set up environment variables for Unity configuration
344
+ env = {
345
+ **os.environ,
346
+ "UNITY_ENV_ID": env_id,
347
+ "UNITY_NO_GRAPHICS": "1" if no_graphics else "0",
348
+ "UNITY_WIDTH": str(width),
349
+ "UNITY_HEIGHT": str(height),
350
+ "UNITY_TIME_SCALE": str(time_scale),
351
+ "UNITY_QUALITY_LEVEL": str(quality_level),
352
+ # Bypass proxy for localhost
353
+ "NO_PROXY": "localhost,127.0.0.1",
354
+ "no_proxy": "localhost,127.0.0.1",
355
+ }
356
+
357
+ # Add src to PYTHONPATH if needed
358
+ if cwd:
359
+ src_path = str(cwd / "src")
360
+ existing_path = env.get("PYTHONPATH", "")
361
+ env["PYTHONPATH"] = f"{src_path}:{cwd}:{existing_path}" if existing_path else f"{src_path}:{cwd}"
362
+
363
+ # Start the server
364
+ cmd = [
365
+ sys.executable,
366
+ "-m",
367
+ "uvicorn",
368
+ server_app,
369
+ "--host",
370
+ "127.0.0.1",
371
+ "--port",
372
+ str(port),
373
+ ]
374
+
375
+ server_process = subprocess.Popen(
376
+ cmd,
377
+ env=env,
378
+ stdout=subprocess.PIPE,
379
+ stderr=subprocess.STDOUT,
380
+ cwd=str(cwd) if cwd else None,
381
+ )
382
+
383
+ # Wait for server to become healthy
384
+ base_url = f"http://127.0.0.1:{port}"
385
+ healthy = False
386
+ for _ in range(30): # Wait up to 30 seconds
387
+ try:
388
+ response = requests.get(
389
+ f"{base_url}/health",
390
+ timeout=2,
391
+ proxies={"http": None, "https": None},
392
+ )
393
+ if response.status_code == 200:
394
+ healthy = True
395
+ break
396
+ except requests.exceptions.RequestException:
397
+ pass
398
+ time.sleep(1)
399
+
400
+ if not healthy:
401
+ server_process.kill()
402
+ raise RuntimeError(
403
+ f"Failed to start local Unity server on port {port}. "
404
+ "Check that the port is available and dependencies are installed."
405
+ )
406
+
407
+ # Create a provider to manage the subprocess lifecycle
408
+ class DirectModeProvider:
409
+ """Provider that manages the embedded server subprocess."""
410
+
411
+ def __init__(self, process: subprocess.Popen):
412
+ self._process = process
413
+
414
+ def stop(self):
415
+ """Stop the embedded server."""
416
+ if self._process:
417
+ self._process.terminate()
418
+ try:
419
+ self._process.wait(timeout=10)
420
+ except subprocess.TimeoutExpired:
421
+ self._process.kill()
422
+ self._process = None
423
+
424
+ provider = DirectModeProvider(server_process)
425
+
426
+ # Create and return the client
427
+ client = cls(base_url=base_url, provider=provider)
428
+ return client
envs/unity_env/.gitignore ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Unity ML-Agents cache (binaries are downloaded here)
2
+ .mlagents-cache/
3
+
4
+ # Python cache
5
+ __pycache__/
6
+ *.py[cod]
7
+ *$py.class
8
+ *.so
9
+
10
+ # Distribution / packaging
11
+ .Python
12
+ build/
13
+ develop-eggs/
14
+ dist/
15
+ downloads/
16
+ eggs/
17
+ .eggs/
18
+ lib/
19
+ lib64/
20
+ parts/
21
+ sdist/
22
+ var/
23
+ wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+
28
+ # Virtual environments
29
+ .venv/
30
+ venv/
31
+ ENV/
32
+
33
+ # IDE
34
+ .idea/
35
+ .vscode/
36
+ *.swp
37
+ *.swo
38
+
39
+ # OS
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # ml-agents source (when developing locally)
44
+ ml-agents/
envs/unity_env/README.md ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Unity Environment Server
3
+ emoji: 🌐
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ - Unity
13
+ - MlAgents
14
+ - MlAgentsUnity
15
+ - MlAgentsEnv
16
+ ---
17
+
18
+ <!--
19
+ Copyright (c) Meta Platforms, Inc. and affiliates.
20
+ All rights reserved.
21
+ This source code is licensed under the BSD-style license found in the
22
+ LICENSE file in the root directory of this source tree.
23
+ -->
24
+
25
+
26
+ # Unity ML-Agents Environment
27
+
28
+ OpenEnv wrapper for [Unity ML-Agents](https://github.com/Unity-Technologies/ml-agents) environments. This environment provides access to Unity's reinforcement learning environments through a standardized HTTP/WebSocket interface.
29
+
30
+ <div align="center">
31
+ <img src="assets/unity_pushblock.gif" alt="PushBlock" width="400"/>
32
+ <img src="assets/unity_3dball.gif" alt="3DBall" width="400"/>
33
+ </div>
34
+
35
+ ## Supported Environments
36
+
37
+ | Environment | Action Type | Description |
38
+ |------------|-------------|-------------|
39
+ | **PushBlock** | Discrete (7) | Push a block to a goal position |
40
+ | **3DBall** | Continuous (2) | Balance a ball on a platform |
41
+ | **3DBallHard** | Continuous (2) | Harder version of 3DBall |
42
+ | **GridWorld** | Discrete (5) | Navigate a grid to find goals |
43
+ | **Basic** | Discrete (3) | Simple left/right movement |
44
+
45
+ More environments may be available depending on the ML-Agents registry version.
46
+
47
+ ## Installation
48
+
49
+ ### Option 1: Non-Docker Installation (Local Development)
50
+
51
+ #### Prerequisites
52
+
53
+ - Python 3.10+
54
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
55
+
56
+ #### Install from OpenEnv Repository
57
+
58
+ ```bash
59
+ # Clone the OpenEnv repository (if not already done)
60
+ git clone https://github.com/your-org/OpenEnv.git
61
+ cd OpenEnv
62
+
63
+ # Install the unity_env package with dependencies
64
+ cd envs/unity_env
65
+ uv pip install -e .
66
+
67
+ # Or with pip
68
+ pip install -e .
69
+ ```
70
+
71
+ #### Install Dependencies Only
72
+
73
+ ```bash
74
+ cd envs/unity_env
75
+
76
+ # Using uv (recommended)
77
+ uv sync
78
+
79
+ # Or using pip
80
+ pip install -r requirements.txt # if available
81
+ pip install mlagents-envs numpy pillow fastapi uvicorn pydantic
82
+ ```
83
+
84
+ #### Verify Installation
85
+
86
+ ```bash
87
+ # Test the installation
88
+ cd envs/unity_env
89
+ python -c "from client import UnityEnv; print('Installation successful!')"
90
+ ```
91
+
92
+ **Note:** The first run will download Unity environment binaries (~500MB). These are cached in `~/.mlagents-cache/` for future use.
93
+
94
+ ### Option 2: Docker Installation
95
+
96
+ #### Prerequisites
97
+
98
+ - Docker installed and running
99
+ - Python 3.10+ (for running the client)
100
+
101
+ #### Build the Docker Image
102
+
103
+ ```bash
104
+ cd envs/unity_env
105
+
106
+ # Build the Docker image
107
+ docker build -f server/Dockerfile -t unity-env:latest .
108
+
109
+ # Verify the build
110
+ docker images | grep unity-env
111
+ ```
112
+
113
+ **Note for Apple Silicon (M1/M2/M3/M4) users:** Docker mode is **not supported** on Apple Silicon because Unity's Mono runtime crashes under x86_64 emulation. Use **direct mode** (`--direct`) or **server mode** (`--url`) instead, which run native macOS binaries. See [Troubleshooting](#docker-mode-fails-on-apple-silicon-m1m2m3m4) for details.
114
+
115
+ #### Run the Docker Container
116
+
117
+ ```bash
118
+ # Run with default settings (graphics enabled, 800x600)
119
+ docker run -p 8000:8000 unity-env:latest
120
+
121
+ # Run with custom settings
122
+ docker run -p 8000:8000 \
123
+ -e UNITY_NO_GRAPHICS=0 \
124
+ -e UNITY_WIDTH=1280 \
125
+ -e UNITY_HEIGHT=720 \
126
+ -e UNITY_TIME_SCALE=1.0 \
127
+ unity-env:latest
128
+
129
+ # Run in headless mode (faster for training)
130
+ docker run -p 8000:8000 \
131
+ -e UNITY_NO_GRAPHICS=1 \
132
+ -e UNITY_TIME_SCALE=20 \
133
+ unity-env:latest
134
+
135
+ # Run with persistent cache (avoid re-downloading binaries)
136
+ docker run -p 8000:8000 \
137
+ -v ~/.mlagents-cache:/root/.mlagents-cache \
138
+ unity-env:latest
139
+ ```
140
+
141
+ #### Install Client Dependencies
142
+
143
+ To connect to the Docker container, install the client on your host machine:
144
+
145
+ ```bash
146
+ cd envs/unity_env
147
+ pip install requests websockets
148
+ ```
149
+
150
+ ## Quick Start
151
+
152
+ ### Option 1: Direct Mode (Fastest for Testing)
153
+
154
+ Run the Unity environment directly without a server:
155
+
156
+ ```bash
157
+ # From the OpenEnv repository root:
158
+
159
+ # Run with graphics (default: 1280x720)
160
+ python examples/unity_simple.py --direct
161
+
162
+ # Run with custom window size
163
+ python examples/unity_simple.py --direct --width 800 --height 600
164
+
165
+ # Run headless (faster for training)
166
+ python examples/unity_simple.py --direct --no-graphics --time-scale 20
167
+
168
+ # Run 3DBall environment
169
+ python examples/unity_simple.py --direct --env 3DBall --episodes 5
170
+ ```
171
+
172
+ ### Option 2: Server Mode
173
+
174
+ Start the server and connect with a client:
175
+
176
+ ```bash
177
+ # Terminal 1: Start the server (graphics enabled by default)
178
+ cd envs/unity_env
179
+ uv run uvicorn server.app:app --host 0.0.0.0 --port 8000
180
+
181
+ # Terminal 2: Run the example client (from repo root)
182
+ python examples/unity_simple.py --url http://localhost:8000
183
+ python examples/unity_simple.py --url http://localhost:8000 --env 3DBall --episodes 5
184
+ ```
185
+
186
+ ### Option 3: Docker Mode
187
+
188
+ Run via Docker container (auto-starts and connects):
189
+
190
+ ```bash
191
+ # Build the Docker image first
192
+ cd envs/unity_env
193
+ docker build -f server/Dockerfile -t unity-env:latest .
194
+
195
+ # Run examples from repo root:
196
+ cd ../..
197
+
198
+ # Run with default settings
199
+ python examples/unity_simple.py --docker
200
+
201
+ # Run with custom window size
202
+ python examples/unity_simple.py --docker --width 1280 --height 720
203
+
204
+ # Run headless (faster for training)
205
+ python examples/unity_simple.py --docker --no-graphics --time-scale 20
206
+
207
+ # Run 3DBall for 10 episodes
208
+ python examples/unity_simple.py --docker --env 3DBall --episodes 10
209
+
210
+ # Use a custom Docker image
211
+ python examples/unity_simple.py --docker --docker-image my-unity-env:v1
212
+ ```
213
+
214
+ ## Example Scripts
215
+
216
+ ### Basic Usage Examples
217
+
218
+ #### 1. Direct Mode - Quick Testing
219
+
220
+ ```bash
221
+ # Run PushBlock with graphics (default)
222
+ python examples/unity_simple.py --direct
223
+
224
+ # Output:
225
+ # ============================================================
226
+ # Unity ML-Agents Environment - Direct Mode
227
+ # ============================================================
228
+ # Environment: PushBlock
229
+ # Episodes: 3
230
+ # Max steps: 500
231
+ # Window size: 1280x720
232
+ # Graphics: Enabled
233
+ # ...
234
+ ```
235
+
236
+ #### 2. Direct Mode - Training Configuration
237
+
238
+ ```bash
239
+ # Headless mode with fast simulation (20x speed)
240
+ python examples/unity_simple.py --direct --no-graphics --time-scale 20 --episodes 10 --max-steps 1000
241
+
242
+ # This is ideal for training - no graphics overhead, faster simulation
243
+ ```
244
+
245
+ #### 3. Direct Mode - 3DBall with Custom Window
246
+
247
+ ```bash
248
+ # Run 3DBall (continuous actions) with larger window
249
+ python examples/unity_simple.py --direct --env 3DBall --width 1280 --height 720 --episodes 5
250
+ ```
251
+
252
+ #### 4. Docker Mode - Production-like Testing
253
+
254
+ ```bash
255
+ # Build the image first
256
+ cd envs/unity_env
257
+ docker build -f server/Dockerfile -t unity-env:latest .
258
+
259
+ # Run via Docker with graphics
260
+ python examples/unity_simple.py --docker --width 1280 --height 720
261
+
262
+ # Run via Docker in headless mode for training
263
+ python examples/unity_simple.py --docker --no-graphics --time-scale 20 --episodes 20
264
+ ```
265
+
266
+ #### 5. Server Mode - Separate Server and Client
267
+
268
+ ```bash
269
+ # Terminal 1: Start server with specific settings
270
+ UNITY_WIDTH=1280 UNITY_HEIGHT=720 uv run uvicorn server.app:app --port 8000
271
+
272
+ # Terminal 2: Connect and run episodes
273
+ python examples/unity_simple.py --url http://localhost:8000 --env PushBlock --episodes 5
274
+ python examples/unity_simple.py --url http://localhost:8000 --env 3DBall --episodes 5
275
+ ```
276
+
277
+ #### 6. Alternating Environments
278
+
279
+ ```bash
280
+ # Run alternating episodes between PushBlock and 3DBall
281
+ python examples/unity_simple.py --direct --env both --episodes 6
282
+ # Episodes 1,3,5 = PushBlock; Episodes 2,4,6 = 3DBall
283
+ ```
284
+
285
+ ### Command Line Options
286
+
287
+ | Option | Default | Description |
288
+ |--------|---------|-------------|
289
+ | `--direct` | - | Run environment directly (no server) |
290
+ | `--docker` | - | Run via Docker container |
291
+ | `--url` | localhost:8000 | Server URL for server mode |
292
+ | `--docker-image` | unity-env:latest | Docker image name |
293
+ | `--env` | PushBlock | Environment: PushBlock, 3DBall, both |
294
+ | `--episodes` | 3 | Number of episodes |
295
+ | `--max-steps` | 500 | Max steps per episode |
296
+ | `--width` | 1280 | Window width in pixels |
297
+ | `--height` | 720 | Window height in pixels |
298
+ | `--no-graphics` | - | Headless mode (faster) |
299
+ | `--time-scale` | 1.0 | Simulation speed multiplier |
300
+ | `--quality-level` | 5 | Graphics quality 0-5 |
301
+ | `--quiet` | - | Reduce output verbosity |
302
+
303
+ ## Python Client Usage
304
+
305
+ ### Connect to Server
306
+
307
+ ```python
308
+ from envs.unity_env import UnityEnv, UnityAction
309
+
310
+ # Connect to the server
311
+ with UnityEnv(base_url="http://localhost:8000") as client:
312
+ # Reset to PushBlock environment
313
+ result = client.reset(env_id="PushBlock")
314
+ print(f"Observation dims: {len(result.observation.vector_observations)}")
315
+
316
+ # Take actions
317
+ for _ in range(100):
318
+ # PushBlock actions: 0=noop, 1=forward, 2=backward,
319
+ # 3=rotate_left, 4=rotate_right, 5=strafe_left, 6=strafe_right
320
+ action = UnityAction(discrete_actions=[1]) # Move forward
321
+ result = client.step(action)
322
+ print(f"Reward: {result.reward}, Done: {result.done}")
323
+
324
+ if result.done:
325
+ result = client.reset()
326
+ ```
327
+
328
+ ### Connect via Docker
329
+
330
+ ```python
331
+ from envs.unity_env import UnityEnv, UnityAction
332
+
333
+ # Automatically start Docker container and connect
334
+ client = UnityEnv.from_docker_image(
335
+ "unity-env:latest",
336
+ environment={
337
+ "UNITY_NO_GRAPHICS": "0",
338
+ "UNITY_WIDTH": "1280",
339
+ "UNITY_HEIGHT": "720",
340
+ }
341
+ )
342
+
343
+ try:
344
+ result = client.reset(env_id="PushBlock")
345
+ for _ in range(100):
346
+ action = UnityAction(discrete_actions=[1])
347
+ result = client.step(action)
348
+ finally:
349
+ client.close()
350
+ ```
351
+
352
+ ### Switch Environments Dynamically
353
+
354
+ ```python
355
+ # Start with PushBlock
356
+ result = client.reset(env_id="PushBlock")
357
+ # ... train on PushBlock ...
358
+
359
+ # Switch to 3DBall (continuous actions)
360
+ result = client.reset(env_id="3DBall")
361
+ action = UnityAction(continuous_actions=[0.5, -0.3])
362
+ result = client.step(action)
363
+ ```
364
+
365
+ ### Direct Mode (Embedded Server)
366
+
367
+ ```python
368
+ from envs.unity_env.client import UnityEnv
369
+ from envs.unity_env.models import UnityAction
370
+
371
+ # Create client with embedded local server (no separate server needed)
372
+ client = UnityEnv.from_direct(
373
+ env_id="PushBlock",
374
+ no_graphics=False, # Show graphics window
375
+ width=1280,
376
+ height=720,
377
+ time_scale=1.0,
378
+ )
379
+
380
+ try:
381
+ result = client.reset()
382
+ print(f"Observation: {len(result.observation.vector_observations)} dimensions")
383
+
384
+ for step in range(100):
385
+ action = UnityAction(discrete_actions=[1]) # Move forward
386
+ result = client.step(action)
387
+ print(f"Step {step}: reward={result.reward}, done={result.done}")
388
+
389
+ if result.done:
390
+ result = client.reset()
391
+ finally:
392
+ client.close()
393
+ ```
394
+
395
+ ## Action Spaces
396
+
397
+ ### PushBlock (Discrete)
398
+
399
+ 7 discrete actions:
400
+ - `0`: No operation
401
+ - `1`: Move forward
402
+ - `2`: Move backward
403
+ - `3`: Rotate left
404
+ - `4`: Rotate right
405
+ - `5`: Strafe left
406
+ - `6`: Strafe right
407
+
408
+ ```python
409
+ action = UnityAction(discrete_actions=[1]) # Move forward
410
+ ```
411
+
412
+ ### 3DBall (Continuous)
413
+
414
+ 2 continuous actions in range [-1, 1]:
415
+ - Action 0: X-axis rotation
416
+ - Action 1: Z-axis rotation
417
+
418
+ ```python
419
+ action = UnityAction(continuous_actions=[0.5, -0.3])
420
+ ```
421
+
422
+ ## Observations
423
+
424
+ All environments provide vector observations. The size depends on the environment:
425
+
426
+ - **PushBlock**: 70 dimensions (14 ray-casts detecting walls/goals/blocks)
427
+ - **3DBall**: 8 dimensions (rotation and ball position/velocity)
428
+ - **GridWorld**: Visual observations (grid view)
429
+
430
+ ```python
431
+ result = client.reset()
432
+ obs = result.observation
433
+
434
+ # Access observations
435
+ print(f"Vector obs: {obs.vector_observations}")
436
+ print(f"Behavior: {obs.behavior_name}")
437
+ print(f"Action spec: {obs.action_spec_info}")
438
+ ```
439
+
440
+ ### Visual Observations (Optional)
441
+
442
+ Some environments support visual observations. Enable with `include_visual=True`:
443
+
444
+ ```python
445
+ result = client.reset(include_visual=True)
446
+ if result.observation.visual_observations:
447
+ # Base64-encoded PNG images
448
+ for img_b64 in result.observation.visual_observations:
449
+ # Decode and use the image
450
+ import base64
451
+ img_bytes = base64.b64decode(img_b64)
452
+ ```
453
+
454
+ ## Configuration
455
+
456
+ ### Direct Mode Arguments
457
+
458
+ When using `UnityEnv.from_direct()` to run with an embedded server:
459
+
460
+ ```python
461
+ from envs.unity_env.client import UnityEnv
462
+
463
+ client = UnityEnv.from_direct(
464
+ env_id="PushBlock", # Unity environment to load
465
+ no_graphics=False, # False = show graphics window
466
+ width=1280, # Window width in pixels
467
+ height=720, # Window height in pixels
468
+ time_scale=1.0, # Simulation speed (20.0 for fast training)
469
+ quality_level=5, # Graphics quality 0-5
470
+ port=8765, # Port for embedded server
471
+ )
472
+ ```
473
+
474
+ ### Environment Variables
475
+
476
+ For Docker deployment, configure via environment variables:
477
+
478
+ | Variable | Default | Description |
479
+ |----------|---------|-------------|
480
+ | `UNITY_ENV_ID` | PushBlock | Default Unity environment |
481
+ | `UNITY_NO_GRAPHICS` | 0 | Set to 1 for headless mode |
482
+ | `UNITY_WIDTH` | 1280 | Window width in pixels |
483
+ | `UNITY_HEIGHT` | 720 | Window height in pixels |
484
+ | `UNITY_TIME_SCALE` | 1.0 | Simulation speed multiplier |
485
+ | `UNITY_QUALITY_LEVEL` | 5 | Graphics quality 0-5 |
486
+ | `UNITY_CACHE_DIR` | ~/.mlagents-cache | Binary cache directory |
487
+
488
+ ## Environment State
489
+
490
+ Access detailed environment information:
491
+
492
+ ```python
493
+ state = client.state()
494
+ print(f"Environment: {state.env_id}")
495
+ print(f"Episode ID: {state.episode_id}")
496
+ print(f"Step count: {state.step_count}")
497
+ print(f"Available envs: {state.available_envs}")
498
+ print(f"Action spec: {state.action_spec}")
499
+ print(f"Observation spec: {state.observation_spec}")
500
+ ```
501
+
502
+ ## Troubleshooting
503
+
504
+ ### Docker Mode Fails on Apple Silicon (M1/M2/M3/M4)
505
+
506
+ **Symptom:** When running with `--docker` on Apple Silicon Macs, you see an error like:
507
+
508
+ ```
509
+ Error running with Docker: Server error: The Unity environment took too long to respond...
510
+ ```
511
+
512
+ Or in Docker logs:
513
+
514
+ ```
515
+ * Assertion: should not be reached at tramp-amd64.c:605
516
+ Environment shut down with return code -6 (SIGABRT)
517
+ ```
518
+
519
+ **Cause:** Unity ML-Agents binaries are x86_64 (Intel) only. When Docker runs the x86_64 Linux container on Apple Silicon, it uses QEMU emulation. The Mono runtime inside Unity has architecture-specific code that crashes under emulation.
520
+
521
+ **Solutions:**
522
+
523
+ 1. **Use Direct Mode** (recommended for macOS):
524
+ ```bash
525
+ python examples/unity_simple.py --direct --no-graphics
526
+ ```
527
+ Direct mode downloads native macOS binaries which work on Apple Silicon.
528
+
529
+ 2. **Use Server Mode** with a local server:
530
+ ```bash
531
+ # Terminal 1: Start server (uses native macOS binaries)
532
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
533
+
534
+ # Terminal 2: Run client
535
+ python examples/unity_simple.py --url http://localhost:8000
536
+ ```
537
+
538
+ 3. **Use an x86_64 Linux machine** for Docker mode:
539
+ The Docker image works correctly on native x86_64 Linux machines (cloud VMs, dedicated servers, etc.).
540
+
541
+ ### First Run is Slow
542
+
543
+ The first run downloads Unity binaries (~500MB). This is normal and only happens once. Binaries are cached in `~/.mlagents-cache/`.
544
+
545
+ ### Graphics Not Showing
546
+
547
+ - Ensure `--no-graphics` is NOT set
548
+ - On Linux, ensure X11 is available
549
+ - For Docker, you may need to set up X11 forwarding
550
+
551
+ ### Docker Container Fails to Start
552
+
553
+ ```bash
554
+ # Check Docker logs
555
+ docker logs <container_id>
556
+
557
+ # Ensure the image is built
558
+ docker images | grep unity-env
559
+
560
+ # Rebuild if necessary
561
+ cd envs/unity_env
562
+ docker build -f server/Dockerfile -t unity-env:latest .
563
+ ```
564
+
565
+ ### Import Errors
566
+
567
+ ```bash
568
+ # Ensure you're in the correct directory
569
+ cd envs/unity_env
570
+
571
+ # Install dependencies
572
+ uv sync
573
+ # or
574
+ pip install -e .
575
+ ```
576
+
577
+ ### mlagents-envs Installation Issues
578
+
579
+ The `mlagents-envs` and `mlagents` packages are installed from source by default (via the GitHub repository). If you encounter issues or want to install manually:
580
+
581
+ ```bash
582
+ # Clone the ml-agents repository
583
+ git clone https://github.com/Unity-Technologies/ml-agents.git
584
+ cd ml-agents
585
+
586
+ # Install mlagents-envs from source
587
+ pip install -e ./ml-agents-envs
588
+
589
+ # Install the full ml-agents package
590
+ pip install -e ./ml-agents
591
+ ```
592
+
593
+ This approach is useful when:
594
+ - You need to modify the mlagents source code
595
+ - You want to use a specific branch or commit
596
+ - The git dependency in pyproject.toml is causing issues
597
+
598
+ ## Caveats
599
+
600
+ 1. **First Run Download**: Unity binaries (~500MB) are downloaded on first use
601
+ 2. **Platform-Specific**: Binaries are platform-specific (macOS, Linux, Windows)
602
+ 3. **Apple Silicon + Docker**: Docker mode does not work on Apple Silicon Macs due to x86_64 emulation issues with Unity's Mono runtime. Use direct mode or server mode instead.
603
+ 4. **Single Worker**: Unity environments are not thread-safe; use `workers=1`
604
+ 5. **Graphics Mode**: Some features require X11/display for graphics mode
605
+ 6. **Multi-Agent**: Currently uses first agent only; full multi-agent support planned
606
+
607
+ ## Dependencies
608
+
609
+ - `mlagents-envs` (installed from source via git)
610
+ - `mlagents` (installed from source via git)
611
+ - `numpy>=1.20.0`
612
+ - `pillow>=9.0.0` (for visual observations)
613
+ - `openenv-core[core]>=0.2.0`
614
+
615
+ ## References
616
+
617
+ - [Unity ML-Agents Documentation](https://docs.unity3d.com/Packages/com.unity.ml-agents@4.0/manual/index.html)
618
+ - [ML-Agents GitHub](https://github.com/Unity-Technologies/ml-agents)
619
+ - [Example Environments](https://docs.unity3d.com/Packages/com.unity.ml-agents@4.0/manual/Learning-Environment-Examples.html)
envs/unity_env/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Unity ML-Agents Environment for OpenEnv."""
8
+
9
+ from .client import UnityEnv
10
+ from .models import UnityAction, UnityObservation, UnityState
11
+
12
+ __all__ = ["UnityAction", "UnityObservation", "UnityState", "UnityEnv"]
envs/unity_env/assets/unity_3dball.gif ADDED

Git LFS Details

  • SHA256: ee94fedb0f70a57752657c988f5c612dbe6145501fa1f896cd3c73d2de029c8d
  • Pointer size: 132 Bytes
  • Size of remote file: 6.59 MB
envs/unity_env/assets/unity_pushblock.gif ADDED

Git LFS Details

  • SHA256: 1a20ea5a7d77c753f7d832948222a4ff06f45d96c5b39006217e44cd1b803e4e
  • Pointer size: 132 Bytes
  • Size of remote file: 1.05 MB
envs/unity_env/client.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Unity ML-Agents Environment Client.
9
+
10
+ This module provides the client for connecting to a Unity ML-Agents
11
+ Environment server via WebSocket for persistent sessions.
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ # Support multiple import scenarios
17
+ try:
18
+ # In-repo imports (when running from OpenEnv repository root)
19
+ from openenv.core.client_types import StepResult
20
+ from openenv.core.env_client import EnvClient
21
+
22
+ from .models import UnityAction, UnityObservation, UnityState
23
+ except ImportError:
24
+ # openenv from pip
25
+ from openenv.core.client_types import StepResult
26
+ from openenv.core.env_client import EnvClient
27
+
28
+ try:
29
+ # Direct execution from envs/unity_env/ directory
30
+ from models import UnityAction, UnityObservation, UnityState
31
+ except ImportError:
32
+ try:
33
+ # Package installed as unity_env
34
+ from unity_env.models import UnityAction, UnityObservation, UnityState
35
+ except ImportError:
36
+ # Running from OpenEnv root with envs prefix
37
+ from envs.unity_env.models import UnityAction, UnityObservation, UnityState
38
+
39
+
40
+ class UnityEnv(EnvClient[UnityAction, UnityObservation, UnityState]):
41
+ """
42
+ Client for Unity ML-Agents environments.
43
+
44
+ This client maintains a persistent WebSocket connection to the environment
45
+ server, enabling efficient multi-step interactions with lower latency.
46
+ Each client instance has its own dedicated environment session on the server.
47
+
48
+ Note: Unity environments can take 30-60+ seconds to initialize on first reset
49
+ (downloading binaries, starting Unity process). The client is configured with
50
+ longer ping timeouts to handle this.
51
+
52
+ Supported Unity Environments:
53
+ - PushBlock: Push a block to a goal (discrete actions: 7)
54
+ - 3DBall: Balance a ball on a platform (continuous actions: 2)
55
+ - 3DBallHard: Harder version of 3DBall
56
+ - GridWorld: Navigate a grid to find goals
57
+ - Basic: Simple movement task
58
+ - And more from the ML-Agents registry
59
+
60
+ Example:
61
+ >>> # Connect to a running server
62
+ >>> with UnityEnv(base_url="http://localhost:8000") as client:
63
+ ... result = client.reset()
64
+ ... print(f"Vector obs: {len(result.observation.vector_observations)} dims")
65
+ ...
66
+ ... # Take action (PushBlock: 1=forward)
67
+ ... result = client.step(UnityAction(discrete_actions=[1]))
68
+ ... print(f"Reward: {result.reward}")
69
+
70
+ Example with Docker:
71
+ >>> # Automatically start container and connect
72
+ >>> client = UnityEnv.from_docker_image("unity-env:latest")
73
+ >>> try:
74
+ ... result = client.reset(env_id="3DBall")
75
+ ... result = client.step(UnityAction(continuous_actions=[0.5, -0.3]))
76
+ ... finally:
77
+ ... client.close()
78
+
79
+ Example switching environments:
80
+ >>> client = UnityEnv(base_url="http://localhost:8000")
81
+ >>> # Start with PushBlock
82
+ >>> result = client.reset(env_id="PushBlock")
83
+ >>> # ... train on PushBlock ...
84
+ >>> # Switch to 3DBall
85
+ >>> result = client.reset(env_id="3DBall")
86
+ >>> # ... train on 3DBall ...
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ base_url: str,
92
+ connect_timeout_s: float = 10.0,
93
+ message_timeout_s: float = 180.0, # 3 minutes for slow Unity initialization
94
+ provider: Optional[Any] = None,
95
+ ):
96
+ """
97
+ Initialize Unity environment client.
98
+
99
+ Uses longer default timeouts than the base EnvClient because Unity
100
+ environments can take 30-60+ seconds to initialize on first reset.
101
+
102
+ Args:
103
+ base_url: Base URL of the environment server (http:// or ws://).
104
+ connect_timeout_s: Timeout for establishing WebSocket connection
105
+ message_timeout_s: Timeout for receiving responses (default 3 min for Unity)
106
+ provider: Optional container/runtime provider for lifecycle management.
107
+ """
108
+ super().__init__(
109
+ base_url=base_url,
110
+ connect_timeout_s=connect_timeout_s,
111
+ message_timeout_s=message_timeout_s,
112
+ provider=provider,
113
+ )
114
+
115
+ def connect(self) -> "UnityEnv":
116
+ """
117
+ Establish WebSocket connection to the server.
118
+
119
+ Overrides the default connection to use longer ping timeouts,
120
+ since Unity environments can take 30-60+ seconds to initialize.
121
+
122
+ Returns:
123
+ self for method chaining
124
+
125
+ Raises:
126
+ ConnectionError: If connection cannot be established
127
+ """
128
+ from websockets.sync.client import connect as ws_connect
129
+
130
+ if self._ws is not None:
131
+ return self
132
+
133
+ try:
134
+ # Use longer ping_timeout for Unity (60s) since environment
135
+ # initialization can block the server for a while
136
+ self._ws = ws_connect(
137
+ self._ws_url,
138
+ open_timeout=self._connect_timeout,
139
+ ping_timeout=120, # 2 minutes for slow Unity initialization
140
+ ping_interval=30, # Send pings every 30 seconds
141
+ close_timeout=30,
142
+ )
143
+ except Exception as e:
144
+ raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e
145
+
146
+ return self
147
+
148
+ def _step_payload(self, action: UnityAction) -> Dict:
149
+ """
150
+ Convert UnityAction to JSON payload for step request.
151
+
152
+ Args:
153
+ action: UnityAction instance
154
+
155
+ Returns:
156
+ Dictionary representation suitable for JSON encoding
157
+ """
158
+ payload: Dict[str, Any] = {}
159
+
160
+ if action.discrete_actions is not None:
161
+ payload["discrete_actions"] = action.discrete_actions
162
+
163
+ if action.continuous_actions is not None:
164
+ payload["continuous_actions"] = action.continuous_actions
165
+
166
+ if action.metadata:
167
+ payload["metadata"] = action.metadata
168
+
169
+ return payload
170
+
171
+ def _parse_result(self, payload: Dict) -> StepResult[UnityObservation]:
172
+ """
173
+ Parse server response into StepResult[UnityObservation].
174
+
175
+ Args:
176
+ payload: JSON response from server
177
+
178
+ Returns:
179
+ StepResult with UnityObservation
180
+ """
181
+ obs_data = payload.get("observation", {})
182
+
183
+ observation = UnityObservation(
184
+ vector_observations=obs_data.get("vector_observations", []),
185
+ visual_observations=obs_data.get("visual_observations"),
186
+ behavior_name=obs_data.get("behavior_name", ""),
187
+ action_spec_info=obs_data.get("action_spec_info", {}),
188
+ observation_spec_info=obs_data.get("observation_spec_info", {}),
189
+ done=payload.get("done", False),
190
+ reward=payload.get("reward"),
191
+ metadata=obs_data.get("metadata", {}),
192
+ )
193
+
194
+ return StepResult(
195
+ observation=observation,
196
+ reward=payload.get("reward"),
197
+ done=payload.get("done", False),
198
+ )
199
+
200
+ def _parse_state(self, payload: Dict) -> UnityState:
201
+ """
202
+ Parse server response into UnityState object.
203
+
204
+ Args:
205
+ payload: JSON response from /state endpoint
206
+
207
+ Returns:
208
+ UnityState object with environment information
209
+ """
210
+ return UnityState(
211
+ episode_id=payload.get("episode_id"),
212
+ step_count=payload.get("step_count", 0),
213
+ env_id=payload.get("env_id", ""),
214
+ behavior_name=payload.get("behavior_name", ""),
215
+ action_spec=payload.get("action_spec", {}),
216
+ observation_spec=payload.get("observation_spec", {}),
217
+ available_envs=payload.get("available_envs", []),
218
+ )
219
+
220
+ def reset(
221
+ self,
222
+ env_id: Optional[str] = None,
223
+ include_visual: bool = False,
224
+ **kwargs,
225
+ ) -> StepResult[UnityObservation]:
226
+ """
227
+ Reset the environment.
228
+
229
+ Args:
230
+ env_id: Optionally switch to a different Unity environment.
231
+ Available: PushBlock, 3DBall, 3DBallHard, GridWorld, Basic
232
+ include_visual: If True, include visual observations in response.
233
+ **kwargs: Additional arguments passed to server.
234
+
235
+ Returns:
236
+ StepResult with initial observation.
237
+ """
238
+ reset_kwargs = dict(kwargs)
239
+ if env_id is not None:
240
+ reset_kwargs["env_id"] = env_id
241
+ reset_kwargs["include_visual"] = include_visual
242
+
243
+ return super().reset(**reset_kwargs)
244
+
245
+ @staticmethod
246
+ def available_environments() -> List[str]:
247
+ """
248
+ List commonly available Unity environments.
249
+
250
+ Note: The actual list may vary based on the ML-Agents registry version.
251
+ Use state.available_envs after connecting for the authoritative list.
252
+
253
+ Returns:
254
+ List of environment identifiers.
255
+ """
256
+ return [
257
+ "PushBlock",
258
+ "3DBall",
259
+ "3DBallHard",
260
+ "GridWorld",
261
+ "Basic",
262
+ "VisualPushBlock",
263
+ ]
264
+
265
+ @classmethod
266
+ def from_direct(
267
+ cls,
268
+ env_id: str = "PushBlock",
269
+ no_graphics: bool = False,
270
+ width: int = 1280,
271
+ height: int = 720,
272
+ time_scale: float = 1.0,
273
+ quality_level: int = 5,
274
+ port: int = 8765,
275
+ ) -> "UnityEnv":
276
+ """
277
+ Create a Unity environment client with an embedded local server.
278
+
279
+ This method starts a local uvicorn server in a subprocess and returns
280
+ a client connected to it. This provides the convenience of direct mode
281
+ while maintaining the client-server separation.
282
+
283
+ Note: The first call will download Unity binaries (~500MB) which may
284
+ take several minutes. Binaries are cached for subsequent runs.
285
+
286
+ Args:
287
+ env_id: Default Unity environment to use (PushBlock, 3DBall, etc.)
288
+ no_graphics: If True, run Unity in headless mode (faster for training)
289
+ width: Window width in pixels (default: 1280)
290
+ height: Window height in pixels (default: 720)
291
+ time_scale: Simulation speed multiplier (default: 1.0, use 20.0 for fast training)
292
+ quality_level: Graphics quality 0-5 (default: 5)
293
+ port: Port for the local server (default: 8765)
294
+
295
+ Returns:
296
+ UnityEnv client connected to the local server
297
+
298
+ Example:
299
+ >>> # Quick start with direct mode
300
+ >>> client = UnityEnv.from_direct(no_graphics=True, time_scale=20)
301
+ >>> try:
302
+ ... result = client.reset(env_id="PushBlock")
303
+ ... for _ in range(100):
304
+ ... result = client.step(UnityAction(discrete_actions=[1]))
305
+ ... finally:
306
+ ... client.close()
307
+
308
+ >>> # With custom settings
309
+ >>> client = UnityEnv.from_direct(
310
+ ... env_id="3DBall",
311
+ ... no_graphics=True,
312
+ ... time_scale=20,
313
+ ... port=9000
314
+ ... )
315
+ """
316
+ import os
317
+ import subprocess
318
+ import sys
319
+ import time
320
+
321
+ import requests
322
+
323
+ # Find the project root and server module
324
+ # Try to locate the server module
325
+ try:
326
+ from pathlib import Path
327
+
328
+ # Get the directory containing this file
329
+ client_dir = Path(__file__).parent
330
+ server_app = "envs.unity_env.server.app:app"
331
+ cwd = client_dir.parent.parent # OpenEnv root
332
+
333
+ # Check if we're in the envs/unity_env directory structure
334
+ if not (cwd / "envs" / "unity_env" / "server" / "app.py").exists():
335
+ # Try alternative paths
336
+ if (client_dir / "server" / "app.py").exists():
337
+ server_app = "server.app:app"
338
+ cwd = client_dir
339
+ except Exception:
340
+ server_app = "envs.unity_env.server.app:app"
341
+ cwd = None
342
+
343
+ # Set up environment variables for Unity configuration
344
+ env = {
345
+ **os.environ,
346
+ "UNITY_ENV_ID": env_id,
347
+ "UNITY_NO_GRAPHICS": "1" if no_graphics else "0",
348
+ "UNITY_WIDTH": str(width),
349
+ "UNITY_HEIGHT": str(height),
350
+ "UNITY_TIME_SCALE": str(time_scale),
351
+ "UNITY_QUALITY_LEVEL": str(quality_level),
352
+ # Bypass proxy for localhost
353
+ "NO_PROXY": "localhost,127.0.0.1",
354
+ "no_proxy": "localhost,127.0.0.1",
355
+ }
356
+
357
+ # Add src to PYTHONPATH if needed
358
+ if cwd:
359
+ src_path = str(cwd / "src")
360
+ existing_path = env.get("PYTHONPATH", "")
361
+ env["PYTHONPATH"] = f"{src_path}:{cwd}:{existing_path}" if existing_path else f"{src_path}:{cwd}"
362
+
363
+ # Start the server
364
+ cmd = [
365
+ sys.executable,
366
+ "-m",
367
+ "uvicorn",
368
+ server_app,
369
+ "--host",
370
+ "127.0.0.1",
371
+ "--port",
372
+ str(port),
373
+ ]
374
+
375
+ server_process = subprocess.Popen(
376
+ cmd,
377
+ env=env,
378
+ stdout=subprocess.PIPE,
379
+ stderr=subprocess.STDOUT,
380
+ cwd=str(cwd) if cwd else None,
381
+ )
382
+
383
+ # Wait for server to become healthy
384
+ base_url = f"http://127.0.0.1:{port}"
385
+ healthy = False
386
+ for _ in range(30): # Wait up to 30 seconds
387
+ try:
388
+ response = requests.get(
389
+ f"{base_url}/health",
390
+ timeout=2,
391
+ proxies={"http": None, "https": None},
392
+ )
393
+ if response.status_code == 200:
394
+ healthy = True
395
+ break
396
+ except requests.exceptions.RequestException:
397
+ pass
398
+ time.sleep(1)
399
+
400
+ if not healthy:
401
+ server_process.kill()
402
+ raise RuntimeError(
403
+ f"Failed to start local Unity server on port {port}. "
404
+ "Check that the port is available and dependencies are installed."
405
+ )
406
+
407
+ # Create a provider to manage the subprocess lifecycle
408
+ class DirectModeProvider:
409
+ """Provider that manages the embedded server subprocess."""
410
+
411
+ def __init__(self, process: subprocess.Popen):
412
+ self._process = process
413
+
414
+ def stop(self):
415
+ """Stop the embedded server."""
416
+ if self._process:
417
+ self._process.terminate()
418
+ try:
419
+ self._process.wait(timeout=10)
420
+ except subprocess.TimeoutExpired:
421
+ self._process.kill()
422
+ self._process = None
423
+
424
+ provider = DirectModeProvider(server_process)
425
+
426
+ # Create and return the client
427
+ client = cls(base_url=base_url, provider=provider)
428
+ return client
envs/unity_env/models.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the Unity ML-Agents Environment.
9
+
10
+ The Unity environment wraps Unity ML-Agents environments (PushBlock, 3DBall,
11
+ GridWorld, etc.) providing a unified interface for reinforcement learning.
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from pydantic import Field
17
+
18
+ # Support both in-repo and standalone imports
19
+ try:
20
+ # In-repo imports (when running from OpenEnv repository)
21
+ from openenv.core.env_server.types import Action, Observation, State
22
+ except ImportError:
23
+ # Standalone imports (when environment is standalone with openenv from pip)
24
+ from openenv.core.env_server.types import Action, Observation, State
25
+
26
+
27
+ class UnityAction(Action):
28
+ """
29
+ Action for Unity ML-Agents environments.
30
+
31
+ Supports both discrete and continuous action spaces. Unity environments
32
+ may use either or both types of actions:
33
+
34
+ - Discrete actions: Integer indices for categorical choices
35
+ (e.g., movement direction: 0=forward, 1=backward, 2=left, 3=right)
36
+ - Continuous actions: Float values typically in [-1, 1] range
37
+ (e.g., joint rotations, force magnitudes)
38
+
39
+ Example (PushBlock - discrete):
40
+ >>> action = UnityAction(discrete_actions=[3]) # Rotate left
41
+
42
+ Example (Walker - continuous):
43
+ >>> action = UnityAction(continuous_actions=[0.5, -0.3, 0.0, ...])
44
+
45
+ Attributes:
46
+ discrete_actions: List of discrete action indices for each action branch.
47
+ For PushBlock: [0-6] where 0=noop, 1=forward, 2=backward,
48
+ 3=rotate_left, 4=rotate_right, 5=strafe_left, 6=strafe_right
49
+ continuous_actions: List of continuous action values, typically in [-1, 1].
50
+ metadata: Additional action parameters.
51
+ """
52
+
53
+ discrete_actions: Optional[List[int]] = Field(
54
+ default=None,
55
+ description="Discrete action indices for each action branch",
56
+ )
57
+ continuous_actions: Optional[List[float]] = Field(
58
+ default=None,
59
+ description="Continuous action values, typically in [-1, 1] range",
60
+ )
61
+
62
+
63
+ class UnityObservation(Observation):
64
+ """
65
+ Observation from Unity ML-Agents environments.
66
+
67
+ Contains vector observations (sensor readings) and optionally visual
68
+ observations (rendered images). Most Unity environments provide vector
69
+ observations; visual observations are optional and must be requested.
70
+
71
+ Attributes:
72
+ vector_observations: Flattened array of all vector observations.
73
+ Size and meaning depends on the specific environment.
74
+ For PushBlock: 70 values from 14 ray-casts detecting walls/goals/blocks.
75
+ visual_observations: Optional list of base64-encoded images (PNG format).
76
+ Only included when include_visual=True in reset/step.
77
+ behavior_name: Name of the Unity behavior (agent type).
78
+ action_spec_info: Information about the action space for this environment.
79
+ observation_spec_info: Information about the observation space.
80
+ """
81
+
82
+ vector_observations: List[float] = Field(
83
+ default_factory=list,
84
+ description="Flattened vector observations from the environment",
85
+ )
86
+ visual_observations: Optional[List[str]] = Field(
87
+ default=None,
88
+ description="Base64-encoded PNG images (when include_visual=True)",
89
+ )
90
+ behavior_name: str = Field(
91
+ default="",
92
+ description="Name of the Unity behavior/agent type",
93
+ )
94
+ action_spec_info: Dict[str, Any] = Field(
95
+ default_factory=dict,
96
+ description="Information about the action space",
97
+ )
98
+ observation_spec_info: Dict[str, Any] = Field(
99
+ default_factory=dict,
100
+ description="Information about the observation space",
101
+ )
102
+
103
+
104
+ class UnityState(State):
105
+ """
106
+ Extended state for Unity ML-Agents environments.
107
+
108
+ Provides additional metadata about the currently loaded environment,
109
+ including action and observation space specifications.
110
+
111
+ Attributes:
112
+ episode_id: Unique identifier for the current episode.
113
+ step_count: Number of steps taken in the current episode.
114
+ env_id: Identifier of the currently loaded Unity environment.
115
+ behavior_name: Name of the Unity behavior (agent type).
116
+ action_spec: Detailed specification of the action space.
117
+ observation_spec: Detailed specification of the observation space.
118
+ available_envs: List of available environment identifiers.
119
+ """
120
+
121
+ env_id: str = Field(
122
+ default="PushBlock",
123
+ description="Identifier of the loaded Unity environment",
124
+ )
125
+ behavior_name: str = Field(
126
+ default="",
127
+ description="Name of the Unity behavior/agent type",
128
+ )
129
+ action_spec: Dict[str, Any] = Field(
130
+ default_factory=dict,
131
+ description="Specification of the action space",
132
+ )
133
+ observation_spec: Dict[str, Any] = Field(
134
+ default_factory=dict,
135
+ description="Specification of the observation space",
136
+ )
137
+ available_envs: List[str] = Field(
138
+ default_factory=list,
139
+ description="List of available Unity environments",
140
+ )
141
+
142
+
143
+ # Available Unity environments from the ML-Agents registry
144
+ # These are pre-built environments that can be downloaded automatically
145
+ AVAILABLE_UNITY_ENVIRONMENTS = [
146
+ "PushBlock",
147
+ "3DBall",
148
+ "3DBallHard",
149
+ "GridWorld",
150
+ "Basic",
151
+ "VisualPushBlock",
152
+ # Note: More environments may be available in newer versions of ML-Agents
153
+ ]
154
+
155
+ # Action descriptions for PushBlock (most commonly used example)
156
+ PUSHBLOCK_ACTIONS = {
157
+ 0: "noop",
158
+ 1: "forward",
159
+ 2: "backward",
160
+ 3: "rotate_left",
161
+ 4: "rotate_right",
162
+ 5: "strafe_left",
163
+ 6: "strafe_right",
164
+ }
envs/unity_env/openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: unity_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
envs/unity_env/pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-unity-env"
13
+ version = "0.1.0"
14
+ description = "Unity ML-Agents Environment for OpenEnv - wraps Unity environments like PushBlock, 3DBall, GridWorld"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv dependencies (installed from git for latest features)
18
+ "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v2.1.0",
19
+ "fastapi>=0.115.0",
20
+ "pydantic>=2.0.0",
21
+ "uvicorn>=0.24.0",
22
+ "requests>=2.31.0",
23
+ # Unity ML-Agents dependencies (installed from source for latest features)
24
+ "mlagents-envs @ git+https://github.com/Unity-Technologies/ml-agents.git#subdirectory=ml-agents-envs",
25
+ # "mlagents @ git+https://github.com/Unity-Technologies/ml-agents.git#subdirectory=ml-agents",
26
+ "numpy>=1.20.0",
27
+ # Optional: for visual observations
28
+ "pillow>=9.0.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m unity_env.server.app
40
+ server = "unity_env.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["unity_env", "unity_env.server"]
45
+ package-dir = { "unity_env" = ".", "unity_env.server" = "server" }
envs/unity_env/server/Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build for Unity ML-Agents environment
8
+ # Uses pip for package installation (no virtual environment)
9
+ # Note: Using Python 3.10.12 specifically because ml-agents requires >=3.10.1,<=3.10.12
10
+ # Note: Unity binaries are x86_64 only, so we force linux/amd64 platform
11
+
12
+ FROM --platform=linux/amd64 python:3.10.12-slim AS builder
13
+
14
+ WORKDIR /app
15
+
16
+ # Install build dependencies
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ build-essential \
19
+ git \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Copy environment code
23
+ COPY . /app/env
24
+
25
+ WORKDIR /app/env
26
+
27
+ # Install dependencies using pip
28
+ # Note: mlagents packages are installed from git source via pyproject.toml
29
+ RUN pip install --upgrade pip && \
30
+ pip install --no-cache-dir -e .
31
+
32
+ # Final runtime stage
33
+ FROM --platform=linux/amd64 python:3.10.12-slim
34
+
35
+ WORKDIR /app
36
+
37
+ # Install runtime dependencies (curl for healthcheck)
38
+ RUN apt-get update && apt-get install -y --no-install-recommends \
39
+ curl \
40
+ && rm -rf /var/lib/apt/lists/*
41
+
42
+ # Copy installed packages from builder
43
+ COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
44
+ COPY --from=builder /usr/local/bin /usr/local/bin
45
+
46
+ # Copy the environment code
47
+ COPY . /app/env
48
+
49
+ # Create cache directory for Unity binaries
50
+ RUN mkdir -p /root/.mlagents-cache
51
+
52
+ # Set PYTHONPATH so imports work correctly
53
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
54
+
55
+ # Expose port
56
+ EXPOSE 8000
57
+
58
+ # Health check
59
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
60
+ CMD curl -f http://localhost:8000/health || exit 1
61
+
62
+ # Note: Longer start period (60s) because Unity environment download may take time on first run
63
+
64
+ # Run the FastAPI server
65
+ # Note: workers=1 because Unity environments are not thread-safe
66
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
envs/unity_env/server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Unity environment server components."""
8
+
9
+ from .unity_environment import UnityMLAgentsEnvironment
10
+
11
+ __all__ = ["UnityMLAgentsEnvironment"]
envs/unity_env/server/app.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the Unity ML-Agents Environment.
9
+
10
+ This module creates an HTTP server that exposes Unity ML-Agents environments
11
+ over HTTP and WebSocket endpoints, compatible with EnvClient.
12
+
13
+ Usage:
14
+ # Development (with auto-reload):
15
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
16
+
17
+ # Production:
18
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 1
19
+
20
+ # Or run directly:
21
+ uv run --project . server
22
+
23
+ Note: Unity environments are not thread-safe, so use workers=1.
24
+ """
25
+
26
+ # Support multiple import scenarios
27
+ try:
28
+ # In-repo imports (when running from OpenEnv repository root)
29
+ from openenv.core.env_server.http_server import create_app
30
+
31
+ from ..models import UnityAction, UnityObservation
32
+ from .unity_environment import UnityMLAgentsEnvironment
33
+ except ImportError:
34
+ # openenv from pip
35
+ from openenv.core.env_server.http_server import create_app
36
+
37
+ try:
38
+ # Direct execution from envs/unity_env/ directory
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ # Add parent directory to path for direct execution
43
+ _parent = str(Path(__file__).parent.parent)
44
+ if _parent not in sys.path:
45
+ sys.path.insert(0, _parent)
46
+ from models import UnityAction, UnityObservation
47
+ from server.unity_environment import UnityMLAgentsEnvironment
48
+ except ImportError:
49
+ try:
50
+ # Package installed as unity_env
51
+ from unity_env.models import UnityAction, UnityObservation
52
+ from unity_env.server.unity_environment import UnityMLAgentsEnvironment
53
+ except ImportError:
54
+ # Running from OpenEnv root with envs prefix
55
+ from envs.unity_env.models import UnityAction, UnityObservation
56
+ from envs.unity_env.server.unity_environment import UnityMLAgentsEnvironment
57
+
58
+ # Create the app with web interface
59
+ # Pass the class (factory) instead of an instance for WebSocket session support
60
+ app = create_app(
61
+ UnityMLAgentsEnvironment,
62
+ UnityAction,
63
+ UnityObservation,
64
+ env_name="unity_env",
65
+ )
66
+
67
+
68
+ def main():
69
+ """
70
+ Entry point for direct execution via uv run or python -m.
71
+
72
+ This function enables running the server without Docker:
73
+ uv run --project . server
74
+ python -m envs.unity_env.server.app
75
+ openenv serve unity_env
76
+ """
77
+ import uvicorn
78
+
79
+ # Note: workers=1 because Unity environments are not thread-safe
80
+ uvicorn.run(app, host="0.0.0.0", port=8000, workers=1)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
envs/unity_env/server/unity_environment.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Unity ML-Agents Environment Implementation.
9
+
10
+ Wraps Unity ML-Agents environments (PushBlock, 3DBall, GridWorld, etc.)
11
+ with the OpenEnv interface for standardized reinforcement learning.
12
+ """
13
+
14
+ import base64
15
+ import glob
16
+ import hashlib
17
+ import io
18
+ import os
19
+ from pathlib import Path
20
+ from sys import platform
21
+ from typing import Any, Dict, List, Optional
22
+ from uuid import uuid4
23
+
24
+ import numpy as np
25
+
26
+ # Support multiple import scenarios
27
+ try:
28
+ # In-repo imports (when running from OpenEnv repository root)
29
+ from openenv.core.env_server.interfaces import Environment
30
+
31
+ from ..models import UnityAction, UnityObservation, UnityState
32
+ except ImportError:
33
+ # openenv from pip
34
+ from openenv.core.env_server.interfaces import Environment
35
+
36
+ try:
37
+ # Direct execution from envs/unity_env/ directory (imports from parent)
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ # Add parent directory to path for direct execution
42
+ _parent = str(Path(__file__).parent.parent)
43
+ if _parent not in sys.path:
44
+ sys.path.insert(0, _parent)
45
+ from models import UnityAction, UnityObservation, UnityState
46
+ except ImportError:
47
+ try:
48
+ # Package installed as unity_env
49
+ from unity_env.models import UnityAction, UnityObservation, UnityState
50
+ except ImportError:
51
+ # Running from OpenEnv root with envs prefix
52
+ from envs.unity_env.models import UnityAction, UnityObservation, UnityState
53
+
54
+
55
+ # Persistent cache directory to avoid re-downloading environment binaries
56
+ PERSISTENT_CACHE_DIR = os.path.join(str(Path.home()), ".mlagents-cache")
57
+
58
+
59
+ def get_cached_binary_path(cache_dir: str, name: str, url: str) -> Optional[str]:
60
+ """Check if binary is cached and return its path."""
61
+ if platform == "darwin":
62
+ extension = "*.app"
63
+ elif platform in ("linux", "linux2"):
64
+ extension = "*.x86_64"
65
+ elif platform == "win32":
66
+ extension = "*.exe"
67
+ else:
68
+ return None
69
+
70
+ bin_dir = os.path.join(cache_dir, "binaries")
71
+ url_hash = "-" + hashlib.md5(url.encode()).hexdigest()
72
+ search_path = os.path.join(bin_dir, name + url_hash, "**", extension)
73
+
74
+ candidates = glob.glob(search_path, recursive=True)
75
+ for c in candidates:
76
+ if "UnityCrashHandler64" not in c:
77
+ return c
78
+ return None
79
+
80
+
81
+ class UnityMLAgentsEnvironment(Environment):
82
+ """
83
+ Wraps Unity ML-Agents environments with the OpenEnv interface.
84
+
85
+ This environment supports all Unity ML-Agents registry environments
86
+ including PushBlock, 3DBall, GridWorld, and more. Environments are
87
+ automatically downloaded on first use.
88
+
89
+ Features:
90
+ - Dynamic environment switching via reset(env_id="...")
91
+ - Support for both discrete and continuous action spaces
92
+ - Optional visual observations (base64-encoded images)
93
+ - Persistent caching to avoid re-downloading binaries
94
+ - Headless mode for faster training (no_graphics=True)
95
+
96
+ Example:
97
+ >>> env = UnityMLAgentsEnvironment()
98
+ >>> obs = env.reset()
99
+ >>> print(obs.vector_observations)
100
+ >>>
101
+ >>> # Take a random action
102
+ >>> obs = env.step(UnityAction(discrete_actions=[1])) # Move forward
103
+ >>> print(obs.reward)
104
+
105
+ Example with different environment:
106
+ >>> env = UnityMLAgentsEnvironment(env_id="3DBall")
107
+ >>> obs = env.reset()
108
+ >>>
109
+ >>> # Or switch environment on reset
110
+ >>> obs = env.reset(env_id="PushBlock")
111
+ """
112
+
113
+ # Each WebSocket session gets its own environment instance
114
+ SUPPORTS_CONCURRENT_SESSIONS = False
115
+
116
+ def __init__(
117
+ self,
118
+ env_id: Optional[str] = None,
119
+ no_graphics: Optional[bool] = None,
120
+ time_scale: Optional[float] = None,
121
+ width: Optional[int] = None,
122
+ height: Optional[int] = None,
123
+ quality_level: Optional[int] = None,
124
+ cache_dir: Optional[str] = None,
125
+ ):
126
+ """
127
+ Initialize the Unity ML-Agents environment.
128
+
129
+ Configuration can be provided via constructor arguments or environment
130
+ variables. Environment variables are used when constructor arguments
131
+ are not provided (useful for Docker deployment).
132
+
133
+ Args:
134
+ env_id: Identifier of the Unity environment to load.
135
+ Available: PushBlock, 3DBall, 3DBallHard, GridWorld, Basic
136
+ Env var: UNITY_ENV_ID (default: PushBlock)
137
+ no_graphics: If True, run in headless mode (faster training).
138
+ Env var: UNITY_NO_GRAPHICS (0 or 1, default: 0 = graphics enabled)
139
+ time_scale: Simulation speed multiplier.
140
+ Env var: UNITY_TIME_SCALE (default: 1.0)
141
+ width: Window width in pixels (when graphics enabled).
142
+ Env var: UNITY_WIDTH (default: 1280)
143
+ height: Window height in pixels (when graphics enabled).
144
+ Env var: UNITY_HEIGHT (default: 720)
145
+ quality_level: Graphics quality 0-5 (when graphics enabled).
146
+ Env var: UNITY_QUALITY_LEVEL (default: 5)
147
+ cache_dir: Directory to cache downloaded environment binaries.
148
+ Env var: UNITY_CACHE_DIR (default: ~/.mlagents-cache)
149
+ """
150
+ # Initialize cleanup-critical attributes first (for __del__ safety)
151
+ self._unity_env = None
152
+ self._behavior_name = None
153
+ self._behavior_spec = None
154
+ self._engine_channel = None
155
+
156
+ # Read from environment variables with defaults, allow constructor override
157
+ self._env_id = env_id or os.environ.get("UNITY_ENV_ID", "PushBlock")
158
+
159
+ # Handle no_graphics: default is False (graphics enabled)
160
+ if no_graphics is not None:
161
+ self._no_graphics = no_graphics
162
+ else:
163
+ env_no_graphics = os.environ.get("UNITY_NO_GRAPHICS", "0")
164
+ self._no_graphics = env_no_graphics.lower() in ("1", "true", "yes")
165
+
166
+ self._time_scale = (
167
+ time_scale
168
+ if time_scale is not None
169
+ else float(os.environ.get("UNITY_TIME_SCALE", "1.0"))
170
+ )
171
+ self._width = (
172
+ width
173
+ if width is not None
174
+ else int(os.environ.get("UNITY_WIDTH", "1280"))
175
+ )
176
+ self._height = (
177
+ height
178
+ if height is not None
179
+ else int(os.environ.get("UNITY_HEIGHT", "720"))
180
+ )
181
+ self._quality_level = (
182
+ quality_level
183
+ if quality_level is not None
184
+ else int(os.environ.get("UNITY_QUALITY_LEVEL", "5"))
185
+ )
186
+ self._cache_dir = cache_dir or os.environ.get(
187
+ "UNITY_CACHE_DIR", PERSISTENT_CACHE_DIR
188
+ )
189
+ self._include_visual = False
190
+
191
+ # State tracking
192
+ self._state = UnityState(
193
+ episode_id=str(uuid4()),
194
+ step_count=0,
195
+ env_id=self._env_id,
196
+ )
197
+
198
+ # Ensure cache directory exists
199
+ os.makedirs(self._cache_dir, exist_ok=True)
200
+
201
+ def _load_environment(self, env_id: str) -> None:
202
+ """Load or switch to a Unity environment."""
203
+ # Close existing environment if any
204
+ if self._unity_env is not None:
205
+ try:
206
+ self._unity_env.close()
207
+ except Exception:
208
+ pass
209
+
210
+ # Import ML-Agents components
211
+ try:
212
+ from mlagents_envs.base_env import ActionTuple
213
+ from mlagents_envs.registry import default_registry
214
+ from mlagents_envs.registry.remote_registry_entry import RemoteRegistryEntry
215
+ from mlagents_envs.side_channel.engine_configuration_channel import (
216
+ EngineConfigurationChannel,
217
+ )
218
+ except ImportError as e:
219
+ raise ImportError(
220
+ "mlagents-envs is required. Install with: pip install mlagents-envs"
221
+ ) from e
222
+
223
+ # Create engine configuration channel
224
+ self._engine_channel = EngineConfigurationChannel()
225
+
226
+ # Check if environment is in registry
227
+ if env_id not in default_registry:
228
+ available = list(default_registry.keys())
229
+ raise ValueError(
230
+ f"Environment '{env_id}' not found. Available: {available}"
231
+ )
232
+
233
+ # Get registry entry and create with persistent cache
234
+ entry = default_registry[env_id]
235
+
236
+ # Create a new entry with our persistent cache directory
237
+ persistent_entry = RemoteRegistryEntry(
238
+ identifier=entry.identifier,
239
+ expected_reward=entry.expected_reward,
240
+ description=entry.description,
241
+ linux_url=getattr(entry, "_linux_url", None),
242
+ darwin_url=getattr(entry, "_darwin_url", None),
243
+ win_url=getattr(entry, "_win_url", None),
244
+ additional_args=getattr(entry, "_add_args", []),
245
+ tmp_dir=self._cache_dir,
246
+ )
247
+
248
+ # Create the environment
249
+ self._unity_env = persistent_entry.make(
250
+ no_graphics=self._no_graphics,
251
+ side_channels=[self._engine_channel],
252
+ )
253
+
254
+ # Configure engine settings
255
+ if not self._no_graphics:
256
+ self._engine_channel.set_configuration_parameters(
257
+ width=self._width,
258
+ height=self._height,
259
+ quality_level=self._quality_level,
260
+ time_scale=self._time_scale,
261
+ )
262
+ else:
263
+ self._engine_channel.set_configuration_parameters(
264
+ time_scale=self._time_scale
265
+ )
266
+
267
+ # Get behavior info
268
+ if not self._unity_env.behavior_specs:
269
+ self._unity_env.step()
270
+
271
+ self._behavior_name = list(self._unity_env.behavior_specs.keys())[0]
272
+ self._behavior_spec = self._unity_env.behavior_specs[self._behavior_name]
273
+
274
+ # Update state
275
+ self._env_id = env_id
276
+ self._state.env_id = env_id
277
+ self._state.behavior_name = self._behavior_name
278
+ self._state.action_spec = self._get_action_spec_info()
279
+ self._state.observation_spec = self._get_observation_spec_info()
280
+ self._state.available_envs = list(default_registry.keys())
281
+
282
+ def _get_action_spec_info(self) -> Dict[str, Any]:
283
+ """Get information about the action space."""
284
+ spec = self._behavior_spec.action_spec
285
+ return {
286
+ "is_discrete": spec.is_discrete(),
287
+ "is_continuous": spec.is_continuous(),
288
+ "discrete_size": spec.discrete_size,
289
+ "discrete_branches": list(spec.discrete_branches) if spec.is_discrete() else [],
290
+ "continuous_size": spec.continuous_size,
291
+ }
292
+
293
+ def _get_observation_spec_info(self) -> Dict[str, Any]:
294
+ """Get information about the observation space."""
295
+ specs = self._behavior_spec.observation_specs
296
+ obs_info = []
297
+ for i, spec in enumerate(specs):
298
+ obs_info.append({
299
+ "index": i,
300
+ "shape": list(spec.shape),
301
+ "dimension_property": str(spec.dimension_property),
302
+ "observation_type": str(spec.observation_type),
303
+ })
304
+ return {"observations": obs_info, "count": len(specs)}
305
+
306
+ def _get_observation(
307
+ self,
308
+ decision_steps=None,
309
+ terminal_steps=None,
310
+ reward: float = 0.0,
311
+ done: bool = False,
312
+ ) -> UnityObservation:
313
+ """Convert Unity observation to UnityObservation."""
314
+ vector_obs = []
315
+ visual_obs = []
316
+
317
+ # Determine which steps to use
318
+ if terminal_steps is not None and len(terminal_steps) > 0:
319
+ steps = terminal_steps
320
+ done = True
321
+ # Get reward from terminal step
322
+ if len(terminal_steps.agent_id) > 0:
323
+ reward = float(terminal_steps[terminal_steps.agent_id[0]].reward)
324
+ elif decision_steps is not None and len(decision_steps) > 0:
325
+ steps = decision_steps
326
+ # Get reward from decision step
327
+ if len(decision_steps.agent_id) > 0:
328
+ reward = float(decision_steps[decision_steps.agent_id[0]].reward)
329
+ else:
330
+ # No agents, return empty observation
331
+ return UnityObservation(
332
+ vector_observations=[],
333
+ visual_observations=None,
334
+ behavior_name=self._behavior_name or "",
335
+ done=done,
336
+ reward=reward,
337
+ action_spec_info=self._state.action_spec,
338
+ observation_spec_info=self._state.observation_spec,
339
+ )
340
+
341
+ # Process observations from first agent
342
+ for obs in steps.obs:
343
+ if len(obs.shape) == 2:
344
+ # Vector observation (agents, features)
345
+ vector_obs.extend(obs[0].tolist())
346
+ elif len(obs.shape) == 4 and self._include_visual:
347
+ # Visual observation (agents, height, width, channels)
348
+ img_array = (obs[0] * 255).astype(np.uint8)
349
+ # Encode as base64 PNG
350
+ try:
351
+ from PIL import Image
352
+ img = Image.fromarray(img_array)
353
+ buffer = io.BytesIO()
354
+ img.save(buffer, format="PNG")
355
+ img_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
356
+ visual_obs.append(img_b64)
357
+ except ImportError:
358
+ # PIL not available, skip visual observations
359
+ pass
360
+
361
+ return UnityObservation(
362
+ vector_observations=vector_obs,
363
+ visual_observations=visual_obs if visual_obs else None,
364
+ behavior_name=self._behavior_name or "",
365
+ done=done,
366
+ reward=reward,
367
+ action_spec_info=self._state.action_spec,
368
+ observation_spec_info=self._state.observation_spec,
369
+ )
370
+
371
+ def reset(
372
+ self,
373
+ env_id: Optional[str] = None,
374
+ seed: Optional[int] = None,
375
+ include_visual: bool = False,
376
+ **kwargs,
377
+ ) -> UnityObservation:
378
+ """
379
+ Reset the environment and return initial observation.
380
+
381
+ Args:
382
+ env_id: Optionally switch to a different Unity environment.
383
+ seed: Random seed (not fully supported by Unity ML-Agents).
384
+ include_visual: If True, include visual observations in output.
385
+ **kwargs: Additional arguments (ignored).
386
+
387
+ Returns:
388
+ UnityObservation with initial state.
389
+ """
390
+ self._include_visual = include_visual
391
+
392
+ # Load or switch environment if needed
393
+ target_env = env_id or self._env_id
394
+ if self._unity_env is None or target_env != self._env_id:
395
+ self._load_environment(target_env)
396
+
397
+ # Reset the environment
398
+ self._unity_env.reset()
399
+
400
+ # Update state
401
+ self._state = UnityState(
402
+ episode_id=str(uuid4()),
403
+ step_count=0,
404
+ env_id=self._env_id,
405
+ behavior_name=self._behavior_name,
406
+ action_spec=self._state.action_spec,
407
+ observation_spec=self._state.observation_spec,
408
+ available_envs=self._state.available_envs,
409
+ )
410
+
411
+ # Get initial observation
412
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
413
+
414
+ return self._get_observation(
415
+ decision_steps=decision_steps,
416
+ terminal_steps=terminal_steps,
417
+ reward=0.0,
418
+ done=False,
419
+ )
420
+
421
+ def step(self, action: UnityAction) -> UnityObservation:
422
+ """
423
+ Execute one step in the environment.
424
+
425
+ Args:
426
+ action: UnityAction with discrete and/or continuous actions.
427
+
428
+ Returns:
429
+ UnityObservation with new state, reward, and done flag.
430
+ """
431
+ if self._unity_env is None:
432
+ raise RuntimeError("Environment not initialized. Call reset() first.")
433
+
434
+ from mlagents_envs.base_env import ActionTuple
435
+
436
+ # Get current decision steps to know how many agents
437
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
438
+
439
+ # Check if episode already ended
440
+ if len(terminal_steps) > 0:
441
+ return self._get_observation(
442
+ decision_steps=decision_steps,
443
+ terminal_steps=terminal_steps,
444
+ done=True,
445
+ )
446
+
447
+ n_agents = len(decision_steps)
448
+ if n_agents == 0:
449
+ # No agents need decisions, just step
450
+ self._unity_env.step()
451
+ self._state.step_count += 1
452
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
453
+ return self._get_observation(
454
+ decision_steps=decision_steps,
455
+ terminal_steps=terminal_steps,
456
+ )
457
+
458
+ # Build action tuple
459
+ action_tuple = ActionTuple()
460
+
461
+ # Handle discrete actions
462
+ if action.discrete_actions is not None:
463
+ discrete = np.array([action.discrete_actions] * n_agents, dtype=np.int32)
464
+ # Ensure correct shape (n_agents, n_branches)
465
+ if discrete.ndim == 1:
466
+ discrete = discrete.reshape(n_agents, -1)
467
+ action_tuple.add_discrete(discrete)
468
+ elif self._behavior_spec.action_spec.is_discrete():
469
+ # Default to no-op (action 0)
470
+ n_branches = self._behavior_spec.action_spec.discrete_size
471
+ discrete = np.zeros((n_agents, n_branches), dtype=np.int32)
472
+ action_tuple.add_discrete(discrete)
473
+
474
+ # Handle continuous actions
475
+ if action.continuous_actions is not None:
476
+ continuous = np.array([action.continuous_actions] * n_agents, dtype=np.float32)
477
+ if continuous.ndim == 1:
478
+ continuous = continuous.reshape(n_agents, -1)
479
+ action_tuple.add_continuous(continuous)
480
+ elif self._behavior_spec.action_spec.is_continuous():
481
+ # Default to zero actions
482
+ n_continuous = self._behavior_spec.action_spec.continuous_size
483
+ continuous = np.zeros((n_agents, n_continuous), dtype=np.float32)
484
+ action_tuple.add_continuous(continuous)
485
+
486
+ # Set actions and step
487
+ self._unity_env.set_actions(self._behavior_name, action_tuple)
488
+ self._unity_env.step()
489
+ self._state.step_count += 1
490
+
491
+ # Get new observation
492
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
493
+
494
+ return self._get_observation(
495
+ decision_steps=decision_steps,
496
+ terminal_steps=terminal_steps,
497
+ )
498
+
499
+ async def reset_async(
500
+ self,
501
+ env_id: Optional[str] = None,
502
+ seed: Optional[int] = None,
503
+ include_visual: bool = False,
504
+ **kwargs,
505
+ ) -> UnityObservation:
506
+ """
507
+ Async version of reset - runs in a thread to avoid blocking the event loop.
508
+
509
+ Unity ML-Agents environments can take 10-60+ seconds to initialize.
510
+ Running in a thread allows the event loop to continue processing
511
+ WebSocket keepalive pings during this time.
512
+ """
513
+ import asyncio
514
+
515
+ return await asyncio.to_thread(
516
+ self.reset,
517
+ env_id=env_id,
518
+ seed=seed,
519
+ include_visual=include_visual,
520
+ **kwargs,
521
+ )
522
+
523
+ async def step_async(self, action: UnityAction) -> UnityObservation:
524
+ """
525
+ Async version of step - runs in a thread to avoid blocking the event loop.
526
+
527
+ Although step() is usually fast, running in a thread ensures
528
+ the event loop remains responsive.
529
+ """
530
+ import asyncio
531
+
532
+ return await asyncio.to_thread(self.step, action)
533
+
534
+ @property
535
+ def state(self) -> UnityState:
536
+ """Get the current environment state."""
537
+ return self._state
538
+
539
+ def close(self) -> None:
540
+ """Close the Unity environment."""
541
+ unity_env = getattr(self, "_unity_env", None)
542
+ if unity_env is not None:
543
+ try:
544
+ unity_env.close()
545
+ except Exception:
546
+ pass
547
+ self._unity_env = None
548
+
549
+ def __del__(self):
550
+ """Cleanup on deletion."""
551
+ try:
552
+ self.close()
553
+ except Exception:
554
+ pass
models.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the Unity ML-Agents Environment.
9
+
10
+ The Unity environment wraps Unity ML-Agents environments (PushBlock, 3DBall,
11
+ GridWorld, etc.) providing a unified interface for reinforcement learning.
12
+ """
13
+
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from pydantic import Field
17
+
18
+ # Support both in-repo and standalone imports
19
+ try:
20
+ # In-repo imports (when running from OpenEnv repository)
21
+ from openenv.core.env_server.types import Action, Observation, State
22
+ except ImportError:
23
+ # Standalone imports (when environment is standalone with openenv from pip)
24
+ from openenv.core.env_server.types import Action, Observation, State
25
+
26
+
27
+ class UnityAction(Action):
28
+ """
29
+ Action for Unity ML-Agents environments.
30
+
31
+ Supports both discrete and continuous action spaces. Unity environments
32
+ may use either or both types of actions:
33
+
34
+ - Discrete actions: Integer indices for categorical choices
35
+ (e.g., movement direction: 0=forward, 1=backward, 2=left, 3=right)
36
+ - Continuous actions: Float values typically in [-1, 1] range
37
+ (e.g., joint rotations, force magnitudes)
38
+
39
+ Example (PushBlock - discrete):
40
+ >>> action = UnityAction(discrete_actions=[3]) # Rotate left
41
+
42
+ Example (Walker - continuous):
43
+ >>> action = UnityAction(continuous_actions=[0.5, -0.3, 0.0, ...])
44
+
45
+ Attributes:
46
+ discrete_actions: List of discrete action indices for each action branch.
47
+ For PushBlock: [0-6] where 0=noop, 1=forward, 2=backward,
48
+ 3=rotate_left, 4=rotate_right, 5=strafe_left, 6=strafe_right
49
+ continuous_actions: List of continuous action values, typically in [-1, 1].
50
+ metadata: Additional action parameters.
51
+ """
52
+
53
+ discrete_actions: Optional[List[int]] = Field(
54
+ default=None,
55
+ description="Discrete action indices for each action branch",
56
+ )
57
+ continuous_actions: Optional[List[float]] = Field(
58
+ default=None,
59
+ description="Continuous action values, typically in [-1, 1] range",
60
+ )
61
+
62
+
63
+ class UnityObservation(Observation):
64
+ """
65
+ Observation from Unity ML-Agents environments.
66
+
67
+ Contains vector observations (sensor readings) and optionally visual
68
+ observations (rendered images). Most Unity environments provide vector
69
+ observations; visual observations are optional and must be requested.
70
+
71
+ Attributes:
72
+ vector_observations: Flattened array of all vector observations.
73
+ Size and meaning depends on the specific environment.
74
+ For PushBlock: 70 values from 14 ray-casts detecting walls/goals/blocks.
75
+ visual_observations: Optional list of base64-encoded images (PNG format).
76
+ Only included when include_visual=True in reset/step.
77
+ behavior_name: Name of the Unity behavior (agent type).
78
+ action_spec_info: Information about the action space for this environment.
79
+ observation_spec_info: Information about the observation space.
80
+ """
81
+
82
+ vector_observations: List[float] = Field(
83
+ default_factory=list,
84
+ description="Flattened vector observations from the environment",
85
+ )
86
+ visual_observations: Optional[List[str]] = Field(
87
+ default=None,
88
+ description="Base64-encoded PNG images (when include_visual=True)",
89
+ )
90
+ behavior_name: str = Field(
91
+ default="",
92
+ description="Name of the Unity behavior/agent type",
93
+ )
94
+ action_spec_info: Dict[str, Any] = Field(
95
+ default_factory=dict,
96
+ description="Information about the action space",
97
+ )
98
+ observation_spec_info: Dict[str, Any] = Field(
99
+ default_factory=dict,
100
+ description="Information about the observation space",
101
+ )
102
+
103
+
104
+ class UnityState(State):
105
+ """
106
+ Extended state for Unity ML-Agents environments.
107
+
108
+ Provides additional metadata about the currently loaded environment,
109
+ including action and observation space specifications.
110
+
111
+ Attributes:
112
+ episode_id: Unique identifier for the current episode.
113
+ step_count: Number of steps taken in the current episode.
114
+ env_id: Identifier of the currently loaded Unity environment.
115
+ behavior_name: Name of the Unity behavior (agent type).
116
+ action_spec: Detailed specification of the action space.
117
+ observation_spec: Detailed specification of the observation space.
118
+ available_envs: List of available environment identifiers.
119
+ """
120
+
121
+ env_id: str = Field(
122
+ default="PushBlock",
123
+ description="Identifier of the loaded Unity environment",
124
+ )
125
+ behavior_name: str = Field(
126
+ default="",
127
+ description="Name of the Unity behavior/agent type",
128
+ )
129
+ action_spec: Dict[str, Any] = Field(
130
+ default_factory=dict,
131
+ description="Specification of the action space",
132
+ )
133
+ observation_spec: Dict[str, Any] = Field(
134
+ default_factory=dict,
135
+ description="Specification of the observation space",
136
+ )
137
+ available_envs: List[str] = Field(
138
+ default_factory=list,
139
+ description="List of available Unity environments",
140
+ )
141
+
142
+
143
+ # Available Unity environments from the ML-Agents registry
144
+ # These are pre-built environments that can be downloaded automatically
145
+ AVAILABLE_UNITY_ENVIRONMENTS = [
146
+ "PushBlock",
147
+ "3DBall",
148
+ "3DBallHard",
149
+ "GridWorld",
150
+ "Basic",
151
+ "VisualPushBlock",
152
+ # Note: More environments may be available in newer versions of ML-Agents
153
+ ]
154
+
155
+ # Action descriptions for PushBlock (most commonly used example)
156
+ PUSHBLOCK_ACTIONS = {
157
+ 0: "noop",
158
+ 1: "forward",
159
+ 2: "backward",
160
+ 3: "rotate_left",
161
+ 4: "rotate_right",
162
+ 5: "strafe_left",
163
+ 6: "strafe_right",
164
+ }
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: unity_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-unity-env"
13
+ version = "0.1.0"
14
+ description = "Unity ML-Agents Environment for OpenEnv - wraps Unity environments like PushBlock, 3DBall, GridWorld"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv dependencies (installed from git for latest features)
18
+ "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v2.1.0",
19
+ "fastapi>=0.115.0",
20
+ "pydantic>=2.0.0",
21
+ "uvicorn>=0.24.0",
22
+ "requests>=2.31.0",
23
+ # Unity ML-Agents dependencies (installed from source for latest features)
24
+ "mlagents-envs @ git+https://github.com/Unity-Technologies/ml-agents.git#subdirectory=ml-agents-envs",
25
+ # "mlagents @ git+https://github.com/Unity-Technologies/ml-agents.git#subdirectory=ml-agents",
26
+ "numpy>=1.20.0",
27
+ # Optional: for visual observations
28
+ "pillow>=9.0.0",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m unity_env.server.app
40
+ server = "unity_env.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["unity_env", "unity_env.server"]
45
+ package-dir = { "unity_env" = ".", "unity_env.server" = "server" }
server/Dockerfile ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build for Unity ML-Agents environment
8
+ # Uses pip for package installation (no virtual environment)
9
+ # Note: Using Python 3.10.12 specifically because ml-agents requires >=3.10.1,<=3.10.12
10
+ # Note: Unity binaries are x86_64 only, so we force linux/amd64 platform
11
+
12
+ FROM --platform=linux/amd64 python:3.10.12-slim AS builder
13
+
14
+ WORKDIR /app
15
+
16
+ # Install build dependencies
17
+ RUN apt-get update && apt-get install -y --no-install-recommends \
18
+ build-essential \
19
+ git \
20
+ && rm -rf /var/lib/apt/lists/*
21
+
22
+ # Copy environment code
23
+ COPY . /app/env
24
+
25
+ WORKDIR /app/env
26
+
27
+ # Install dependencies using pip
28
+ # Note: mlagents packages are installed from git source via pyproject.toml
29
+ RUN pip install --upgrade pip && \
30
+ pip install --no-cache-dir -e .
31
+
32
+ # Final runtime stage
33
+ FROM --platform=linux/amd64 python:3.10.12-slim
34
+
35
+ WORKDIR /app
36
+
37
+ # Install runtime dependencies (curl for healthcheck)
38
+ RUN apt-get update && apt-get install -y --no-install-recommends \
39
+ curl \
40
+ && rm -rf /var/lib/apt/lists/*
41
+
42
+ # Copy installed packages from builder
43
+ COPY --from=builder /usr/local/lib/python3.10/site-packages /usr/local/lib/python3.10/site-packages
44
+ COPY --from=builder /usr/local/bin /usr/local/bin
45
+
46
+ # Copy the environment code
47
+ COPY . /app/env
48
+
49
+ # Create cache directory for Unity binaries
50
+ RUN mkdir -p /root/.mlagents-cache
51
+
52
+ # Set PYTHONPATH so imports work correctly
53
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
54
+
55
+ # Expose port
56
+ EXPOSE 8000
57
+
58
+ # Health check
59
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
60
+ CMD curl -f http://localhost:8000/health || exit 1
61
+
62
+ # Note: Longer start period (60s) because Unity environment download may take time on first run
63
+
64
+ # Run the FastAPI server
65
+ # Note: workers=1 because Unity environments are not thread-safe
66
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Unity environment server components."""
8
+
9
+ from .unity_environment import UnityMLAgentsEnvironment
10
+
11
+ __all__ = ["UnityMLAgentsEnvironment"]
server/app.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ FastAPI application for the Unity ML-Agents Environment.
9
+
10
+ This module creates an HTTP server that exposes Unity ML-Agents environments
11
+ over HTTP and WebSocket endpoints, compatible with EnvClient.
12
+
13
+ Usage:
14
+ # Development (with auto-reload):
15
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
16
+
17
+ # Production:
18
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 1
19
+
20
+ # Or run directly:
21
+ uv run --project . server
22
+
23
+ Note: Unity environments are not thread-safe, so use workers=1.
24
+ """
25
+
26
+ # Support multiple import scenarios
27
+ try:
28
+ # In-repo imports (when running from OpenEnv repository root)
29
+ from openenv.core.env_server.http_server import create_app
30
+
31
+ from ..models import UnityAction, UnityObservation
32
+ from .unity_environment import UnityMLAgentsEnvironment
33
+ except ImportError:
34
+ # openenv from pip
35
+ from openenv.core.env_server.http_server import create_app
36
+
37
+ try:
38
+ # Direct execution from envs/unity_env/ directory
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ # Add parent directory to path for direct execution
43
+ _parent = str(Path(__file__).parent.parent)
44
+ if _parent not in sys.path:
45
+ sys.path.insert(0, _parent)
46
+ from models import UnityAction, UnityObservation
47
+ from server.unity_environment import UnityMLAgentsEnvironment
48
+ except ImportError:
49
+ try:
50
+ # Package installed as unity_env
51
+ from unity_env.models import UnityAction, UnityObservation
52
+ from unity_env.server.unity_environment import UnityMLAgentsEnvironment
53
+ except ImportError:
54
+ # Running from OpenEnv root with envs prefix
55
+ from envs.unity_env.models import UnityAction, UnityObservation
56
+ from envs.unity_env.server.unity_environment import UnityMLAgentsEnvironment
57
+
58
+ # Create the app with web interface
59
+ # Pass the class (factory) instead of an instance for WebSocket session support
60
+ app = create_app(
61
+ UnityMLAgentsEnvironment,
62
+ UnityAction,
63
+ UnityObservation,
64
+ env_name="unity_env",
65
+ )
66
+
67
+
68
+ def main():
69
+ """
70
+ Entry point for direct execution via uv run or python -m.
71
+
72
+ This function enables running the server without Docker:
73
+ uv run --project . server
74
+ python -m envs.unity_env.server.app
75
+ openenv serve unity_env
76
+ """
77
+ import uvicorn
78
+
79
+ # Note: workers=1 because Unity environments are not thread-safe
80
+ uvicorn.run(app, host="0.0.0.0", port=8000, workers=1)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
server/unity_environment.py ADDED
@@ -0,0 +1,554 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Unity ML-Agents Environment Implementation.
9
+
10
+ Wraps Unity ML-Agents environments (PushBlock, 3DBall, GridWorld, etc.)
11
+ with the OpenEnv interface for standardized reinforcement learning.
12
+ """
13
+
14
+ import base64
15
+ import glob
16
+ import hashlib
17
+ import io
18
+ import os
19
+ from pathlib import Path
20
+ from sys import platform
21
+ from typing import Any, Dict, List, Optional
22
+ from uuid import uuid4
23
+
24
+ import numpy as np
25
+
26
+ # Support multiple import scenarios
27
+ try:
28
+ # In-repo imports (when running from OpenEnv repository root)
29
+ from openenv.core.env_server.interfaces import Environment
30
+
31
+ from ..models import UnityAction, UnityObservation, UnityState
32
+ except ImportError:
33
+ # openenv from pip
34
+ from openenv.core.env_server.interfaces import Environment
35
+
36
+ try:
37
+ # Direct execution from envs/unity_env/ directory (imports from parent)
38
+ import sys
39
+ from pathlib import Path
40
+
41
+ # Add parent directory to path for direct execution
42
+ _parent = str(Path(__file__).parent.parent)
43
+ if _parent not in sys.path:
44
+ sys.path.insert(0, _parent)
45
+ from models import UnityAction, UnityObservation, UnityState
46
+ except ImportError:
47
+ try:
48
+ # Package installed as unity_env
49
+ from unity_env.models import UnityAction, UnityObservation, UnityState
50
+ except ImportError:
51
+ # Running from OpenEnv root with envs prefix
52
+ from envs.unity_env.models import UnityAction, UnityObservation, UnityState
53
+
54
+
55
+ # Persistent cache directory to avoid re-downloading environment binaries
56
+ PERSISTENT_CACHE_DIR = os.path.join(str(Path.home()), ".mlagents-cache")
57
+
58
+
59
+ def get_cached_binary_path(cache_dir: str, name: str, url: str) -> Optional[str]:
60
+ """Check if binary is cached and return its path."""
61
+ if platform == "darwin":
62
+ extension = "*.app"
63
+ elif platform in ("linux", "linux2"):
64
+ extension = "*.x86_64"
65
+ elif platform == "win32":
66
+ extension = "*.exe"
67
+ else:
68
+ return None
69
+
70
+ bin_dir = os.path.join(cache_dir, "binaries")
71
+ url_hash = "-" + hashlib.md5(url.encode()).hexdigest()
72
+ search_path = os.path.join(bin_dir, name + url_hash, "**", extension)
73
+
74
+ candidates = glob.glob(search_path, recursive=True)
75
+ for c in candidates:
76
+ if "UnityCrashHandler64" not in c:
77
+ return c
78
+ return None
79
+
80
+
81
+ class UnityMLAgentsEnvironment(Environment):
82
+ """
83
+ Wraps Unity ML-Agents environments with the OpenEnv interface.
84
+
85
+ This environment supports all Unity ML-Agents registry environments
86
+ including PushBlock, 3DBall, GridWorld, and more. Environments are
87
+ automatically downloaded on first use.
88
+
89
+ Features:
90
+ - Dynamic environment switching via reset(env_id="...")
91
+ - Support for both discrete and continuous action spaces
92
+ - Optional visual observations (base64-encoded images)
93
+ - Persistent caching to avoid re-downloading binaries
94
+ - Headless mode for faster training (no_graphics=True)
95
+
96
+ Example:
97
+ >>> env = UnityMLAgentsEnvironment()
98
+ >>> obs = env.reset()
99
+ >>> print(obs.vector_observations)
100
+ >>>
101
+ >>> # Take a random action
102
+ >>> obs = env.step(UnityAction(discrete_actions=[1])) # Move forward
103
+ >>> print(obs.reward)
104
+
105
+ Example with different environment:
106
+ >>> env = UnityMLAgentsEnvironment(env_id="3DBall")
107
+ >>> obs = env.reset()
108
+ >>>
109
+ >>> # Or switch environment on reset
110
+ >>> obs = env.reset(env_id="PushBlock")
111
+ """
112
+
113
+ # Each WebSocket session gets its own environment instance
114
+ SUPPORTS_CONCURRENT_SESSIONS = False
115
+
116
+ def __init__(
117
+ self,
118
+ env_id: Optional[str] = None,
119
+ no_graphics: Optional[bool] = None,
120
+ time_scale: Optional[float] = None,
121
+ width: Optional[int] = None,
122
+ height: Optional[int] = None,
123
+ quality_level: Optional[int] = None,
124
+ cache_dir: Optional[str] = None,
125
+ ):
126
+ """
127
+ Initialize the Unity ML-Agents environment.
128
+
129
+ Configuration can be provided via constructor arguments or environment
130
+ variables. Environment variables are used when constructor arguments
131
+ are not provided (useful for Docker deployment).
132
+
133
+ Args:
134
+ env_id: Identifier of the Unity environment to load.
135
+ Available: PushBlock, 3DBall, 3DBallHard, GridWorld, Basic
136
+ Env var: UNITY_ENV_ID (default: PushBlock)
137
+ no_graphics: If True, run in headless mode (faster training).
138
+ Env var: UNITY_NO_GRAPHICS (0 or 1, default: 0 = graphics enabled)
139
+ time_scale: Simulation speed multiplier.
140
+ Env var: UNITY_TIME_SCALE (default: 1.0)
141
+ width: Window width in pixels (when graphics enabled).
142
+ Env var: UNITY_WIDTH (default: 1280)
143
+ height: Window height in pixels (when graphics enabled).
144
+ Env var: UNITY_HEIGHT (default: 720)
145
+ quality_level: Graphics quality 0-5 (when graphics enabled).
146
+ Env var: UNITY_QUALITY_LEVEL (default: 5)
147
+ cache_dir: Directory to cache downloaded environment binaries.
148
+ Env var: UNITY_CACHE_DIR (default: ~/.mlagents-cache)
149
+ """
150
+ # Initialize cleanup-critical attributes first (for __del__ safety)
151
+ self._unity_env = None
152
+ self._behavior_name = None
153
+ self._behavior_spec = None
154
+ self._engine_channel = None
155
+
156
+ # Read from environment variables with defaults, allow constructor override
157
+ self._env_id = env_id or os.environ.get("UNITY_ENV_ID", "PushBlock")
158
+
159
+ # Handle no_graphics: default is False (graphics enabled)
160
+ if no_graphics is not None:
161
+ self._no_graphics = no_graphics
162
+ else:
163
+ env_no_graphics = os.environ.get("UNITY_NO_GRAPHICS", "0")
164
+ self._no_graphics = env_no_graphics.lower() in ("1", "true", "yes")
165
+
166
+ self._time_scale = (
167
+ time_scale
168
+ if time_scale is not None
169
+ else float(os.environ.get("UNITY_TIME_SCALE", "1.0"))
170
+ )
171
+ self._width = (
172
+ width
173
+ if width is not None
174
+ else int(os.environ.get("UNITY_WIDTH", "1280"))
175
+ )
176
+ self._height = (
177
+ height
178
+ if height is not None
179
+ else int(os.environ.get("UNITY_HEIGHT", "720"))
180
+ )
181
+ self._quality_level = (
182
+ quality_level
183
+ if quality_level is not None
184
+ else int(os.environ.get("UNITY_QUALITY_LEVEL", "5"))
185
+ )
186
+ self._cache_dir = cache_dir or os.environ.get(
187
+ "UNITY_CACHE_DIR", PERSISTENT_CACHE_DIR
188
+ )
189
+ self._include_visual = False
190
+
191
+ # State tracking
192
+ self._state = UnityState(
193
+ episode_id=str(uuid4()),
194
+ step_count=0,
195
+ env_id=self._env_id,
196
+ )
197
+
198
+ # Ensure cache directory exists
199
+ os.makedirs(self._cache_dir, exist_ok=True)
200
+
201
+ def _load_environment(self, env_id: str) -> None:
202
+ """Load or switch to a Unity environment."""
203
+ # Close existing environment if any
204
+ if self._unity_env is not None:
205
+ try:
206
+ self._unity_env.close()
207
+ except Exception:
208
+ pass
209
+
210
+ # Import ML-Agents components
211
+ try:
212
+ from mlagents_envs.base_env import ActionTuple
213
+ from mlagents_envs.registry import default_registry
214
+ from mlagents_envs.registry.remote_registry_entry import RemoteRegistryEntry
215
+ from mlagents_envs.side_channel.engine_configuration_channel import (
216
+ EngineConfigurationChannel,
217
+ )
218
+ except ImportError as e:
219
+ raise ImportError(
220
+ "mlagents-envs is required. Install with: pip install mlagents-envs"
221
+ ) from e
222
+
223
+ # Create engine configuration channel
224
+ self._engine_channel = EngineConfigurationChannel()
225
+
226
+ # Check if environment is in registry
227
+ if env_id not in default_registry:
228
+ available = list(default_registry.keys())
229
+ raise ValueError(
230
+ f"Environment '{env_id}' not found. Available: {available}"
231
+ )
232
+
233
+ # Get registry entry and create with persistent cache
234
+ entry = default_registry[env_id]
235
+
236
+ # Create a new entry with our persistent cache directory
237
+ persistent_entry = RemoteRegistryEntry(
238
+ identifier=entry.identifier,
239
+ expected_reward=entry.expected_reward,
240
+ description=entry.description,
241
+ linux_url=getattr(entry, "_linux_url", None),
242
+ darwin_url=getattr(entry, "_darwin_url", None),
243
+ win_url=getattr(entry, "_win_url", None),
244
+ additional_args=getattr(entry, "_add_args", []),
245
+ tmp_dir=self._cache_dir,
246
+ )
247
+
248
+ # Create the environment
249
+ self._unity_env = persistent_entry.make(
250
+ no_graphics=self._no_graphics,
251
+ side_channels=[self._engine_channel],
252
+ )
253
+
254
+ # Configure engine settings
255
+ if not self._no_graphics:
256
+ self._engine_channel.set_configuration_parameters(
257
+ width=self._width,
258
+ height=self._height,
259
+ quality_level=self._quality_level,
260
+ time_scale=self._time_scale,
261
+ )
262
+ else:
263
+ self._engine_channel.set_configuration_parameters(
264
+ time_scale=self._time_scale
265
+ )
266
+
267
+ # Get behavior info
268
+ if not self._unity_env.behavior_specs:
269
+ self._unity_env.step()
270
+
271
+ self._behavior_name = list(self._unity_env.behavior_specs.keys())[0]
272
+ self._behavior_spec = self._unity_env.behavior_specs[self._behavior_name]
273
+
274
+ # Update state
275
+ self._env_id = env_id
276
+ self._state.env_id = env_id
277
+ self._state.behavior_name = self._behavior_name
278
+ self._state.action_spec = self._get_action_spec_info()
279
+ self._state.observation_spec = self._get_observation_spec_info()
280
+ self._state.available_envs = list(default_registry.keys())
281
+
282
+ def _get_action_spec_info(self) -> Dict[str, Any]:
283
+ """Get information about the action space."""
284
+ spec = self._behavior_spec.action_spec
285
+ return {
286
+ "is_discrete": spec.is_discrete(),
287
+ "is_continuous": spec.is_continuous(),
288
+ "discrete_size": spec.discrete_size,
289
+ "discrete_branches": list(spec.discrete_branches) if spec.is_discrete() else [],
290
+ "continuous_size": spec.continuous_size,
291
+ }
292
+
293
+ def _get_observation_spec_info(self) -> Dict[str, Any]:
294
+ """Get information about the observation space."""
295
+ specs = self._behavior_spec.observation_specs
296
+ obs_info = []
297
+ for i, spec in enumerate(specs):
298
+ obs_info.append({
299
+ "index": i,
300
+ "shape": list(spec.shape),
301
+ "dimension_property": str(spec.dimension_property),
302
+ "observation_type": str(spec.observation_type),
303
+ })
304
+ return {"observations": obs_info, "count": len(specs)}
305
+
306
+ def _get_observation(
307
+ self,
308
+ decision_steps=None,
309
+ terminal_steps=None,
310
+ reward: float = 0.0,
311
+ done: bool = False,
312
+ ) -> UnityObservation:
313
+ """Convert Unity observation to UnityObservation."""
314
+ vector_obs = []
315
+ visual_obs = []
316
+
317
+ # Determine which steps to use
318
+ if terminal_steps is not None and len(terminal_steps) > 0:
319
+ steps = terminal_steps
320
+ done = True
321
+ # Get reward from terminal step
322
+ if len(terminal_steps.agent_id) > 0:
323
+ reward = float(terminal_steps[terminal_steps.agent_id[0]].reward)
324
+ elif decision_steps is not None and len(decision_steps) > 0:
325
+ steps = decision_steps
326
+ # Get reward from decision step
327
+ if len(decision_steps.agent_id) > 0:
328
+ reward = float(decision_steps[decision_steps.agent_id[0]].reward)
329
+ else:
330
+ # No agents, return empty observation
331
+ return UnityObservation(
332
+ vector_observations=[],
333
+ visual_observations=None,
334
+ behavior_name=self._behavior_name or "",
335
+ done=done,
336
+ reward=reward,
337
+ action_spec_info=self._state.action_spec,
338
+ observation_spec_info=self._state.observation_spec,
339
+ )
340
+
341
+ # Process observations from first agent
342
+ for obs in steps.obs:
343
+ if len(obs.shape) == 2:
344
+ # Vector observation (agents, features)
345
+ vector_obs.extend(obs[0].tolist())
346
+ elif len(obs.shape) == 4 and self._include_visual:
347
+ # Visual observation (agents, height, width, channels)
348
+ img_array = (obs[0] * 255).astype(np.uint8)
349
+ # Encode as base64 PNG
350
+ try:
351
+ from PIL import Image
352
+ img = Image.fromarray(img_array)
353
+ buffer = io.BytesIO()
354
+ img.save(buffer, format="PNG")
355
+ img_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
356
+ visual_obs.append(img_b64)
357
+ except ImportError:
358
+ # PIL not available, skip visual observations
359
+ pass
360
+
361
+ return UnityObservation(
362
+ vector_observations=vector_obs,
363
+ visual_observations=visual_obs if visual_obs else None,
364
+ behavior_name=self._behavior_name or "",
365
+ done=done,
366
+ reward=reward,
367
+ action_spec_info=self._state.action_spec,
368
+ observation_spec_info=self._state.observation_spec,
369
+ )
370
+
371
+ def reset(
372
+ self,
373
+ env_id: Optional[str] = None,
374
+ seed: Optional[int] = None,
375
+ include_visual: bool = False,
376
+ **kwargs,
377
+ ) -> UnityObservation:
378
+ """
379
+ Reset the environment and return initial observation.
380
+
381
+ Args:
382
+ env_id: Optionally switch to a different Unity environment.
383
+ seed: Random seed (not fully supported by Unity ML-Agents).
384
+ include_visual: If True, include visual observations in output.
385
+ **kwargs: Additional arguments (ignored).
386
+
387
+ Returns:
388
+ UnityObservation with initial state.
389
+ """
390
+ self._include_visual = include_visual
391
+
392
+ # Load or switch environment if needed
393
+ target_env = env_id or self._env_id
394
+ if self._unity_env is None or target_env != self._env_id:
395
+ self._load_environment(target_env)
396
+
397
+ # Reset the environment
398
+ self._unity_env.reset()
399
+
400
+ # Update state
401
+ self._state = UnityState(
402
+ episode_id=str(uuid4()),
403
+ step_count=0,
404
+ env_id=self._env_id,
405
+ behavior_name=self._behavior_name,
406
+ action_spec=self._state.action_spec,
407
+ observation_spec=self._state.observation_spec,
408
+ available_envs=self._state.available_envs,
409
+ )
410
+
411
+ # Get initial observation
412
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
413
+
414
+ return self._get_observation(
415
+ decision_steps=decision_steps,
416
+ terminal_steps=terminal_steps,
417
+ reward=0.0,
418
+ done=False,
419
+ )
420
+
421
+ def step(self, action: UnityAction) -> UnityObservation:
422
+ """
423
+ Execute one step in the environment.
424
+
425
+ Args:
426
+ action: UnityAction with discrete and/or continuous actions.
427
+
428
+ Returns:
429
+ UnityObservation with new state, reward, and done flag.
430
+ """
431
+ if self._unity_env is None:
432
+ raise RuntimeError("Environment not initialized. Call reset() first.")
433
+
434
+ from mlagents_envs.base_env import ActionTuple
435
+
436
+ # Get current decision steps to know how many agents
437
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
438
+
439
+ # Check if episode already ended
440
+ if len(terminal_steps) > 0:
441
+ return self._get_observation(
442
+ decision_steps=decision_steps,
443
+ terminal_steps=terminal_steps,
444
+ done=True,
445
+ )
446
+
447
+ n_agents = len(decision_steps)
448
+ if n_agents == 0:
449
+ # No agents need decisions, just step
450
+ self._unity_env.step()
451
+ self._state.step_count += 1
452
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
453
+ return self._get_observation(
454
+ decision_steps=decision_steps,
455
+ terminal_steps=terminal_steps,
456
+ )
457
+
458
+ # Build action tuple
459
+ action_tuple = ActionTuple()
460
+
461
+ # Handle discrete actions
462
+ if action.discrete_actions is not None:
463
+ discrete = np.array([action.discrete_actions] * n_agents, dtype=np.int32)
464
+ # Ensure correct shape (n_agents, n_branches)
465
+ if discrete.ndim == 1:
466
+ discrete = discrete.reshape(n_agents, -1)
467
+ action_tuple.add_discrete(discrete)
468
+ elif self._behavior_spec.action_spec.is_discrete():
469
+ # Default to no-op (action 0)
470
+ n_branches = self._behavior_spec.action_spec.discrete_size
471
+ discrete = np.zeros((n_agents, n_branches), dtype=np.int32)
472
+ action_tuple.add_discrete(discrete)
473
+
474
+ # Handle continuous actions
475
+ if action.continuous_actions is not None:
476
+ continuous = np.array([action.continuous_actions] * n_agents, dtype=np.float32)
477
+ if continuous.ndim == 1:
478
+ continuous = continuous.reshape(n_agents, -1)
479
+ action_tuple.add_continuous(continuous)
480
+ elif self._behavior_spec.action_spec.is_continuous():
481
+ # Default to zero actions
482
+ n_continuous = self._behavior_spec.action_spec.continuous_size
483
+ continuous = np.zeros((n_agents, n_continuous), dtype=np.float32)
484
+ action_tuple.add_continuous(continuous)
485
+
486
+ # Set actions and step
487
+ self._unity_env.set_actions(self._behavior_name, action_tuple)
488
+ self._unity_env.step()
489
+ self._state.step_count += 1
490
+
491
+ # Get new observation
492
+ decision_steps, terminal_steps = self._unity_env.get_steps(self._behavior_name)
493
+
494
+ return self._get_observation(
495
+ decision_steps=decision_steps,
496
+ terminal_steps=terminal_steps,
497
+ )
498
+
499
+ async def reset_async(
500
+ self,
501
+ env_id: Optional[str] = None,
502
+ seed: Optional[int] = None,
503
+ include_visual: bool = False,
504
+ **kwargs,
505
+ ) -> UnityObservation:
506
+ """
507
+ Async version of reset - runs in a thread to avoid blocking the event loop.
508
+
509
+ Unity ML-Agents environments can take 10-60+ seconds to initialize.
510
+ Running in a thread allows the event loop to continue processing
511
+ WebSocket keepalive pings during this time.
512
+ """
513
+ import asyncio
514
+
515
+ return await asyncio.to_thread(
516
+ self.reset,
517
+ env_id=env_id,
518
+ seed=seed,
519
+ include_visual=include_visual,
520
+ **kwargs,
521
+ )
522
+
523
+ async def step_async(self, action: UnityAction) -> UnityObservation:
524
+ """
525
+ Async version of step - runs in a thread to avoid blocking the event loop.
526
+
527
+ Although step() is usually fast, running in a thread ensures
528
+ the event loop remains responsive.
529
+ """
530
+ import asyncio
531
+
532
+ return await asyncio.to_thread(self.step, action)
533
+
534
+ @property
535
+ def state(self) -> UnityState:
536
+ """Get the current environment state."""
537
+ return self._state
538
+
539
+ def close(self) -> None:
540
+ """Close the Unity environment."""
541
+ unity_env = getattr(self, "_unity_env", None)
542
+ if unity_env is not None:
543
+ try:
544
+ unity_env.close()
545
+ except Exception:
546
+ pass
547
+ self._unity_env = None
548
+
549
+ def __del__(self):
550
+ """Cleanup on deletion."""
551
+ try:
552
+ self.close()
553
+ except Exception:
554
+ pass
src/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """EnvTorch: Standardized agentic execution environments."""
src/openenv/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified OpenEnv package bundling the CLI and core runtime.
3
+ """
4
+
5
+ from importlib import metadata
6
+
7
+ from .auto import AutoAction, AutoEnv
8
+ from .core import GenericEnvClient, GenericAction, SyncEnvClient
9
+
10
+ __all__ = [
11
+ "core",
12
+ "cli",
13
+ "AutoEnv",
14
+ "AutoAction",
15
+ "GenericEnvClient",
16
+ "GenericAction",
17
+ "SyncEnvClient",
18
+ ]
19
+
20
+ try:
21
+ __version__ = metadata.version("openenv") # type: ignore[arg-type]
22
+ except metadata.PackageNotFoundError: # pragma: no cover - local dev
23
+ __version__ = "0.0.0"
src/openenv/auto/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ OpenEnv Auto Module
9
+ ===================
10
+
11
+ Provides HuggingFace-style auto-discovery API for OpenEnv environments.
12
+
13
+ This module enables automatic environment and action class loading without
14
+ manual imports:
15
+
16
+ >>> from openenv import AutoEnv, AutoAction
17
+ >>>
18
+ >>> # Load environment from installed package or HuggingFace Hub
19
+ >>> env = AutoEnv.from_name("coding-env")
20
+ >>>
21
+ >>> # Get action class
22
+ >>> CodeAction = AutoAction.from_name("coding")
23
+ >>> action = CodeAction(code="print('Hello!')")
24
+
25
+ Classes:
26
+ AutoEnv: Automatic environment client selection and instantiation
27
+ AutoAction: Automatic action class selection
28
+
29
+ The auto-discovery system works by:
30
+ 1. Discovering installed openenv-* packages via importlib.metadata
31
+ 2. Loading environment manifests (openenv.yaml) from package resources
32
+ 3. Supporting HuggingFace Hub repositories for remote environments
33
+ 4. Caching discovery results for performance
34
+ """
35
+
36
+ from .auto_action import AutoAction
37
+ from .auto_env import AutoEnv
38
+
39
+ __all__ = ["AutoEnv", "AutoAction"]
src/openenv/auto/_discovery.py ADDED
@@ -0,0 +1,584 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Environment Auto-Discovery System
9
+ ==================================
10
+
11
+ This module provides automatic discovery of OpenEnv environments by:
12
+ 1. Discovering installed openenv-* packages using importlib.metadata
13
+ 2. Loading manifests (openenv.yaml) from package resources
14
+ 3. Caching results for performance
15
+ 4. Supporting HuggingFace Hub downloads
16
+
17
+ This enables AutoEnv to work without coupling to src/envs/ directory.
18
+ """
19
+
20
+ import importlib
21
+ import importlib.metadata
22
+ import importlib.resources
23
+ import json
24
+ import logging
25
+ import re
26
+ import tempfile
27
+ from dataclasses import dataclass, asdict
28
+ from pathlib import Path
29
+ from typing import Dict, Optional, Type, Any
30
+
31
+ import yaml
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ @dataclass
37
+ class EnvironmentInfo:
38
+ """
39
+ Rich information about a discovered environment.
40
+
41
+ Attributes:
42
+ env_key: Environment key (e.g., "echo", "coding")
43
+ name: Full environment name (e.g., "echo_env")
44
+ package_name: Package name (e.g., "openenv-echo_env")
45
+ version: Version string
46
+ description: Human-readable description
47
+ client_module_path: Full module path to client (e.g., "echo_env.client")
48
+ client_class_name: Client class name (e.g., "EchoEnv")
49
+ action_class_name: Action class name (e.g., "EchoAction")
50
+ observation_class_name: Observation class name (e.g., "EchoObservation")
51
+ default_image: Default Docker image name (e.g., "echo-env:latest")
52
+ spec_version: OpenEnv spec version (from openenv.yaml)
53
+ manifest: Original manifest data
54
+ """
55
+
56
+ env_key: str
57
+ name: str
58
+ package_name: str
59
+ version: str
60
+ description: str
61
+ client_module_path: str
62
+ client_class_name: str
63
+ action_class_name: str
64
+ observation_class_name: str
65
+ default_image: str
66
+ spec_version: Optional[int] = None
67
+ manifest: Optional[Dict[str, Any]] = None
68
+
69
+ def get_client_class(self) -> Type:
70
+ """
71
+ Dynamically import and return the client class.
72
+
73
+ Returns:
74
+ Client class (e.g., EchoEnv)
75
+
76
+ Raises:
77
+ ImportError: If module or class cannot be imported
78
+ """
79
+ try:
80
+ module = importlib.import_module(self.client_module_path)
81
+ return getattr(module, self.client_class_name)
82
+ except ImportError as e:
83
+ raise ImportError(
84
+ f"Failed to import {self.client_class_name} from {self.client_module_path}: {e}\n"
85
+ f"Make sure the package '{self.package_name}' is installed: "
86
+ f"pip install {self.package_name}"
87
+ ) from e
88
+ except AttributeError as e:
89
+ raise ImportError(
90
+ f"Class {self.client_class_name} not found in {self.client_module_path}: {e}"
91
+ ) from e
92
+
93
+ def get_action_class(self) -> Type:
94
+ """
95
+ Dynamically import and return the action class.
96
+
97
+ Returns:
98
+ Action class (e.g., EchoAction)
99
+
100
+ Raises:
101
+ ImportError: If module or class cannot be imported
102
+ """
103
+ try:
104
+ module = importlib.import_module(self.client_module_path)
105
+ return getattr(module, self.action_class_name)
106
+ except ImportError as e:
107
+ raise ImportError(
108
+ f"Failed to import {self.action_class_name} from {self.client_module_path}: {e}\n"
109
+ f"Make sure the package '{self.package_name}' is installed: "
110
+ f"pip install {self.package_name}"
111
+ ) from e
112
+ except AttributeError as e:
113
+ raise ImportError(
114
+ f"Class {self.action_class_name} not found in {self.client_module_path}: {e}"
115
+ ) from e
116
+
117
+ def get_observation_class(self) -> Type:
118
+ """
119
+ Dynamically import and return the observation class.
120
+
121
+ Returns:
122
+ Observation class (e.g., EchoObservation)
123
+
124
+ Raises:
125
+ ImportError: If module or class cannot be imported
126
+ """
127
+ try:
128
+ module = importlib.import_module(self.client_module_path)
129
+ return getattr(module, self.observation_class_name)
130
+ except ImportError as e:
131
+ raise ImportError(
132
+ f"Failed to import {self.observation_class_name} from {self.client_module_path}: {e}\n"
133
+ f"Make sure the package '{self.package_name}' is installed: "
134
+ f"pip install {self.package_name}"
135
+ ) from e
136
+ except AttributeError as e:
137
+ raise ImportError(
138
+ f"Class {self.observation_class_name} not found in {self.client_module_path}: {e}"
139
+ ) from e
140
+
141
+
142
+ def _normalize_env_name(name: str) -> str:
143
+ """
144
+ Normalize environment name to standard format.
145
+
146
+ Args:
147
+ name: Input name (e.g., "echo", "echo-env", "echo_env")
148
+
149
+ Returns:
150
+ Normalized name (e.g., "echo_env")
151
+
152
+ Examples:
153
+ >>> _normalize_env_name("echo")
154
+ 'echo_env'
155
+ >>> _normalize_env_name("echo-env")
156
+ 'echo_env'
157
+ >>> _normalize_env_name("echo_env")
158
+ 'echo_env'
159
+ """
160
+ # Remove common suffixes
161
+ name = re.sub(r"[-_]env$", "", name)
162
+ # Convert hyphens to underscores
163
+ name = name.replace("-", "_")
164
+ # Add _env suffix if not present
165
+ if not name.endswith("_env"):
166
+ name = f"{name}_env"
167
+ return name
168
+
169
+
170
+ def _is_hub_url(name: str) -> bool:
171
+ """
172
+ Check if name is a HuggingFace Hub URL or repo ID.
173
+
174
+ Args:
175
+ name: Input name
176
+
177
+ Returns:
178
+ True if it looks like a Hub URL
179
+
180
+ Examples:
181
+ >>> _is_hub_url("meta-pytorch/echo_env")
182
+ True
183
+ >>> _is_hub_url("https://huggingface.co/meta-pytorch/echo_env")
184
+ True
185
+ >>> _is_hub_url("echo")
186
+ False
187
+ """
188
+ # Contains org/repo pattern or huggingface.co domain
189
+ return "/" in name or "huggingface.co" in name
190
+
191
+
192
+ def _infer_class_name(env_name: str, class_type: str) -> str:
193
+ """
194
+ Infer class name from environment name using simple conventions.
195
+
196
+ Args:
197
+ env_name: Environment name (e.g., "echo_env")
198
+ class_type: Type of class ("client", "action", "observation")
199
+
200
+ Returns:
201
+ Inferred class name
202
+
203
+ Examples:
204
+ >>> _infer_class_name("echo_env", "client")
205
+ 'EchoEnv'
206
+ >>> _infer_class_name("echo_env", "action")
207
+ 'EchoAction'
208
+ """
209
+ # Remove _env suffix for base name
210
+ base_name = env_name.replace("_env", "")
211
+
212
+ # Convert to PascalCase
213
+ pascal_name = "".join(word.capitalize() for word in base_name.split("_"))
214
+
215
+ # Add suffix based on type
216
+ if class_type == "client":
217
+ return f"{pascal_name}Env"
218
+ elif class_type == "action":
219
+ return f"{pascal_name}Action"
220
+ elif class_type == "observation":
221
+ return f"{pascal_name}Observation"
222
+ else:
223
+ raise ValueError(f"Unknown class type: {class_type}")
224
+
225
+
226
+ def _load_manifest_from_package(
227
+ package_name: str, module_name: str
228
+ ) -> Optional[Dict[str, Any]]:
229
+ """
230
+ Load openenv.yaml manifest from an installed package.
231
+
232
+ Args:
233
+ package_name: Package name (e.g., "openenv-echo_env")
234
+ module_name: Module name (e.g., "echo_env")
235
+
236
+ Returns:
237
+ Parsed manifest dictionary, or None if not found
238
+
239
+ """
240
+ try:
241
+ # Try to read openenv.yaml from package
242
+ if hasattr(importlib.resources, "files"):
243
+ # Python 3.9+
244
+ package_files = importlib.resources.files(module_name)
245
+ if (package_files / "openenv.yaml").is_file():
246
+ manifest_text = (package_files / "openenv.yaml").read_text()
247
+ return yaml.safe_load(manifest_text)
248
+ else:
249
+ # Python 3.7-3.8 fallback
250
+ with importlib.resources.open_text(module_name, "openenv.yaml") as f:
251
+ return yaml.safe_load(f)
252
+ except (FileNotFoundError, ModuleNotFoundError, AttributeError):
253
+ logger.debug(f"No openenv.yaml found in {module_name}")
254
+ return None
255
+ except Exception as e:
256
+ logger.warning(f"Failed to load openenv.yaml from {module_name}: {e}")
257
+ return None
258
+
259
+
260
+ def _create_env_info_from_package(
261
+ package_name: str, module_name: str, version: str
262
+ ) -> Optional[EnvironmentInfo]:
263
+ """
264
+ Create EnvironmentInfo from an installed package.
265
+
266
+ Args:
267
+ package_name: Package name (e.g., "openenv-echo_env")
268
+ module_name: Module name (e.g., "echo_env")
269
+ version: Package version
270
+
271
+ Returns:
272
+ EnvironmentInfo instance, or None if invalid
273
+ """
274
+ # Load manifest
275
+ manifest = _load_manifest_from_package(package_name, module_name)
276
+
277
+ # Get environment name
278
+ if manifest and "name" in manifest:
279
+ env_name = manifest["name"]
280
+ else:
281
+ # Infer from module name
282
+ env_name = module_name
283
+
284
+ # Normalize to ensure _env suffix
285
+ if not env_name.endswith("_env"):
286
+ env_name = f"{env_name}_env"
287
+
288
+ # Determine env_key (e.g., "echo_env" → "echo")
289
+ env_key = env_name.replace("_env", "") if env_name.endswith("_env") else env_name
290
+
291
+ # Get description
292
+ description = (
293
+ manifest.get("description", f"{env_name} environment")
294
+ if manifest
295
+ else f"{env_name} environment"
296
+ )
297
+
298
+ # Get spec version
299
+ spec_version = manifest.get("spec_version") if manifest else None
300
+
301
+ # Determine class names
302
+ # Check if manifest has custom class names (custom format)
303
+ if manifest and "action" in manifest and "observation" in manifest:
304
+ # Custom format (like coding_env)
305
+ client_class_name = _infer_class_name(env_name, "client")
306
+ action_class_name = manifest.get(
307
+ "action", _infer_class_name(env_name, "action")
308
+ )
309
+ observation_class_name = manifest.get(
310
+ "observation", _infer_class_name(env_name, "observation")
311
+ )
312
+ else:
313
+ # Use conventions
314
+ client_class_name = _infer_class_name(env_name, "client")
315
+ action_class_name = _infer_class_name(env_name, "action")
316
+ observation_class_name = _infer_class_name(env_name, "observation")
317
+
318
+ # Module path is just module_name.client
319
+ client_module_path = f"{module_name}.client"
320
+
321
+ # Determine default Docker image name
322
+ image_name = env_name.replace("_", "-")
323
+ default_image = f"{image_name}:latest"
324
+
325
+ return EnvironmentInfo(
326
+ env_key=env_key,
327
+ name=env_name,
328
+ package_name=package_name,
329
+ version=version,
330
+ description=description,
331
+ client_module_path=client_module_path,
332
+ client_class_name=client_class_name,
333
+ action_class_name=action_class_name,
334
+ observation_class_name=observation_class_name,
335
+ default_image=default_image,
336
+ spec_version=spec_version,
337
+ manifest=manifest,
338
+ )
339
+
340
+
341
+ class EnvironmentDiscovery:
342
+ """
343
+ Auto-discovery system for OpenEnv environments using installed packages.
344
+
345
+ This class discovers installed openenv-* packages and loads their metadata.
346
+ """
347
+
348
+ def __init__(self):
349
+ """Initialize discovery system."""
350
+ self._cache: Optional[Dict[str, EnvironmentInfo]] = None
351
+ self._cache_file = Path(tempfile.gettempdir()) / "openenv_discovery_cache.json"
352
+
353
+ def _discover_installed_packages(self) -> Dict[str, EnvironmentInfo]:
354
+ """
355
+ Discover all installed openenv-* packages.
356
+
357
+ Returns:
358
+ Dictionary mapping env_key to EnvironmentInfo
359
+ """
360
+ environments = {}
361
+
362
+ # Invalidate import caches to ensure we pick up newly installed packages
363
+ importlib.invalidate_caches()
364
+
365
+ # Get all installed packages
366
+ try:
367
+ distributions = importlib.metadata.distributions()
368
+ except Exception as e:
369
+ logger.warning(f"Failed to get installed packages: {e}")
370
+ return environments
371
+
372
+ # Filter for openenv-* packages (exclude openenv-core)
373
+ for dist in distributions:
374
+ package_name = dist.metadata["Name"]
375
+
376
+ if not package_name.startswith("openenv-"):
377
+ continue
378
+
379
+ if package_name == "openenv-core":
380
+ continue
381
+
382
+ # Get module name (e.g., "openenv-echo_env" → "echo_env")
383
+ module_name = package_name.replace("openenv-", "").replace("-", "_")
384
+
385
+ # Get version
386
+ version = dist.version
387
+
388
+ try:
389
+ # Create environment info
390
+ env_info = _create_env_info_from_package(
391
+ package_name, module_name, version
392
+ )
393
+
394
+ if env_info:
395
+ environments[env_info.env_key] = env_info
396
+ logger.debug(
397
+ f"Discovered environment: {env_info.env_key} ({package_name})"
398
+ )
399
+
400
+ except Exception as e:
401
+ logger.warning(f"Failed to load environment from {package_name}: {e}")
402
+ continue
403
+
404
+ return environments
405
+
406
+ def _load_cache(self) -> Optional[Dict[str, EnvironmentInfo]]:
407
+ """
408
+ Load cached discovery results.
409
+
410
+ Returns:
411
+ Dictionary of env_key -> EnvironmentInfo, or None if cache invalid
412
+ """
413
+ if not self._cache_file.exists():
414
+ return None
415
+
416
+ try:
417
+ with open(self._cache_file, "r") as f:
418
+ cache_data = json.load(f)
419
+
420
+ # Reconstruct EnvironmentInfo objects
421
+ cache = {}
422
+ for env_key, env_data in cache_data.items():
423
+ cache[env_key] = EnvironmentInfo(**env_data)
424
+
425
+ return cache
426
+ except Exception as e:
427
+ logger.warning(f"Failed to load discovery cache: {e}")
428
+ return None
429
+
430
+ def _save_cache(self, environments: Dict[str, EnvironmentInfo]) -> None:
431
+ """
432
+ Save discovery results to cache.
433
+
434
+ Args:
435
+ environments: Dictionary of env_key -> EnvironmentInfo
436
+ """
437
+ try:
438
+ cache_data = {}
439
+ for env_key, env_info in environments.items():
440
+ cache_data[env_key] = asdict(env_info)
441
+
442
+ with open(self._cache_file, "w") as f:
443
+ json.dump(cache_data, f, indent=2)
444
+
445
+ except Exception as e:
446
+ logger.warning(f"Failed to save discovery cache: {e}")
447
+
448
+ def discover(self, use_cache: bool = True) -> Dict[str, EnvironmentInfo]:
449
+ """
450
+ Discover all installed OpenEnv environments.
451
+
452
+ Args:
453
+ use_cache: If True, try to load from cache first
454
+
455
+ Returns:
456
+ Dictionary mapping env_key to EnvironmentInfo
457
+
458
+ Examples:
459
+ >>> discovery = EnvironmentDiscovery()
460
+ >>> envs = discovery.discover()
461
+ >>> print(envs.keys())
462
+ dict_keys(['echo', 'coding', ...])
463
+ """
464
+ # Try to load from memory cache first
465
+ if use_cache and self._cache is not None:
466
+ return self._cache
467
+
468
+ # Try to load from file cache
469
+ if use_cache:
470
+ cached = self._load_cache()
471
+ if cached is not None:
472
+ self._cache = cached
473
+ return self._cache
474
+
475
+ # Discover from installed packages
476
+ environments = self._discover_installed_packages()
477
+
478
+ # Save to cache
479
+ self._save_cache(environments)
480
+ self._cache = environments
481
+
482
+ return environments
483
+
484
+ def get_environment(self, env_key: str) -> Optional[EnvironmentInfo]:
485
+ """
486
+ Get information about a specific environment.
487
+
488
+ Args:
489
+ env_key: Environment key (e.g., "echo", "coding")
490
+
491
+ Returns:
492
+ EnvironmentInfo if found, None otherwise
493
+
494
+ Examples:
495
+ >>> discovery = EnvironmentDiscovery()
496
+ >>> env = discovery.get_environment("echo")
497
+ >>> print(env.client_class_name)
498
+ 'EchoEnv'
499
+ """
500
+ environments = self.discover()
501
+ return environments.get(env_key)
502
+
503
+ def get_environment_by_name(self, name: str) -> Optional[EnvironmentInfo]:
504
+ """
505
+ Get environment info by flexible name matching.
506
+
507
+ Args:
508
+ name: Environment name (e.g., "echo", "echo-env", "echo_env")
509
+
510
+ Returns:
511
+ EnvironmentInfo if found, None otherwise
512
+ """
513
+ # Normalize name to env_key
514
+ normalized = _normalize_env_name(name)
515
+ env_key = normalized.replace("_env", "")
516
+
517
+ return self.get_environment(env_key)
518
+
519
+ def list_environments(self) -> None:
520
+ """
521
+ Print a formatted list of all discovered environments.
522
+
523
+ Examples:
524
+ >>> discovery = EnvironmentDiscovery()
525
+ >>> discovery.list_environments()
526
+ Available OpenEnv Environments:
527
+ ----------------------------------------------------------------------
528
+ echo : Echo Environment (v0.1.0) - openenv-echo_env
529
+ coding : Coding Environment (v0.1.0) - openenv-coding_env
530
+ ...
531
+ """
532
+ environments = self.discover()
533
+
534
+ print("Available OpenEnv Environments:")
535
+ print("-" * 70)
536
+
537
+ if not environments:
538
+ print(" No OpenEnv environments found.")
539
+ print(" Install environments with: pip install openenv-<env-name>")
540
+ else:
541
+ for env_key in sorted(environments.keys()):
542
+ env = environments[env_key]
543
+ print(f" {env_key:<15}: {env.description} (v{env.version})")
544
+ print(f" Package: {env.package_name}")
545
+
546
+ print("-" * 70)
547
+ print(f"Total: {len(environments)} environments")
548
+
549
+ def clear_cache(self) -> None:
550
+ """Clear the discovery cache."""
551
+ if self._cache_file.exists():
552
+ self._cache_file.unlink()
553
+ self._cache = None
554
+
555
+
556
+ # Global discovery instance
557
+ _global_discovery: Optional[EnvironmentDiscovery] = None
558
+
559
+
560
+ def get_discovery() -> EnvironmentDiscovery:
561
+ """
562
+ Get or create the global discovery instance.
563
+
564
+ Returns:
565
+ Global EnvironmentDiscovery instance
566
+
567
+ Examples:
568
+ >>> discovery = get_discovery()
569
+ >>> envs = discovery.discover()
570
+ """
571
+ global _global_discovery
572
+
573
+ if _global_discovery is None:
574
+ _global_discovery = EnvironmentDiscovery()
575
+
576
+ return _global_discovery
577
+
578
+
579
+ def reset_discovery() -> None:
580
+ """Reset the global discovery instance (useful for testing)."""
581
+ global _global_discovery
582
+ if _global_discovery is not None:
583
+ _global_discovery.clear_cache()
584
+ _global_discovery = None
src/openenv/auto/auto_action.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ AutoAction - Automatic Action Class Selection
9
+ ==============================================
10
+
11
+ AutoAction provides a HuggingFace-style API for automatically retrieving the
12
+ correct Action class from installed packages or HuggingFace Hub.
13
+
14
+ This module simplifies working with environment actions by automatically
15
+ detecting and returning the appropriate Action class without requiring
16
+ manual imports.
17
+
18
+ Example:
19
+ >>> from openenv import AutoEnv, AutoAction
20
+ >>>
21
+ >>> # Get Action class from environment name
22
+ >>> CodeAction = AutoAction.from_env("coding")
23
+ >>> action = CodeAction(code="print('Hello!')")
24
+ >>>
25
+ >>> # From HuggingFace Hub
26
+ >>> CodeAction = AutoAction.from_env("meta-pytorch/coding-env")
27
+ >>>
28
+ >>> # Use with AutoEnv
29
+ >>> env = AutoEnv.from_env("coding-env")
30
+ >>> result = env.step(action)
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import logging
36
+ from typing import Type, Dict, Any
37
+
38
+ from ._discovery import get_discovery, _is_hub_url
39
+ from .auto_env import AutoEnv
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class AutoAction:
45
+ """
46
+ AutoAction automatically retrieves the correct Action class based on
47
+ environment names or HuggingFace Hub repositories.
48
+
49
+ This class follows the HuggingFace AutoModel pattern, making it easy to
50
+ get the right Action class without needing to know which module to import.
51
+
52
+ The class provides factory methods that look up the Action class and
53
+ return the class (not an instance) for you to instantiate.
54
+
55
+ Example:
56
+ >>> # From installed package
57
+ >>> CodeAction = AutoAction.from_env("coding")
58
+ >>> action = CodeAction(code="print('test')")
59
+ >>>
60
+ >>> # From HuggingFace Hub
61
+ >>> CodeAction = AutoAction.from_env("meta-pytorch/coding-env")
62
+ >>> action = CodeAction(code="print('test')")
63
+ >>>
64
+ >>> # Use with AutoEnv for a complete workflow
65
+ >>> env = AutoEnv.from_env("coding-env")
66
+ >>> ActionClass = AutoAction.from_env("coding-env")
67
+ >>> action = ActionClass(code="print('Hello, AutoAction!')")
68
+ >>> result = env.step(action)
69
+
70
+ Note:
71
+ AutoAction is not meant to be instantiated directly. Use the class
72
+ method from_env() instead.
73
+ """
74
+
75
+ def __init__(self):
76
+ """AutoAction should not be instantiated directly. Use class methods instead."""
77
+ raise TypeError(
78
+ "AutoAction is a factory class and should not be instantiated directly. "
79
+ "Use AutoAction.from_hub() or AutoAction.from_env() instead."
80
+ )
81
+
82
+ @classmethod
83
+ def from_env(cls, name: str, skip_install: bool = False) -> Type:
84
+ """
85
+ Get the Action class from environment name or HuggingFace Hub repository.
86
+
87
+ This method automatically:
88
+ 1. Checks if the name is a HuggingFace Hub URL/repo ID
89
+ 2. If Hub: downloads and installs the environment package
90
+ 3. If local: looks up the installed openenv-* package
91
+ 4. Imports and returns the Action class
92
+
93
+ Args:
94
+ name: Environment name or HuggingFace Hub repo ID
95
+ Examples:
96
+ - "coding" / "coding-env" / "coding_env"
97
+ - "meta-pytorch/coding-env" (Hub repo ID)
98
+ - "https://huggingface.co/meta-pytorch/coding-env" (Hub URL)
99
+ skip_install: If True, skip package installation and return
100
+ GenericAction class instead. Use this when working with
101
+ GenericEnvClient to avoid installing remote packages.
102
+
103
+ Returns:
104
+ Action class (not an instance!). Returns GenericAction when
105
+ skip_install=True.
106
+
107
+ Raises:
108
+ ValueError: If environment not found (only when skip_install=False)
109
+ ImportError: If environment package is not installed (only when skip_install=False)
110
+
111
+ Examples:
112
+ >>> # From installed package
113
+ >>> CodeAction = AutoAction.from_env("coding-env")
114
+ >>> action = CodeAction(code="print('Hello!')")
115
+ >>>
116
+ >>> # From HuggingFace Hub
117
+ >>> CodeAction = AutoAction.from_env("meta-pytorch/coding-env")
118
+ >>> action = CodeAction(code="print('Hello!')")
119
+ >>>
120
+ >>> # Skip installation, use GenericAction (for GenericEnvClient)
121
+ >>> ActionClass = AutoAction.from_env("user/repo", skip_install=True)
122
+ >>> action = ActionClass(code="print('Hello!')") # Returns GenericAction
123
+ >>>
124
+ >>> # Different name formats
125
+ >>> EchoAction = AutoAction.from_env("echo")
126
+ >>> EchoAction = AutoAction.from_env("echo-env")
127
+ >>> EchoAction = AutoAction.from_env("echo_env")
128
+ """
129
+ # If skip_install is True, return GenericAction without any package lookup
130
+ if skip_install:
131
+ from openenv.core.generic_client import GenericAction
132
+
133
+ logger.info(
134
+ f"Returning GenericAction for '{name}' (skip_install=True). "
135
+ f"Use keyword arguments to create actions: GenericAction(code='...')"
136
+ )
137
+ return GenericAction
138
+
139
+ # Check if it's a HuggingFace Hub URL or repo ID
140
+ if _is_hub_url(name):
141
+ # Ensure package is installed (reuse AutoEnv logic, downloads only if needed)
142
+ env_name = AutoEnv._ensure_package_from_hub(name)
143
+ else:
144
+ env_name = name
145
+
146
+ # Get environment info from discovery
147
+ discovery = get_discovery()
148
+ env_info = discovery.get_environment_by_name(env_name)
149
+
150
+ if not env_info:
151
+ # Environment not found - provide helpful error message
152
+ available_envs = discovery.discover()
153
+
154
+ if not available_envs:
155
+ raise ValueError(
156
+ "No OpenEnv environments found.\n"
157
+ "Install an environment with: pip install openenv-<env-name>\n"
158
+ "Or specify a HuggingFace Hub repository: AutoAction.from_env('openenv/echo_env')"
159
+ )
160
+
161
+ # Try to suggest similar environment names
162
+ from difflib import get_close_matches
163
+
164
+ env_keys = list(available_envs.keys())
165
+ suggestions = get_close_matches(env_name, env_keys, n=3, cutoff=0.6)
166
+
167
+ error_msg = f"Unknown environment '{env_name}'.\n"
168
+ if suggestions:
169
+ error_msg += f"Did you mean: {', '.join(suggestions)}?\n"
170
+ error_msg += f"Available environments: {', '.join(sorted(env_keys))}"
171
+
172
+ raise ValueError(error_msg)
173
+
174
+ # Get the action class
175
+ try:
176
+ action_class = env_info.get_action_class()
177
+ return action_class
178
+ except ImportError as e:
179
+ raise ImportError(
180
+ f"Failed to import action class for '{env_name}'.\n"
181
+ f"Package '{env_info.package_name}' appears to be installed but the module cannot be imported.\n"
182
+ f"Try reinstalling: pip install --force-reinstall {env_info.package_name}\n"
183
+ f"Original error: {e}"
184
+ ) from e
185
+
186
+ @classmethod
187
+ def from_hub(cls, env_name: str, skip_install: bool = False) -> Type:
188
+ """
189
+ Get the Action class from environment name.
190
+
191
+ This is an alias for from_env() for backward compatibility and clarity.
192
+
193
+ Args:
194
+ env_name: Environment name (e.g., "coding", "echo")
195
+ skip_install: If True, skip package installation and return
196
+ GenericAction class instead.
197
+
198
+ Returns:
199
+ Action class (not an instance!)
200
+
201
+ Examples:
202
+ >>> CodeAction = AutoAction.from_hub("coding")
203
+ >>> action = CodeAction(code="print('Hello!')")
204
+ """
205
+ return cls.from_env(env_name, skip_install=skip_install)
206
+
207
+ @classmethod
208
+ def get_action_info(cls, name: str) -> Dict[str, Any]:
209
+ """
210
+ Get detailed information about an action class.
211
+
212
+ Args:
213
+ name: Environment name
214
+
215
+ Returns:
216
+ Dictionary with action class metadata
217
+
218
+ Raises:
219
+ ValueError: If environment not found
220
+
221
+ Examples:
222
+ >>> info = AutoAction.get_action_info("coding")
223
+ >>> print(info['action_class'])
224
+ 'CodingAction'
225
+ >>> print(info['module'])
226
+ 'coding_env.client'
227
+ """
228
+ discovery = get_discovery()
229
+ env_info = discovery.get_environment_by_name(name)
230
+
231
+ if not env_info:
232
+ raise ValueError(f"Unknown environment: {name}")
233
+
234
+ return {
235
+ "env_key": env_info.env_key,
236
+ "env_name": env_info.name,
237
+ "package": env_info.package_name,
238
+ "action_class": env_info.action_class_name,
239
+ "observation_class": env_info.observation_class_name,
240
+ "module": env_info.client_module_path,
241
+ }
242
+
243
+ @classmethod
244
+ def list_actions(cls) -> None:
245
+ """
246
+ Print a formatted list of all available action classes.
247
+
248
+ This discovers all installed openenv-* packages and displays
249
+ their action class information in a user-friendly format.
250
+
251
+ Examples:
252
+ >>> AutoAction.list_actions()
253
+ Available Action Classes:
254
+ ----------------------------------------------------------------------
255
+ echo : EchoAction (from openenv-echo-env)
256
+ coding : CodingAction (from openenv-coding_env)
257
+ ----------------------------------------------------------------------
258
+ Total: 2 action classes
259
+ """
260
+ discovery = get_discovery()
261
+ environments = discovery.discover()
262
+
263
+ print("Available Action Classes:")
264
+ print("-" * 70)
265
+
266
+ if not environments:
267
+ print(" No OpenEnv environments found.")
268
+ print(" Install environments with: pip install openenv-<env-name>")
269
+ else:
270
+ for env_key in sorted(environments.keys()):
271
+ env = environments[env_key]
272
+ print(f" {env_key:<15}: {env.action_class_name}")
273
+ print(f" Package: {env.package_name}")
274
+
275
+ print("-" * 70)
276
+ print(f"Total: {len(environments)} action classes")
src/openenv/auto/auto_env.py ADDED
@@ -0,0 +1,896 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ AutoEnv - Automatic Environment Selection
9
+ ==========================================
10
+
11
+ AutoEnv provides a HuggingFace-style API for automatically selecting and
12
+ instantiating the correct environment client from installed packages or
13
+ HuggingFace Hub.
14
+
15
+ This module simplifies environment creation by automatically detecting the
16
+ environment type from the name and instantiating the appropriate client class.
17
+
18
+ Example:
19
+ >>> from openenv import AutoEnv, AutoAction
20
+ >>>
21
+ >>> # From installed package
22
+ >>> env = AutoEnv.from_env("coding-env")
23
+ >>>
24
+ >>> # From HuggingFace Hub
25
+ >>> env = AutoEnv.from_env("meta-pytorch/coding-env")
26
+ >>>
27
+ >>> # With configuration
28
+ >>> env = AutoEnv.from_env("coding", env_vars={"DEBUG": "1"})
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import importlib
34
+ import logging
35
+ import os
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import requests
40
+ from typing import Any, Optional, TYPE_CHECKING, Dict
41
+
42
+ from ._discovery import get_discovery, _is_hub_url
43
+ from openenv.core.utils import run_async_safely
44
+
45
+
46
+ if TYPE_CHECKING:
47
+ from openenv.core.containers.runtime import ContainerProvider
48
+ from openenv.core.env_client import EnvClient
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+ # Cache for repo ID → env_name mapping to avoid redundant downloads
53
+ _hub_env_name_cache: Dict[str, str] = {}
54
+
55
+ # Environment variable to skip user confirmation for remote installs
56
+ OPENENV_TRUST_REMOTE_CODE = "OPENENV_TRUST_REMOTE_CODE"
57
+
58
+
59
+ def _has_uv() -> bool:
60
+ """Check if uv is available in the system."""
61
+ return shutil.which("uv") is not None
62
+
63
+
64
+ def _get_pip_command() -> list[str]:
65
+ """
66
+ Get the appropriate pip command (uv pip or pip).
67
+
68
+ Returns:
69
+ List of command parts for pip installation
70
+ """
71
+ if _has_uv():
72
+ return ["uv", "pip"]
73
+ return [sys.executable, "-m", "pip"]
74
+
75
+
76
+ def _confirm_remote_install(repo_id: str) -> bool:
77
+ """
78
+ Ask user for confirmation before installing remote code.
79
+
80
+ This is a security measure since we're executing code from the internet.
81
+
82
+ Args:
83
+ repo_id: The HuggingFace repo ID being installed
84
+
85
+ Returns:
86
+ True if user confirms, False otherwise
87
+ """
88
+ # Check environment variable for automated/CI environments
89
+ if os.environ.get(OPENENV_TRUST_REMOTE_CODE, "").lower() in ("1", "true", "yes"):
90
+ logger.info("Skipping confirmation (OPENENV_TRUST_REMOTE_CODE is set)")
91
+ return True
92
+
93
+ # Check if we're in an interactive terminal
94
+ if not sys.stdin.isatty():
95
+ logger.warning(
96
+ "Cannot prompt for confirmation in non-interactive mode. "
97
+ "Set OPENENV_TRUST_REMOTE_CODE=1 to allow remote installs."
98
+ )
99
+ return False
100
+
101
+ print(f"\n{'=' * 60}")
102
+ print("⚠️ SECURITY WARNING: Remote Code Installation")
103
+ print(f"{'=' * 60}")
104
+ print("You are about to install code from a remote repository:")
105
+ print(f" Repository: {repo_id}")
106
+ print(f" Source: https://huggingface.co/spaces/{repo_id}")
107
+ print("\nThis will execute code from the internet on your machine.")
108
+ print("Only proceed if you trust the source.")
109
+ print(f"{'=' * 60}\n")
110
+
111
+ try:
112
+ response = input("Do you want to proceed? [y/N]: ").strip().lower()
113
+ return response in ("y", "yes")
114
+ except (EOFError, KeyboardInterrupt):
115
+ print("\nInstallation cancelled.")
116
+ return False
117
+
118
+
119
+ class AutoEnv:
120
+ """
121
+ AutoEnv automatically selects and instantiates the correct environment client
122
+ based on environment names or HuggingFace Hub repositories.
123
+
124
+ This class follows the HuggingFace AutoModel pattern, making it easy to work
125
+ with different environments without needing to import specific client classes.
126
+
127
+ The class provides factory methods that:
128
+ 1. Check if name is a HuggingFace Hub URL/repo ID
129
+ 2. If Hub: download and install the environment package
130
+ 3. If local: look up the installed openenv-* package
131
+ 4. Import and instantiate the client class
132
+
133
+ Example:
134
+ >>> # From installed package
135
+ >>> env = AutoEnv.from_env("coding-env")
136
+ >>>
137
+ >>> # From HuggingFace Hub
138
+ >>> env = AutoEnv.from_env("meta-pytorch/coding-env")
139
+ >>>
140
+ >>> # List available environments
141
+ >>> AutoEnv.list_environments()
142
+
143
+ Note:
144
+ AutoEnv is not meant to be instantiated directly. Use the class method
145
+ from_env() instead.
146
+ """
147
+
148
+ def __init__(self):
149
+ """AutoEnv should not be instantiated directly. Use class methods instead."""
150
+ raise TypeError(
151
+ "AutoEnv is a factory class and should not be instantiated directly. "
152
+ "Use AutoEnv.from_hub() or AutoEnv.from_env() instead."
153
+ )
154
+
155
+ @classmethod
156
+ def _resolve_space_url(cls, repo_id: str) -> str:
157
+ """
158
+ Resolve HuggingFace Space repo ID to Space URL.
159
+
160
+ Args:
161
+ repo_id: HuggingFace repo ID (e.g., "wukaixingxp/coding-env-test")
162
+
163
+ Returns:
164
+ Space URL (e.g., "https://wukaixingxp-coding-env-test.hf.space")
165
+
166
+ Examples:
167
+ >>> AutoEnv._resolve_space_url("wukaixingxp/coding-env-test")
168
+ 'https://wukaixingxp-coding-env-test.hf.space'
169
+ """
170
+ # Clean up repo_id if it's a full URL
171
+ if "huggingface.co" in repo_id:
172
+ # Extract org/repo from URL
173
+ # https://huggingface.co/wukaixingxp/coding-env-test -> wukaixingxp/coding-env-test
174
+ parts = repo_id.split("/")
175
+ if len(parts) >= 2:
176
+ repo_id = f"{parts[-2]}/{parts[-1]}"
177
+
178
+ # Convert user/space-name to user-space-name.hf.space
179
+ space_slug = repo_id.replace("/", "-")
180
+ return f"https://{space_slug}.hf.space"
181
+
182
+ @classmethod
183
+ def _is_local_url(cls, url: str) -> bool:
184
+ """
185
+ Check if a URL points to a local server.
186
+
187
+ Args:
188
+ url: URL to check
189
+
190
+ Returns:
191
+ True if URL is localhost or 127.0.0.1, False otherwise
192
+
193
+ Examples:
194
+ >>> AutoEnv._is_local_url("http://localhost:8000")
195
+ True
196
+ >>> AutoEnv._is_local_url("http://127.0.0.1:8000")
197
+ True
198
+ >>> AutoEnv._is_local_url("https://example.com")
199
+ False
200
+ """
201
+ url_lower = url.lower()
202
+ return "localhost" in url_lower or "127.0.0.1" in url_lower
203
+
204
+ @classmethod
205
+ def _check_server_availability(cls, base_url: str, timeout: float = 2.0) -> bool:
206
+ """
207
+ Check if a server at the given URL is running and accessible.
208
+
209
+ Args:
210
+ base_url: Server base URL to check
211
+ timeout: Request timeout in seconds
212
+
213
+ Returns:
214
+ True if server is accessible, False otherwise
215
+
216
+ Examples:
217
+ >>> AutoEnv._check_server_availability("http://localhost:8000")
218
+ True # if server is running
219
+ """
220
+ try:
221
+ # Bypass proxy for localhost to avoid proxy issues
222
+ proxies = None
223
+ if cls._is_local_url(base_url):
224
+ proxies = {"http": None, "https": None}
225
+
226
+ # Try to access the health endpoint
227
+ response = requests.get(
228
+ f"{base_url}/health", timeout=timeout, proxies=proxies
229
+ )
230
+ if response.status_code == 200:
231
+ return True
232
+
233
+ # If health endpoint doesn't exist, try root endpoint
234
+ response = requests.get(base_url, timeout=timeout, proxies=proxies)
235
+ return response.status_code == 200
236
+ except (requests.RequestException, Exception) as e:
237
+ logger.debug(f"Server {base_url} not accessible: {e}")
238
+ return False
239
+
240
+ @classmethod
241
+ def _check_space_availability(cls, space_url: str, timeout: float = 5.0) -> bool:
242
+ """
243
+ Check if HuggingFace Space is running and accessible.
244
+
245
+ Args:
246
+ space_url: Space URL to check
247
+ timeout: Request timeout in seconds
248
+
249
+ Returns:
250
+ True if Space is accessible, False otherwise
251
+
252
+ Examples:
253
+ >>> AutoEnv._check_space_availability("https://wukaixingxp-coding-env-test.hf.space")
254
+ True
255
+ """
256
+ try:
257
+ # Try to access the health endpoint
258
+ response = requests.get(f"{space_url}/health", timeout=timeout)
259
+ if response.status_code == 200:
260
+ return True
261
+
262
+ # If health endpoint doesn't exist, try root endpoint
263
+ response = requests.get(space_url, timeout=timeout)
264
+ return response.status_code == 200
265
+ except (requests.RequestException, Exception) as e:
266
+ logger.debug(f"Space {space_url} not accessible: {e}")
267
+ return False
268
+
269
+ @classmethod
270
+ def _get_hub_git_url(cls, repo_id: str) -> str:
271
+ """
272
+ Get the git URL for a HuggingFace Space.
273
+
274
+ Args:
275
+ repo_id: HuggingFace repo ID (e.g., "wukaixingxp/coding-env-test")
276
+
277
+ Returns:
278
+ Git URL for pip installation (e.g., "git+https://huggingface.co/spaces/wukaixingxp/coding-env-test")
279
+ """
280
+ # Clean up repo_id if it's a full URL
281
+ if "huggingface.co" in repo_id:
282
+ parts = repo_id.split("/")
283
+ if len(parts) >= 2:
284
+ repo_id = f"{parts[-2]}/{parts[-1]}"
285
+
286
+ return f"git+https://huggingface.co/spaces/{repo_id}"
287
+
288
+ @classmethod
289
+ def _install_from_hub(cls, repo_id: str, trust_remote_code: bool = False) -> str:
290
+ """
291
+ Install environment package directly from HuggingFace Hub using git+.
292
+
293
+ This is the preferred method as it avoids downloading the entire repo
294
+ and uses pip/uv's native git support.
295
+
296
+ Args:
297
+ repo_id: HuggingFace repo ID (e.g., "wukaixingxp/coding-env-test")
298
+ trust_remote_code: If True, skip user confirmation
299
+
300
+ Returns:
301
+ Package name that was installed
302
+
303
+ Raises:
304
+ ValueError: If installation fails or user declines
305
+ """
306
+ # Security check - confirm with user before installing remote code
307
+ if not trust_remote_code and not _confirm_remote_install(repo_id):
308
+ raise ValueError(
309
+ "Installation cancelled by user.\n"
310
+ "To allow remote installs without prompting, set OPENENV_TRUST_REMOTE_CODE=1"
311
+ )
312
+
313
+ git_url = cls._get_hub_git_url(repo_id)
314
+ pip_cmd = _get_pip_command()
315
+ pip_name = "uv pip" if pip_cmd[0] == "uv" else "pip"
316
+
317
+ logger.info(f"Installing from HuggingFace Space using {pip_name}: {repo_id}")
318
+ logger.info(f"Command: {' '.join(pip_cmd)} install {git_url}")
319
+
320
+ try:
321
+ result = subprocess.run(
322
+ [*pip_cmd, "install", git_url],
323
+ check=True,
324
+ capture_output=True,
325
+ text=True,
326
+ )
327
+
328
+ # Try to extract package name from pip output
329
+ # Look for "Successfully installed <package-name>-<version>"
330
+ for line in result.stdout.split("\n"):
331
+ if "Successfully installed" in line:
332
+ # Parse package name from the line
333
+ parts = line.replace("Successfully installed", "").strip().split()
334
+ for part in parts:
335
+ if part.startswith("openenv-"):
336
+ # Remove version suffix (e.g., "openenv-coding_env-0.1.0" -> "openenv-coding_env")
337
+ # Check if last segment looks like a version number
338
+ last_segment = part.rsplit("-", 1)[-1]
339
+ if last_segment.replace(".", "").isdigit():
340
+ package_name = "-".join(part.rsplit("-", 1)[:-1])
341
+ else:
342
+ package_name = part
343
+ logger.info(f"Successfully installed: {package_name}")
344
+ return package_name
345
+
346
+ # Fallback: try to determine package name from repo_id
347
+ # Convention: repo name like "coding-env-test" -> package "openenv-coding_env"
348
+ env_name = repo_id.split("/")[-1] # Get repo name from "user/repo"
349
+ env_name = env_name.replace("-", "_")
350
+ if not env_name.endswith("_env"):
351
+ env_name = f"{env_name}_env"
352
+ package_name = f"openenv-{env_name}"
353
+
354
+ logger.info(f"Installed (inferred package name): {package_name}")
355
+ return package_name
356
+
357
+ except subprocess.CalledProcessError as e:
358
+ error_msg = e.stderr or e.stdout or str(e)
359
+ raise ValueError(
360
+ f"Failed to install environment from HuggingFace Space: {repo_id}\n"
361
+ f"Command: {' '.join(pip_cmd)} install {git_url}\n"
362
+ f"Error: {error_msg}\n"
363
+ f"Make sure the repository exists and contains a valid Python package."
364
+ ) from e
365
+
366
+ @classmethod
367
+ def _is_package_installed(cls, package_name: str) -> bool:
368
+ """
369
+ Check if a package is already installed.
370
+
371
+ Args:
372
+ package_name: Package name (e.g., "openenv-coding_env")
373
+
374
+ Returns:
375
+ True if installed, False otherwise
376
+ """
377
+ try:
378
+ import importlib.metadata
379
+
380
+ importlib.metadata.distribution(package_name)
381
+ return True
382
+ except importlib.metadata.PackageNotFoundError:
383
+ return False
384
+
385
+ @classmethod
386
+ def _ensure_package_from_hub(
387
+ cls, name: str, trust_remote_code: bool = False
388
+ ) -> str:
389
+ """
390
+ Ensure package from HuggingFace Hub is installed.
391
+
392
+ Uses git+ URLs for direct installation without downloading the entire repo.
393
+ Prompts user for confirmation before installing remote code.
394
+
395
+ Args:
396
+ name: HuggingFace repo ID (e.g., "wukaixingxp/coding-env-test")
397
+ trust_remote_code: If True, skip user confirmation
398
+
399
+ Returns:
400
+ Environment name (e.g., "coding_env")
401
+ """
402
+ global _hub_env_name_cache
403
+
404
+ # Check if we already resolved this repo ID
405
+ if name in _hub_env_name_cache:
406
+ env_name = _hub_env_name_cache[name]
407
+ logger.debug(f"Using cached env name for {name}: {env_name}")
408
+ return env_name
409
+
410
+ # Try to infer expected package name from repo ID
411
+ # Convention: repo "user/coding-env" -> package "openenv-coding_env"
412
+ repo_name = name.split("/")[-1] if "/" in name else name
413
+ expected_env_name = repo_name.replace("-", "_")
414
+ if not expected_env_name.endswith("_env"):
415
+ expected_env_name = f"{expected_env_name}_env"
416
+ expected_package_name = f"openenv-{expected_env_name}"
417
+
418
+ # Check if already installed
419
+ if cls._is_package_installed(expected_package_name):
420
+ logger.info(f"Package already installed: {expected_package_name}")
421
+ # Clear and refresh discovery cache to make sure it's detected
422
+ get_discovery().clear_cache()
423
+ get_discovery().discover(use_cache=False)
424
+ # Cache the result
425
+ _hub_env_name_cache[name] = expected_env_name
426
+ return expected_env_name
427
+
428
+ # Not installed, install using git+ URL
429
+ logger.info(f"Package not found locally, installing from Hub: {name}")
430
+
431
+ # Track existing packages before installation
432
+ get_discovery().clear_cache()
433
+ existing_envs = set(get_discovery().discover(use_cache=False).keys())
434
+
435
+ # Install the package
436
+ cls._install_from_hub(name, trust_remote_code=trust_remote_code)
437
+
438
+ # Clear discovery cache to pick up the newly installed package
439
+ try:
440
+ importlib.invalidate_caches()
441
+ except Exception:
442
+ pass
443
+ get_discovery().clear_cache()
444
+ discovered_envs = get_discovery().discover(use_cache=False)
445
+
446
+ # Find the newly installed environment by comparing before/after
447
+ new_envs = set(discovered_envs.keys()) - existing_envs
448
+
449
+ if new_envs:
450
+ # Use the first newly discovered environment
451
+ env_name = next(iter(new_envs))
452
+ logger.info(f"Found newly installed environment: '{env_name}'")
453
+ else:
454
+ # Fallback: try to find by matching module patterns
455
+ # Look for any env that might match the repo name pattern
456
+ repo_name = name.split("/")[-1] if "/" in name else name
457
+ repo_base = (
458
+ repo_name.replace("-", "_").replace("_env", "").replace("_test", "")
459
+ )
460
+
461
+ env_name = None
462
+ for env_key, env_info in discovered_envs.items():
463
+ # Check if env_key is a prefix/substring match
464
+ if env_key in repo_base or repo_base.startswith(env_key):
465
+ env_name = env_key
466
+ logger.info(
467
+ f"Found matching environment '{env_name}' for repo '{name}'"
468
+ )
469
+ break
470
+
471
+ if env_name is None:
472
+ # Last resort: use inferred name from repo
473
+ env_name = repo_name.replace("-", "_")
474
+ if not env_name.endswith("_env"):
475
+ env_name = f"{env_name}_env"
476
+ # Strip to get env_key
477
+ env_name = env_name.replace("_env", "")
478
+ logger.warning(
479
+ f"Could not find newly installed environment for repo '{name}', "
480
+ f"using inferred name: {env_name}"
481
+ )
482
+
483
+ # Cache the result to avoid redundant installs
484
+ _hub_env_name_cache[name] = env_name
485
+
486
+ return env_name
487
+
488
+ @classmethod
489
+ def from_env(
490
+ cls,
491
+ name: str,
492
+ base_url: Optional[str] = None,
493
+ docker_image: Optional[str] = None,
494
+ container_provider: Optional[ContainerProvider] = None,
495
+ wait_timeout: float = 30.0,
496
+ env_vars: Optional[Dict[str, str]] = None,
497
+ trust_remote_code: bool = False,
498
+ skip_install: bool = False,
499
+ **kwargs: Any,
500
+ ) -> "EnvClient":
501
+ """
502
+ Create an environment client from a name or HuggingFace Hub repository.
503
+
504
+ This method automatically:
505
+ 1. Checks if the name is a HuggingFace Hub URL/repo ID
506
+ 2. If Hub: installs the environment package using git+ URL
507
+ 3. If local: looks up the installed openenv-* package
508
+ 4. Imports the client class and instantiates it
509
+
510
+ Args:
511
+ name: Environment name or HuggingFace Hub repo ID
512
+ Examples:
513
+ - "coding" / "coding-env" / "coding_env"
514
+ - "meta-pytorch/coding-env" (Hub repo ID)
515
+ - "https://huggingface.co/meta-pytorch/coding-env" (Hub URL)
516
+ base_url: Optional base URL for HTTP connection
517
+ docker_image: Optional Docker image name (overrides default)
518
+ container_provider: Optional container provider
519
+ wait_timeout: Timeout for container startup (seconds)
520
+ env_vars: Optional environment variables for the container
521
+ trust_remote_code: If True, skip user confirmation when installing
522
+ from HuggingFace Hub. Can also be set via OPENENV_TRUST_REMOTE_CODE
523
+ environment variable.
524
+ skip_install: If True, skip package installation and return a
525
+ GenericEnvClient for remote environments. Useful when you only
526
+ want to connect to a running server without installing any
527
+ remote code. When True:
528
+ - If base_url is provided: connects directly using GenericEnvClient
529
+ - If HF Space is running: connects to Space using GenericEnvClient
530
+ - If HF Space is not running: uses Docker from HF registry
531
+ **kwargs: Additional arguments passed to the client class
532
+
533
+ Returns:
534
+ Instance of the environment client class
535
+
536
+ Raises:
537
+ ValueError: If environment not found or cannot be loaded
538
+ ImportError: If environment package is not installed
539
+
540
+ Examples:
541
+ >>> # From installed package
542
+ >>> env = AutoEnv.from_env("coding-env")
543
+ >>>
544
+ >>> # From HuggingFace Hub
545
+ >>> env = AutoEnv.from_env("meta-pytorch/coding-env")
546
+ >>>
547
+ >>> # With custom Docker image
548
+ >>> env = AutoEnv.from_env("coding", docker_image="my-coding-env:v2")
549
+ >>>
550
+ >>> # With environment variables
551
+ >>> env = AutoEnv.from_env(
552
+ ... "dipg",
553
+ ... env_vars={"DIPG_DATASET_PATH": "/data/dipg"}
554
+ ... )
555
+ >>>
556
+ >>> # Skip package installation, use GenericEnvClient
557
+ >>> env = AutoEnv.from_env(
558
+ ... "user/my-env",
559
+ ... skip_install=True
560
+ ... )
561
+ """
562
+ from openenv.core import GenericEnvClient
563
+
564
+ # Handle skip_install mode - return GenericEnvClient without package installation
565
+ if skip_install:
566
+ # If base_url is provided, connect directly
567
+ if base_url:
568
+ if cls._check_server_availability(base_url):
569
+ logger.info(
570
+ f"Using GenericEnvClient for {base_url} (skip_install=True)"
571
+ )
572
+ return GenericEnvClient(base_url=base_url, **kwargs)
573
+ else:
574
+ raise ConnectionError(
575
+ f"Server not available at {base_url}. "
576
+ f"Please ensure the server is running."
577
+ )
578
+
579
+ # If it's a Hub URL, try to connect to Space or use Docker
580
+ if _is_hub_url(name):
581
+ space_url = cls._resolve_space_url(name)
582
+ logger.info(f"Checking if HuggingFace Space is accessible: {space_url}")
583
+
584
+ if cls._check_space_availability(space_url):
585
+ logger.info(
586
+ f"Using GenericEnvClient for Space {space_url} (skip_install=True)"
587
+ )
588
+ return GenericEnvClient(base_url=space_url, **kwargs)
589
+ else:
590
+ # Space not running, use Docker from HF registry
591
+ logger.info(
592
+ f"Space not running at {space_url}, "
593
+ f"using GenericEnvClient with HF Docker registry"
594
+ )
595
+ return run_async_safely(
596
+ GenericEnvClient.from_env(
597
+ name,
598
+ use_docker=True,
599
+ provider=container_provider,
600
+ env_vars=env_vars or {},
601
+ **kwargs,
602
+ )
603
+ )
604
+
605
+ # For local environments with skip_install, we need docker_image
606
+ if docker_image:
607
+ logger.info(
608
+ f"Using GenericEnvClient with Docker image {docker_image} "
609
+ f"(skip_install=True)"
610
+ )
611
+ return run_async_safely(
612
+ GenericEnvClient.from_docker_image(
613
+ image=docker_image,
614
+ provider=container_provider,
615
+ wait_timeout=wait_timeout,
616
+ env_vars=env_vars or {},
617
+ **kwargs,
618
+ )
619
+ )
620
+ else:
621
+ raise ValueError(
622
+ f"Cannot use skip_install=True for local environment '{name}' "
623
+ f"without providing base_url or docker_image. "
624
+ f"For local environments, either:\n"
625
+ f" 1. Provide base_url to connect to a running server\n"
626
+ f" 2. Provide docker_image to start a container\n"
627
+ f" 3. Set skip_install=False to use the installed package"
628
+ )
629
+
630
+ # Check if it's a HuggingFace Hub URL or repo ID
631
+ if _is_hub_url(name):
632
+ # Try to connect to Space directly first
633
+ space_url = cls._resolve_space_url(name)
634
+ logger.info(f"Checking if HuggingFace Space is accessible: {space_url}")
635
+
636
+ space_is_available = cls._check_space_availability(space_url)
637
+
638
+ if space_is_available and base_url is None:
639
+ # Space is accessible! We'll connect directly without Docker
640
+ logger.info(f"Space is accessible at: {space_url}")
641
+ logger.info("Installing package for client code (no Docker needed)...")
642
+
643
+ # Ensure package is installed (uses git+ URL)
644
+ env_name = cls._ensure_package_from_hub(
645
+ name, trust_remote_code=trust_remote_code
646
+ )
647
+
648
+ # Set base_url to connect to remote Space
649
+ base_url = space_url
650
+ logger.info("Will connect to remote Space (no local Docker)")
651
+ else:
652
+ # Space not accessible or user provided explicit base_url
653
+ if not space_is_available:
654
+ logger.info(f"Space not accessible at {space_url}")
655
+ logger.info("Falling back to local Docker mode...")
656
+
657
+ # Ensure package is installed (uses git+ URL)
658
+ env_name = cls._ensure_package_from_hub(
659
+ name, trust_remote_code=trust_remote_code
660
+ )
661
+ else:
662
+ env_name = name
663
+
664
+ # Get environment info from discovery
665
+ discovery = get_discovery()
666
+ env_info = discovery.get_environment_by_name(env_name)
667
+
668
+ if not env_info:
669
+ # Environment not found - provide helpful error message
670
+ available_envs = discovery.discover()
671
+
672
+ if not available_envs:
673
+ raise ValueError(
674
+ "No OpenEnv environments found.\n"
675
+ "Install an environment with: pip install openenv-<env-name>\n"
676
+ "Or specify a HuggingFace Hub repository: AutoEnv.from_env('openenv/echo_env')"
677
+ )
678
+
679
+ # Try to suggest similar environment names
680
+ from difflib import get_close_matches
681
+
682
+ env_keys = list(available_envs.keys())
683
+ suggestions = get_close_matches(env_name, env_keys, n=3, cutoff=0.6)
684
+
685
+ error_msg = f"Unknown environment '{env_name}'.\n"
686
+ if suggestions:
687
+ error_msg += f"Did you mean: {', '.join(suggestions)}?\n"
688
+ error_msg += f"Available environments: {', '.join(sorted(env_keys))}"
689
+
690
+ raise ValueError(error_msg)
691
+
692
+ # Get the client class
693
+ try:
694
+ client_class = env_info.get_client_class()
695
+ except ImportError as e:
696
+ raise ImportError(
697
+ f"Failed to import environment client for '{env_name}'.\n"
698
+ f"Package '{env_info.package_name}' appears to be installed but the module cannot be imported.\n"
699
+ f"Try reinstalling: pip install --force-reinstall {env_info.package_name}\n"
700
+ f"Original error: {e}"
701
+ ) from e
702
+
703
+ # Determine Docker image to use
704
+ if docker_image is None:
705
+ docker_image = env_info.default_image
706
+
707
+ # Create client instance
708
+ try:
709
+ if base_url:
710
+ # Check if the server at base_url is available
711
+ is_local = cls._is_local_url(base_url)
712
+ server_available = cls._check_server_availability(base_url)
713
+
714
+ if server_available:
715
+ # Server is running, connect directly
716
+ logger.info(
717
+ f"✅ Server available at {base_url}, connecting directly"
718
+ )
719
+ return client_class(base_url=base_url, provider=None, **kwargs)
720
+ elif is_local:
721
+ # Local server not running, auto-start Docker container
722
+ logger.info(f"❌ Server not available at {base_url}")
723
+ logger.info(f"🐳 Auto-starting Docker container: {docker_image}")
724
+ return run_async_safely(
725
+ client_class.from_docker_image(
726
+ image=docker_image,
727
+ provider=container_provider,
728
+ wait_timeout=wait_timeout,
729
+ env_vars=env_vars or {},
730
+ **kwargs,
731
+ )
732
+ )
733
+ else:
734
+ # Remote server not available, cannot auto-start
735
+ raise ConnectionError(
736
+ f"Remote server not available at {base_url}. "
737
+ f"Please ensure the server is running."
738
+ )
739
+ else:
740
+ # No base_url provided, start new Docker container
741
+ return run_async_safely(
742
+ client_class.from_docker_image(
743
+ image=docker_image,
744
+ provider=container_provider,
745
+ wait_timeout=wait_timeout,
746
+ env_vars=env_vars or {},
747
+ **kwargs,
748
+ )
749
+ )
750
+ except Exception as e:
751
+ raise ValueError(
752
+ f"Failed to create environment client for '{env_name}'.\n"
753
+ f"Client class: {client_class.__name__}\n"
754
+ f"Docker image: {docker_image}\n"
755
+ f"Error: {e}"
756
+ ) from e
757
+
758
+ @classmethod
759
+ def from_hub(
760
+ cls,
761
+ name: str,
762
+ base_url: Optional[str] = None,
763
+ docker_image: Optional[str] = None,
764
+ container_provider: Optional["ContainerProvider"] = None,
765
+ wait_timeout: float = 30.0,
766
+ env_vars: Optional[Dict[str, str]] = None,
767
+ trust_remote_code: bool = False,
768
+ skip_install: bool = False,
769
+ **kwargs: Any,
770
+ ) -> "EnvClient":
771
+ """
772
+ Create an environment client from a name or HuggingFace Hub repository.
773
+
774
+ This is an alias for from_env() for backward compatibility.
775
+
776
+ Args:
777
+ name: Environment name or HuggingFace Hub repo ID
778
+ base_url: Optional base URL for HTTP connection
779
+ docker_image: Optional Docker image name (overrides default)
780
+ container_provider: Optional container provider
781
+ wait_timeout: Timeout for container startup (seconds)
782
+ env_vars: Optional environment variables for the container
783
+ trust_remote_code: If True, skip user confirmation when installing
784
+ from HuggingFace Hub
785
+ skip_install: If True, skip package installation and return a
786
+ GenericEnvClient for remote environments
787
+ **kwargs: Additional arguments passed to the client class
788
+
789
+ Returns:
790
+ Instance of the environment client class
791
+
792
+ Examples:
793
+ >>> env = AutoEnv.from_hub("coding-env")
794
+ >>> env = AutoEnv.from_hub("meta-pytorch/coding-env")
795
+ """
796
+ return cls.from_env(
797
+ name=name,
798
+ base_url=base_url,
799
+ docker_image=docker_image,
800
+ container_provider=container_provider,
801
+ wait_timeout=wait_timeout,
802
+ env_vars=env_vars,
803
+ trust_remote_code=trust_remote_code,
804
+ skip_install=skip_install,
805
+ **kwargs,
806
+ )
807
+
808
+ @classmethod
809
+ def get_env_class(cls, name: str):
810
+ """
811
+ Get the environment client class without instantiating it.
812
+
813
+ Args:
814
+ name: Environment name
815
+
816
+ Returns:
817
+ The environment client class
818
+
819
+ Raises:
820
+ ValueError: If environment not found
821
+
822
+ Examples:
823
+ >>> CodingEnv = AutoEnv.get_env_class("coding")
824
+ >>> # Now you can instantiate it yourself
825
+ >>> env = CodingEnv(base_url="http://localhost:8000")
826
+ """
827
+ discovery = get_discovery()
828
+ env_info = discovery.get_environment_by_name(name)
829
+
830
+ if not env_info:
831
+ raise ValueError(f"Unknown environment: {name}")
832
+
833
+ return env_info.get_client_class()
834
+
835
+ @classmethod
836
+ def get_env_info(cls, name: str) -> Dict[str, Any]:
837
+ """
838
+ Get detailed information about an environment.
839
+
840
+ Args:
841
+ name: Environment name
842
+
843
+ Returns:
844
+ Dictionary with environment metadata
845
+
846
+ Raises:
847
+ ValueError: If environment not found
848
+
849
+ Examples:
850
+ >>> info = AutoEnv.get_env_info("coding")
851
+ >>> print(info['description'])
852
+ 'Coding environment for OpenEnv'
853
+ >>> print(info['default_image'])
854
+ 'coding-env:latest'
855
+ """
856
+ discovery = get_discovery()
857
+ env_info = discovery.get_environment_by_name(name)
858
+
859
+ if not env_info:
860
+ raise ValueError(f"Unknown environment: {name}")
861
+
862
+ return {
863
+ "env_key": env_info.env_key,
864
+ "name": env_info.name,
865
+ "package": env_info.package_name,
866
+ "version": env_info.version,
867
+ "description": env_info.description,
868
+ "env_class": env_info.client_class_name,
869
+ "action_class": env_info.action_class_name,
870
+ "observation_class": env_info.observation_class_name,
871
+ "module": env_info.client_module_path,
872
+ "default_image": env_info.default_image,
873
+ "spec_version": env_info.spec_version,
874
+ }
875
+
876
+ @classmethod
877
+ def list_environments(cls) -> None:
878
+ """
879
+ Print a formatted list of all available environments.
880
+
881
+ This discovers all installed openenv-* packages and displays
882
+ their metadata in a user-friendly format.
883
+
884
+ Examples:
885
+ >>> AutoEnv.list_environments()
886
+ Available OpenEnv Environments:
887
+ ----------------------------------------------------------------------
888
+ echo : Echo Environment (v0.1.0)
889
+ Package: openenv-echo-env
890
+ coding : Coding Environment (v0.1.0)
891
+ Package: openenv-coding_env
892
+ ----------------------------------------------------------------------
893
+ Total: 2 environments
894
+ """
895
+ discovery = get_discovery()
896
+ discovery.list_environments()
src/openenv/cli/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI package."""
8
+
9
+ __version__ = "0.1.0"
src/openenv/cli/__main__.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ OpenEnv CLI entry point.
9
+
10
+ This module provides the main entry point for the OpenEnv command-line interface,
11
+ following the Hugging Face CLI pattern.
12
+ """
13
+
14
+ import sys
15
+
16
+ import typer
17
+
18
+ from openenv.cli.commands import build, fork, init, push, serve, validate
19
+
20
+ # Create the main CLI app
21
+ app = typer.Typer(
22
+ name="openenv",
23
+ help="OpenEnv - An e2e framework for creating, deploying and using isolated execution environments for agentic RL training",
24
+ no_args_is_help=True,
25
+ )
26
+
27
+ # Register commands
28
+ app.command(name="init", help="Initialize a new OpenEnv environment")(init.init)
29
+ app.command(name="build", help="Build Docker images for OpenEnv environments")(
30
+ build.build
31
+ )
32
+ app.command(
33
+ name="validate", help="Validate environment structure and deployment readiness"
34
+ )(validate.validate)
35
+ app.command(
36
+ name="push",
37
+ help="Push an OpenEnv environment to Hugging Face Spaces or custom registry",
38
+ )(push.push)
39
+ app.command(name="serve", help="Serve environments locally (TODO: Phase 4)")(
40
+ serve.serve
41
+ )
42
+ app.command(
43
+ name="fork",
44
+ help="Fork (duplicate) a Hugging Face Space to your account",
45
+ )(fork.fork)
46
+
47
+
48
+ # Entry point for setuptools
49
+ def main() -> None:
50
+ """Main entry point for the CLI."""
51
+ try:
52
+ app()
53
+ except KeyboardInterrupt:
54
+ print("\nOperation cancelled by user.")
55
+ sys.exit(130)
56
+ except Exception as e:
57
+ print(f"Error: {e}", file=sys.stderr)
58
+ sys.exit(1)
59
+
60
+
61
+ if __name__ == "__main__":
62
+ main()
src/openenv/cli/_cli_utils.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """CLI utilities for OpenEnv command-line interface."""
8
+
9
+ from pathlib import Path
10
+ from typing import List
11
+
12
+ from rich.console import Console
13
+
14
+ # Create a console instance for CLI output
15
+ console = Console()
16
+
17
+
18
+ def validate_env_structure(env_dir: Path, strict: bool = False) -> List[str]:
19
+ """
20
+ Validate that the directory follows OpenEnv environment structure.
21
+
22
+ Args:
23
+ env_dir: Path to environment directory
24
+ strict: If True, enforce all optional requirements
25
+
26
+ Returns:
27
+ List of validation warnings (empty if all checks pass)
28
+
29
+ Raises:
30
+ FileNotFoundError: If required files are missing
31
+ """
32
+ warnings = []
33
+
34
+ # Required files
35
+ required_files = [
36
+ "openenv.yaml",
37
+ "__init__.py",
38
+ "client.py",
39
+ "models.py",
40
+ "README.md",
41
+ ]
42
+
43
+ for file in required_files:
44
+ if not (env_dir / file).exists():
45
+ raise FileNotFoundError(f"Required file missing: {file}")
46
+
47
+ # Dockerfile: must exist in server/ or at env root
48
+ has_root_dockerfile = (env_dir / "Dockerfile").exists()
49
+ has_server_dockerfile = (env_dir / "server" / "Dockerfile").exists()
50
+
51
+ if not has_root_dockerfile and not has_server_dockerfile:
52
+ raise FileNotFoundError(
53
+ "Required file missing: server/Dockerfile or Dockerfile at env root"
54
+ )
55
+
56
+ # When no root Dockerfile, require the traditional server/ layout
57
+ if not has_root_dockerfile:
58
+ server_dir = env_dir / "server"
59
+ if not server_dir.exists() or not server_dir.is_dir():
60
+ raise FileNotFoundError("Required directory missing: server/")
61
+
62
+ for file in ["server/__init__.py", "server/app.py"]:
63
+ if not (env_dir / file).exists():
64
+ raise FileNotFoundError(f"Required file missing: {file}")
65
+
66
+ # Check for dependency management (pyproject.toml required)
67
+ has_pyproject = (env_dir / "pyproject.toml").exists()
68
+
69
+ if not has_pyproject:
70
+ raise FileNotFoundError(
71
+ "No dependency specification found. 'pyproject.toml' is required."
72
+ )
73
+
74
+ # Warnings for recommended structure
75
+
76
+ if not (env_dir / "outputs").exists():
77
+ warnings.append("Recommended directory missing: outputs/")
78
+
79
+ return warnings
src/openenv/cli/_validation.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Validation utilities for multi-mode deployment readiness.
9
+
10
+ This module provides functions to check if environments are properly
11
+ configured for multi-mode deployment (Docker, direct Python, notebooks, clusters).
12
+ """
13
+
14
+ import subprocess
15
+ from pathlib import Path
16
+
17
+ try:
18
+ import tomllib
19
+ except ModuleNotFoundError:
20
+ import tomli as tomllib
21
+
22
+
23
+ def validate_multi_mode_deployment(env_path: Path) -> tuple[bool, list[str]]:
24
+ """
25
+ Validate that an environment is ready for multi-mode deployment.
26
+
27
+ Checks:
28
+ 1. pyproject.toml exists
29
+ 2. uv.lock exists and is up-to-date
30
+ 3. pyproject.toml has [project.scripts] with server entry point
31
+ 4. server/app.py has a main() function
32
+ 5. Required dependencies are present
33
+
34
+ Returns:
35
+ Tuple of (is_valid, list of issues found)
36
+ """
37
+ issues = []
38
+
39
+ # Check pyproject.toml exists
40
+ pyproject_path = env_path / "pyproject.toml"
41
+ if not pyproject_path.exists():
42
+ issues.append("Missing pyproject.toml")
43
+ return False, issues
44
+
45
+ # Check uv.lock exists
46
+ lockfile_path = env_path / "uv.lock"
47
+ if not lockfile_path.exists():
48
+ issues.append("Missing uv.lock - run 'uv lock' to generate it")
49
+ else:
50
+ # Check if uv.lock is up-to-date (optional, can be expensive)
51
+ # We can add a check using `uv lock --check` if needed
52
+ try:
53
+ result = subprocess.run(
54
+ ["uv", "lock", "--check", "--directory", str(env_path)],
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=5,
58
+ )
59
+ if result.returncode != 0:
60
+ issues.append(
61
+ "uv.lock is out of date with pyproject.toml - run 'uv lock' to update"
62
+ )
63
+ except (subprocess.TimeoutExpired, FileNotFoundError):
64
+ # If uv is not available or times out, skip this check
65
+ pass
66
+
67
+ # Parse pyproject.toml
68
+ try:
69
+ with open(pyproject_path, "rb") as f:
70
+ pyproject = tomllib.load(f)
71
+ except Exception as e:
72
+ issues.append(f"Failed to parse pyproject.toml: {e}")
73
+ return False, issues
74
+
75
+ # Check [project.scripts] section
76
+ scripts = pyproject.get("project", {}).get("scripts", {})
77
+ if "server" not in scripts:
78
+ issues.append("Missing [project.scripts] server entry point")
79
+
80
+ # Check server entry point format
81
+ server_entry = scripts.get("server", "")
82
+ if server_entry and ":main" not in server_entry:
83
+ issues.append(
84
+ f"Server entry point should reference main function, got: {server_entry}"
85
+ )
86
+
87
+ # Check required dependencies
88
+ deps = [dep.lower() for dep in pyproject.get("project", {}).get("dependencies", [])]
89
+ has_openenv = any(
90
+ dep.startswith("openenv") and not dep.startswith("openenv-core") for dep in deps
91
+ )
92
+ has_legacy_core = any(dep.startswith("openenv-core") for dep in deps)
93
+
94
+ if not (has_openenv or has_legacy_core):
95
+ issues.append(
96
+ "Missing required dependency: openenv-core>=0.2.0 (or openenv>=0.2.0)"
97
+ )
98
+
99
+ # Check server/app.py exists
100
+ server_app = env_path / "server" / "app.py"
101
+ if not server_app.exists():
102
+ issues.append("Missing server/app.py")
103
+ else:
104
+ # Check for main() function (flexible - with or without parameters)
105
+ app_content = server_app.read_text(encoding="utf-8")
106
+ if "def main(" not in app_content:
107
+ issues.append("server/app.py missing main() function")
108
+
109
+ # Check if main() is callable
110
+ if "__name__" not in app_content or "main()" not in app_content:
111
+ issues.append(
112
+ "server/app.py main() function not callable (missing if __name__ == '__main__')"
113
+ )
114
+
115
+ return len(issues) == 0, issues
116
+
117
+
118
+ def get_deployment_modes(env_path: Path) -> dict[str, bool]:
119
+ """
120
+ Check which deployment modes are supported by the environment.
121
+
122
+ Returns:
123
+ Dictionary with deployment mode names and whether they're supported
124
+ """
125
+ modes = {
126
+ "docker": False,
127
+ "openenv_serve": False,
128
+ "uv_run": False,
129
+ "python_module": False,
130
+ }
131
+
132
+ # Check Docker (Dockerfile may be in server/ or at env root)
133
+ modes["docker"] = (env_path / "server" / "Dockerfile").exists() or (
134
+ env_path / "Dockerfile"
135
+ ).exists()
136
+
137
+ # Check multi-mode deployment readiness
138
+ is_valid, _ = validate_multi_mode_deployment(env_path)
139
+ if is_valid:
140
+ modes["openenv_serve"] = True
141
+ modes["uv_run"] = True
142
+ modes["python_module"] = True
143
+
144
+ return modes
145
+
146
+
147
+ def format_validation_report(env_name: str, is_valid: bool, issues: list[str]) -> str:
148
+ """
149
+ Format a validation report for display.
150
+
151
+ Returns:
152
+ Formatted report string
153
+ """
154
+ if is_valid:
155
+ return f"[OK] {env_name}: Ready for multi-mode deployment"
156
+
157
+ report = [f"[FAIL] {env_name}: Not ready for multi-mode deployment", ""]
158
+ report.append("Issues found:")
159
+ for issue in issues:
160
+ report.append(f" - {issue}")
161
+
162
+ return "\n".join(report)
src/openenv/cli/commands/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI commands."""
8
+
9
+ from . import build, fork, init, push, serve, validate
10
+
11
+ __all__ = ["build", "fork", "init", "push", "serve", "validate"]
src/openenv/cli/commands/build.py ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Build Docker images for OpenEnv environments."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ import tempfile
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+
20
+ from .._cli_utils import console
21
+
22
+ app = typer.Typer(help="Build Docker images for OpenEnv environments")
23
+
24
+
25
+ def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]:
26
+ """
27
+ Detect whether we're building a standalone or in-repo environment.
28
+
29
+ Returns:
30
+ tuple: (build_mode, build_context_path, repo_root)
31
+ - build_mode: "standalone" or "in-repo"
32
+ - build_context_path: Path to use as Docker build context
33
+ - repo_root: Path to repo root (None for standalone)
34
+ """
35
+ # Ensure env_path is absolute for proper comparison
36
+ env_path = env_path.absolute()
37
+
38
+ # Check if we're in a git repository
39
+ current = env_path
40
+ repo_root = None
41
+
42
+ # Walk up to find .git directory
43
+ for parent in [current] + list(current.parents):
44
+ if (parent / ".git").exists():
45
+ repo_root = parent
46
+ break
47
+
48
+ if repo_root is None:
49
+ # Not in a git repo = standalone
50
+ return "standalone", env_path, None
51
+
52
+ # Check if environment is under envs/ (in-repo pattern)
53
+ try:
54
+ rel_path = env_path.relative_to(repo_root)
55
+ rel_str = str(rel_path)
56
+ if (
57
+ rel_str.startswith("envs/")
58
+ or rel_str.startswith("envs\\")
59
+ or rel_str.startswith("envs/")
60
+ ):
61
+ # In-repo environment
62
+ return "in-repo", repo_root, repo_root
63
+ except ValueError:
64
+ pass
65
+
66
+ # Otherwise, it's standalone (environment outside repo structure)
67
+ return "standalone", env_path, None
68
+
69
+
70
+ def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path:
71
+ """
72
+ Prepare a standalone environment for building.
73
+
74
+ For standalone builds:
75
+ 1. Copy environment to temp directory
76
+ 2. Ensure pyproject.toml depends on openenv
77
+
78
+ Returns:
79
+ Path to the prepared build directory
80
+ """
81
+ console.print("[cyan]Preparing standalone build...[/cyan]")
82
+
83
+ # Copy environment to temp directory
84
+ build_dir = temp_dir / env_path.name
85
+ shutil.copytree(env_path, build_dir, symlinks=True)
86
+
87
+ console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}")
88
+
89
+ # Check if pyproject.toml has openenv dependency
90
+ pyproject_path = build_dir / "pyproject.toml"
91
+ if pyproject_path.exists():
92
+ with open(pyproject_path, "rb") as f:
93
+ try:
94
+ import tomli
95
+
96
+ pyproject = tomli.load(f)
97
+ deps = pyproject.get("project", {}).get("dependencies", [])
98
+
99
+ # Check if openenv dependency is declared
100
+ has_openenv = any(dep.startswith("openenv") for dep in deps)
101
+
102
+ if not has_openenv:
103
+ console.print(
104
+ "[yellow]Warning:[/yellow] pyproject.toml doesn't list the openenv dependency",
105
+ )
106
+ console.print(
107
+ "[yellow]You may need to add:[/yellow] openenv>=0.2.0",
108
+ )
109
+ except ImportError:
110
+ console.print(
111
+ "[yellow]Warning:[/yellow] tomli not available, skipping dependency check",
112
+ )
113
+
114
+ return build_dir
115
+
116
+
117
+ def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path:
118
+ """
119
+ Prepare an in-repo environment for building.
120
+
121
+ For in-repo builds:
122
+ 1. Create temp directory with environment and core
123
+ 2. Set up structure that matches expected layout
124
+
125
+ Returns:
126
+ Path to the prepared build directory
127
+ """
128
+ console.print("[cyan]Preparing in-repo build...[/cyan]")
129
+
130
+ # Copy environment to temp directory
131
+ build_dir = temp_dir / env_path.name
132
+ shutil.copytree(env_path, build_dir, symlinks=True)
133
+
134
+ # Copy OpenEnv package metadata + sources to temp directory.
135
+ # Keep the src/ layout since pyproject.toml uses package-dir = {"" = "src"}.
136
+ package_src = repo_root / "src" / "openenv"
137
+ package_dest = build_dir / "openenv"
138
+ if package_src.exists():
139
+ package_dest.mkdir(parents=True, exist_ok=True)
140
+ shutil.copytree(package_src, package_dest / "src" / "openenv", symlinks=True)
141
+
142
+ for filename in ("pyproject.toml", "README.md"):
143
+ src_file = repo_root / filename
144
+ if src_file.exists():
145
+ shutil.copy2(src_file, package_dest / filename)
146
+
147
+ console.print(f"[cyan]Copied OpenEnv package to:[/cyan] {package_dest}")
148
+
149
+ # Update pyproject.toml to reference local OpenEnv copy
150
+ pyproject_path = build_dir / "pyproject.toml"
151
+ if pyproject_path.exists():
152
+ with open(pyproject_path, "rb") as f:
153
+ try:
154
+ import tomli
155
+
156
+ pyproject = tomli.load(f)
157
+ deps = pyproject.get("project", {}).get("dependencies", [])
158
+
159
+ # Replace openenv/openenv-core with local reference
160
+ new_deps = []
161
+ for dep in deps:
162
+ if (
163
+ dep.startswith("openenv-core")
164
+ or dep.startswith("openenv_core")
165
+ or dep.startswith("openenv")
166
+ ):
167
+ # Skip - we'll use local core
168
+ continue
169
+ new_deps.append(dep)
170
+
171
+ # Write back with local core reference
172
+ pyproject["project"]["dependencies"] = new_deps + [
173
+ "openenv-core @ file:///app/env/openenv"
174
+ ]
175
+
176
+ # Write updated pyproject.toml
177
+ with open(pyproject_path, "wb") as out_f:
178
+ import tomli_w
179
+
180
+ tomli_w.dump(pyproject, out_f)
181
+
182
+ console.print(
183
+ "[cyan]Updated pyproject.toml to use local core[/cyan]"
184
+ )
185
+
186
+ # Remove old lockfile since dependencies changed
187
+ lockfile = build_dir / "uv.lock"
188
+ if lockfile.exists():
189
+ lockfile.unlink()
190
+ console.print("[cyan]Removed outdated uv.lock[/cyan]")
191
+
192
+ except ImportError:
193
+ console.print(
194
+ "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is",
195
+ )
196
+ else:
197
+ console.print(
198
+ "[yellow]Warning:[/yellow] OpenEnv package not found, building without it"
199
+ )
200
+
201
+ console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}")
202
+ return build_dir
203
+
204
+
205
+ def _run_command(
206
+ cmd: list[str],
207
+ cwd: Path | None = None,
208
+ check: bool = True,
209
+ ) -> subprocess.CompletedProcess:
210
+ """Run a shell command and handle errors."""
211
+ console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}")
212
+ try:
213
+ result = subprocess.run(
214
+ cmd, cwd=cwd, check=check, capture_output=True, text=True
215
+ )
216
+ if result.stdout:
217
+ console.print(result.stdout)
218
+ if result.stderr:
219
+ print(result.stderr, file=sys.stderr)
220
+ return result
221
+ except subprocess.CalledProcessError as e:
222
+ print(f"Error running command: {e}", file=sys.stderr)
223
+ if e.stdout:
224
+ console.print(e.stdout)
225
+ if e.stderr:
226
+ print(e.stderr, file=sys.stderr)
227
+ if check:
228
+ raise typer.Exit(1) from e
229
+ return e
230
+
231
+
232
+ def _build_docker_image(
233
+ env_path: Path,
234
+ tag: str | None = None,
235
+ context_path: Path | None = None,
236
+ dockerfile: Path | None = None,
237
+ build_args: dict[str, str] | None = None,
238
+ no_cache: bool = False,
239
+ ) -> bool:
240
+ """Build Docker image for the environment with smart context detection."""
241
+
242
+ # Detect build context (standalone vs in-repo)
243
+ build_mode, detected_context, repo_root = _detect_build_context(env_path)
244
+
245
+ console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}")
246
+
247
+ # Use detected context unless explicitly overridden
248
+ if context_path is None:
249
+ context_path = detected_context
250
+
251
+ # Create temporary build directory
252
+ with tempfile.TemporaryDirectory() as temp_dir_str:
253
+ temp_dir = Path(temp_dir_str)
254
+
255
+ # Prepare build directory based on mode
256
+ if build_mode == "standalone":
257
+ build_dir = _prepare_standalone_build(env_path, temp_dir)
258
+ else: # in-repo
259
+ build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir)
260
+
261
+ # Determine Dockerfile path
262
+ if dockerfile is None:
263
+ # Look for Dockerfile in server/ subdirectory
264
+ dockerfile = build_dir / "server" / "Dockerfile"
265
+ if not dockerfile.exists():
266
+ # Fallback to root of build directory
267
+ dockerfile = build_dir / "Dockerfile"
268
+
269
+ if not dockerfile.exists():
270
+ console.print(
271
+ f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}",
272
+ )
273
+ return False
274
+
275
+ # Generate tag if not provided
276
+ if tag is None:
277
+ env_name = env_path.name
278
+ if env_name.endswith("_env"):
279
+ env_name = env_name[:-4]
280
+ tag = f"openenv-{env_name}"
281
+
282
+ console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}")
283
+ console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}")
284
+ console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}")
285
+
286
+ # Prepare build args
287
+ if build_args is None:
288
+ build_args = {}
289
+
290
+ # Add build mode and env name to build args
291
+ build_args["BUILD_MODE"] = build_mode
292
+ build_args["ENV_NAME"] = env_path.name.replace("_env", "")
293
+
294
+ # Build Docker command
295
+ cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)]
296
+
297
+ if no_cache:
298
+ cmd.append("--no-cache")
299
+
300
+ for key, value in build_args.items():
301
+ cmd.extend(["--build-arg", f"{key}={value}"])
302
+
303
+ cmd.append(str(build_dir))
304
+
305
+ result = _run_command(cmd, check=False)
306
+ return result.returncode == 0
307
+
308
+
309
+ def _push_docker_image(tag: str, registry: str | None = None) -> bool:
310
+ """Push Docker image to registry."""
311
+ if registry:
312
+ full_tag = f"{registry}/{tag}"
313
+ console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]")
314
+ _run_command(["docker", "tag", tag, full_tag])
315
+ tag = full_tag
316
+
317
+ console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}")
318
+ result = _run_command(["docker", "push", tag], check=False)
319
+ return result.returncode == 0
320
+
321
+
322
+ @app.command()
323
+ def build(
324
+ env_path: Annotated[
325
+ str | None,
326
+ typer.Argument(
327
+ help="Path to the environment directory (default: current directory)"
328
+ ),
329
+ ] = None,
330
+ tag: Annotated[
331
+ str | None,
332
+ typer.Option(
333
+ "--tag",
334
+ "-t",
335
+ help="Docker image tag (default: openenv-<env_name>)",
336
+ ),
337
+ ] = None,
338
+ context: Annotated[
339
+ str | None,
340
+ typer.Option(
341
+ "--context",
342
+ "-c",
343
+ help="Build context path (default: <env_path>/server)",
344
+ ),
345
+ ] = None,
346
+ dockerfile: Annotated[
347
+ str | None,
348
+ typer.Option(
349
+ "--dockerfile",
350
+ "-f",
351
+ help="Path to Dockerfile (default: <context>/Dockerfile)",
352
+ ),
353
+ ] = None,
354
+ no_cache: Annotated[
355
+ bool,
356
+ typer.Option(
357
+ "--no-cache",
358
+ help="Build without using cache",
359
+ ),
360
+ ] = False,
361
+ build_arg: Annotated[
362
+ list[str] | None,
363
+ typer.Option(
364
+ "--build-arg",
365
+ help="Build arguments (can be used multiple times, format: KEY=VALUE)",
366
+ ),
367
+ ] = None,
368
+ ) -> None:
369
+ """
370
+ Build Docker images for OpenEnv environments.
371
+
372
+ This command builds Docker images using the environment's pyproject.toml
373
+ and uv for dependency management. Run from the environment root directory.
374
+
375
+ Examples:
376
+ # Build from environment root (recommended)
377
+ $ cd my_env
378
+ $ openenv build
379
+
380
+ # Build with custom tag
381
+ $ openenv build -t my-custom-tag
382
+
383
+ # Build without cache
384
+ $ openenv build --no-cache
385
+
386
+ # Build with custom build arguments
387
+ $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod
388
+
389
+ # Build from different directory
390
+ $ openenv build envs/echo_env
391
+ """
392
+ # Determine environment path (default to current directory)
393
+ if env_path is None:
394
+ env_path_obj = Path.cwd()
395
+ else:
396
+ env_path_obj = Path(env_path)
397
+
398
+ # Validate environment path
399
+ if not env_path_obj.exists():
400
+ print(
401
+ f"Error: Environment path does not exist: {env_path_obj}",
402
+ file=sys.stderr,
403
+ )
404
+ raise typer.Exit(1)
405
+
406
+ if not env_path_obj.is_dir():
407
+ print(
408
+ f"Error: Environment path is not a directory: {env_path_obj}",
409
+ file=sys.stderr,
410
+ )
411
+ raise typer.Exit(1)
412
+
413
+ # Check for openenv.yaml to confirm this is an environment directory
414
+ openenv_yaml = env_path_obj / "openenv.yaml"
415
+ if not openenv_yaml.exists():
416
+ print(
417
+ f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}",
418
+ file=sys.stderr,
419
+ )
420
+ print(
421
+ "Hint: Run this command from the environment root directory or specify the path",
422
+ file=sys.stderr,
423
+ )
424
+ raise typer.Exit(1)
425
+
426
+ console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}")
427
+ console.print("=" * 60)
428
+
429
+ # Parse build args
430
+ build_args = {}
431
+ if build_arg:
432
+ for arg in build_arg:
433
+ if "=" in arg:
434
+ key, value = arg.split("=", 1)
435
+ build_args[key] = value
436
+ else:
437
+ print(
438
+ f"Warning: Invalid build arg format: {arg}",
439
+ file=sys.stderr,
440
+ )
441
+
442
+ # Convert string paths to Path objects
443
+ context_path_obj = Path(context) if context else None
444
+ dockerfile_path_obj = Path(dockerfile) if dockerfile else None
445
+
446
+ # Build Docker image
447
+ success = _build_docker_image(
448
+ env_path=env_path_obj,
449
+ tag=tag,
450
+ context_path=context_path_obj,
451
+ dockerfile=dockerfile_path_obj,
452
+ build_args=build_args if build_args else None,
453
+ no_cache=no_cache,
454
+ )
455
+
456
+ if not success:
457
+ print("✗ Docker build failed", file=sys.stderr)
458
+ raise typer.Exit(1)
459
+
460
+ console.print("[bold green]✓ Docker build successful[/bold green]")
461
+ console.print("\n[bold green]Done![/bold green]")
src/openenv/cli/commands/fork.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Fork (duplicate) a Hugging Face Space using the Hub API."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated
12
+
13
+ import typer
14
+ from huggingface_hub import HfApi, login, whoami
15
+
16
+ from .._cli_utils import console
17
+
18
+ app = typer.Typer(
19
+ help="Fork (duplicate) an OpenEnv environment on Hugging Face to your account"
20
+ )
21
+
22
+
23
+ def _parse_key_value(s: str) -> tuple[str, str]:
24
+ """Parse KEY=VALUE string. Raises BadParameter if no '='."""
25
+ if "=" not in s:
26
+ raise typer.BadParameter(
27
+ f"Expected KEY=VALUE format, got: {s!r}. "
28
+ "Use --set-env KEY=VALUE or --set-secret KEY=VALUE"
29
+ )
30
+ key, _, value = s.partition("=")
31
+ key = key.strip()
32
+ if not key:
33
+ raise typer.BadParameter(f"Empty key in: {s!r}")
34
+ return key, value.strip()
35
+
36
+
37
+ def _ensure_hf_authenticated() -> str:
38
+ """Ensure user is authenticated with Hugging Face. Returns username."""
39
+ try:
40
+ user_info = whoami()
41
+ if isinstance(user_info, dict):
42
+ username = (
43
+ user_info.get("name")
44
+ or user_info.get("fullname")
45
+ or user_info.get("username")
46
+ )
47
+ else:
48
+ username = (
49
+ getattr(user_info, "name", None)
50
+ or getattr(user_info, "fullname", None)
51
+ or getattr(user_info, "username", None)
52
+ )
53
+ if not username:
54
+ raise ValueError("Could not extract username from whoami response")
55
+ console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
56
+ return username
57
+ except Exception:
58
+ console.print(
59
+ "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"
60
+ )
61
+ try:
62
+ login()
63
+ user_info = whoami()
64
+ if isinstance(user_info, dict):
65
+ username = (
66
+ user_info.get("name")
67
+ or user_info.get("fullname")
68
+ or user_info.get("username")
69
+ )
70
+ else:
71
+ username = (
72
+ getattr(user_info, "name", None)
73
+ or getattr(user_info, "fullname", None)
74
+ or getattr(user_info, "username", None)
75
+ )
76
+ if not username:
77
+ raise ValueError("Could not extract username from whoami response")
78
+ console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
79
+ return username
80
+ except Exception as e:
81
+ raise typer.BadParameter(
82
+ f"Hugging Face authentication failed: {e}. Please run login manually."
83
+ ) from e
84
+
85
+
86
+ @app.command()
87
+ def fork(
88
+ source_space: Annotated[
89
+ str,
90
+ typer.Argument(
91
+ help="Source Space ID in format 'owner/space-name' (e.g. org/my-openenv-space)"
92
+ ),
93
+ ],
94
+ repo_id: Annotated[
95
+ str | None,
96
+ typer.Option(
97
+ "--repo-id",
98
+ "-r",
99
+ help="Target repo ID for the fork (default: created under your account with same name)",
100
+ ),
101
+ ] = None,
102
+ private: Annotated[
103
+ bool,
104
+ typer.Option("--private", help="Create the forked Space as private"),
105
+ ] = False,
106
+ set_env: Annotated[
107
+ list[str],
108
+ typer.Option(
109
+ "--set-env",
110
+ "-e",
111
+ help="Set Space variable (public). Can be repeated. Format: KEY=VALUE",
112
+ ),
113
+ ] = [],
114
+ set_secret: Annotated[
115
+ list[str],
116
+ typer.Option(
117
+ "--set-secret",
118
+ "--secret",
119
+ "-s",
120
+ help="Set Space secret. Can be repeated. Format: KEY=VALUE",
121
+ ),
122
+ ] = [],
123
+ hardware: Annotated[
124
+ str | None,
125
+ typer.Option(
126
+ "--hardware",
127
+ "-H",
128
+ help="Request hardware (e.g. t4-medium, cpu-basic). See Hub docs for options.",
129
+ ),
130
+ ] = None,
131
+ ) -> None:
132
+ """
133
+ Fork (duplicate) a Hugging Face Space to your account using the Hub API.
134
+
135
+ Uses the Hugging Face duplicate_space API. You can set environment variables
136
+ and secrets, and request hardware/storage/sleep time at creation time.
137
+
138
+ Examples:
139
+ $ openenv fork owner/source-space
140
+ $ openenv fork owner/source-space --private
141
+ $ openenv fork owner/source-space --repo-id myuser/my-fork
142
+ $ openenv fork owner/source-space --set-env MODEL_ID=user/model --set-secret HF_TOKEN=hf_xxx
143
+ $ openenv fork owner/source-space --hardware t4-medium
144
+ """
145
+ if "/" not in source_space or source_space.count("/") != 1:
146
+ raise typer.BadParameter(
147
+ f"Invalid source Space ID: {source_space!r}. Expected format: 'owner/space-name'"
148
+ )
149
+
150
+ _ensure_hf_authenticated()
151
+ api = HfApi()
152
+
153
+ # Build kwargs for duplicate_space (only pass what we have)
154
+ dup_kwargs: dict = {
155
+ "from_id": source_space,
156
+ "private": private,
157
+ }
158
+ if set_env:
159
+ dup_kwargs["variables"] = [
160
+ {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_env)
161
+ ]
162
+ if set_secret:
163
+ dup_kwargs["secrets"] = [
164
+ {"key": k, "value": v} for k, v in (_parse_key_value(x) for x in set_secret)
165
+ ]
166
+ # HF API requires hardware when duplicating; default to free cpu-basic
167
+ dup_kwargs["hardware"] = hardware if hardware is not None else "cpu-basic"
168
+ if repo_id is not None:
169
+ if "/" not in repo_id or repo_id.count("/") != 1:
170
+ raise typer.BadParameter(
171
+ f"Invalid --repo-id: {repo_id!r}. Expected format: 'username/repo-name'"
172
+ )
173
+ dup_kwargs["to_id"] = repo_id
174
+
175
+ console.print(f"[bold cyan]Forking Space {source_space}...[/bold cyan]")
176
+ try:
177
+ result = api.duplicate_space(**dup_kwargs)
178
+ except Exception as e:
179
+ console.print(f"[bold red]✗[/bold red] Fork failed: {e}")
180
+ raise typer.Exit(1) from e
181
+
182
+ # result is RepoUrl (str-like) or similar; get repo_id for display
183
+ if hasattr(result, "repo_id"):
184
+ new_repo_id = result.repo_id
185
+ elif isinstance(result, str):
186
+ # URL like https://huggingface.co/spaces/owner/name -> owner/name
187
+ if "/spaces/" in result:
188
+ new_repo_id = result.split("/spaces/")[-1].rstrip("/")
189
+ else:
190
+ new_repo_id = result
191
+ else:
192
+ new_repo_id = getattr(result, "repo_id", str(result))
193
+
194
+ console.print("[bold green]✓[/bold green] Space forked successfully")
195
+ console.print(
196
+ f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{new_repo_id}"
197
+ )
src/openenv/cli/commands/init.py ADDED
@@ -0,0 +1,500 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Initialize a new OpenEnv environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import shutil
7
+ import subprocess
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Annotated, Dict, List, Tuple
11
+
12
+ import typer
13
+
14
+ from .._cli_utils import console
15
+
16
+ app = typer.Typer(help="Initialize a new OpenEnv environment")
17
+
18
+
19
+ def _snake_to_pascal(snake_str: str) -> str:
20
+ """Convert snake_case to PascalCase (e.g., 'my_env' -> 'MyEnv')."""
21
+ return "".join(word.capitalize() for word in snake_str.split("_"))
22
+
23
+
24
+ def _get_env_prefix(env_name: str) -> str:
25
+ """Extract the prefix for class names (e.g., 'my_env' -> 'My', 'test_env' -> 'Test')."""
26
+ # Remove trailing '_env' if present
27
+ if env_name.endswith("_env"):
28
+ base = env_name[:-4] # Remove '_env'
29
+ else:
30
+ base = env_name
31
+
32
+ # If empty or just one part, use the whole thing
33
+ if not base or "_" not in base:
34
+ return base.capitalize() if base else env_name.capitalize()
35
+
36
+ # PascalCase all parts except the last
37
+ parts = base.split("_")
38
+ return "".join(word.capitalize() for word in parts)
39
+
40
+
41
+ def _snake_to_camel(snake_str: str) -> str:
42
+ """Convert snake_case to camelCase (e.g., 'my_env' -> 'myEnv')."""
43
+ parts = snake_str.split("_")
44
+ return parts[0] + "".join(word.capitalize() for word in parts[1:])
45
+
46
+
47
+ def _snake_to_title(snake_str: str) -> str:
48
+ """Convert snake_case to Title Case (e.g., 'my_env' -> 'My Env')."""
49
+ return " ".join(word.capitalize() for word in snake_str.split("_"))
50
+
51
+
52
+ def _validate_env_name(name: str) -> str:
53
+ """Validate environment name (must be valid Python identifier in snake_case)."""
54
+ if not name:
55
+ raise typer.BadParameter("Environment name cannot be empty")
56
+
57
+ # Check if it's a valid Python identifier
58
+ if not name.isidentifier():
59
+ raise typer.BadParameter(
60
+ f"Environment name '{name}' is not a valid Python identifier. Use snake_case (e.g., 'my_env', 'game_env')."
61
+ )
62
+
63
+ # Check if it starts with a number
64
+ if name[0].isdigit():
65
+ raise typer.BadParameter(
66
+ f"Environment name '{name}' cannot start with a number."
67
+ )
68
+
69
+ return name
70
+
71
+
72
+ def _get_random_hf_space_config() -> Dict[str, str]:
73
+ """
74
+ Get random Hugging Face Space configuration values.
75
+
76
+ Returns:
77
+ Dictionary with 'emoji', 'colorFrom', and 'colorTo' keys
78
+ """
79
+ # Valid emojis (emoji-only characters)
80
+ emojis = [
81
+ "🎮",
82
+ "🎯",
83
+ "🚀",
84
+ "🌟",
85
+ "🎨",
86
+ "🎪",
87
+ "🎭",
88
+ "🎬",
89
+ "🎤",
90
+ "🎧",
91
+ "🎵",
92
+ "🎶",
93
+ "🎸",
94
+ "🎹",
95
+ "🥁",
96
+ "🎺",
97
+ "🎻",
98
+ "🎼",
99
+ "🎯",
100
+ "🎲",
101
+ "🎳",
102
+ "🎰",
103
+ "🎴",
104
+ "🃏",
105
+ "🀄",
106
+ "🎴",
107
+ "🎨",
108
+ "🖼️",
109
+ "🎬",
110
+ "🎭",
111
+ "🎪",
112
+ "🎤",
113
+ "🎧",
114
+ "🎵",
115
+ "🎶",
116
+ "🎸",
117
+ "🎹",
118
+ "🎺",
119
+ "🎻",
120
+ "🥁",
121
+ "🎯",
122
+ "🎲",
123
+ "🎳",
124
+ "🎰",
125
+ "🏀",
126
+ "⚽",
127
+ "🏈",
128
+ "⚾",
129
+ "🎾",
130
+ "🏐",
131
+ "🏉",
132
+ "🎱",
133
+ "🏓",
134
+ "🏸",
135
+ "🥅",
136
+ "🏒",
137
+ "🏑",
138
+ "🏏",
139
+ "⛳",
140
+ "🏹",
141
+ "🎣",
142
+ "🥊",
143
+ "🥋",
144
+ "🎽",
145
+ "🏅",
146
+ "🎖️",
147
+ "🏆",
148
+ "🥇",
149
+ "🥈",
150
+ "🥉",
151
+ "🔊",
152
+ "🔉",
153
+ "🔈",
154
+ "🔇",
155
+ "📢",
156
+ "📣",
157
+ "📯",
158
+ "🔔",
159
+ "🔕",
160
+ "📻",
161
+ "📡",
162
+ "💻",
163
+ "🖥️",
164
+ "🖨️",
165
+ "⌨️",
166
+ "🖱️",
167
+ "🖲️",
168
+ "🕹️",
169
+ "🗜️",
170
+ "💾",
171
+ "💿",
172
+ "📀",
173
+ "📼",
174
+ "📷",
175
+ "📸",
176
+ "📹",
177
+ "🎥",
178
+ "📽️",
179
+ "🎞️",
180
+ "📞",
181
+ "☎️",
182
+ "📟",
183
+ "📠",
184
+ "📺",
185
+ "📻",
186
+ "🎙️",
187
+ "🎚️",
188
+ "🎛️",
189
+ "⏱️",
190
+ "⏲️",
191
+ "⏰",
192
+ "🕰️",
193
+ "⌚",
194
+ "📱",
195
+ "📲",
196
+ "💻",
197
+ "⌨️",
198
+ "🖥️",
199
+ "🖨️",
200
+ "🖱️",
201
+ ]
202
+
203
+ # Valid colors from HF Spaces config reference
204
+ colors = ["red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"]
205
+
206
+ return {
207
+ "emoji": random.choice(emojis),
208
+ "colorFrom": random.choice(colors),
209
+ "colorTo": random.choice(colors),
210
+ }
211
+
212
+
213
+ def _create_template_replacements(env_name: str) -> Dict[str, str]:
214
+ """
215
+ Create comprehensive template replacement dictionary.
216
+
217
+ Supports all naming conventions:
218
+ - PascalCase for class names
219
+ - camelCase for variable names
220
+ - snake_case for module names, file paths
221
+ """
222
+ env_prefix = _get_env_prefix(env_name)
223
+ env_camel = _snake_to_camel(env_name)
224
+ env_title = _snake_to_title(env_name)
225
+
226
+ # Get random HF Space config values
227
+ hf_config = _get_random_hf_space_config()
228
+
229
+ replacements = {
230
+ # Template placeholders (MUST come first - full class names before partial)
231
+ "__ENV_CLASS_NAME__Environment": f"{env_prefix}Environment",
232
+ "__ENV_CLASS_NAME__Action": f"{env_prefix}Action",
233
+ "__ENV_CLASS_NAME__Observation": f"{env_prefix}Observation",
234
+ "__ENV_CLASS_NAME__Env": f"{env_prefix}Env",
235
+ # Template placeholders (partial - must come after full replacements)
236
+ "__ENV_NAME__": env_name,
237
+ "__ENV_CLASS_NAME__": env_prefix, # Use prefix, not full PascalCase
238
+ "__ENV_TITLE_NAME__": env_title,
239
+ "__ENV_CAMEL_NAME__": env_camel,
240
+ # Hugging Face Space config placeholders
241
+ "__HF_EMOJI__": hf_config["emoji"],
242
+ "__HF_COLOR_FROM__": hf_config["colorFrom"],
243
+ "__HF_COLOR_TO__": hf_config["colorTo"],
244
+ }
245
+
246
+ return replacements
247
+
248
+
249
+ def _replace_in_content(content: str, replacements: Dict[str, str]) -> str:
250
+ """Replace all occurrences in content using case-sensitive replacements."""
251
+ result = content
252
+ # Sort by length (longest first) to avoid partial replacements
253
+ for old, new in sorted(replacements.items(), key=lambda x: len(x[0]), reverse=True):
254
+ result = result.replace(old, new)
255
+ return result
256
+
257
+
258
+ def _should_rename_file(filename: str, env_name: str) -> Tuple[bool, str]:
259
+ """
260
+ Check if a file should be renamed and return the new name.
261
+
262
+ Handles template placeholders in filenames like:
263
+ - `__ENV_NAME___environment.py` → `<env_name>_environment.py`
264
+ """
265
+ # Check for template placeholder
266
+ if "__ENV_NAME__" in filename:
267
+ new_name = filename.replace("__ENV_NAME__", env_name)
268
+ return True, new_name
269
+
270
+ return False, filename
271
+
272
+
273
+ def _copy_and_template_file(
274
+ src_path: Path,
275
+ dest_path: Path,
276
+ replacements: Dict[str, str],
277
+ ) -> None:
278
+ """Copy a file and apply template replacements."""
279
+ dest_path.parent.mkdir(parents=True, exist_ok=True)
280
+
281
+ try:
282
+ # Read source file
283
+ content = src_path.read_bytes()
284
+
285
+ # Try to decode as text and apply replacements
286
+ try:
287
+ text = content.decode("utf-8")
288
+ # Normalize line endings to LF before applying replacements
289
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
290
+ text = _replace_in_content(text, replacements)
291
+ dest_path.write_text(text, encoding="utf-8", newline="\n")
292
+ except UnicodeDecodeError:
293
+ # Binary file, just copy
294
+ dest_path.write_bytes(content)
295
+ except Exception as e:
296
+ raise RuntimeError(
297
+ f"Failed to copy template file {src_path} to {dest_path}: {e}"
298
+ ) from e
299
+
300
+
301
+ def _copy_template_directory(
302
+ template_pkg: str,
303
+ template_dir: str,
304
+ dest_dir: Path,
305
+ replacements: Dict[str, str],
306
+ env_name: str,
307
+ ) -> List[Path]:
308
+ """Recursively copy template directory and apply replacements."""
309
+ created_files: List[Path] = []
310
+
311
+ # Get the package path using importlib.resources but avoid importing the template package
312
+ # We'll use the package's __file__ to get the directory path
313
+ import importlib
314
+
315
+ try:
316
+ # Import the parent package (not the template package itself)
317
+ if "." in template_pkg:
318
+ parent_pkg = ".".join(template_pkg.split(".")[:-1])
319
+ pkg = importlib.import_module(parent_pkg)
320
+ template_path = Path(pkg.__file__).parent / template_pkg.split(".")[-1]
321
+ else:
322
+ pkg = importlib.import_module(template_pkg.split(".")[0])
323
+ template_path = Path(pkg.__file__).parent / template_pkg.split(".")[-1]
324
+ except Exception:
325
+ # Fallback: try to use resources.files but handle import errors
326
+ try:
327
+ base = resources.files(template_pkg.split(".")[0])
328
+ template_path = base.joinpath(*template_pkg.split(".")[1:])
329
+ if not template_path.exists():
330
+ raise FileNotFoundError(f"Template directory not found: {template_pkg}")
331
+ except Exception as e:
332
+ raise FileNotFoundError(
333
+ f"Template directory not found: {template_pkg}"
334
+ ) from e
335
+
336
+ if template_dir:
337
+ template_path = template_path / template_dir
338
+
339
+ if not template_path.exists() or not template_path.is_dir():
340
+ raise FileNotFoundError(
341
+ f"Template directory not found: {template_pkg}.{template_dir}"
342
+ )
343
+
344
+ # Walk through all files in template directory using Path
345
+ for item in template_path.rglob("*"):
346
+ if item.is_file():
347
+ rel_path = item.relative_to(template_path)
348
+ dest_path = dest_dir / rel_path
349
+
350
+ # Apply filename templating
351
+ should_rename, new_name = _should_rename_file(dest_path.name, env_name)
352
+ if should_rename:
353
+ dest_path = dest_path.parent / new_name
354
+
355
+ # Copy and apply replacements
356
+ _copy_and_template_file(item, dest_path, replacements)
357
+ created_files.append(dest_path)
358
+
359
+ return created_files
360
+
361
+
362
+ def _generate_uv_lock(env_dir: Path) -> bool:
363
+ """Generate uv.lock from pyproject.toml using uv."""
364
+ pyproject_path = env_dir / "pyproject.toml"
365
+
366
+ if not pyproject_path.exists():
367
+ return False
368
+
369
+ try:
370
+ cmd = [
371
+ "uv",
372
+ "lock",
373
+ "--directory",
374
+ str(env_dir),
375
+ ]
376
+
377
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
378
+
379
+ if result.stdout:
380
+ console.print(result.stdout)
381
+
382
+ return True
383
+
384
+ except subprocess.CalledProcessError as e:
385
+ console.print(
386
+ f"[yellow]Warning: Could not generate uv.lock: {e.stderr}[/yellow]"
387
+ )
388
+ return False
389
+ except FileNotFoundError:
390
+ console.print(
391
+ "[yellow]Warning: 'uv' not found. Install it to generate uv.lock[/yellow]"
392
+ )
393
+ return False
394
+
395
+
396
+ @app.command()
397
+ def init(
398
+ env_name: Annotated[
399
+ str,
400
+ typer.Argument(
401
+ help="Name of the environment to create (snake_case, e.g., 'my_env')"
402
+ ),
403
+ ],
404
+ output_dir: Annotated[
405
+ str | None,
406
+ typer.Option(
407
+ "--output-dir",
408
+ "-o",
409
+ help="Output directory (defaults to current working directory)",
410
+ ),
411
+ ] = None,
412
+ ) -> None:
413
+ """
414
+ Initialize a new OpenEnv environment.
415
+
416
+ Creates a new directory with the environment name and generates all necessary
417
+ files based on the OpenEnv template structure.
418
+
419
+ Example:
420
+ $ openenv init my_game_env
421
+ $ openenv init my_env --output-dir /path/to/projects
422
+ """
423
+ # Validate environment name
424
+ env_name = _validate_env_name(env_name)
425
+
426
+ # Determine output directory
427
+ base_dir = Path(output_dir).resolve() if output_dir else Path.cwd().resolve()
428
+ env_dir = base_dir / env_name
429
+
430
+ # Check if directory already exists
431
+ if env_dir.exists():
432
+ if env_dir.is_file():
433
+ raise typer.BadParameter(f"Path '{env_dir}' exists and is a file")
434
+ if any(env_dir.iterdir()):
435
+ raise typer.BadParameter(
436
+ f"Directory '{env_dir}' already exists and is not empty. "
437
+ "Please choose a different name or remove the existing directory."
438
+ )
439
+
440
+ try:
441
+ # Create template replacements
442
+ replacements = _create_template_replacements(env_name)
443
+
444
+ # Create environment directory
445
+ env_dir.mkdir(parents=True, exist_ok=True)
446
+
447
+ console.print(
448
+ f"[bold cyan]Creating OpenEnv environment '{env_name}'...[/bold cyan]"
449
+ )
450
+
451
+ # Copy template files from template structure
452
+ template_pkg = "openenv.cli.templates.openenv_env"
453
+ created_files = _copy_template_directory(
454
+ template_pkg,
455
+ "",
456
+ env_dir,
457
+ replacements,
458
+ env_name,
459
+ )
460
+
461
+ console.print(f"[bold green]✓[/bold green] Created {len(created_files)} files")
462
+
463
+ # Generate uv.lock
464
+ console.print("\n[bold]Generating uv.lock...[/bold]")
465
+ if _generate_uv_lock(env_dir):
466
+ console.print("[green]✓[/green] Generated uv.lock")
467
+ else:
468
+ console.print("[yellow]⚠[/yellow] Could not generate uv.lock automatically")
469
+ console.print(" You can generate it manually with:")
470
+ console.print(f" cd {env_dir} && uv lock")
471
+
472
+ console.print(
473
+ f"\n[bold green]Environment created successfully at: {env_dir}[/bold green]"
474
+ )
475
+ console.print("\n[bold]Next steps:[/bold]")
476
+ console.print(f" cd {env_dir}")
477
+ console.print(
478
+ f" # Edit your environment implementation in server/{env_name}_environment.py"
479
+ )
480
+ console.print(" # Edit your models in models.py")
481
+ console.print(" # Install dependencies: uv sync")
482
+ console.print("\n # To integrate into OpenEnv repo:")
483
+ console.print(f" # 1. Copy this directory to <repo_root>/envs/{env_name}_env")
484
+ console.print(
485
+ f" # 2. Build from repo root: docker build -t {env_name}_env:latest -f envs/{env_name}_env/server/Dockerfile ."
486
+ )
487
+ console.print(
488
+ f" # 3. Run your image: docker run -p 8000:8000 {env_name}_env:latest"
489
+ )
490
+
491
+ except Exception as e:
492
+ # Cleanup on error
493
+ if env_dir.exists() and env_dir.is_dir():
494
+ try:
495
+ shutil.rmtree(env_dir)
496
+ except Exception:
497
+ pass
498
+
499
+ console.print(f"[bold red]Error:[/bold red] {e}")
500
+ raise typer.Exit(1) from e
src/openenv/cli/commands/push.py ADDED
@@ -0,0 +1,718 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Push an OpenEnv environment to Hugging Face Spaces."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import sys
13
+ import tempfile
14
+ from fnmatch import fnmatch
15
+ from pathlib import Path
16
+ from typing import Annotated
17
+
18
+ import typer
19
+ import yaml
20
+ from huggingface_hub import HfApi, login, whoami
21
+
22
+ from .._cli_utils import console, validate_env_structure
23
+
24
+ app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces")
25
+
26
+
27
+ DEFAULT_PUSH_IGNORE_PATTERNS = [".*", "__pycache__", "*.pyc"]
28
+
29
+
30
+ def _path_matches_pattern(relative_path: Path, pattern: str) -> bool:
31
+ """Return True if a relative path matches an exclude pattern."""
32
+ normalized_pattern = pattern.strip()
33
+ if normalized_pattern.startswith("!"):
34
+ return False
35
+
36
+ while normalized_pattern.startswith("./"):
37
+ normalized_pattern = normalized_pattern[2:]
38
+
39
+ if normalized_pattern.startswith("/"):
40
+ normalized_pattern = normalized_pattern[1:]
41
+
42
+ if not normalized_pattern:
43
+ return False
44
+
45
+ posix_path = relative_path.as_posix()
46
+ pattern_candidates = [normalized_pattern]
47
+ if normalized_pattern.startswith("**/"):
48
+ # Gitignore-style "**/" can also match directly at the root.
49
+ pattern_candidates.append(normalized_pattern[3:])
50
+
51
+ # Support directory patterns such as "artifacts/" and "**/outputs/".
52
+ if normalized_pattern.endswith("/"):
53
+ dir_pattern_candidates: list[str] = []
54
+ for candidate in pattern_candidates:
55
+ base = candidate.rstrip("/")
56
+ if not base:
57
+ continue
58
+ dir_pattern_candidates.extend([base, f"{base}/*"])
59
+
60
+ return any(
61
+ fnmatch(posix_path, candidate) for candidate in dir_pattern_candidates
62
+ )
63
+
64
+ # Match both full relative path and basename for convenience.
65
+ return any(
66
+ fnmatch(posix_path, candidate) for candidate in pattern_candidates
67
+ ) or any(fnmatch(relative_path.name, candidate) for candidate in pattern_candidates)
68
+
69
+
70
+ def _should_exclude_path(relative_path: Path, ignore_patterns: list[str]) -> bool:
71
+ """Return True when the path should be excluded from staging/upload."""
72
+ return any(
73
+ _path_matches_pattern(relative_path, pattern) for pattern in ignore_patterns
74
+ )
75
+
76
+
77
+ def _read_ignore_file(ignore_path: Path) -> tuple[list[str], int]:
78
+ """Read ignore patterns from a file and return (patterns, ignored_negations)."""
79
+ patterns: list[str] = []
80
+ ignored_negations = 0
81
+
82
+ for line in ignore_path.read_text().splitlines():
83
+ stripped = line.strip()
84
+ if not stripped or stripped.startswith("#"):
85
+ continue
86
+ if stripped.startswith("!"):
87
+ ignored_negations += 1
88
+ continue
89
+ patterns.append(stripped)
90
+
91
+ return patterns, ignored_negations
92
+
93
+
94
+ def _load_ignore_patterns(env_dir: Path, exclude_file: str | None) -> list[str]:
95
+ """Load ignore patterns from defaults and an optional ignore file."""
96
+ patterns = list(DEFAULT_PUSH_IGNORE_PATTERNS)
97
+ ignored_negations = 0
98
+
99
+ def _merge_ignore_file(ignore_path: Path, *, source_label: str) -> None:
100
+ nonlocal ignored_negations
101
+ file_patterns, skipped_negations = _read_ignore_file(ignore_path)
102
+ patterns.extend(file_patterns)
103
+ ignored_negations += skipped_negations
104
+ console.print(
105
+ f"[bold green]✓[/bold green] Loaded {len(file_patterns)} ignore patterns from {source_label}: {ignore_path}"
106
+ )
107
+
108
+ # Optional source: explicit exclude file from CLI.
109
+ if exclude_file:
110
+ ignore_path = Path(exclude_file)
111
+ if not ignore_path.is_absolute():
112
+ ignore_path = env_dir / ignore_path
113
+ ignore_path = ignore_path.resolve()
114
+
115
+ if not ignore_path.exists() or not ignore_path.is_file():
116
+ raise typer.BadParameter(
117
+ f"Exclude file not found or not a file: {ignore_path}"
118
+ )
119
+
120
+ _merge_ignore_file(ignore_path, source_label="--exclude")
121
+
122
+ # Keep stable order while removing duplicates.
123
+ patterns = list(dict.fromkeys(patterns))
124
+
125
+ if ignored_negations > 0:
126
+ console.print(
127
+ f"[bold yellow]⚠[/bold yellow] Skipped {ignored_negations} negated ignore patterns ('!') because negation is not supported for push excludes"
128
+ )
129
+
130
+ return patterns
131
+
132
+
133
+ def _copytree_ignore_factory(env_dir: Path, ignore_patterns: list[str]):
134
+ """Build a shutil.copytree ignore callback from path-based patterns."""
135
+
136
+ def _ignore(path: str, names: list[str]) -> set[str]:
137
+ current_dir = Path(path)
138
+ ignored: set[str] = set()
139
+
140
+ for name in names:
141
+ candidate = current_dir / name
142
+ try:
143
+ relative_path = candidate.relative_to(env_dir)
144
+ except ValueError:
145
+ # candidate is not under env_dir (e.g. symlink or
146
+ # copytree root differs from env_dir); skip filtering.
147
+ continue
148
+ if _should_exclude_path(relative_path, ignore_patterns):
149
+ ignored.add(name)
150
+
151
+ return ignored
152
+
153
+ return _ignore
154
+
155
+
156
+ def _validate_openenv_directory(directory: Path) -> tuple[str, dict]:
157
+ """
158
+ Validate that the directory is an OpenEnv environment.
159
+
160
+ Returns:
161
+ Tuple of (env_name, manifest_data)
162
+ """
163
+ # Use the comprehensive validation function
164
+ try:
165
+ warnings = validate_env_structure(directory)
166
+ for warning in warnings:
167
+ console.print(f"[bold yellow]⚠[/bold yellow] {warning}")
168
+ except FileNotFoundError as e:
169
+ raise typer.BadParameter(f"Invalid OpenEnv environment structure: {e}") from e
170
+
171
+ # Load and validate manifest
172
+ manifest_path = directory / "openenv.yaml"
173
+ try:
174
+ with open(manifest_path, "r") as f:
175
+ manifest = yaml.safe_load(f)
176
+ except Exception as e:
177
+ raise typer.BadParameter(f"Failed to parse openenv.yaml: {e}") from e
178
+
179
+ if not isinstance(manifest, dict):
180
+ raise typer.BadParameter("openenv.yaml must be a YAML dictionary")
181
+
182
+ env_name = manifest.get("name")
183
+ if not env_name:
184
+ raise typer.BadParameter("openenv.yaml must contain a 'name' field")
185
+
186
+ return env_name, manifest
187
+
188
+
189
+ def _ensure_hf_authenticated() -> str:
190
+ """
191
+ Ensure user is authenticated with Hugging Face.
192
+
193
+ Returns:
194
+ Username of authenticated user
195
+ """
196
+ try:
197
+ # Try to get current user
198
+ user_info = whoami()
199
+ # Handle both dict and object return types
200
+ if isinstance(user_info, dict):
201
+ username = (
202
+ user_info.get("name")
203
+ or user_info.get("fullname")
204
+ or user_info.get("username")
205
+ )
206
+ else:
207
+ # If it's an object, try to get name attribute
208
+ username = (
209
+ getattr(user_info, "name", None)
210
+ or getattr(user_info, "fullname", None)
211
+ or getattr(user_info, "username", None)
212
+ )
213
+
214
+ if not username:
215
+ raise ValueError("Could not extract username from whoami response")
216
+
217
+ console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
218
+ return username
219
+ except Exception:
220
+ # Not authenticated, prompt for login
221
+ console.print(
222
+ "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]"
223
+ )
224
+
225
+ try:
226
+ login()
227
+ # Verify login worked
228
+ user_info = whoami()
229
+ # Handle both dict and object return types
230
+ if isinstance(user_info, dict):
231
+ username = (
232
+ user_info.get("name")
233
+ or user_info.get("fullname")
234
+ or user_info.get("username")
235
+ )
236
+ else:
237
+ username = (
238
+ getattr(user_info, "name", None)
239
+ or getattr(user_info, "fullname", None)
240
+ or getattr(user_info, "username", None)
241
+ )
242
+
243
+ if not username:
244
+ raise ValueError("Could not extract username from whoami response")
245
+
246
+ console.print(f"[bold green]✓[/bold green] Authenticated as: {username}")
247
+ return username
248
+ except Exception as e:
249
+ raise typer.BadParameter(
250
+ f"Hugging Face authentication failed: {e}. Please run login manually."
251
+ ) from e
252
+
253
+
254
+ def _prepare_staging_directory(
255
+ env_dir: Path,
256
+ env_name: str,
257
+ staging_dir: Path,
258
+ ignore_patterns: list[str],
259
+ base_image: str | None = None,
260
+ enable_interface: bool = True,
261
+ ) -> None:
262
+ """
263
+ Prepare files for deployment.
264
+
265
+ This includes:
266
+ - Copying necessary files
267
+ - Modifying Dockerfile to optionally enable web interface and update base image
268
+ - Ensuring README has proper HF frontmatter (if interface enabled)
269
+ """
270
+ # Create staging directory structure
271
+ staging_dir.mkdir(parents=True, exist_ok=True)
272
+
273
+ # Copy all files from env directory
274
+ copy_ignore = _copytree_ignore_factory(env_dir, ignore_patterns)
275
+ for item in env_dir.iterdir():
276
+ relative_path = item.relative_to(env_dir)
277
+ if _should_exclude_path(relative_path, ignore_patterns):
278
+ continue
279
+
280
+ dest = staging_dir / item.name
281
+ if item.is_dir():
282
+ shutil.copytree(item, dest, dirs_exist_ok=True, ignore=copy_ignore)
283
+ else:
284
+ shutil.copy2(item, dest)
285
+
286
+ # Dockerfile must be at repo root for Hugging Face. Prefer root if present
287
+ # (it was copied there); otherwise move server/Dockerfile to root.
288
+ dockerfile_server_path = staging_dir / "server" / "Dockerfile"
289
+ dockerfile_root_path = staging_dir / "Dockerfile"
290
+ dockerfile_path: Path | None = None
291
+
292
+ if dockerfile_root_path.exists():
293
+ dockerfile_path = dockerfile_root_path
294
+ elif dockerfile_server_path.exists():
295
+ dockerfile_server_path.rename(dockerfile_root_path)
296
+ console.print(
297
+ "[bold cyan]Moved Dockerfile to repository root for deployment[/bold cyan]"
298
+ )
299
+ dockerfile_path = dockerfile_root_path
300
+
301
+ # Modify Dockerfile to optionally enable web interface and update base image
302
+ if dockerfile_path and dockerfile_path.exists():
303
+ dockerfile_content = dockerfile_path.read_text()
304
+ lines = dockerfile_content.split("\n")
305
+ new_lines = []
306
+ cmd_found = False
307
+ base_image_updated = False
308
+ web_interface_env_exists = "ENABLE_WEB_INTERFACE" in dockerfile_content
309
+ last_instruction = None
310
+
311
+ for line in lines:
312
+ stripped = line.strip()
313
+ token = stripped.split(maxsplit=1)[0] if stripped else ""
314
+ current_instruction = token.upper()
315
+
316
+ is_healthcheck_continuation = last_instruction == "HEALTHCHECK"
317
+
318
+ # Update base image if specified
319
+ if base_image and stripped.startswith("FROM") and not base_image_updated:
320
+ new_lines.append(f"FROM {base_image}")
321
+ base_image_updated = True
322
+ last_instruction = "FROM"
323
+ continue
324
+
325
+ if (
326
+ stripped.startswith("CMD")
327
+ and not cmd_found
328
+ and not web_interface_env_exists
329
+ and enable_interface
330
+ and not is_healthcheck_continuation
331
+ ):
332
+ new_lines.append("ENV ENABLE_WEB_INTERFACE=true")
333
+ cmd_found = True
334
+
335
+ new_lines.append(line)
336
+
337
+ if current_instruction:
338
+ last_instruction = current_instruction
339
+
340
+ if not cmd_found and not web_interface_env_exists and enable_interface:
341
+ new_lines.append("ENV ENABLE_WEB_INTERFACE=true")
342
+
343
+ if base_image and not base_image_updated:
344
+ new_lines.insert(0, f"FROM {base_image}")
345
+
346
+ dockerfile_path.write_text("\n".join(new_lines))
347
+
348
+ changes = []
349
+ if base_image and base_image_updated:
350
+ changes.append("updated base image")
351
+ if enable_interface and not web_interface_env_exists:
352
+ changes.append("enabled web interface")
353
+ if changes:
354
+ console.print(
355
+ f"[bold green]✓[/bold green] Updated Dockerfile: {', '.join(changes)}"
356
+ )
357
+ else:
358
+ console.print(
359
+ "[bold yellow]⚠[/bold yellow] No Dockerfile at server/ or repo root"
360
+ )
361
+
362
+ # Ensure README has proper HF frontmatter (only if interface enabled)
363
+ if enable_interface:
364
+ readme_path = staging_dir / "README.md"
365
+ if readme_path.exists():
366
+ readme_content = readme_path.read_text()
367
+ if "base_path: /web" not in readme_content:
368
+ # Check if frontmatter exists
369
+ if readme_content.startswith("---"):
370
+ # Add base_path to existing frontmatter
371
+ lines = readme_content.split("\n")
372
+ new_lines = []
373
+ _in_frontmatter = True
374
+ for i, line in enumerate(lines):
375
+ new_lines.append(line)
376
+ if line.strip() == "---" and i > 0:
377
+ # End of frontmatter, add base_path before this line
378
+ if "base_path:" not in "\n".join(new_lines):
379
+ new_lines.insert(-1, "base_path: /web")
380
+ _in_frontmatter = False
381
+ readme_path.write_text("\n".join(new_lines))
382
+ else:
383
+ # No frontmatter, add it
384
+ frontmatter = f"""---
385
+ title: {env_name.replace("_", " ").title()} Environment Server
386
+ emoji: 🔊
387
+ colorFrom: '#00C9FF'
388
+ colorTo: '#1B2845'
389
+ sdk: docker
390
+ pinned: false
391
+ app_port: 8000
392
+ base_path: /web
393
+ tags:
394
+ - openenv
395
+ ---
396
+
397
+ """
398
+ readme_path.write_text(frontmatter + readme_content)
399
+ console.print(
400
+ "[bold green]✓[/bold green] Updated README with HF Space frontmatter"
401
+ )
402
+ else:
403
+ console.print("[bold yellow]⚠[/bold yellow] No README.md found")
404
+
405
+
406
+ def _create_hf_space(
407
+ repo_id: str,
408
+ api: HfApi,
409
+ private: bool = False,
410
+ ) -> None:
411
+ """Create a Hugging Face Space if it doesn't exist."""
412
+ console.print(f"[bold cyan]Creating/verifying space: {repo_id}[/bold cyan]")
413
+
414
+ try:
415
+ api.create_repo(
416
+ repo_id=repo_id,
417
+ repo_type="space",
418
+ space_sdk="docker",
419
+ private=private,
420
+ exist_ok=True,
421
+ )
422
+ console.print(f"[bold green]✓[/bold green] Space {repo_id} is ready")
423
+ except Exception as e:
424
+ # Space might already exist, which is okay with exist_ok=True
425
+ # But if there's another error, log it
426
+ console.print(f"[bold yellow]⚠[/bold yellow] Space creation: {e}")
427
+
428
+
429
+ def _upload_to_hf_space(
430
+ repo_id: str,
431
+ staging_dir: Path,
432
+ api: HfApi,
433
+ ignore_patterns: list[str],
434
+ private: bool = False,
435
+ create_pr: bool = False,
436
+ commit_message: str | None = None,
437
+ ) -> None:
438
+ """Upload files to Hugging Face Space."""
439
+ if create_pr:
440
+ console.print(
441
+ f"[bold cyan]Uploading files to {repo_id} (will open a Pull Request)...[/bold cyan]"
442
+ )
443
+ else:
444
+ console.print(f"[bold cyan]Uploading files to {repo_id}...[/bold cyan]")
445
+
446
+ upload_kwargs: dict = {
447
+ "folder_path": str(staging_dir),
448
+ "repo_id": repo_id,
449
+ "repo_type": "space",
450
+ "create_pr": create_pr,
451
+ "ignore_patterns": ignore_patterns,
452
+ }
453
+ if commit_message:
454
+ upload_kwargs["commit_message"] = commit_message
455
+
456
+ try:
457
+ result = api.upload_folder(**upload_kwargs)
458
+ console.print("[bold green]✓[/bold green] Upload completed successfully")
459
+ if create_pr and result is not None and hasattr(result, "pr_url"):
460
+ console.print(f"[bold]Pull request:[/bold] {result.pr_url}")
461
+ console.print(
462
+ f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{repo_id}"
463
+ )
464
+ except Exception as e:
465
+ console.print(f"[bold red]✗[/bold red] Upload failed: {e}")
466
+ raise typer.Exit(1) from e
467
+
468
+
469
+ @app.command()
470
+ def push(
471
+ directory: Annotated[
472
+ str | None,
473
+ typer.Argument(
474
+ help="Directory containing the OpenEnv environment (default: current directory)"
475
+ ),
476
+ ] = None,
477
+ repo_id: Annotated[
478
+ str | None,
479
+ typer.Option(
480
+ "--repo-id",
481
+ "-r",
482
+ help="Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)",
483
+ ),
484
+ ] = None,
485
+ base_image: Annotated[
486
+ str | None,
487
+ typer.Option(
488
+ "--base-image",
489
+ "-b",
490
+ help="Base Docker image to use (overrides Dockerfile FROM)",
491
+ ),
492
+ ] = None,
493
+ interface: Annotated[
494
+ bool,
495
+ typer.Option(
496
+ "--interface",
497
+ help="Enable web interface (default: True if no registry specified)",
498
+ ),
499
+ ] = None,
500
+ no_interface: Annotated[
501
+ bool,
502
+ typer.Option(
503
+ "--no-interface",
504
+ help="Disable web interface",
505
+ ),
506
+ ] = False,
507
+ registry: Annotated[
508
+ str | None,
509
+ typer.Option(
510
+ "--registry",
511
+ help="Custom registry URL (e.g., docker.io/username). Disables web interface by default.",
512
+ ),
513
+ ] = None,
514
+ private: Annotated[
515
+ bool,
516
+ typer.Option(
517
+ "--private",
518
+ help="Deploy the space as private",
519
+ ),
520
+ ] = False,
521
+ create_pr: Annotated[
522
+ bool,
523
+ typer.Option(
524
+ "--create-pr",
525
+ help="Create a Pull Request instead of pushing to the default branch",
526
+ ),
527
+ ] = False,
528
+ exclude: Annotated[
529
+ str | None,
530
+ typer.Option(
531
+ "--exclude",
532
+ help="Optional additional ignore file with newline-separated glob patterns to exclude from Hugging Face uploads",
533
+ ),
534
+ ] = None,
535
+ ) -> None:
536
+ """
537
+ Push an OpenEnv environment to Hugging Face Spaces or a custom Docker registry.
538
+
539
+ This command:
540
+ 1. Validates that the directory is an OpenEnv environment (openenv.yaml present)
541
+ 2. Builds and pushes to Hugging Face Spaces or custom Docker registry
542
+ 3. Optionally enables web interface for deployment
543
+
544
+ The web interface is enabled by default when pushing to HuggingFace Spaces,
545
+ but disabled by default when pushing to a custom Docker registry.
546
+
547
+ Examples:
548
+ # Push to HuggingFace Spaces from current directory (web interface enabled)
549
+ $ cd my_env
550
+ $ openenv push
551
+
552
+ # Push to HuggingFace repo and open a Pull Request
553
+ $ openenv push my-org/my-env --create-pr
554
+ $ openenv push --repo-id my-org/my-env --create-pr
555
+
556
+ # Push to HuggingFace without web interface
557
+ $ openenv push --no-interface
558
+
559
+ # Push to Docker Hub
560
+ $ openenv push --registry docker.io/myuser
561
+
562
+ # Push to GitHub Container Registry
563
+ $ openenv push --registry ghcr.io/myorg
564
+
565
+ # Push to custom registry with web interface
566
+ $ openenv push --registry myregistry.io/path1/path2 --interface
567
+
568
+ # Push to specific HuggingFace repo
569
+ $ openenv push --repo-id my-org/my-env
570
+
571
+ # Push privately with custom base image
572
+ $ openenv push --private --base-image ghcr.io/meta-pytorch/openenv-base:latest
573
+ """
574
+ # Handle interface flag logic
575
+ if no_interface and interface:
576
+ console.print(
577
+ "[bold red]Error:[/bold red] Cannot specify both --interface and --no-interface",
578
+ file=sys.stderr,
579
+ )
580
+ raise typer.Exit(1)
581
+
582
+ # Determine if web interface should be enabled
583
+ if no_interface:
584
+ enable_interface = False
585
+ elif interface is not None:
586
+ enable_interface = interface
587
+ elif registry is not None:
588
+ # Custom registry: disable interface by default
589
+ enable_interface = False
590
+ else:
591
+ # HuggingFace: enable interface by default
592
+ enable_interface = True
593
+
594
+ # Determine directory
595
+ if directory:
596
+ env_dir = Path(directory).resolve()
597
+ else:
598
+ env_dir = Path.cwd().resolve()
599
+
600
+ if not env_dir.exists() or not env_dir.is_dir():
601
+ raise typer.BadParameter(f"Directory does not exist: {env_dir}")
602
+
603
+ # Check for openenv.yaml to confirm this is an environment directory
604
+ openenv_yaml = env_dir / "openenv.yaml"
605
+ if not openenv_yaml.exists():
606
+ console.print(
607
+ f"[bold red]Error:[/bold red] Not an OpenEnv environment directory (missing openenv.yaml): {env_dir}",
608
+ )
609
+ console.print(
610
+ "[yellow]Hint:[/yellow] Run this command from the environment root directory",
611
+ )
612
+ raise typer.Exit(1)
613
+
614
+ # Validate OpenEnv environment
615
+ console.print(
616
+ f"[bold cyan]Validating OpenEnv environment in {env_dir}...[/bold cyan]"
617
+ )
618
+ env_name, manifest = _validate_openenv_directory(env_dir)
619
+ console.print(f"[bold green]✓[/bold green] Found OpenEnv environment: {env_name}")
620
+
621
+ # Handle custom registry push
622
+ if registry:
623
+ console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]")
624
+ if enable_interface:
625
+ console.print("[bold cyan]Web interface will be enabled[/bold cyan]")
626
+
627
+ # Import build functions
628
+ from .build import _build_docker_image, _push_docker_image
629
+
630
+ # Prepare build args for custom registry deployment
631
+ build_args = {}
632
+ if enable_interface:
633
+ build_args["ENABLE_WEB_INTERFACE"] = "true"
634
+
635
+ # Build Docker image from the environment directory
636
+ tag = f"{registry}/{env_name}"
637
+ console.print(f"[bold cyan]Building Docker image: {tag}[/bold cyan]")
638
+
639
+ success = _build_docker_image(
640
+ env_path=env_dir,
641
+ tag=tag,
642
+ build_args=build_args if build_args else None,
643
+ )
644
+
645
+ if not success:
646
+ console.print("[bold red]✗ Docker build failed[/bold red]")
647
+ raise typer.Exit(1)
648
+
649
+ console.print("[bold green]✓ Docker build successful[/bold green]")
650
+
651
+ # Push to registry
652
+ console.print(f"[bold cyan]Pushing to registry: {registry}[/bold cyan]")
653
+
654
+ success = _push_docker_image(
655
+ tag, registry=None
656
+ ) # Tag already includes registry
657
+
658
+ if not success:
659
+ console.print("[bold red]✗ Docker push failed[/bold red]")
660
+ raise typer.Exit(1)
661
+
662
+ console.print("\n[bold green]✓ Deployment complete![/bold green]")
663
+ console.print(f"[bold]Image:[/bold] {tag}")
664
+ return
665
+
666
+ ignore_patterns = _load_ignore_patterns(env_dir, exclude)
667
+
668
+ # Ensure authentication for HuggingFace
669
+ username = _ensure_hf_authenticated()
670
+
671
+ # Determine repo_id
672
+ if not repo_id:
673
+ repo_id = f"{username}/{env_name}"
674
+
675
+ # Validate repo_id format
676
+ if "/" not in repo_id or repo_id.count("/") != 1:
677
+ raise typer.BadParameter(
678
+ f"Invalid repo-id format: {repo_id}. Expected format: 'username/repo-name'"
679
+ )
680
+
681
+ # Initialize Hugging Face API
682
+ api = HfApi()
683
+
684
+ # Prepare staging directory
685
+ deployment_type = (
686
+ "with web interface" if enable_interface else "without web interface"
687
+ )
688
+ console.print(
689
+ f"[bold cyan]Preparing files for Hugging Face deployment ({deployment_type})...[/bold cyan]"
690
+ )
691
+ with tempfile.TemporaryDirectory() as tmpdir:
692
+ staging_dir = Path(tmpdir) / "staging"
693
+ _prepare_staging_directory(
694
+ env_dir,
695
+ env_name,
696
+ staging_dir,
697
+ ignore_patterns=ignore_patterns,
698
+ base_image=base_image,
699
+ enable_interface=enable_interface,
700
+ )
701
+
702
+ # Create/verify space (no-op if exists; needed when pushing to own new repo)
703
+ if not create_pr:
704
+ _create_hf_space(repo_id, api, private=private)
705
+ # When create_pr we rely on upload_folder to create branch and PR
706
+
707
+ # Upload files
708
+ _upload_to_hf_space(
709
+ repo_id,
710
+ staging_dir,
711
+ api,
712
+ private=private,
713
+ create_pr=create_pr,
714
+ ignore_patterns=ignore_patterns,
715
+ )
716
+
717
+ console.print("\n[bold green]✓ Deployment complete![/bold green]")
718
+ console.print(f"Visit your space at: https://huggingface.co/spaces/{repo_id}")
src/openenv/cli/commands/serve.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Serve OpenEnv environments locally (TO BE IMPLEMENTED)."""
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from .._cli_utils import console
17
+
18
+ app = typer.Typer(help="Serve OpenEnv environments locally")
19
+
20
+
21
+ @app.command()
22
+ def serve(
23
+ env_path: Annotated[
24
+ str | None,
25
+ typer.Argument(
26
+ help="Path to the environment directory (default: current directory)"
27
+ ),
28
+ ] = None,
29
+ port: Annotated[
30
+ int,
31
+ typer.Option("--port", "-p", help="Port to serve on"),
32
+ ] = 8000,
33
+ host: Annotated[
34
+ str,
35
+ typer.Option("--host", help="Host to bind to"),
36
+ ] = "0.0.0.0",
37
+ reload: Annotated[
38
+ bool,
39
+ typer.Option("--reload", help="Enable auto-reload on code changes"),
40
+ ] = False,
41
+ ) -> None:
42
+ """
43
+ Serve an OpenEnv environment locally.
44
+
45
+ TODO: This command is currently not implemented and has been deferred for later.
46
+
47
+ Planned functionality:
48
+ - Run environment server locally without Docker
49
+ - Support multiple deployment modes (local, notebook, cluster)
50
+ - Auto-reload for development
51
+ - Integration with environment's [project.scripts] entry point
52
+
53
+ For now, use Docker-based serving:
54
+ 1. Build the environment: openenv build
55
+ 2. Run the container: docker run -p 8000:8000 <image-name>
56
+
57
+ Or use uv directly:
58
+ uv run --project . server --port 8000
59
+ """
60
+ console.print("[bold yellow]⚠ This command is not yet implemented[/bold yellow]\n")
61
+
62
+ console.print(
63
+ "The [bold cyan]openenv serve[/bold cyan] command has been deferred for later."
64
+ )
65
+
66
+ console.print("[bold]Alternative approaches:[/bold]\n")
67
+
68
+ console.print("[cyan]Option 1: Docker-based serving (recommended)[/cyan]")
69
+ console.print(" 1. Build the environment:")
70
+ console.print(" [dim]$ openenv build[/dim]")
71
+ console.print(" 2. Run the Docker container:")
72
+ console.print(
73
+ f" [dim]$ docker run -p {port}:{port} openenv-<env-name>:latest[/dim]\n"
74
+ )
75
+
76
+ console.print("[cyan]Option 2: Direct execution with uv[/cyan]")
77
+
78
+ # Determine environment path
79
+ if env_path is None:
80
+ env_path_obj = Path.cwd()
81
+ else:
82
+ env_path_obj = Path(env_path)
83
+
84
+ # Check for openenv.yaml
85
+ openenv_yaml = env_path_obj / "openenv.yaml"
86
+ if openenv_yaml.exists():
87
+ console.print(" From your environment directory:")
88
+ console.print(f" [dim]$ cd {env_path_obj}[/dim]")
89
+ console.print(f" [dim]$ uv run --project . server --port {port}[/dim]\n")
90
+ else:
91
+ console.print(" From an environment directory with pyproject.toml:")
92
+ console.print(f" [dim]$ uv run --project . server --port {port}[/dim]\n")
93
+
94
+ raise typer.Exit(0)
src/openenv/cli/commands/validate.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ OpenEnv validate command.
9
+
10
+ This module provides the 'openenv validate' command to check if environments
11
+ are properly configured for multi-mode deployment.
12
+ """
13
+
14
+ from pathlib import Path
15
+
16
+ import typer
17
+
18
+ from openenv.cli._validation import (
19
+ format_validation_report,
20
+ get_deployment_modes,
21
+ validate_multi_mode_deployment,
22
+ )
23
+
24
+
25
+ def validate(
26
+ env_path: str | None = typer.Argument(
27
+ None, help="Path to the environment directory (default: current directory)"
28
+ ),
29
+ verbose: bool = typer.Option(
30
+ False, "--verbose", "-v", help="Show detailed information"
31
+ ),
32
+ ) -> None:
33
+ """
34
+ Validate an environment for standardized structure and deployment readiness.
35
+
36
+ This command checks if an environment is properly configured with:
37
+ - Required files (pyproject.toml, openenv.yaml, server/app.py, etc.)
38
+ - Docker deployment support
39
+ - uv run server capability
40
+ - python -m module execution
41
+
42
+ Examples:
43
+ # Validate current directory (recommended)
44
+ $ cd my_env
45
+ $ openenv validate
46
+
47
+ # Validate with detailed output
48
+ $ openenv validate --verbose
49
+
50
+ # Validate specific environment
51
+ $ openenv validate envs/echo_env
52
+ """
53
+ # Determine environment path (default to current directory)
54
+ if env_path is None:
55
+ env_path_obj = Path.cwd()
56
+ else:
57
+ env_path_obj = Path(env_path)
58
+
59
+ if not env_path_obj.exists():
60
+ typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True)
61
+ raise typer.Exit(1)
62
+
63
+ if not env_path_obj.is_dir():
64
+ typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True)
65
+ raise typer.Exit(1)
66
+
67
+ # Check for openenv.yaml to confirm this is an environment directory
68
+ openenv_yaml = env_path_obj / "openenv.yaml"
69
+ if not openenv_yaml.exists():
70
+ typer.echo(
71
+ f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}",
72
+ err=True,
73
+ )
74
+ typer.echo(
75
+ "Hint: Run this command from the environment root directory or specify the path",
76
+ err=True,
77
+ )
78
+ raise typer.Exit(1)
79
+
80
+ env_name = env_path_obj.name
81
+ if env_name.endswith("_env"):
82
+ base_name = env_name[:-4]
83
+ else:
84
+ base_name = env_name
85
+
86
+ # Run validation
87
+ is_valid, issues = validate_multi_mode_deployment(env_path_obj)
88
+
89
+ # Show validation report
90
+ report = format_validation_report(base_name, is_valid, issues)
91
+ typer.echo(report)
92
+
93
+ # Show deployment modes if verbose
94
+ if verbose:
95
+ typer.echo("\nSupported deployment modes:")
96
+ modes = get_deployment_modes(env_path_obj)
97
+ for mode, supported in modes.items():
98
+ status = "[YES]" if supported else "[NO]"
99
+ typer.echo(f" {status} {mode}")
100
+
101
+ if is_valid:
102
+ typer.echo("\nUsage examples:")
103
+ typer.echo(f" cd {env_path_obj.name} && uv run server")
104
+ typer.echo(f" cd {env_path_obj.name} && openenv build")
105
+ typer.echo(f" cd {env_path_obj.name} && openenv push")
106
+
107
+ if not is_valid:
108
+ raise typer.Exit(1)
src/openenv/cli/templates/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """OpenEnv CLI templates package."""
src/openenv/cli/templates/openenv_env/.dockerignore ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .venv
2
+ .git
3
+ .gitignore
4
+ .env
5
+ __pycache__/
6
+ *.pyc
7
+ *.pyo
8
+ *.pyd
9
+ *.pyw
10
+ *.pyz
11
+ *.pywz
12
+ *.pyzw
13
+ *.pyzwz
14
+
15
+
src/openenv/cli/templates/openenv_env/README.md ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: __ENV_TITLE_NAME__ Environment Server
3
+ emoji: __HF_EMOJI__
4
+ colorFrom: __HF_COLOR_FROM__
5
+ colorTo: __HF_COLOR_TO__
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 8000
9
+ base_path: /web
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # __ENV_TITLE_NAME__ Environment
15
+
16
+ A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns.
17
+
18
+ ## Quick Start
19
+
20
+ The simplest way to use the __ENV_TITLE_NAME__ environment is through the `__ENV_CLASS_NAME__Env` class:
21
+
22
+ ```python
23
+ from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env
24
+
25
+ try:
26
+ # Create environment from Docker image
27
+ __ENV_NAME__env = __ENV_CLASS_NAME__Env.from_docker_image("__ENV_NAME__-env:latest")
28
+
29
+ # Reset
30
+ result = __ENV_NAME__env.reset()
31
+ print(f"Reset: {result.observation.echoed_message}")
32
+
33
+ # Send multiple messages
34
+ messages = ["Hello, World!", "Testing echo", "Final message"]
35
+
36
+ for msg in messages:
37
+ result = __ENV_NAME__env.step(__ENV_CLASS_NAME__Action(message=msg))
38
+ print(f"Sent: '{msg}'")
39
+ print(f" → Echoed: '{result.observation.echoed_message}'")
40
+ print(f" → Length: {result.observation.message_length}")
41
+ print(f" → Reward: {result.reward}")
42
+
43
+ finally:
44
+ # Always clean up
45
+ __ENV_NAME__env.close()
46
+ ```
47
+
48
+ That's it! The `__ENV_CLASS_NAME__Env.from_docker_image()` method handles:
49
+ - Starting the Docker container
50
+ - Waiting for the server to be ready
51
+ - Connecting to the environment
52
+ - Container cleanup when you call `close()`
53
+
54
+ ## Building the Docker Image
55
+
56
+ Before using the environment, you need to build the Docker image:
57
+
58
+ ```bash
59
+ # From project root
60
+ docker build -t __ENV_NAME__-env:latest -f server/Dockerfile .
61
+ ```
62
+
63
+ ## Deploying to Hugging Face Spaces
64
+
65
+ You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command:
66
+
67
+ ```bash
68
+ # From the environment directory (where openenv.yaml is located)
69
+ openenv push
70
+
71
+ # Or specify options
72
+ openenv push --namespace my-org --private
73
+ ```
74
+
75
+ The `openenv push` command will:
76
+ 1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`)
77
+ 2. Prepare a custom build for Hugging Face Docker space (enables web interface)
78
+ 3. Upload to Hugging Face (ensuring you're logged in)
79
+
80
+ ### Prerequisites
81
+
82
+ - Authenticate with Hugging Face: The command will prompt for login if not already authenticated
83
+
84
+ ### Options
85
+
86
+ - `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory)
87
+ - `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)
88
+ - `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM)
89
+ - `--private`: Deploy the space as private (default: public)
90
+
91
+ ### Examples
92
+
93
+ ```bash
94
+ # Push to your personal namespace (defaults to username/env-name from openenv.yaml)
95
+ openenv push
96
+
97
+ # Push to a specific repository
98
+ openenv push --repo-id my-org/my-env
99
+
100
+ # Push with a custom base image
101
+ openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest
102
+
103
+ # Push as a private space
104
+ openenv push --private
105
+
106
+ # Combine options
107
+ openenv push --repo-id my-org/my-env --base-image custom-base:latest --private
108
+ ```
109
+
110
+ After deployment, your space will be available at:
111
+ `https://huggingface.co/spaces/<repo-id>`
112
+
113
+ The deployed space includes:
114
+ - **Web Interface** at `/web` - Interactive UI for exploring the environment
115
+ - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface
116
+ - **Health Check** at `/health` - Container health monitoring
117
+ - **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions
118
+
119
+ ## Environment Details
120
+
121
+ ### Action
122
+ **__ENV_CLASS_NAME__Action**: Contains a single field
123
+ - `message` (str) - The message to echo back
124
+
125
+ ### Observation
126
+ **__ENV_CLASS_NAME__Observation**: Contains the echo response and metadata
127
+ - `echoed_message` (str) - The message echoed back
128
+ - `message_length` (int) - Length of the message
129
+ - `reward` (float) - Reward based on message length (length × 0.1)
130
+ - `done` (bool) - Always False for echo environment
131
+ - `metadata` (dict) - Additional info like step count
132
+
133
+ ### Reward
134
+ The reward is calculated as: `message_length × 0.1`
135
+ - "Hi" → reward: 0.2
136
+ - "Hello, World!" → reward: 1.3
137
+ - Empty message → reward: 0.0
138
+
139
+ ## Advanced Usage
140
+
141
+ ### Connecting to an Existing Server
142
+
143
+ If you already have a __ENV_TITLE_NAME__ environment server running, you can connect directly:
144
+
145
+ ```python
146
+ from __ENV_NAME__ import __ENV_CLASS_NAME__Env
147
+
148
+ # Connect to existing server
149
+ __ENV_NAME__env = __ENV_CLASS_NAME__Env(base_url="<ENV_HTTP_URL_HERE>")
150
+
151
+ # Use as normal
152
+ result = __ENV_NAME__env.reset()
153
+ result = __ENV_NAME__env.step(__ENV_CLASS_NAME__Action(message="Hello!"))
154
+ ```
155
+
156
+ Note: When connecting to an existing server, `__ENV_NAME__env.close()` will NOT stop the server.
157
+
158
+ ### Using the Context Manager
159
+
160
+ The client supports context manager usage for automatic connection management:
161
+
162
+ ```python
163
+ from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env
164
+
165
+ # Connect with context manager (auto-connects and closes)
166
+ with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as env:
167
+ result = env.reset()
168
+ print(f"Reset: {result.observation.echoed_message}")
169
+ # Multiple steps with low latency
170
+ for msg in ["Hello", "World", "!"]:
171
+ result = env.step(__ENV_CLASS_NAME__Action(message=msg))
172
+ print(f"Echoed: {result.observation.echoed_message}")
173
+ ```
174
+
175
+ The client uses WebSocket connections for:
176
+ - **Lower latency**: No HTTP connection overhead per request
177
+ - **Persistent session**: Server maintains your environment state
178
+ - **Efficient for episodes**: Better for many sequential steps
179
+
180
+ ### Concurrent WebSocket Sessions
181
+
182
+ The server supports multiple concurrent WebSocket connections. To enable this,
183
+ modify `server/app.py` to use factory mode:
184
+
185
+ ```python
186
+ # In server/app.py - use factory mode for concurrent sessions
187
+ app = create_app(
188
+ __ENV_CLASS_NAME__Environment, # Pass class, not instance
189
+ __ENV_CLASS_NAME__Action,
190
+ __ENV_CLASS_NAME__Observation,
191
+ max_concurrent_envs=4, # Allow 4 concurrent sessions
192
+ )
193
+ ```
194
+
195
+ Then multiple clients can connect simultaneously:
196
+
197
+ ```python
198
+ from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env
199
+ from concurrent.futures import ThreadPoolExecutor
200
+
201
+ def run_episode(client_id: int):
202
+ with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as env:
203
+ result = env.reset()
204
+ for i in range(10):
205
+ result = env.step(__ENV_CLASS_NAME__Action(message=f"Client {client_id}, step {i}"))
206
+ return client_id, result.observation.message_length
207
+
208
+ # Run 4 episodes concurrently
209
+ with ThreadPoolExecutor(max_workers=4) as executor:
210
+ results = list(executor.map(run_episode, range(4)))
211
+ ```
212
+
213
+ ## Development & Testing
214
+
215
+ ### Direct Environment Testing
216
+
217
+ Test the environment logic directly without starting the HTTP server:
218
+
219
+ ```bash
220
+ # From the server directory
221
+ python3 server/__ENV_NAME___environment.py
222
+ ```
223
+
224
+ This verifies that:
225
+ - Environment resets correctly
226
+ - Step executes actions properly
227
+ - State tracking works
228
+ - Rewards are calculated correctly
229
+
230
+ ### Running Locally
231
+
232
+ Run the server locally for development:
233
+
234
+ ```bash
235
+ uvicorn server.app:app --reload
236
+ ```
237
+
238
+ ## Project Structure
239
+
240
+ ```
241
+ __ENV_NAME__/
242
+ ├── .dockerignore # Docker build exclusions
243
+ ├── __init__.py # Module exports
244
+ ├── README.md # This file
245
+ ├── openenv.yaml # OpenEnv manifest
246
+ ├── pyproject.toml # Project metadata and dependencies
247
+ ├── uv.lock # Locked dependencies (generated)
248
+ ├── client.py # __ENV_CLASS_NAME__Env client
249
+ ├── models.py # Action and Observation models
250
+ └── server/
251
+ ├── __init__.py # Server module exports
252
+ ├── __ENV_NAME___environment.py # Core environment logic
253
+ ├── app.py # FastAPI application (HTTP + WebSocket endpoints)
254
+ └── Dockerfile # Container image definition
255
+ ```
src/openenv/cli/templates/openenv_env/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """__ENV_TITLE_NAME__ Environment."""
8
+
9
+ from .client import __ENV_CLASS_NAME__Env
10
+ from .models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation
11
+
12
+ __all__ = [
13
+ "__ENV_CLASS_NAME__Action",
14
+ "__ENV_CLASS_NAME__Observation",
15
+ "__ENV_CLASS_NAME__Env",
16
+ ]
src/openenv/cli/templates/openenv_env/client.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """__ENV_TITLE_NAME__ Environment Client."""
8
+
9
+ from typing import Dict
10
+
11
+ from openenv.core.client_types import StepResult
12
+ from openenv.core.env_server.types import State
13
+ from openenv.core import EnvClient
14
+
15
+ from .models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation
16
+
17
+
18
+ class __ENV_CLASS_NAME__Env(
19
+ EnvClient[__ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation]
20
+ ):
21
+ """
22
+ Client for the __ENV_TITLE_NAME__ Environment.
23
+
24
+ This client maintains a persistent WebSocket connection to the environment server,
25
+ enabling efficient multi-step interactions with lower latency.
26
+ Each client instance has its own dedicated environment session on the server.
27
+
28
+ Example:
29
+ >>> # Connect to a running server
30
+ >>> with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as client:
31
+ ... result = client.reset()
32
+ ... print(result.observation.echoed_message)
33
+ ...
34
+ ... result = client.step(__ENV_CLASS_NAME__Action(message="Hello!"))
35
+ ... print(result.observation.echoed_message)
36
+
37
+ Example with Docker:
38
+ >>> # Automatically start container and connect
39
+ >>> client = __ENV_CLASS_NAME__Env.from_docker_image("__ENV_NAME__-env:latest")
40
+ >>> try:
41
+ ... result = client.reset()
42
+ ... result = client.step(__ENV_CLASS_NAME__Action(message="Test"))
43
+ ... finally:
44
+ ... client.close()
45
+ """
46
+
47
+ def _step_payload(self, action: __ENV_CLASS_NAME__Action) -> Dict:
48
+ """
49
+ Convert __ENV_CLASS_NAME__Action to JSON payload for step message.
50
+
51
+ Args:
52
+ action: __ENV_CLASS_NAME__Action instance
53
+
54
+ Returns:
55
+ Dictionary representation suitable for JSON encoding
56
+ """
57
+ return {
58
+ "message": action.message,
59
+ }
60
+
61
+ def _parse_result(self, payload: Dict) -> StepResult[__ENV_CLASS_NAME__Observation]:
62
+ """
63
+ Parse server response into StepResult[__ENV_CLASS_NAME__Observation].
64
+
65
+ Args:
66
+ payload: JSON response data from server
67
+
68
+ Returns:
69
+ StepResult with __ENV_CLASS_NAME__Observation
70
+ """
71
+ obs_data = payload.get("observation", {})
72
+ observation = __ENV_CLASS_NAME__Observation(
73
+ echoed_message=obs_data.get("echoed_message", ""),
74
+ message_length=obs_data.get("message_length", 0),
75
+ done=payload.get("done", False),
76
+ reward=payload.get("reward"),
77
+ metadata=obs_data.get("metadata", {}),
78
+ )
79
+
80
+ return StepResult(
81
+ observation=observation,
82
+ reward=payload.get("reward"),
83
+ done=payload.get("done", False),
84
+ )
85
+
86
+ def _parse_state(self, payload: Dict) -> State:
87
+ """
88
+ Parse server response into State object.
89
+
90
+ Args:
91
+ payload: JSON response from state request
92
+
93
+ Returns:
94
+ State object with episode_id and step_count
95
+ """
96
+ return State(
97
+ episode_id=payload.get("episode_id"),
98
+ step_count=payload.get("step_count", 0),
99
+ )