Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +4 -0
- .gitignore +44 -0
- Dockerfile +68 -0
- README.md +630 -5
- __init__.py +12 -0
- assets/unity_3dball.gif +3 -0
- assets/unity_pushblock.gif +3 -0
- client.py +428 -0
- envs/unity_env/.gitignore +44 -0
- envs/unity_env/README.md +619 -0
- envs/unity_env/__init__.py +12 -0
- envs/unity_env/assets/unity_3dball.gif +3 -0
- envs/unity_env/assets/unity_pushblock.gif +3 -0
- envs/unity_env/client.py +428 -0
- envs/unity_env/models.py +164 -0
- envs/unity_env/openenv.yaml +6 -0
- envs/unity_env/pyproject.toml +45 -0
- envs/unity_env/server/Dockerfile +66 -0
- envs/unity_env/server/__init__.py +11 -0
- envs/unity_env/server/app.py +84 -0
- envs/unity_env/server/unity_environment.py +554 -0
- models.py +164 -0
- openenv.yaml +6 -0
- pyproject.toml +45 -0
- server/Dockerfile +66 -0
- server/__init__.py +11 -0
- server/app.py +84 -0
- server/unity_environment.py +554 -0
- src/__init__.py +7 -0
- src/openenv/__init__.py +23 -0
- src/openenv/auto/__init__.py +39 -0
- src/openenv/auto/_discovery.py +584 -0
- src/openenv/auto/auto_action.py +276 -0
- src/openenv/auto/auto_env.py +896 -0
- src/openenv/cli/__init__.py +9 -0
- src/openenv/cli/__main__.py +62 -0
- src/openenv/cli/_cli_utils.py +79 -0
- src/openenv/cli/_validation.py +162 -0
- src/openenv/cli/commands/__init__.py +11 -0
- src/openenv/cli/commands/build.py +461 -0
- src/openenv/cli/commands/fork.py +197 -0
- src/openenv/cli/commands/init.py +500 -0
- src/openenv/cli/commands/push.py +718 -0
- src/openenv/cli/commands/serve.py +94 -0
- src/openenv/cli/commands/validate.py +108 -0
- src/openenv/cli/templates/__init__.py +7 -0
- src/openenv/cli/templates/openenv_env/.dockerignore +15 -0
- src/openenv/cli/templates/openenv_env/README.md +255 -0
- src/openenv/cli/templates/openenv_env/__init__.py +16 -0
- 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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
assets/unity_pushblock.gif
ADDED
|
Git LFS Details
|
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
|
envs/unity_env/assets/unity_pushblock.gif
ADDED
|
Git LFS Details
|
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 |
+
)
|