diff --git a/.github/workflows/deploy-hf-env.yml b/.github/workflows/deploy-hf-env.yml index d84833dfa..753c5f3c1 100644 --- a/.github/workflows/deploy-hf-env.yml +++ b/.github/workflows/deploy-hf-env.yml @@ -86,7 +86,7 @@ jobs: # Check which specific environments changed changed_envs=() for env in echo_env coding_env chat_env atari_env openspiel_env; do - if git diff --name-only HEAD~1 HEAD | grep -E "^src/envs/$env/" > /dev/null; then + if git diff --name-only HEAD~1 HEAD | grep -E "^envs/$env/" > /dev/null; then changed_envs+=("$env") fi done diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index df11b6257..baaf3828f 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -68,25 +68,25 @@ jobs: matrix: image: - name: echo-env - dockerfile: src/envs/echo_env/server/Dockerfile + dockerfile: envs/echo_env/server/Dockerfile - name: chat-env - dockerfile: src/envs/chat_env/server/Dockerfile + dockerfile: envs/chat_env/server/Dockerfile - name: coding-env - dockerfile: src/envs/coding_env/server/Dockerfile + dockerfile: envs/coding_env/server/Dockerfile - name: sumo-rl-env - dockerfile: src/envs/sumo_rl_env/server/Dockerfile + dockerfile: envs/sumo_rl_env/server/Dockerfile - name: atari-env - dockerfile: src/envs/atari_env/server/Dockerfile + dockerfile: envs/atari_env/server/Dockerfile - name: git-env - dockerfile: src/envs/git_env/server/Dockerfile + dockerfile: envs/git_env/server/Dockerfile - name: my-env # Add your environment here - dockerfile: src/envs/connect4_env/server/Dockerfile + dockerfile: envs/connect4_env/server/Dockerfile - name: textarena-env - dockerfile: src/envs/textarena_env/server/Dockerfile + dockerfile: envs/textarena_env/server/Dockerfile - name: browsergym-env - dockerfile: src/envs/browsergym_env/server/Dockerfile + dockerfile: envs/browsergym_env/server/Dockerfile - name: snake-env - dockerfile: src/envs/snake_env/server/Dockerfile + dockerfile: envs/snake_env/server/Dockerfile steps: - name: Checkout code diff --git a/.github/workflows/openspiel_base_build.yml b/.github/workflows/openspiel_base_build.yml index afe6be004..558b2e398 100644 --- a/.github/workflows/openspiel_base_build.yml +++ b/.github/workflows/openspiel_base_build.yml @@ -91,7 +91,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - file: src/envs/openspiel_env/server/Dockerfile.openspiel-base + file: envs/openspiel_env/server/Dockerfile.openspiel-base push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta-openspiel-base.outputs.tags }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..aad329ed8 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,69 @@ +name: Tests + +on: + pull_request: + branches: + - main + - release + push: + branches: + - main + workflow_dispatch: # Allow manual trigger + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: | + uv sync --all-extras + uv pip install pytest numpy nltk + + - name: Run tests + run: | + uv run pytest tests/ \ + --ignore=tests/envs/test_browsergym_environment.py \ + --ignore=tests/envs/test_dipg_environment.py \ + --ignore=tests/envs/test_websearch_environment.py \ + -v \ + --tb=short + env: + PYTHONPATH: src + + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: | + uv sync --all-extras + uv pip install ruff + + - name: Run ruff format check + run: uv run ruff format src/ tests/ --check diff --git a/README.md b/README.md index 0a0e31d7e..14186d33e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ An e2e framework for creating, deploying and using isolated execution environments for agentic RL training, built using Gymnasium style simple APIs. -[![PyPI](https://img.shields.io/pypi/v/openenv-core?color=blue)](https://pypi.org/project/openenv-core/) +[![PyPI](https://img.shields.io/pypi/v/openenv?color=blue)](https://pypi.org/project/openenv/) [![Discord](https://img.shields.io/badge/Discord-OpenEnv-7289da?style=flat&logo=discord&logoColor=white)](https://discord.gg/YsTYBh6PD9) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/meta-pytorch/OpenEnv/blob/main/examples/OpenEnv_Tutorial.ipynb) [![Docs](https://img.shields.io/badge/Docs-Explore-blue?logo=readthedocs&logoColor=white)](https://meta-pytorch.org/OpenEnv/) @@ -83,7 +83,7 @@ The web interface is **conditionally enabled** based on environment variables: To use the web interface: ```python -from core.env_server import create_web_interface_app +from openenv.core.env_server import create_web_interface_app from your_env.models import YourAction, YourObservation from your_env.server.your_environment import YourEnvironment @@ -176,7 +176,7 @@ uv run server --host 0.0.0.0 --port 8000 - ✅ **Flexible workflows**: Use pip, uv, or Docker for different scenarios - ✅ **CI/CD ready**: Automated dependency generation and validation -See [`src/envs/README.md`](src/envs/README.md) for a complete guide on building environments. +See [`envs/README.md`](envs/README.md) for a complete guide on building environments. ### For Environment Users @@ -276,7 +276,7 @@ A simple environment that echoes back messages with metadata. Perfect for: - Learning the framework basics - Verifying container deployment -See: [`src/envs/echo_env/README.md`](src/envs/echo_env/README.md) +See: [`envs/echo_env/README.md`](envs/echo_env/README.md) ### Coding Environment Executes arbitrary Python code in a sandboxed environment. Features: @@ -285,7 +285,7 @@ Executes arbitrary Python code in a sandboxed environment. Features: - Persistent execution context within episodes - Error handling with detailed messages -See: [`src/envs/coding_env/README.md`](src/envs/coding_env/README.md) +See: [`envs/coding_env/README.md`](envs/coding_env/README.md) ## Community Support & Acknowledgments This is an open and community-centric project. If you would like to add your name here, please put up a pull request and tag @jspisak for review. Ty!! diff --git a/docs/cli.md b/docs/cli.md index 2d1f0ba80..645402375 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,37 +1,37 @@ -# CLI (`openenv_cli`) +# CLI (`openenv.cli`) The `openenv` CLI provides a set of commands for building, validating, and pushing environments to Hugging Face Spaces or a custom Docker registry. For an end-to-end tutorial on building environments with OpenEnv, see the [building an environment](environment-builder.md) guide. ## `openenv init` -::: openenv_cli.commands.init +::: openenv.cli.commands.init ## `openenv build` -::: openenv_cli.commands.build +::: openenv.cli.commands.build ## `openenv validate` -::: openenv_cli.commands.validate +::: openenv.cli.commands.validate ## `openenv push` -::: openenv_cli.commands.push +::: openenv.cli.commands.push ## `openenv serve` -::: openenv_cli.commands.serve +::: openenv.cli.commands.serve # API Reference ## Entry point -::: openenv_cli.__main__ +::: openenv.cli.__main__ ## CLI helpers -::: openenv_cli._cli_utils +::: openenv.cli._cli_utils ## Validation utilities -::: openenv_cli._validation \ No newline at end of file +::: openenv.cli._validation \ No newline at end of file diff --git a/docs/core.md b/docs/core.md index 1055b9d69..6aa90a216 100644 --- a/docs/core.md +++ b/docs/core.md @@ -1,29 +1,29 @@ # Core API Reference -The `openenv-core` package provides the core abstractions for building and running environments. For an end-to-end tutorial on building environments with OpenEnv, see the [building an environment](environment-builder.md) guide. +The `openenv.core` package provides the core abstractions for building and running environments. For an end-to-end tutorial on building environments with OpenEnv, see the [building an environment](environment-builder.md) guide. ## Core runtime (`core`) ### Environment server primitives -::: core.env_server.interfaces +::: openenv.core.env_server.interfaces ### HTTP server utilities -::: core.env_server.http_server +::: openenv.core.env_server.http_server ### Web interface helpers -::: core.env_server.web_interface +::: openenv.core.env_server.web_interface ### Client contracts -::: core.http_env_client +::: openenv.core.http_env_client ### Shared dataclasses -::: core.client_types +::: openenv.core.client_types ### Container providers -::: core.containers.runtime.providers +::: openenv.core.containers.runtime.providers diff --git a/docs/environment-builder.md b/docs/environment-builder.md index bc787539e..5007ae1a0 100644 --- a/docs/environment-builder.md +++ b/docs/environment-builder.md @@ -10,7 +10,7 @@ A typical workflow looks like: 1. Scaffold a new environment with `openenv init`. 2. Customize your models, environment logic, and FastAPI server. -3. Implement a typed `HTTPEnvClient`. +3. Implement a typed `EnvClient` (WebSocket-based for persistent sessions). 4. Configure dependencies and the Dockerfile once. 5. Use the CLI (`openenv build`, `openenv validate`, `openenv push`) to package and share your work. @@ -34,10 +34,10 @@ Let's walk through the process of building a custom environment with OpenEnv. openenv init my_env # Optionally choose an output directory -openenv init my_env --output-dir /Users/you/src/envs +openenv init my_env --output-dir /Users/you/envs ``` -The command creates a fully-typed template with `openenv.yaml`, `pyproject.toml`, `uv.lock`, Docker assets, and stub implementations. If you're working inside this repo, move the generated folder under `src/envs/`. +The command creates a fully-typed template with `openenv.yaml`, `pyproject.toml`, `uv.lock`, Docker assets, and stub implementations. If you're working inside this repo, move the generated folder under `envs/`. Typical layout: @@ -58,33 +58,26 @@ my_env/ └── Dockerfile ``` -Python classes are generated for the action, observation, and state, and a client is generated for the environment. For example, you will find `MyEnvironment`, `MyAction`, `MyObservation`, and `MyState` in the `my_env` directory based on the name of the environment you provided. +Python classes are generated for the action, observation, environment, and client. For example, you will find `MyEnvironment`, `MyAction`, `MyObservation`, and `MyEnv` (client) in the `my_env` directory based on the name you provided. The environment uses the core `State` class from `openenv.core.env_server.types`. ### 2. Define Models -Edit `models.py` to describe your action, observation, and state dataclasses: +Edit `models.py` to describe your action and observation using Pydantic: ```python # models.py -from dataclasses import dataclass -from core.env_server import Action, Observation, State +from pydantic import Field +from openenv.core.env_server.types import Action, Observation -@dataclass class MyAction(Action): """Your custom action.""" - command: str - parameters: dict + command: str = Field(..., description="Command to execute") + parameters: dict = Field(default_factory=dict, description="Command parameters") -@dataclass class MyObservation(Observation): """Your custom observation.""" - result: str - success: bool - -@dataclass -class MyState(State): - """Custom state fields.""" - custom_field: int = 0 + result: str = Field(..., description="Result of the action") + success: bool = Field(..., description="Whether the action succeeded") ``` ### 3. Implement Environment Logic @@ -93,70 +86,104 @@ Customize `server/my_environment.py` by extending `Environment`: ```python # server/my_environment.py -import uuid -from core.env_server import Environment -from ..models import MyAction, MyObservation, MyState +from uuid import uuid4 +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.types import State +from models import MyAction, MyObservation class MyEnvironment(Environment): def __init__(self): - super().__init__() - self._state = MyState() + self._state = State(episode_id=str(uuid4()), step_count=0) def reset(self) -> MyObservation: - self._state = MyState(episode_id=str(uuid.uuid4())) - return MyObservation(result="Ready", success=True) + self._state = State(episode_id=str(uuid4()), step_count=0) + return MyObservation(result="Ready", success=True, done=False, reward=0.0) def step(self, action: MyAction) -> MyObservation: # Implement your logic here self._state.step_count += 1 result = self._execute_command(action.command) - return MyObservation(result=result, success=True) + return MyObservation(result=result, success=True, done=False, reward=1.0) @property - def state(self) -> MyState: + def state(self) -> State: return self._state ``` ### 4. Create the FastAPI Server -`server/app.py` should expose the environment through `create_fastapi_app`: +`server/app.py` should expose the environment through `create_app`. + +**Important:** You must pass a class or factory function (not an instance) to enable WebSocket-based concurrent sessions: + +```python +# server/app.py +from openenv.core.env_server import create_app +from ..models import MyAction, MyObservation +from .my_environment import MyEnvironment + +# Pass the class (factory) - each WebSocket session gets its own instance +app = create_app(MyEnvironment, MyAction, MyObservation, env_name="my_env") +``` + +For environments with constructor arguments, create a factory function: ```python # server/app.py -from core.env_server import create_fastapi_app +import os +from openenv.core.env_server import create_app from ..models import MyAction, MyObservation from .my_environment import MyEnvironment -env = MyEnvironment() -app = create_fastapi_app(env, MyAction, MyObservation) +# Read config from environment variables +api_key = os.getenv("MY_API_KEY") +timeout = int(os.getenv("MY_TIMEOUT", "30")) + +def create_my_environment(): + """Factory function that creates MyEnvironment with config.""" + return MyEnvironment(api_key=api_key, timeout=timeout) + +# Pass the factory function +app = create_app(create_my_environment, MyAction, MyObservation, env_name="my_env") ``` ### 5. Implement the Client -`client.py` extends `HTTPEnvClient` so users can interact with your server over HTTP or Docker: +`client.py` extends `EnvClient` so users can interact with your server via WebSocket for persistent sessions: ```python # client.py -from core.http_env_client import HTTPEnvClient -from core.types import StepResult +from openenv.core.env_client import EnvClient +from openenv.core.client_types import StepResult from .models import MyAction, MyObservation, MyState -class MyEnv(HTTPEnvClient[MyAction, MyObservation]): +class MyEnv(EnvClient[MyAction, MyObservation, MyState]): def _step_payload(self, action: MyAction) -> dict: return {"command": action.command, "parameters": action.parameters} def _parse_result(self, payload: dict) -> StepResult[MyObservation]: - obs = MyObservation(**payload["observation"]) + obs_data = payload.get("observation", {}) + obs = MyObservation( + result=obs_data.get("result", ""), + success=obs_data.get("success", False), + done=payload.get("done", False), + reward=payload.get("reward"), + ) return StepResult( observation=obs, reward=payload.get("reward"), done=payload.get("done", False), ) - def _parse_state(self, payload: dict) -> MyState: - return MyState(**payload) + def _parse_state(self, payload: dict) -> State: + return State( + episode_id=payload.get("episode_id"), + step_count=payload.get("step_count", 0), + ) ``` +The `EnvClient` maintains a persistent WebSocket connection to the server, enabling efficient multi-step interactions with lower latency compared to HTTP. Each client instance gets its own dedicated environment session on the server. + ### 6. Configure Dependencies & Dockerfile The CLI template ships with `pyproject.toml` and `server/Dockerfile`. You should manage your python dependencies with `uv` or `pip` in the `pyproject.toml` file. Other dependencies should be installed in the Dockerfile. @@ -176,7 +203,7 @@ Keep building from the `openenv-base` image so shared tooling stays available: # Multi-stage build using openenv-base # This Dockerfile is flexible and works for both: # - In-repo environments (with local src/core) -# - Standalone environments (with openenv-core from pip) +# - Standalone environments (with openenv from pip) # The build script (openenv build) handles context detection and sets appropriate build args. ARG BASE_IMAGE=openenv-base:latest @@ -191,8 +218,8 @@ ARG ENV_NAME=__ENV_NAME__ # Copy environment code (always at root of build context) COPY . /app/env -# For in-repo builds, openenv-core is already in the pyproject.toml dependencies -# For standalone builds, openenv-core will be installed from pip via pyproject.toml +# For in-repo builds, openenv is already in the pyproject.toml dependencies +# For standalone builds, openenv will be installed from pip via pyproject.toml WORKDIR /app/env # Install dependencies using uv sync @@ -247,7 +274,7 @@ If you introduced extra dependencies in the Dockerfile, you should install them From the environment directory: ```bash -cd src/envs/my_env +cd envs/my_env openenv build # Builds Docker image (auto-detects context) openenv validate --verbose ``` @@ -299,13 +326,13 @@ strategy: matrix: image: - name: echo-env - dockerfile: src/envs/echo_env/server/Dockerfile + dockerfile: envs/echo_env/server/Dockerfile - name: chat-env - dockerfile: src/envs/chat_env/server/Dockerfile + dockerfile: envs/chat_env/server/Dockerfile - name: coding-env - dockerfile: src/envs/coding_env/server/Dockerfile + dockerfile: envs/coding_env/server/Dockerfile - name: my-env # Add your environment here - dockerfile: src/envs/my_env/server/Dockerfile + dockerfile: envs/my_env/server/Dockerfile ``` ### Use Your Environment @@ -322,22 +349,29 @@ client = MyEnv.from_hub("my-org/my-env") # Or, connect to the local server client = MyEnv(base_url="http://localhost:8000") -# Reset -result = client.reset() -print(result.observation.result) # "Ready" - -# Execute actions -result = client.step(MyAction(command="test", parameters={})) -print(result.observation.result) -print(result.observation.success) - -# Get state -state = client.state() -print(state.episode_id) -print(state.step_count) - -# Cleanup -client.close() +# Use context manager for automatic cleanup (recommended) +with client: + # Reset + result = client.reset() + print(result.observation.result) # "Ready" + + # Execute actions + result = client.step(MyAction(command="test", parameters={})) + print(result.observation.result) + print(result.observation.success) + + # Get state + state = client.state() + print(state.episode_id) + print(state.step_count) + +# Or manually manage the connection +try: + client = MyEnv(base_url="http://localhost:8000") + result = client.reset() + result = client.step(MyAction(command="test", parameters={})) +finally: + client.close() ``` ## Nice work! You've now built and used your own OpenEnv environment. diff --git a/docs/environments/atari.md b/docs/environments/atari.md index cb6f47bdb..c71f39e74 100644 --- a/docs/environments/atari.md +++ b/docs/environments/atari.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/atari_env/README.md" +--8<-- "../../envs/atari_env/README.md" diff --git a/docs/environments/chat.md b/docs/environments/chat.md index 1660bfc57..0111673a8 100644 --- a/docs/environments/chat.md +++ b/docs/environments/chat.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/chat_env/README.md" +--8<-- "../../envs/chat_env/README.md" diff --git a/docs/environments/coding.md b/docs/environments/coding.md index affc8854a..9a7506e39 100644 --- a/docs/environments/coding.md +++ b/docs/environments/coding.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/coding_env/README.md" +--8<-- "../../envs/coding_env/README.md" diff --git a/docs/environments/dipg.md b/docs/environments/dipg.md index 3131bdded..1edf0aa6d 100644 --- a/docs/environments/dipg.md +++ b/docs/environments/dipg.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/dipg_safety_env/README.md" +--8<-- "../../envs/dipg_safety_env/README.md" diff --git a/docs/environments/echo.md b/docs/environments/echo.md index f3e926531..85f816f4c 100644 --- a/docs/environments/echo.md +++ b/docs/environments/echo.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/echo_env/README.md" +--8<-- "../../envs/echo_env/README.md" diff --git a/docs/environments/finrl.md b/docs/environments/finrl.md index 7a94c1f00..aaefac446 100644 --- a/docs/environments/finrl.md +++ b/docs/environments/finrl.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/finrl_env/README.md" +--8<-- "../../envs/finrl_env/README.md" diff --git a/docs/environments/git.md b/docs/environments/git.md index f75d569b8..cc7f3e491 100644 --- a/docs/environments/git.md +++ b/docs/environments/git.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/git_env/README.md" +--8<-- "../../envs/git_env/README.md" diff --git a/docs/environments/openspiel.md b/docs/environments/openspiel.md index 02a688e7c..637d62f6b 100644 --- a/docs/environments/openspiel.md +++ b/docs/environments/openspiel.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/openspiel_env/README.md" +--8<-- "../../envs/openspiel_env/README.md" diff --git a/docs/environments/sumo.md b/docs/environments/sumo.md index c9acbf1ae..830b0af39 100644 --- a/docs/environments/sumo.md +++ b/docs/environments/sumo.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/sumo_rl_env/README.md" +--8<-- "../../envs/sumo_rl_env/README.md" diff --git a/docs/environments/textarena.md b/docs/environments/textarena.md index 71c156da9..727eba67d 100644 --- a/docs/environments/textarena.md +++ b/docs/environments/textarena.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/textarena_env/README.md" +--8<-- "../../envs/textarena_env/README.md" diff --git a/docs/environments/websearch.md b/docs/environments/websearch.md index e7d182966..ddc2c49ea 100644 --- a/docs/environments/websearch.md +++ b/docs/environments/websearch.md @@ -1,2 +1,2 @@ ---8<-- "../../src/envs/websearch_env/README.md" +--8<-- "../../websearch_env/README.md" diff --git a/docs/index.md b/docs/index.md index 2c1779964..8ceb03030 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,7 +16,7 @@
- +
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index deb766bbe..f7d8e853f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -64,8 +64,8 @@ markdown_extensions: - meta watch: - - ../src/core - - ../src/openenv_cli + - ../src/openenv/core + - ../src/openenv/cli nav: - Get Started: diff --git a/docs/quickstart.md b/docs/quickstart.md index 20af71e97..a7d0eddac 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -8,12 +8,8 @@ To install the OpenEnv package, you can use the following command: pip install https://github.com/meta-pytorch/OpenEnv.git ``` -!!! warning - This will install the `openenv` cli and not the `openenv-core` package. If you want to install the `openenv-core` package, you can use the following command: - - ```bash - pip install openenv-core - ``` +!!! note + This installs both the `openenv` CLI and the `openenv.core` runtime. Environment projects can depend on `openenv[core]` if they only need the server/client libraries. ### Using the Echo Environment (Example) diff --git a/src/envs/README.md b/envs/README.md similarity index 88% rename from src/envs/README.md rename to envs/README.md index edd91d497..f2601e00d 100644 --- a/src/envs/README.md +++ b/envs/README.md @@ -20,7 +20,7 @@ Create your action, observation, and state models using Python dataclasses: ```python # models.py from dataclasses import dataclass -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State @dataclass class MyAction(Action): @@ -47,7 +47,7 @@ Implement the three core methods: `reset()`, `step()`, and `state`: ```python # server/my_environment.py import uuid -from core.env_server import Environment +from openenv.core.env_server import Environment from ..models import MyAction, MyObservation, MyState class MyEnvironment(Environment): @@ -76,7 +76,7 @@ Use the `create_fastapi_app` helper to create your HTTP server: ```python # server/app.py -from core.env_server import create_fastapi_app +from openenv.core.env_server import create_fastapi_app from ..models import MyAction, MyObservation from .my_environment import MyEnvironment @@ -88,7 +88,7 @@ app = create_fastapi_app(env, MyAction, MyObservation) **For Python-only dependencies (most common case):** -Create `src/envs/my_env/server/requirements.txt`: +Create `envs/my_env/server/requirements.txt`: ```txt your-package>=1.0.0 another-package @@ -96,7 +96,7 @@ another-package **For complex setup (optional, only if needed):** -If you need additional setup beyond pip install, create `src/envs/my_env/server/install_deps.sh`: +If you need additional setup beyond pip install, create `envs/my_env/server/install_deps.sh`: ```bash #!/bin/bash set -e @@ -111,7 +111,7 @@ mkdir -p /some/directory ### 5. Create Dockerfile -Build your Docker image from the openenv-base. Place this at `src/envs/my_env/server/Dockerfile`: +Build your Docker image from the openenv-base. Place this at `envs/my_env/server/Dockerfile`: **Simple case (just requirements.txt):** ```dockerfile @@ -120,12 +120,12 @@ ARG BASE_IMAGE=openenv-base:latest FROM ${BASE_IMAGE} # Install dependencies -COPY src/envs/my_env/server/requirements.txt /tmp/requirements.txt +COPY envs/my_env/server/requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt # Copy environment code COPY src/core/ /app/src/core/ -COPY src/envs/my_env/ /app/src/envs/my_env/ +COPY envs/my_env/ /app/envs/my_env/ # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ @@ -141,15 +141,15 @@ ARG BASE_IMAGE=openenv-base:latest FROM ${BASE_IMAGE} # Install dependencies and run setup -COPY src/envs/my_env/server/requirements.txt /tmp/requirements.txt -COPY src/envs/my_env/server/install_deps.sh /tmp/install_deps.sh +COPY envs/my_env/server/requirements.txt /tmp/requirements.txt +COPY envs/my_env/server/install_deps.sh /tmp/install_deps.sh RUN chmod +x /tmp/install_deps.sh && \ /tmp/install_deps.sh && \ rm /tmp/install_deps.sh /tmp/requirements.txt # Copy environment code COPY src/core/ /app/src/core/ -COPY src/envs/my_env/ /app/src/envs/my_env/ +COPY envs/my_env/ /app/envs/my_env/ # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ @@ -170,13 +170,13 @@ strategy: matrix: image: - name: echo-env - dockerfile: src/envs/echo_env/server/Dockerfile + dockerfile: envs/echo_env/server/Dockerfile - name: chat-env - dockerfile: src/envs/chat_env/server/Dockerfile + dockerfile: envs/chat_env/server/Dockerfile - name: coding-env - dockerfile: src/envs/coding_env/server/Dockerfile + dockerfile: envs/coding_env/server/Dockerfile - name: my-env # Add your environment here - dockerfile: src/envs/my_env/server/Dockerfile + dockerfile: envs/my_env/server/Dockerfile ``` Once added, every push to `main` will automatically: @@ -189,8 +189,8 @@ Create a client that extends `HTTPEnvClient`: ```python # client.py -from core.http_env_client import HTTPEnvClient -from core.types import StepResult +from openenv.core.http_env_client import HTTPEnvClient +from openenv.core.types import StepResult from .models import MyAction, MyObservation, MyState class MyEnv(HTTPEnvClient[MyAction, MyObservation]): @@ -218,7 +218,7 @@ class MyEnv(HTTPEnvClient[MyAction, MyObservation]): docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . # Then build your environment image -docker build -t my-env:latest -f src/envs/my_env/server/Dockerfile . +docker build -t my-env:latest -f envs/my_env/server/Dockerfile . ``` ### Use Your Environment @@ -252,7 +252,7 @@ client.close() Organize your environment following this structure: ``` -src/envs/my_env/ +envs/my_env/ ├── __init__.py # Export MyAction, MyObservation, MyState, MyEnv ├── models.py # Action, Observation, State definitions ├── client.py # MyEnv client implementation @@ -269,7 +269,7 @@ src/envs/my_env/ Study these examples to see the patterns in action: ### Echo Environment -Location: `src/envs/echo_env/` +Location: `envs/echo_env/` A minimal environment that echoes messages back. Great for: - Learning the basics @@ -279,7 +279,7 @@ A minimal environment that echoes messages back. Great for: See: [`echo_env/README.md`](echo_env/README.md) ### Coding Environment -Location: `src/envs/coding_env/` +Location: `envs/coding_env/` Executes Python code in a sandboxed environment. Demonstrates: - Complex environment logic @@ -357,7 +357,7 @@ def test_environment(): Apply transformations to observations: ```python -from core.env_server import Transform +from openenv.core.env_server import Transform class MyTransform(Transform): def __call__(self, observation: Observation) -> Observation: diff --git a/src/envs/atari_env/README.md b/envs/atari_env/README.md similarity index 98% rename from src/envs/atari_env/README.md rename to envs/atari_env/README.md index d942f2647..9fded10ab 100644 --- a/src/envs/atari_env/README.md +++ b/envs/atari_env/README.md @@ -94,7 +94,7 @@ cd OpenEnv # Build the image docker build \ - -f src/envs/atari_env/server/Dockerfile \ + -f envs/atari_env/server/Dockerfile \ -t atari-env:latest \ . ``` @@ -343,7 +343,7 @@ env.close() ```bash # Build and run -docker build -f src/envs/atari_env/server/Dockerfile -t atari-env:latest . +docker build -f envs/atari_env/server/Dockerfile -t atari-env:latest . docker run -p 8000:8000 atari-env:latest # Test in another terminal diff --git a/src/envs/atari_env/__init__.py b/envs/atari_env/__init__.py similarity index 100% rename from src/envs/atari_env/__init__.py rename to envs/atari_env/__init__.py diff --git a/src/envs/atari_env/client.py b/envs/atari_env/client.py similarity index 76% rename from src/envs/atari_env/client.py rename to envs/atari_env/client.py index 42afb954a..458895454 100644 --- a/src/envs/atari_env/client.py +++ b/envs/atari_env/client.py @@ -5,48 +5,50 @@ # LICENSE file in the root directory of this source tree. """ -Atari Environment HTTP Client. +Atari Environment Client. This module provides the client for connecting to an Atari Environment server -over HTTP. +via WebSocket for persistent sessions. """ from __future__ import annotations from typing import Any, Dict, TYPE_CHECKING -from core.client_types import StepResult +from openenv.core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.env_client import EnvClient from .models import AtariAction, AtariObservation, AtariState if TYPE_CHECKING: - from core.containers.runtime import ContainerProvider + from openenv.core.containers.runtime import ContainerProvider -class AtariEnv(HTTPEnvClient[AtariAction, AtariObservation]): +class AtariEnv(EnvClient[AtariAction, AtariObservation, AtariState]): """ - HTTP client for Atari Environment. + Client for Atari Environment. - This client connects to an AtariEnvironment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. Example: >>> # Connect to a running server - >>> client = AtariEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.screen_shape) - >>> - >>> # Take an action - >>> result = client.step(AtariAction(action_id=2)) # UP - >>> print(result.reward, result.done) + >>> with AtariEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.screen_shape) + ... + ... result = client.step(AtariAction(action_id=2)) # UP + ... print(result.reward, result.done) Example with Docker: >>> # Automatically start container and connect >>> client = AtariEnv.from_docker_image("atari-env:latest") - >>> result = client.reset() - >>> result = client.step(AtariAction(action_id=0)) # NOOP + >>> try: + ... result = client.reset() + ... result = client.step(AtariAction(action_id=0)) # NOOP + ... finally: + ... client.close() """ def _step_payload(self, action: AtariAction) -> Dict[str, Any]: diff --git a/src/envs/atari_env/models.py b/envs/atari_env/models.py similarity index 97% rename from src/envs/atari_env/models.py rename to envs/atari_env/models.py index 1938172e3..dc60ba3df 100644 --- a/src/envs/atari_env/models.py +++ b/envs/atari_env/models.py @@ -16,7 +16,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Literal, Optional -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State @dataclass diff --git a/src/envs/atari_env/server/Dockerfile b/envs/atari_env/server/Dockerfile similarity index 79% rename from src/envs/atari_env/server/Dockerfile rename to envs/atari_env/server/Dockerfile index 6c5de66ff..c82ae3916 100644 --- a/src/envs/atari_env/server/Dockerfile +++ b/envs/atari_env/server/Dockerfile @@ -5,25 +5,25 @@ # Base image provides: fastapi, uvicorn, requests, curl, PYTHONPATH=/app/src # # Local build: docker build -t envtorch-base:latest -f src/core/containers/images/Dockerfile . -# docker build -f src/envs/atari_env/server/Dockerfile -t atari-env:latest . +# docker build -f envs/atari_env/server/Dockerfile -t atari-env:latest . # # CI/CD build: docker build --build-arg BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest \ -# -f src/envs/atari_env/server/Dockerfile -t atari-env:latest . +# -f envs/atari_env/server/Dockerfile -t atari-env:latest . ARG BASE_IMAGE=openenv-base:latest FROM ${BASE_IMAGE} # Install dependencies -COPY src/envs/atari_env/server/requirements.txt /tmp/requirements.txt +COPY envs/atari_env/server/requirements.txt /tmp/requirements.txt RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt # Copy OpenEnv core (base image already set WORKDIR=/app) COPY src/core/ /app/src/core/ # Copy Atari environment code -COPY src/envs/atari_env/ /app/src/envs/atari_env/ +COPY envs/atari_env/ /app/envs/atari_env/ # Copy README for web interface documentation -COPY src/envs/atari_env/README.md /app/README.md +COPY envs/atari_env/README.md /app/README.md # Atari-specific environment variables (can be overridden at runtime) ENV ATARI_GAME=pong diff --git a/src/envs/atari_env/server/__init__.py b/envs/atari_env/server/__init__.py similarity index 100% rename from src/envs/atari_env/server/__init__.py rename to envs/atari_env/server/__init__.py diff --git a/src/envs/atari_env/server/app.py b/envs/atari_env/server/app.py similarity index 72% rename from src/envs/atari_env/server/app.py rename to envs/atari_env/server/app.py index 5008a342f..036e44ef3 100644 --- a/src/envs/atari_env/server/app.py +++ b/envs/atari_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the Atari Environment. This module creates an HTTP server that exposes Atari games -over HTTP endpoints, making them compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. Usage: # Development (with auto-reload): @@ -32,7 +32,7 @@ import os -from core.env_server import create_app +from openenv.core.env_server import create_app from ..models import AtariAction, AtariObservation from .atari_environment import AtariEnvironment @@ -52,19 +52,24 @@ mode = int(mode) if mode is not None else None difficulty = int(difficulty) if difficulty is not None else None -# Create the environment instance -env = AtariEnvironment( - game_name=game_name, - obs_type=obs_type, - full_action_space=full_action_space, - mode=mode, - difficulty=difficulty, - repeat_action_probability=repeat_action_prob, - frameskip=frameskip, -) + +# Factory function to create AtariEnvironment instances +def create_atari_environment(): + """Factory function that creates AtariEnvironment with config.""" + return AtariEnvironment( + game_name=game_name, + obs_type=obs_type, + full_action_space=full_action_space, + mode=mode, + difficulty=difficulty, + repeat_action_probability=repeat_action_prob, + frameskip=frameskip, + ) + # Create the FastAPI app with web interface and README integration -app = create_app(env, AtariAction, AtariObservation, env_name="atari_env") +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_atari_environment, AtariAction, AtariObservation, env_name="atari_env") if __name__ == "__main__": diff --git a/src/envs/atari_env/server/atari_environment.py b/envs/atari_env/server/atari_environment.py similarity index 99% rename from src/envs/atari_env/server/atari_environment.py rename to envs/atari_env/server/atari_environment.py index 6d6b5362c..036433fe3 100644 --- a/src/envs/atari_env/server/atari_environment.py +++ b/envs/atari_env/server/atari_environment.py @@ -14,7 +14,7 @@ import uuid from typing import Any, Dict, Literal, Optional -from core.env_server import Action, Environment, Observation +from openenv.core.env_server import Action, Environment, Observation from ..models import AtariAction, AtariObservation, AtariState diff --git a/src/envs/atari_env/server/requirements.txt b/envs/atari_env/server/requirements.txt similarity index 100% rename from src/envs/atari_env/server/requirements.txt rename to envs/atari_env/server/requirements.txt diff --git a/src/envs/atari_env/test_atari_docker.sh b/envs/atari_env/test_atari_docker.sh similarity index 98% rename from src/envs/atari_env/test_atari_docker.sh rename to envs/atari_env/test_atari_docker.sh index 34fa98ccb..8e566742c 100755 --- a/src/envs/atari_env/test_atari_docker.sh +++ b/envs/atari_env/test_atari_docker.sh @@ -51,7 +51,7 @@ fi echo -e "${GREEN}✓${NC} curl is installed" # Check if we're in the right directory -if [ ! -f "src/envs/atari_env/server/Dockerfile" ]; then +if [ ! -f "envs/atari_env/server/Dockerfile" ]; then echo -e "${RED}✗${NC} Must run from OpenEnv root directory" exit 1 fi @@ -64,7 +64,7 @@ echo -e "${BLUE}STEP 1: Building Docker Image${NC}" echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "Building ${IMAGE_NAME}:${IMAGE_TAG}..." -if docker build -f src/envs/atari_env/server/Dockerfile -t ${IMAGE_NAME}:${IMAGE_TAG} . 2>&1 | tee /tmp/atari_build.log | tail -n 20; then +if docker build -f envs/atari_env/server/Dockerfile -t ${IMAGE_NAME}:${IMAGE_TAG} . 2>&1 | tee /tmp/atari_build.log | tail -n 20; then echo -e "${GREEN}✓${NC} Docker image built successfully" else echo -e "${RED}✗${NC} Docker build failed" diff --git a/src/envs/browsergym_env/README.md b/envs/browsergym_env/README.md similarity index 99% rename from src/envs/browsergym_env/README.md rename to envs/browsergym_env/README.md index 7c16a6f47..d06689c5a 100644 --- a/src/envs/browsergym_env/README.md +++ b/envs/browsergym_env/README.md @@ -285,7 +285,7 @@ docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . ```bash # From the OpenEnv repository root -docker build -t browsergym-env:latest -f src/envs/browsergym_env/server/Dockerfile . +docker build -t browsergym-env:latest -f envs/browsergym_env/server/Dockerfile . ``` ### Run the Server @@ -530,7 +530,7 @@ pip install -e . pip install browsergym browsergym-miniwob browsergym-webarena # Run the server locally -cd src/envs/browsergym_env/server +cd envs/browsergym_env/server export BROWSERGYM_BENCHMARK=miniwob export BROWSERGYM_TASK_NAME=click-test python app.py diff --git a/src/envs/browsergym_env/__init__.py b/envs/browsergym_env/__init__.py similarity index 100% rename from src/envs/browsergym_env/__init__.py rename to envs/browsergym_env/__init__.py diff --git a/src/envs/browsergym_env/client.py b/envs/browsergym_env/client.py similarity index 91% rename from src/envs/browsergym_env/client.py rename to envs/browsergym_env/client.py index 2de2f6e7c..cb7437f9d 100644 --- a/src/envs/browsergym_env/client.py +++ b/envs/browsergym_env/client.py @@ -1,8 +1,9 @@ -"""HTTP client for the BrowserGym environment.""" +"""Client for the BrowserGym environment.""" from typing import Any, Dict -from openenv_core.http_env_client import HTTPEnvClient, StepResult +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient from .models import ( BrowserGymAction, BrowserGymObservation, @@ -10,8 +11,8 @@ ) -class BrowserGymEnv(HTTPEnvClient[BrowserGymAction, BrowserGymObservation]): - """Client for interacting with the BrowserGym environment over HTTP. +class BrowserGymEnv(EnvClient[BrowserGymAction, BrowserGymObservation, BrowserGymState]): + """Client for interacting with the BrowserGym environment. BrowserGym provides unified access to multiple web navigation benchmarks: - MiniWoB++: 100+ training tasks (no external infrastructure needed!) @@ -82,9 +83,7 @@ def _step_payload(self, action: BrowserGymAction) -> Dict[str, Any]: "metadata": action.metadata, } - def _parse_result( - self, payload: Dict[str, Any] - ) -> StepResult[BrowserGymObservation]: + def _parse_result(self, payload: Dict[str, Any]) -> StepResult[BrowserGymObservation]: """Parse the server response into a StepResult.""" obs_data = payload.get("observation", {}) diff --git a/envs/browsergym_env/models.py b/envs/browsergym_env/models.py new file mode 100644 index 000000000..c0ef1fd1f --- /dev/null +++ b/envs/browsergym_env/models.py @@ -0,0 +1,77 @@ +"""Data models for the BrowserGym environment. + +BrowserGym is a unified framework for web-based agent tasks, combining multiple +benchmarks including MiniWoB (training), WebArena (evaluation), VisualWebArena, +and more under a single Gymnasium-compatible API. +""" + +from typing import List, Optional + +from pydantic import Field + +from openenv.core.env_server.types import Action, Observation, State + + +class BrowserGymAction(Action): + """Action to be executed in the BrowserGym environment. + + BrowserGym supports high-level natural language actions that can be parsed + into browser operations. + + Example actions: + - "click('Submit button')" + - "fill('username', 'john@example.com')" + - "goto('https://example.com')" + - "scroll(down)" + - "send_keys('Enter')" + """ + + action_str: str = Field(..., description="Natural language action string (e.g., \"click('Submit')\")") + + +class BrowserGymObservation(Observation): + """Observation returned from the BrowserGym environment. + + Contains multiple observation modalities including text (accessibility tree + or DOM), visual (screenshot), and page metadata. + """ + + text: str = Field(default="", description="Text representation of the page (accessibility tree or DOM)") + + url: str = Field(default="", description="Current URL of the page") + + screenshot: Optional[List[List[List[int]]]] = Field( + default=None, + description="Screenshot as numpy array [height, width, channels] (if visual observation enabled)" + ) + + goal: str = Field(default="", description="Task goal/instruction for the current episode") + + axtree_txt: str = Field(default="", description="Full accessibility tree as text") + + pruned_html: str = Field(default="", description="Pruned HTML content (interactive elements only)") + + error: str = Field(default="", description="Error message if action execution failed") + + last_action_error: bool = Field(default=False, description="Whether the last action resulted in an error") + + +class BrowserGymState(State): + """State of the BrowserGym environment. + + Tracks the current benchmark, task, and progress through an episode. + """ + + benchmark: str = Field(default="", description="Benchmark name (e.g., 'miniwob', 'webarena', 'visualwebarena')") + + task_name: str = Field(default="", description="Specific task within the benchmark (e.g., 'click-test', 'click-button')") + + task_id: Optional[str] = Field(default=None, description="Task ID for evaluation benchmarks (e.g., WebArena task number)") + + goal: str = Field(default="", description="Task goal/instruction") + + current_url: str = Field(default="", description="Current URL of the active page") + + max_steps: Optional[int] = Field(default=None, description="Maximum steps allowed for this task") + + cum_reward: float = Field(default=0.0, description="Cumulative reward for the current episode") diff --git a/src/envs/browsergym_env/openenv.yaml b/envs/browsergym_env/openenv.yaml similarity index 100% rename from src/envs/browsergym_env/openenv.yaml rename to envs/browsergym_env/openenv.yaml diff --git a/src/envs/browsergym_env/pyproject.toml b/envs/browsergym_env/pyproject.toml similarity index 88% rename from src/envs/browsergym_env/pyproject.toml rename to envs/browsergym_env/pyproject.toml index c13c7fed0..2cc249734 100644 --- a/src/envs/browsergym_env/pyproject.toml +++ b/envs/browsergym_env/pyproject.toml @@ -8,9 +8,9 @@ version = "0.1.0" description = "BrowserGym Environment for OpenEnv - Web automation using Playwright" requires-python = ">=3.10" dependencies = [ - "openenv-core @ git+https://github.com/meta-pytorch/OpenEnv.git#subdirectory=src/core", + "openenv[core]>=0.2.0", "fastapi>=0.104.0", - "uvicorn>=0.24.0", + "uvicorn[standard]>=0.24.0", "pydantic>=2.0.0", "requests>=2.25.0", "browsergym-core>=0.2.0", @@ -18,6 +18,7 @@ dependencies = [ "browsergym-webarena>=0.2.0", "gymnasium>=0.29.0", "playwright>=1.40.0", + "greenlet>=3.1.0", # Required for Python 3.13 compatibility "Pillow>=10.0.0", ] diff --git a/src/envs/browsergym_env/server/Dockerfile b/envs/browsergym_env/server/Dockerfile similarity index 100% rename from src/envs/browsergym_env/server/Dockerfile rename to envs/browsergym_env/server/Dockerfile diff --git a/src/envs/browsergym_env/server/__init__.py b/envs/browsergym_env/server/__init__.py similarity index 100% rename from src/envs/browsergym_env/server/__init__.py rename to envs/browsergym_env/server/__init__.py diff --git a/src/envs/browsergym_env/server/app.py b/envs/browsergym_env/server/app.py similarity index 64% rename from src/envs/browsergym_env/server/app.py rename to envs/browsergym_env/server/app.py index 275f4900e..fa8214dc3 100644 --- a/src/envs/browsergym_env/server/app.py +++ b/envs/browsergym_env/server/app.py @@ -2,7 +2,7 @@ import os -from openenv_core.env_server.http_server import create_app +from openenv.core.env_server.http_server import create_app from browsergym_env.models import BrowserGymAction, BrowserGymObservation from browsergym_env.server.browsergym_environment import BrowserGymEnvironment @@ -15,19 +15,24 @@ timeout = float(os.environ.get("BROWSERGYM_TIMEOUT", "10000")) port = int(os.environ.get("BROWSERGYM_PORT", "8000")) -# Create the environment instance -env = BrowserGymEnvironment( - benchmark=benchmark, - task_name=task_name, - headless=headless, - viewport_width=viewport_width, - viewport_height=viewport_height, - timeout=timeout, -) + +# Factory function to create BrowserGymEnvironment instances +def create_browsergym_environment(): + """Factory function that creates BrowserGymEnvironment with config.""" + return BrowserGymEnvironment( + benchmark=benchmark, + task_name=task_name, + headless=headless, + viewport_width=viewport_width, + viewport_height=viewport_height, + timeout=timeout, + ) + # Create the FastAPI app +# Pass the factory function instead of an instance for WebSocket session support app = create_app( - env, + create_browsergym_environment, BrowserGymAction, BrowserGymObservation, env_name="browsergym_env", diff --git a/src/envs/browsergym_env/server/browsergym_environment.py b/envs/browsergym_env/server/browsergym_environment.py similarity index 67% rename from src/envs/browsergym_env/server/browsergym_environment.py rename to envs/browsergym_env/server/browsergym_environment.py index 1bafbbc5e..a66734994 100644 --- a/src/envs/browsergym_env/server/browsergym_environment.py +++ b/envs/browsergym_env/server/browsergym_environment.py @@ -9,19 +9,70 @@ """ import importlib -import os +import logging from typing import Any, Dict, Optional from uuid import uuid4 import gymnasium as gym -from openenv_core.env_server.interfaces import Environment +from openenv.core.env_server.interfaces import Environment from browsergym_env.models import ( BrowserGymAction, BrowserGymObservation, BrowserGymState, ) +logger = logging.getLogger(__name__) + + +def _get_axtree_txt(obs: Dict[str, Any]) -> str: + """Extract accessibility tree text from BrowserGym observation. + + BrowserGym returns raw `axtree_object` which needs to be converted to text + using the `flatten_axtree_to_str` utility function. + """ + # If already processed as text, return directly + if "axtree_txt" in obs and obs["axtree_txt"]: + return obs["axtree_txt"] + + # Try to convert from raw axtree_object + if "axtree_object" in obs and obs["axtree_object"]: + try: + from browsergym.utils.obs import flatten_axtree_to_str + + return flatten_axtree_to_str(obs["axtree_object"]) + except ImportError: + logger.warning("browsergym.utils.obs not available, cannot convert axtree_object to text") + except Exception as e: + logger.warning(f"Failed to convert axtree_object to text: {e}") + + return "" + + +def _get_pruned_html(obs: Dict[str, Any]) -> str: + """Extract pruned HTML from BrowserGym observation. + + BrowserGym returns raw `dom_object` which needs to be converted to text + and then pruned using the `flatten_dom_to_str` and `prune_html` utilities. + """ + # If already processed as pruned_html, return directly + if "pruned_html" in obs and obs["pruned_html"]: + return obs["pruned_html"] + + # Try to convert from raw dom_object + if "dom_object" in obs and obs["dom_object"]: + try: + from browsergym.utils.obs import flatten_dom_to_str, prune_html + + dom_str = flatten_dom_to_str(obs["dom_object"]) + return prune_html(dom_str) + except ImportError: + logger.warning("browsergym.utils.obs not available, cannot convert dom_object to pruned_html") + except Exception as e: + logger.warning(f"Failed to convert dom_object to pruned_html: {e}") + + return "" + _MINIWOB_LOAD_HELP = ( "MiniWoB tasks require the MiniWoB HTML bundle to be served over HTTP. " @@ -193,9 +244,7 @@ def step(self, action: BrowserGymAction) -> BrowserGymObservation: # Execute action in gym environment try: - obs, reward, terminated, truncated, info = self.gym_env.step( - action.action_str - ) + obs, reward, terminated, truncated, info = self.gym_env.step(action.action_str) self._last_obs = obs self._last_info = info @@ -241,26 +290,42 @@ def _create_observation( Returns: BrowserGymObservation """ - # Extract text observation (could be AXTree, DOM, or other) - text = "" - if "axtree_txt" in obs: - text = obs["axtree_txt"] - elif "pruned_html" in obs: - text = obs["pruned_html"] - elif "dom_txt" in obs: - text = obs["dom_txt"] - elif isinstance(obs, str): + # Generate text representations from raw BrowserGym objects + # BrowserGym returns axtree_object and dom_object which need conversion + axtree_txt = _get_axtree_txt(obs) if isinstance(obs, dict) else "" + pruned_html = _get_pruned_html(obs) if isinstance(obs, dict) else "" + + # Extract text observation - prefer axtree_txt, fallback to pruned_html + text = axtree_txt or pruned_html + if not text and isinstance(obs, str): text = obs - # Extract URL - url = info.get("url", "") - if not url and "page" in info: - url = info["page"].get("url", "") - - # Extract goal/instruction - goal = info.get("goal", "") - if not goal and "task" in info: - goal = info["task"].get("goal", "") + # Extract URL from obs (BrowserGym stores it there) + url = "" + if isinstance(obs, dict): + url = obs.get("url", "") + + # Extract goal/instruction from goal_object or legacy goal field + goal = "" + if isinstance(obs, dict): + # New format: goal_object is a list of messages + goal_object = obs.get("goal_object", []) + if goal_object: + # Extract text content from goal messages + goal_texts = [] + for msg in goal_object: + if isinstance(msg, dict): + content = msg.get("content", "") + if isinstance(content, str): + goal_texts.append(content) + elif isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + goal_texts.append(item.get("text", "")) + goal = " ".join(goal_texts) + # Fallback to legacy goal field + if not goal: + goal = obs.get("goal", "") # Update state self._state.current_url = url @@ -268,15 +333,22 @@ def _create_observation( # Extract additional observation modalities screenshot = obs.get("screenshot") if isinstance(obs, dict) else None - axtree_txt = obs.get("axtree_txt", "") if isinstance(obs, dict) else "" - pruned_html = obs.get("pruned_html", "") if isinstance(obs, dict) else "" + + # Extract last_action_error from obs (BrowserGym includes this) + last_action_error = False + if isinstance(obs, dict): + last_action_error = bool(obs.get("last_action_error")) # Store full BrowserGym observation and info in metadata # This preserves timestamps, additional fields, and any future extensions - browsergym_metadata = { - "browsergym_obs": obs if isinstance(obs, dict) else {}, - "browsergym_info": info, - } + # Note: We exclude large objects (dom_object, axtree_object) to reduce payload size + browsergym_metadata = {} + if isinstance(obs, dict): + # Include useful fields but exclude large raw objects + browsergym_metadata["browsergym_obs"] = { + k: v for k, v in obs.items() if k not in ("dom_object", "axtree_object", "screenshot") + } + browsergym_metadata["browsergym_info"] = info return BrowserGymObservation( text=text, @@ -286,7 +358,7 @@ def _create_observation( axtree_txt=axtree_txt, pruned_html=pruned_html, error="", - last_action_error=False, + last_action_error=last_action_error, done=done, reward=reward, metadata=browsergym_metadata, diff --git a/envs/browsergym_env/server/requirements.txt b/envs/browsergym_env/server/requirements.txt new file mode 100644 index 000000000..3a80710f0 --- /dev/null +++ b/envs/browsergym_env/server/requirements.txt @@ -0,0 +1,10 @@ +browsergym>=0.10.0 +browsergym-core>=0.10.0 +browsergym-miniwob>=0.10.0 +browsergym-webarena>=0.10.0 +gymnasium>=0.29.0 +playwright>=1.40.0 +Pillow>=10.0.0 +beautifulsoup4>=4.12.0 +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 diff --git a/src/envs/browsergym_env/server/start.sh b/envs/browsergym_env/server/start.sh similarity index 100% rename from src/envs/browsergym_env/server/start.sh rename to envs/browsergym_env/server/start.sh diff --git a/src/envs/chat_env/README.md b/envs/chat_env/README.md similarity index 96% rename from src/envs/chat_env/README.md rename to envs/chat_env/README.md index 6cd11e274..67f83fc33 100644 --- a/src/envs/chat_env/README.md +++ b/envs/chat_env/README.md @@ -35,7 +35,7 @@ ChatEnvironment can be used in **two ways**: from transformers import AutoTokenizer from envs.chat_env import ChatAction, ChatObservation from envs.chat_env.server import ChatEnvironment -from core.env_server import Message +from openenv.core.env_server import Message # Initialize with a tokenizer and optional system prompt tokenizer = AutoTokenizer.from_pretrained("gpt2") @@ -96,12 +96,12 @@ Before using the HTTP client, build the Docker image: ```bash # From project root -docker build -t chat-env:latest -f src/envs/chat_env/server/Dockerfile . +docker build -t chat-env:latest -f envs/chat_env/server/Dockerfile . # Optionally specify a different tokenizer docker build -t chat-env:latest \ --build-arg TOKENIZER_NAME=meta-llama/Llama-2-7b-chat-hf \ - -f src/envs/chat_env/server/Dockerfile . + -f envs/chat_env/server/Dockerfile . ``` ## Architecture @@ -154,7 +154,7 @@ Convenience method to convert a message dict to a tokenized ChatAction. ```python from transformers import AutoTokenizer from envs.chat_env.server import ChatEnvironment -from core.env_server import Message +from openenv.core.env_server import Message tokenizer = AutoTokenizer.from_pretrained("gpt2") env = ChatEnvironment(tokenizer=tokenizer) @@ -182,7 +182,7 @@ print(f"All tokens: {obs.tokens}") You can add transforms to compute rewards or modify observations: ```python -from core.env_server import Transform, Observation +from openenv.core.env_server import Transform, Observation class LengthRewardTransform(Transform): """Reward based on response length.""" diff --git a/src/envs/chat_env/__init__.py b/envs/chat_env/__init__.py similarity index 100% rename from src/envs/chat_env/__init__.py rename to envs/chat_env/__init__.py diff --git a/src/envs/chat_env/client.py b/envs/chat_env/client.py similarity index 81% rename from src/envs/chat_env/client.py rename to envs/chat_env/client.py index 96e5927fd..a1b265cd4 100644 --- a/src/envs/chat_env/client.py +++ b/envs/chat_env/client.py @@ -5,52 +5,54 @@ # LICENSE file in the root directory of this source tree. """ -Chat Environment HTTP Client. +Chat Environment Client. This module provides the client for connecting to a Chat Environment server -over HTTP. +via WebSocket for persistent sessions. """ from typing import Any, Dict import torch -from core.client_types import StepResult +from openenv.core.client_types import StepResult -from core.env_server.interfaces import Message -from core.env_server.types import State -from core.http_env_client import HTTPEnvClient +from openenv.core.env_server.interfaces import Message +from openenv.core.env_client import EnvClient from .models import ChatAction, ChatObservation, ChatState -class ChatEnv(HTTPEnvClient[ChatAction, ChatObservation]): +class ChatEnv(EnvClient[ChatAction, ChatObservation, ChatState]): """ - HTTP client for the Chat Environment. + Client for the Chat Environment. - This client connects to a ChatEnvironment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. - Note: Since ChatEnvironment works with PyTorch tensors, the HTTP layer + Note: Since ChatEnvironment works with PyTorch tensors, the client serializes tokens as lists for transport and deserializes them back to tensors. Example: >>> # Connect to a running server - >>> client = ChatEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.messages) - >>> - >>> # Send an action with tokens - >>> import torch - >>> tokens = torch.tensor([[1, 2, 3, 4, 5]]) - >>> result = client.step(ChatAction(tokens=tokens)) - >>> print(result.observation.messages) - >>> print(result.reward) + >>> with ChatEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.messages) + ... + ... # Send an action with tokens + ... import torch + ... tokens = torch.tensor([[1, 2, 3, 4, 5]]) + ... result = client.step(ChatAction(tokens=tokens)) + ... print(result.observation.messages) + ... print(result.reward) Example with Docker: >>> # Automatically start container and connect >>> client = ChatEnv.from_docker_image("chat-env:latest") - >>> result = client.reset() - >>> result = client.step(ChatAction(tokens=torch.tensor([[1, 2, 3]]))) + >>> try: + ... result = client.reset() + ... result = client.step(ChatAction(tokens=torch.tensor([[1, 2, 3]]))) + ... finally: + ... client.close() """ def _step_payload(self, action: ChatAction) -> Dict: diff --git a/src/envs/chat_env/models.py b/envs/chat_env/models.py similarity index 94% rename from src/envs/chat_env/models.py rename to envs/chat_env/models.py index 321565ed9..712317089 100644 --- a/src/envs/chat_env/models.py +++ b/envs/chat_env/models.py @@ -15,8 +15,8 @@ import torch -from core.env_server.interfaces import Message -from core.env_server.types import Action, Observation, State +from openenv.core.env_server.interfaces import Message +from openenv.core.env_server.types import Action, Observation, State @dataclass diff --git a/src/envs/chat_env/server/Dockerfile b/envs/chat_env/server/Dockerfile similarity index 83% rename from src/envs/chat_env/server/Dockerfile rename to envs/chat_env/server/Dockerfile index 041643fa3..6f42387fa 100644 --- a/src/envs/chat_env/server/Dockerfile +++ b/envs/chat_env/server/Dockerfile @@ -11,8 +11,8 @@ ARG BASE_IMAGE=openenv-base:latest FROM ${BASE_IMAGE} # Install dependencies and run setup -COPY src/envs/chat_env/server/requirements.txt /tmp/requirements.txt -COPY src/envs/chat_env/server/install_deps.sh /tmp/install_deps.sh +COPY envs/chat_env/server/requirements.txt /tmp/requirements.txt +COPY envs/chat_env/server/install_deps.sh /tmp/install_deps.sh RUN chmod +x /tmp/install_deps.sh && \ /tmp/install_deps.sh && \ rm /tmp/install_deps.sh /tmp/requirements.txt @@ -27,10 +27,10 @@ ENV SYSTEM_PROMPT="You are a helpful AI assistant." # Copy only what's needed for this environment COPY src/core/ /app/src/core/ -COPY src/envs/chat_env/ /app/src/envs/chat_env/ +COPY envs/chat_env/ /app/envs/chat_env/ # Copy README for web interface documentation -COPY src/envs/chat_env/README.md /app/README.md +COPY envs/chat_env/README.md /app/README.md # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/src/envs/chat_env/server/__init__.py b/envs/chat_env/server/__init__.py similarity index 100% rename from src/envs/chat_env/server/__init__.py rename to envs/chat_env/server/__init__.py diff --git a/src/envs/chat_env/server/app.py b/envs/chat_env/server/app.py similarity index 78% rename from src/envs/chat_env/server/app.py rename to envs/chat_env/server/app.py index 0ccb6abe8..88b9694f7 100644 --- a/src/envs/chat_env/server/app.py +++ b/envs/chat_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the Chat Environment. This module creates an HTTP server that exposes the ChatEnvironment -over HTTP endpoints, making it compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. Note: This server requires a tokenizer to be initialized. The tokenizer must be specified when starting the server. @@ -26,8 +26,7 @@ import os -from core.env_server import create_app -from core.env_server.web_interface import create_web_interface_app +from openenv.core.env_server import create_app from ..models import ChatAction, ChatObservation from .chat_environment import ChatEnvironment @@ -64,12 +63,17 @@ def get_tokenizer(): # Get system prompt from environment system_prompt = os.environ.get("SYSTEM_PROMPT", None) -# Create the environment instance with tokenizer -tokenizer = get_tokenizer() -env = ChatEnvironment(tokenizer=tokenizer, system_prompt=system_prompt) + +# Factory function to create ChatEnvironment instances +def create_chat_environment(): + """Factory function that creates ChatEnvironment with tokenizer.""" + tokenizer = get_tokenizer() + return ChatEnvironment(tokenizer=tokenizer, system_prompt=system_prompt) + # Create the FastAPI app with web interface and README integration -app = create_app(env, ChatAction, ChatObservation, env_name="chat_env") +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_chat_environment, ChatAction, ChatObservation, env_name="chat_env") if __name__ == "__main__": diff --git a/src/envs/chat_env/server/chat_environment.py b/envs/chat_env/server/chat_environment.py similarity index 98% rename from src/envs/chat_env/server/chat_environment.py rename to envs/chat_env/server/chat_environment.py index 80aa5a7ca..6b22c8190 100644 --- a/src/envs/chat_env/server/chat_environment.py +++ b/envs/chat_env/server/chat_environment.py @@ -12,7 +12,7 @@ import torch -from core.env_server.interfaces import Environment, Message, ModelTokenizer, Transform +from openenv.core.env_server.interfaces import Environment, Message, ModelTokenizer, Transform from ..models import ChatAction, ChatObservation, ChatState diff --git a/src/envs/chat_env/server/install_deps.sh b/envs/chat_env/server/install_deps.sh similarity index 100% rename from src/envs/chat_env/server/install_deps.sh rename to envs/chat_env/server/install_deps.sh diff --git a/src/envs/chat_env/server/requirements.txt b/envs/chat_env/server/requirements.txt similarity index 100% rename from src/envs/chat_env/server/requirements.txt rename to envs/chat_env/server/requirements.txt diff --git a/src/envs/chat_env/server/test_chat_env.py b/envs/chat_env/server/test_chat_env.py similarity index 99% rename from src/envs/chat_env/server/test_chat_env.py rename to envs/chat_env/server/test_chat_env.py index 92a67d0e1..85295eb4b 100644 --- a/src/envs/chat_env/server/test_chat_env.py +++ b/envs/chat_env/server/test_chat_env.py @@ -12,7 +12,7 @@ import torch -from core.env_server.interfaces import Message +from openenv.core.env_server.interfaces import Message from ..models import ChatAction from .chat_environment import ChatEnvironment diff --git a/src/envs/coding_env/README.md b/envs/coding_env/README.md similarity index 96% rename from src/envs/coding_env/README.md rename to envs/coding_env/README.md index b99921b8e..75bc67e41 100644 --- a/src/envs/coding_env/README.md +++ b/envs/coding_env/README.md @@ -60,7 +60,7 @@ Before using the environment, you need to build the Docker image: ```bash # From project root -docker build -t coding-env:latest -f src/envs/coding_env/server/Dockerfile . +docker build -t coding-env:latest -f envs/coding_env/server/Dockerfile . ``` ## Environment Details @@ -107,7 +107,7 @@ Note: When connecting to an existing server, `coding_env.close()` will NOT stop Run the complete example that demonstrates the full workflow: ```bash -python3 src/envs/coding_env/client/example_usage.py +python3 envs/coding_env/client/example_usage.py ``` This example shows: diff --git a/src/envs/coding_env/__init__.py b/envs/coding_env/__init__.py similarity index 100% rename from src/envs/coding_env/__init__.py rename to envs/coding_env/__init__.py diff --git a/src/envs/coding_env/client.py b/envs/coding_env/client.py similarity index 76% rename from src/envs/coding_env/client.py rename to envs/coding_env/client.py index d65c5152e..a05db092e 100644 --- a/src/envs/coding_env/client.py +++ b/envs/coding_env/client.py @@ -2,25 +2,26 @@ CodingEnv --------- Client-side wrapper for the Coding environment server. -Talks HTTP to a single base_url exposing: /reset and /step. + +This client maintains a persistent WebSocket connection to the environment +server, enabling efficient multi-step interactions with lower latency. - users instantiate CodingEnv with a base_url provided by the higher-level vector/orchestration layer. -- Environment authors ship the Docker image that serves the HTTP API. +- Environment authors ship the Docker image that serves the API. (Seeds, episode IDs, request IDs, capabilities can be added later in the payloads.) """ from __future__ import annotations -from openenv_core.client_types import StepResult - -from openenv_core.http_env_client import HTTPEnvClient +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient -from coding_env.models import CodeAction, CodeObservation, CodeState +from .models import CodeAction, CodeObservation, CodeState -class CodingEnv(HTTPEnvClient[CodeAction, CodeObservation]): +class CodingEnv(EnvClient[CodeAction, CodeObservation, CodeState]): # --- HTTPEnvClient abstract hooks --- def _step_payload(self, action: CodeAction) -> dict: diff --git a/src/envs/coding_env/models.py b/envs/coding_env/models.py similarity index 91% rename from src/envs/coding_env/models.py rename to envs/coding_env/models.py index a92c2560e..19991d0bb 100644 --- a/src/envs/coding_env/models.py +++ b/envs/coding_env/models.py @@ -8,7 +8,7 @@ from dataclasses import dataclass -from openenv_core.env_server.interfaces import Action, Observation, State +from openenv.core.env_server.interfaces import Action, Observation, State @dataclass diff --git a/src/envs/coding_env/openenv.yaml b/envs/coding_env/openenv.yaml similarity index 100% rename from src/envs/coding_env/openenv.yaml rename to envs/coding_env/openenv.yaml diff --git a/src/envs/coding_env/pyproject.toml b/envs/coding_env/pyproject.toml similarity index 96% rename from src/envs/coding_env/pyproject.toml rename to envs/coding_env/pyproject.toml index 06b70f2ba..703f26fa8 100644 --- a/src/envs/coding_env/pyproject.toml +++ b/envs/coding_env/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Coding Environment for OpenEnv" requires-python = ">=3.10" dependencies = [ - "openenv-core>=0.1.0", + "openenv[core]>=0.2.0", "fastapi>=0.115.0", "pydantic>=2.0.0", "uvicorn[standard]>=0.24.0", diff --git a/src/envs/coding_env/server/Dockerfile b/envs/coding_env/server/Dockerfile similarity index 100% rename from src/envs/coding_env/server/Dockerfile rename to envs/coding_env/server/Dockerfile diff --git a/src/envs/coding_env/server/Dockerfile.backup b/envs/coding_env/server/Dockerfile.backup similarity index 89% rename from src/envs/coding_env/server/Dockerfile.backup rename to envs/coding_env/server/Dockerfile.backup index 152f9e59e..30e8e6e68 100644 --- a/src/envs/coding_env/server/Dockerfile.backup +++ b/envs/coding_env/server/Dockerfile.backup @@ -12,10 +12,10 @@ FROM ${BASE_IMAGE} # Copy only what's needed for this environment COPY src/core/ /app/src/core/ -COPY src/envs/coding_env/ /app/src/envs/coding_env/ +COPY envs/coding_env/ /app/envs/coding_env/ # Copy README for web interface documentation -COPY src/envs/coding_env/README.md /app/README.md +COPY envs/coding_env/README.md /app/README.md # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/src/envs/coding_env/server/README.md b/envs/coding_env/server/README.md similarity index 100% rename from src/envs/coding_env/server/README.md rename to envs/coding_env/server/README.md diff --git a/src/envs/coding_env/server/__init__.py b/envs/coding_env/server/__init__.py similarity index 100% rename from src/envs/coding_env/server/__init__.py rename to envs/coding_env/server/__init__.py diff --git a/src/envs/coding_env/server/app.py b/envs/coding_env/server/app.py similarity index 79% rename from src/envs/coding_env/server/app.py rename to envs/coding_env/server/app.py index 1a5edf7cb..4859585fa 100644 --- a/src/envs/coding_env/server/app.py +++ b/envs/coding_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the Coding Environment. This module creates an HTTP server that exposes the PythonCodeActEnv -over HTTP endpoints, making it compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. Usage: # Development (with auto-reload): @@ -21,16 +21,14 @@ python -m envs.coding_env.server.app """ -from openenv_core.env_server import create_app +from openenv.core.env_server import create_app from coding_env.models import CodeAction, CodeObservation from coding_env.server.python_codeact_env import PythonCodeActEnv -# Create the environment instance -env = PythonCodeActEnv() - # Create the app with web interface and README integration -app = create_app(env, CodeAction, CodeObservation, env_name="coding_env") +# Pass the class (factory) instead of an instance for WebSocket session support +app = create_app(PythonCodeActEnv, CodeAction, CodeObservation, env_name="coding_env") if __name__ == "__main__": diff --git a/src/envs/coding_env/server/python_codeact_env.py b/envs/coding_env/server/python_codeact_env.py similarity index 94% rename from src/envs/coding_env/server/python_codeact_env.py rename to envs/coding_env/server/python_codeact_env.py index ecc93d9fe..a73ed1e55 100644 --- a/src/envs/coding_env/server/python_codeact_env.py +++ b/envs/coding_env/server/python_codeact_env.py @@ -13,10 +13,10 @@ import uuid -from openenv_core.env_server.interfaces import Action, Environment, Observation -from coding_env.server.python_executor import PyExecutor +from openenv.core.env_server.interfaces import Action, Environment, Observation +from .python_executor import PyExecutor -from coding_env.models import CodeAction, CodeObservation, CodeState +from ..models import CodeAction, CodeObservation, CodeState from .transforms import create_safe_coding_transform diff --git a/src/envs/coding_env/server/python_executor.py b/envs/coding_env/server/python_executor.py similarity index 99% rename from src/envs/coding_env/server/python_executor.py rename to envs/coding_env/server/python_executor.py index 17b6ecc13..ab49b48e9 100644 --- a/src/envs/coding_env/server/python_executor.py +++ b/envs/coding_env/server/python_executor.py @@ -27,7 +27,7 @@ from smolagents import LocalPythonExecutor -from openenv_core.env_server.types import CodeExecResult +from openenv.core.env_server.types import CodeExecResult logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) diff --git a/src/envs/coding_env/server/transforms.py b/envs/coding_env/server/transforms.py similarity index 94% rename from src/envs/coding_env/server/transforms.py rename to envs/coding_env/server/transforms.py index ee5a1c4b0..2baf0d6f1 100644 --- a/src/envs/coding_env/server/transforms.py +++ b/envs/coding_env/server/transforms.py @@ -9,9 +9,9 @@ import ast import re -from openenv_core.env_server.base_transforms import CompositeTransform -from openenv_core.env_server.interfaces import Transform -from openenv_core.env_server.types import Observation +from openenv.core.env_server.base_transforms import CompositeTransform +from openenv.core.env_server.interfaces import Transform +from openenv.core.env_server.types import Observation from coding_env.models import CodeObservation diff --git a/src/envs/connect4_env/README.md b/envs/connect4_env/README.md similarity index 100% rename from src/envs/connect4_env/README.md rename to envs/connect4_env/README.md diff --git a/src/envs/connect4_env/__init__.py b/envs/connect4_env/__init__.py similarity index 100% rename from src/envs/connect4_env/__init__.py rename to envs/connect4_env/__init__.py diff --git a/src/envs/connect4_env/client.py b/envs/connect4_env/client.py similarity index 74% rename from src/envs/connect4_env/client.py rename to envs/connect4_env/client.py index 56aee8439..d9f6c2165 100644 --- a/src/envs/connect4_env/client.py +++ b/envs/connect4_env/client.py @@ -5,40 +5,39 @@ # LICENSE file in the root directory of this source tree. """ -Connect4 Environment HTTP Client. +Connect4 Environment Client. This module provides the client for connecting to a Connect4 Environment server -over HTTP. +via WebSocket for persistent sessions. """ from __future__ import annotations from typing import Any, Dict, TYPE_CHECKING -from core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient from .models import Connect4Action, Connect4Observation, Connect4State if TYPE_CHECKING: - from core.containers.runtime import ContainerProvider + from openenv.core.containers.runtime import ContainerProvider -class Connect4Env(HTTPEnvClient[Connect4Action, Connect4Observation]): +class Connect4Env(EnvClient[Connect4Action, Connect4Observation, Connect4State]): """ - HTTP client for Connect4 Environment. + Client for Connect4 Environment. - This client connects to a Connect4Environment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. Example: - >>> client = Connect4Env(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.board) - >>> - >>> # Take an action - >>> result = client.step(Connect4Action(column=3)) - >>> print(result.reward, result.done) + >>> with Connect4Env(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.board) + ... + ... result = client.step(Connect4Action(column=3)) + ... print(result.reward, result.done) """ def _step_payload(self, action: Connect4Action) -> Dict[str, Any]: diff --git a/src/envs/connect4_env/models.py b/envs/connect4_env/models.py similarity index 73% rename from src/envs/connect4_env/models.py rename to envs/connect4_env/models.py index d10bb5ef1..90ee90742 100644 --- a/src/envs/connect4_env/models.py +++ b/envs/connect4_env/models.py @@ -12,14 +12,12 @@ """ from __future__ import annotations -from dataclasses import dataclass, field -import numpy as np -from typing import List +from typing import List, Dict, Any +from pydantic import Field -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State -@dataclass class Connect4Action(Action): """ Action for Connect4 environment. @@ -30,7 +28,6 @@ class Connect4Action(Action): column: int -@dataclass(kw_only=True) class Connect4Observation(Observation): """ Observation for Connect4 environment. @@ -43,15 +40,10 @@ class Connect4Observation(Observation): reward: Reward for the last action. """ - board: List[List[int]] - legal_actions: List[int] - done: bool = False - reward: float = 0.0 - metadata: dict = field(default_factory=dict) - + board: List[List[int]] = Field(default_factory=list) + legal_actions: List[int] = Field(default_factory=list) -@dataclass(kw_only=True) class Connect4State(State): """ State for Connect4 environment. @@ -62,7 +54,5 @@ class Connect4State(State): next_player: Whose turn it is (1 or -1). step_count: Number of steps taken in the game. """ - episode_id: str - board: List[List[int]] = field(default_factory=lambda: np.zeros((6,7), dtype=int).tolist()) + board: List[List[int]] = Field(default_factory=lambda: [[0]*7 for _ in range(6)]) next_player: int = 1 - step_count: int = 0 diff --git a/src/envs/connect4_env/server/Dockerfile b/envs/connect4_env/server/Dockerfile similarity index 89% rename from src/envs/connect4_env/server/Dockerfile rename to envs/connect4_env/server/Dockerfile index 04d40ff2b..c9d93ed62 100644 --- a/src/envs/connect4_env/server/Dockerfile +++ b/envs/connect4_env/server/Dockerfile @@ -8,7 +8,7 @@ RUN pip install --no-cache-dir \ numpy>=1.24.0 # Copy environment code COPY src/core/ /app/src/core/ -COPY src/envs/connect4_env/ /app/src/envs/connect4_env/ +COPY envs/connect4_env/ /app/envs/connect4_env/ # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/src/envs/connect4_env/server/__init__.py b/envs/connect4_env/server/__init__.py similarity index 100% rename from src/envs/connect4_env/server/__init__.py rename to envs/connect4_env/server/__init__.py diff --git a/envs/connect4_env/server/app.py b/envs/connect4_env/server/app.py new file mode 100644 index 000000000..2025b2c37 --- /dev/null +++ b/envs/connect4_env/server/app.py @@ -0,0 +1,15 @@ +"""FastAPI application for the Connect4 Environment.""" + +from openenv.core.env_server import create_app +from ..models import Connect4Action, Connect4Observation +from .connect4_environment import Connect4Environment + +# Create the FastAPI app +# Pass the class (factory) instead of an instance for WebSocket session support +app = create_app(Connect4Environment, Connect4Action, Connect4Observation, env_name="connect4_env") + +if __name__ == "__main__": + + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/envs/connect4_env/server/connect4_environment.py b/envs/connect4_env/server/connect4_environment.py similarity index 98% rename from src/envs/connect4_env/server/connect4_environment.py rename to envs/connect4_env/server/connect4_environment.py index 1ef6414b3..568d32636 100644 --- a/src/envs/connect4_env/server/connect4_environment.py +++ b/envs/connect4_env/server/connect4_environment.py @@ -1,6 +1,6 @@ import uuid import numpy as np -from core.env_server import Environment +from openenv.core.env_server import Environment from ..models import Connect4Action, Connect4Observation, Connect4State diff --git a/src/envs/dipg_safety_env/README.md b/envs/dipg_safety_env/README.md similarity index 100% rename from src/envs/dipg_safety_env/README.md rename to envs/dipg_safety_env/README.md diff --git a/src/envs/dipg_safety_env/__init__.py b/envs/dipg_safety_env/__init__.py similarity index 100% rename from src/envs/dipg_safety_env/__init__.py rename to envs/dipg_safety_env/__init__.py diff --git a/src/envs/dipg_safety_env/client.py b/envs/dipg_safety_env/client.py similarity index 89% rename from src/envs/dipg_safety_env/client.py rename to envs/dipg_safety_env/client.py index f5352d709..2d11503b3 100644 --- a/src/envs/dipg_safety_env/client.py +++ b/envs/dipg_safety_env/client.py @@ -1,24 +1,26 @@ -# src/envs/dipg_safety_env/client.py +# envs/dipg_safety_env/client.py """ Client implementation for the custom DIPGSafetyEnv. This file defines the `DIPGSafetyEnv` class, which acts as the "remote control" -for the environment server. Its primary job is to handle the HTTP communication: +for the environment server. It maintains a persistent WebSocket connection +for efficient multi-step interactions: 1. It takes Python objects (like an Action) from the agent's code. 2. It converts them into JSON to send to the server. 3. It receives JSON responses from the server. 4. It parses that JSON back into useful Python objects (like Observations and Rewards). """ -from core.http_env_client import HTTPEnvClient, StepResult +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient from .models import DIPGAction, DIPGObservation, DIPGState -class DIPGSafetyEnv(HTTPEnvClient[DIPGAction, DIPGObservation]): +class DIPGSafetyEnv(EnvClient[DIPGAction, DIPGObservation, DIPGState]): """ Client for interacting with the `DIPGSafetyEnv` server. - This class inherits from the base `HTTPEnvClient` and is specialized to handle + This class inherits from the base `EnvClient` and is specialized to handle the specific data types of our environment: `DIPGAction` and `DIPGObservation`. """ @@ -31,8 +33,8 @@ def __init__(self, base_url: str, timeout: float = 60.0): timeout: The number of seconds to wait for a server response. """ # This correctly calls the parent initializer with the expected - # 'request_timeout_s' keyword argument. - super().__init__(base_url=base_url, request_timeout_s=timeout) + # 'message_timeout_s' keyword argument. + super().__init__(base_url=base_url, message_timeout_s=timeout) # ---------------------------------------- def _step_payload(self, action: DIPGAction) -> dict: diff --git a/src/envs/dipg_safety_env/models.py b/envs/dipg_safety_env/models.py similarity index 65% rename from src/envs/dipg_safety_env/models.py rename to envs/dipg_safety_env/models.py index 5cf3fa2b2..a770e7355 100644 --- a/src/envs/dipg_safety_env/models.py +++ b/envs/dipg_safety_env/models.py @@ -1,24 +1,25 @@ -# src/envs/dipg_safety_env/models.py +# envs/dipg_safety_env/models.py + +from typing import Dict, Any +from pydantic import Field +from openenv.core.env_server import Action, Observation, State -from dataclasses import dataclass, field -from core.env_server import Action, Observation, State -@dataclass class DIPGAction(Action): """The action taken by the agent, which is its generated response.""" llm_response: str -@dataclass + class DIPGObservation(Observation): """The observation given to the agent: a context and a question.""" - context: str - question: str + context: str = "" + question: str = "" + -@dataclass class DIPGState(State): """The internal state of the environment for tracking the current challenge.""" current_context: str = "" current_question: str = "" # This will hold the ground-truth 'analysis' and 'final' answer # for scoring purposes. - expected_answer: dict = field(default_factory=dict) \ No newline at end of file + expected_answer: Dict[str, Any] = Field(default_factory=dict) \ No newline at end of file diff --git a/src/envs/dipg_safety_env/server/Dockerfile b/envs/dipg_safety_env/server/Dockerfile similarity index 90% rename from src/envs/dipg_safety_env/server/Dockerfile rename to envs/dipg_safety_env/server/Dockerfile index e9c273497..0fd2504e7 100644 --- a/src/envs/dipg_safety_env/server/Dockerfile +++ b/envs/dipg_safety_env/server/Dockerfile @@ -12,7 +12,7 @@ WORKDIR /app # Copy requirements file and install dependencies. This is done in a separate # step to leverage Docker's layer caching. Dependencies are only re-installed # when the requirements.txt file changes. -COPY src/envs/dipg_safety_env/server/requirements.txt . +COPY envs/dipg_safety_env/server/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Set the working directory and PYTHONPATH inside the container @@ -21,7 +21,7 @@ ENV PYTHONPATH="/app/src" # Copy all the application source code into the container COPY src/core/ /app/src/core/ -COPY src/envs/dipg_safety_env/ /app/src/envs/dipg_safety_env/ +COPY envs/dipg_safety_env/ /app/envs/dipg_safety_env/ # Expose the port the server will run on EXPOSE 8000 diff --git a/src/envs/dipg_safety_env/server/__init__.py b/envs/dipg_safety_env/server/__init__.py similarity index 100% rename from src/envs/dipg_safety_env/server/__init__.py rename to envs/dipg_safety_env/server/__init__.py diff --git a/src/envs/dipg_safety_env/server/app.py b/envs/dipg_safety_env/server/app.py similarity index 67% rename from src/envs/dipg_safety_env/server/app.py rename to envs/dipg_safety_env/server/app.py index 1261496b6..f6dcaa8a1 100644 --- a/src/envs/dipg_safety_env/server/app.py +++ b/envs/dipg_safety_env/server/app.py @@ -1,6 +1,13 @@ -# src/envs/dipg_safety_env/server/app.py +# envs/dipg_safety_env/server/app.py +""" +FastAPI application for the DIPG Safety Environment. + +This module creates an HTTP server that exposes the DIPGEnvironment +over HTTP and WebSocket endpoints, compatible with EnvClient. +""" + import os -from core.env_server import create_app +from openenv.core.env_server import create_app from .dipg_environment import DIPGEnvironment from ..models import DIPGAction, DIPGObservation @@ -49,33 +56,27 @@ FINAL_CHANNEL_START = os.environ.get("FINAL_CHANNEL_START", "<|channel|>final<|message|>") CHANNEL_END = os.environ.get("CHANNEL_END", "<|end|>") -# Create the environment instance, passing all reward configurations to it. -env = DIPGEnvironment( - dataset_path=DATASET_PATH, - # V1 - conflict_reward=CONFLICT_REWARD, - abstain_reward=ABSTAIN_REWARD, - hallucination_penalty=HALLUCINATION_PENALTY, - missing_answer_penalty=MISSING_ANSWER_PENALTY, - # V2 - hallucinated_trace_penalty=HALLUCINATED_TRACE_PENALTY, - proof_inconsistency_penalty=PROOF_INCONSISTENCY_PENALTY, - incorrect_answer_penalty=INCORRECT_ANSWER_PENALTY, - conflict_penalty=CONFLICT_PENALTY, - abstain_penalty=ABSTAIN_PENALTY, - missing_trace_penalty=MISSING_TRACE_PENALTY, - correct_abstention_reward=CORRECT_ABSTENTION_REWARD, - verifiable_trace_reward=VERIFIABLE_TRACE_REWARD, - correct_synthesis_reward=CORRECT_SYNTHESIS_REWARD, - exact_format_reward=EXACT_FORMAT_REWARD, - format_mismatch_penalty=FORMAT_MISMATCH_PENALTY, - no_hallucination_reward=NO_HALLUCINATION_REWARD, - # Channels - analysis_channel_start=ANALYSIS_CHANNEL_START, - proof_channel_start=PROOF_CHANNEL_START, - final_channel_start=FINAL_CHANNEL_START, - channel_end=CHANNEL_END, -) -# The rest is the same. -app = create_app(env, DIPGAction, DIPGObservation, env_name="dipg_safety_env") \ No newline at end of file +# Factory function to create DIPGEnvironment instances +def create_dipg_environment(): + """Factory function that creates DIPGEnvironment with config.""" + return DIPGEnvironment( + dataset_path=DATASET_PATH, + conflict_reward=CONFLICT_REWARD, + conflict_penalty=CONFLICT_PENALTY, + abstain_reward=ABSTAIN_REWARD, + abstain_penalty=ABSTAIN_PENALTY, + format_mismatch_penalty=FORMAT_MISMATCH_PENALTY, + exact_format_reward=EXACT_FORMAT_REWARD, + hallucination_penalty=HALLUCINATION_PENALTY, + no_hallucination_reward=NO_HALLUCINATION_REWARD, + missing_answer_penalty=MISSING_ANSWER_PENALTY, + analysis_channel_start=ANALYSIS_CHANNEL_START, + final_channel_start=FINAL_CHANNEL_START, + channel_end=CHANNEL_END, + ) + + +# Create the FastAPI app +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_dipg_environment, DIPGAction, DIPGObservation, env_name="dipg_safety_env") diff --git a/src/envs/dipg_safety_env/server/dipg_environment.py b/envs/dipg_safety_env/server/dipg_environment.py similarity index 98% rename from src/envs/dipg_safety_env/server/dipg_environment.py rename to envs/dipg_safety_env/server/dipg_environment.py index 24cd553c9..c8a596ba8 100644 --- a/src/envs/dipg_safety_env/server/dipg_environment.py +++ b/envs/dipg_safety_env/server/dipg_environment.py @@ -1,10 +1,10 @@ -# src/envs/dipg_safety_env/server/dipg_environment.py +# envs/dipg_safety_env/server/dipg_environment.py import json import random from pathlib import Path -from core.http_env_client import StepResult -from core.env_server import Environment +from openenv.core.client_types import StepResult +from openenv.core.env_server import Environment from ..models import DIPGAction, DIPGObservation, DIPGState import re import logging diff --git a/src/envs/dipg_safety_env/server/requirements.txt b/envs/dipg_safety_env/server/requirements.txt similarity index 100% rename from src/envs/dipg_safety_env/server/requirements.txt rename to envs/dipg_safety_env/server/requirements.txt diff --git a/src/envs/echo_env/README.md b/envs/echo_env/README.md similarity index 96% rename from src/envs/echo_env/README.md rename to envs/echo_env/README.md index c4b7af379..14cb8ec21 100644 --- a/src/envs/echo_env/README.md +++ b/envs/echo_env/README.md @@ -57,7 +57,7 @@ Before using the environment, you need to build the Docker image: ```bash # From project root -docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +docker build -t echo-env:latest -f envs/echo_env/server/Dockerfile . ``` ## Environment Details @@ -107,7 +107,7 @@ Test the environment logic directly without starting the HTTP server: ```bash # From the server directory -python3 src/envs/echo_env/server/test_echo_env.py +python3 envs/echo_env/server/test_echo_env.py ``` This verifies that: diff --git a/src/envs/echo_env/__init__.py b/envs/echo_env/__init__.py similarity index 100% rename from src/envs/echo_env/__init__.py rename to envs/echo_env/__init__.py diff --git a/src/envs/echo_env/client.py b/envs/echo_env/client.py similarity index 66% rename from src/envs/echo_env/client.py rename to envs/echo_env/client.py index d8d1615f1..9c7ee2c64 100644 --- a/src/envs/echo_env/client.py +++ b/envs/echo_env/client.py @@ -5,10 +5,10 @@ # LICENSE file in the root directory of this source tree. """ -Echo Environment HTTP Client. +Echo Environment Client. This module provides the client for connecting to an Echo Environment server -over HTTP. +via WebSocket for persistent sessions. """ from typing import Any, Dict @@ -16,41 +16,44 @@ # Support both in-repo and standalone imports try: # In-repo imports (when running from OpenEnv repository) - from core.client_types import StepResult - from core.env_server.types import State - from core.http_env_client import HTTPEnvClient + from openenv.core.client_types import StepResult + from openenv.core.env_server.types import State + from openenv.core.env_client import EnvClient from .models import EchoAction, EchoObservation except ImportError: - # Standalone imports (when environment is standalone with openenv-core from pip) - from openenv_core.client_types import StepResult - from openenv_core.env_server.types import State - from openenv_core.http_env_client import HTTPEnvClient + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.client_types import StepResult + from openenv.core.env_server.types import State + from openenv.core.env_client import EnvClient from models import EchoAction, EchoObservation -class EchoEnv(HTTPEnvClient[EchoAction, EchoObservation]): +class EchoEnv(EnvClient[EchoAction, EchoObservation, State]): """ - HTTP client for the Echo Environment. + Client for the Echo Environment. - This client connects to an EchoEnvironment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. + Each client instance has its own dedicated environment session on the server. Example: >>> # Connect to a running server - >>> client = EchoEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.echoed_message) - >>> - >>> # Send a message - >>> result = client.step(EchoAction(message="Hello!")) - >>> print(result.observation.echoed_message) - >>> print(result.reward) + >>> with EchoEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.echoed_message) + ... + ... result = client.step(EchoAction(message="Hello!")) + ... print(result.observation.echoed_message) + ... print(result.reward) Example with Docker: >>> # Automatically start container and connect >>> client = EchoEnv.from_docker_image("echo-env:latest") - >>> result = client.reset() - >>> result = client.step(EchoAction(message="Test")) + >>> try: + ... result = client.reset() + ... result = client.step(EchoAction(message="Test")) + ... finally: + ... client.close() """ def _step_payload(self, action: EchoAction) -> Dict: diff --git a/src/envs/echo_env/models.py b/envs/echo_env/models.py similarity index 62% rename from src/envs/echo_env/models.py rename to envs/echo_env/models.py index c962629b9..3032b7511 100644 --- a/src/envs/echo_env/models.py +++ b/envs/echo_env/models.py @@ -10,27 +10,25 @@ The Echo environment is a simple test environment that echoes back messages. """ -from dataclasses import dataclass +from pydantic import Field # Support both in-repo and standalone imports try: # In-repo imports (when running from OpenEnv repository) - from core.env_server.types import Action, Observation + from openenv.core.env_server.types import Action, Observation except ImportError: - # Standalone imports (when environment is standalone with openenv-core from pip) - from openenv_core.env_server.types import Action, Observation + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server.types import Action, Observation -@dataclass(kw_only=True) class EchoAction(Action): """Action for the Echo environment - just a message to echo.""" - message: str + message: str = Field(..., min_length=1, description="Message to echo back") -@dataclass(kw_only=True) class EchoObservation(Observation): """Observation from the Echo environment - the echoed message.""" - echoed_message: str - message_length: int = 0 \ No newline at end of file + echoed_message: str = Field(..., description="The echoed message from the environment") + message_length: int = Field(default=0, ge=0, description="Length of the echoed message") \ No newline at end of file diff --git a/src/envs/echo_env/openenv.yaml b/envs/echo_env/openenv.yaml similarity index 100% rename from src/envs/echo_env/openenv.yaml rename to envs/echo_env/openenv.yaml diff --git a/src/envs/echo_env/pyproject.toml b/envs/echo_env/pyproject.toml similarity index 94% rename from src/envs/echo_env/pyproject.toml rename to envs/echo_env/pyproject.toml index a337f8faa..6705945f8 100644 --- a/src/envs/echo_env/pyproject.toml +++ b/envs/echo_env/pyproject.toml @@ -1,41 +1,41 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "openenv-echo-env" -version = "0.1.0" -description = "Echo Environment for OpenEnv - simple test environment that echoes back messages" -requires-python = ">=3.10" -dependencies = [ - # Core OpenEnv dependencies (required for server functionality) - "openenv-core>=0.1.0", - "fastapi>=0.115.0", - "pydantic>=2.0.0", - "uvicorn>=0.24.0", - "requests>=2.31.0", - # No additional environment-specific dependencies needed for echo_env -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-cov>=4.0.0", -] - -[project.scripts] -# Server entry point - enables running via: uv run --project . server -# or: python -m echo_env.server.app -server = "echo_env.server.app:main" - -[tool.setuptools] -package-dir = {"" = "."} - -[tool.setuptools.packages.find] -where = ["."] +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-echo-env" +version = "0.1.0" +description = "Echo Environment for OpenEnv - simple test environment that echoes back messages" +requires-python = ">=3.10" +dependencies = [ + # Core OpenEnv dependencies (required for server functionality) + "openenv[core]>=0.2.0", + "fastapi>=0.115.0", + "pydantic>=2.0.0", + "uvicorn>=0.24.0", + "requests>=2.31.0", + # No additional environment-specific dependencies needed for echo_env +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +# Server entry point - enables running via: uv run --project . server +# or: python -m echo_env.server.app +server = "echo_env.server.app:main" + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] diff --git a/src/envs/echo_env/server/Dockerfile b/envs/echo_env/server/Dockerfile similarity index 89% rename from src/envs/echo_env/server/Dockerfile rename to envs/echo_env/server/Dockerfile index deb08bc35..24d37dcd5 100644 --- a/src/envs/echo_env/server/Dockerfile +++ b/envs/echo_env/server/Dockerfile @@ -7,7 +7,7 @@ # Multi-stage build using openenv-base # This Dockerfile is flexible and works for both: # - In-repo environments (with local src/core) -# - Standalone environments (with openenv-core from pip) +# - Standalone environments (with openenv from pip) # The build script (openenv build) handles context detection and sets appropriate build args. ARG BASE_IMAGE=openenv-base:latest @@ -22,8 +22,8 @@ ARG ENV_NAME=echo_env # Copy environment code (always at root of build context) COPY . /app/env -# For in-repo builds, openenv-core is already in the pyproject.toml dependencies -# For standalone builds, openenv-core will be installed from pip via pyproject.toml +# For in-repo builds, openenv is already in the pyproject.toml dependencies +# For standalone builds, openenv will be installed from pip via pyproject.toml WORKDIR /app/env # Install dependencies using uv sync diff --git a/src/envs/echo_env/server/__init__.py b/envs/echo_env/server/__init__.py similarity index 100% rename from src/envs/echo_env/server/__init__.py rename to envs/echo_env/server/__init__.py diff --git a/src/envs/echo_env/server/app.py b/envs/echo_env/server/app.py similarity index 79% rename from src/envs/echo_env/server/app.py rename to envs/echo_env/server/app.py index 83d22b5d2..07fe59ecb 100644 --- a/src/envs/echo_env/server/app.py +++ b/envs/echo_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the Echo Environment. This module creates an HTTP server that exposes the EchoEnvironment -over HTTP endpoints, making it compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. Usage: # Development (with auto-reload): @@ -24,20 +24,18 @@ # Support both in-repo and standalone imports try: # In-repo imports (when running from OpenEnv repository) - from core.env_server.http_server import create_app + from openenv.core.env_server.http_server import create_app from ..models import EchoAction, EchoObservation from .echo_environment import EchoEnvironment except ImportError: - # Standalone imports (when environment is standalone with openenv-core from pip) - from openenv_core.env_server.http_server import create_app + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server.http_server import create_app from models import EchoAction, EchoObservation from server.echo_environment import EchoEnvironment -# Create the environment instance -env = EchoEnvironment() - # Create the app with web interface and README integration -app = create_app(env, EchoAction, EchoObservation, env_name="echo_env") +# Pass the class (factory) instead of an instance for WebSocket session support +app = create_app(EchoEnvironment, EchoAction, EchoObservation, env_name="echo_env") def main(): diff --git a/src/envs/echo_env/server/echo_environment.py b/envs/echo_env/server/echo_environment.py similarity index 92% rename from src/envs/echo_env/server/echo_environment.py rename to envs/echo_env/server/echo_environment.py index 53b383af2..fdc0f923c 100644 --- a/src/envs/echo_env/server/echo_environment.py +++ b/envs/echo_env/server/echo_environment.py @@ -16,13 +16,13 @@ # Support both in-repo and standalone imports try: # In-repo imports (when running from OpenEnv repository) - from core.env_server.interfaces import Environment - from core.env_server.types import State + from openenv.core.env_server.interfaces import Environment + from openenv.core.env_server.types import State from ..models import EchoAction, EchoObservation except ImportError: - # Standalone imports (when environment is standalone with openenv-core from pip) - from openenv_core.env_server.interfaces import Environment - from openenv_core.env_server.types import State + # Standalone imports (when environment is standalone with openenv from pip) + from openenv.core.env_server.interfaces import Environment + from openenv.core.env_server.types import State from models import EchoAction, EchoObservation diff --git a/src/envs/finrl_env/README.md b/envs/finrl_env/README.md similarity index 99% rename from src/envs/finrl_env/README.md rename to envs/finrl_env/README.md index fb27f2df5..be4c2e8d7 100644 --- a/src/envs/finrl_env/README.md +++ b/envs/finrl_env/README.md @@ -25,7 +25,7 @@ docker build -t envtorch-base:latest -f src/core/containers/images/Dockerfile . Then build the FinRL environment image: ```bash -docker build -t finrl-env:latest -f src/envs/finrl_env/server/Dockerfile . +docker build -t finrl-env:latest -f envs/finrl_env/server/Dockerfile . ``` ### 2. Run the Server diff --git a/src/envs/finrl_env/__init__.py b/envs/finrl_env/__init__.py similarity index 100% rename from src/envs/finrl_env/__init__.py rename to envs/finrl_env/__init__.py diff --git a/envs/finrl_env/client.py b/envs/finrl_env/client.py new file mode 100644 index 000000000..9fb1a51ed --- /dev/null +++ b/envs/finrl_env/client.py @@ -0,0 +1,135 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +FinRL Environment Client. + +This module provides the client for connecting to a FinRL Environment server +via WebSocket for persistent sessions. +""" + +from typing import Any, Dict + +from openenv.core.client_types import StepResult + +from openenv.core.env_server.types import State +from openenv.core.env_client import EnvClient + +from .models import FinRLAction, FinRLObservation + + +class FinRLEnv(EnvClient[FinRLAction, FinRLObservation, State]): + """ + Client for the FinRL Environment. + + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions for stock trading RL tasks. + + Example: + >>> # Connect to a running server + >>> with FinRLEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.state) + ... print(result.observation.portfolio_value) + ... + ... # Execute a trading action + ... action = FinRLAction(actions=[0.5, -0.3]) # Buy stock 0, sell stock 1 + ... result = client.step(action) + ... print(result.reward) + ... print(result.observation.portfolio_value) + + Example with Docker: + >>> # Automatically start container and connect + >>> client = FinRLEnv.from_docker_image("finrl-env:latest") + >>> try: + ... result = client.reset() + ... result = client.step(FinRLAction(actions=[0.1])) + ... finally: + ... client.close() + + Example training loop: + >>> import numpy as np + >>> from envs.finrl_env import FinRLEnv, FinRLAction + >>> + >>> with FinRLEnv(base_url="http://localhost:8000") as client: + ... # Training loop + ... for episode in range(10): + ... result = client.reset() + ... done = False + ... episode_reward = 0 + ... + ... while not done: + ... # Get state + ... state = result.observation.state + ... + ... # Simple random policy (replace with your RL agent) + ... num_stocks = len(state) // 7 # Simplified calculation + ... actions = np.random.uniform(-1, 1, size=num_stocks).tolist() + ... + ... # Execute action + ... result = client.step(FinRLAction(actions=actions)) + ... + ... episode_reward += result.reward or 0 + ... done = result.done + ... + ... print(f"Episode {episode}: reward={episode_reward:.2f}, " + ... f"final value={result.observation.portfolio_value:.2f}") + """ + + def _step_payload(self, action: FinRLAction) -> Dict: + """ + Convert FinRLAction to JSON payload for step request. + + Args: + action: FinRLAction instance + + Returns: + Dictionary representation suitable for JSON encoding + """ + return { + "actions": action.actions, + } + + def _parse_result(self, payload: Dict) -> StepResult[FinRLObservation]: + """ + Parse server response into StepResult[FinRLObservation]. + + Args: + payload: JSON response from server + + Returns: + StepResult with FinRLObservation + """ + obs_data = payload.get("observation", {}) + observation = FinRLObservation( + state=obs_data.get("state", []), + portfolio_value=obs_data.get("portfolio_value", 0.0), + date=obs_data.get("date", ""), + done=payload.get("done", False), + reward=payload.get("reward"), + metadata=obs_data.get("metadata", {}), + ) + + return StepResult( + observation=observation, + reward=payload.get("reward"), + done=payload.get("done", False), + ) + + def _parse_state(self, payload: Dict) -> State: + """ + Parse server response into State object. + + Args: + payload: JSON response from /state endpoint + + Returns: + State object with episode_id and step_count + """ + return State( + episode_id=payload.get("episode_id"), + step_count=payload.get("step_count", 0), + ) diff --git a/src/envs/finrl_env/models.py b/envs/finrl_env/models.py similarity index 96% rename from src/envs/finrl_env/models.py rename to envs/finrl_env/models.py index d841c0c83..7c12bbf24 100644 --- a/src/envs/finrl_env/models.py +++ b/envs/finrl_env/models.py @@ -13,7 +13,7 @@ from dataclasses import dataclass, field -from core.env_server.types import Action, Observation +from openenv.core.env_server.types import Action, Observation @dataclass(kw_only=True) diff --git a/src/envs/finrl_env/server/Dockerfile b/envs/finrl_env/server/Dockerfile similarity index 97% rename from src/envs/finrl_env/server/Dockerfile rename to envs/finrl_env/server/Dockerfile index b1b9b4bdc..d6f6146af 100644 --- a/src/envs/finrl_env/server/Dockerfile +++ b/envs/finrl_env/server/Dockerfile @@ -32,7 +32,7 @@ RUN pip install --no-cache-dir \ COPY src/core/ /app/src/core/ # Copy FinRL environment -COPY src/envs/finrl_env/ /app/src/envs/finrl_env/ +COPY envs/finrl_env/ /app/envs/finrl_env/ # Set working directory for the application WORKDIR /app/src diff --git a/src/envs/finrl_env/server/__init__.py b/envs/finrl_env/server/__init__.py similarity index 100% rename from src/envs/finrl_env/server/__init__.py rename to envs/finrl_env/server/__init__.py diff --git a/src/envs/finrl_env/server/app.py b/envs/finrl_env/server/app.py similarity index 90% rename from src/envs/finrl_env/server/app.py rename to envs/finrl_env/server/app.py index 720f9fa53..f02f659c7 100644 --- a/src/envs/finrl_env/server/app.py +++ b/envs/finrl_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the FinRL Environment. This module creates an HTTP server that exposes the FinRLEnvironment -over HTTP endpoints, making it compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. The server expects environment configuration to be provided either: 1. Through environment variables (FINRL_CONFIG_PATH) @@ -32,7 +32,7 @@ from pathlib import Path import pandas as pd -from core.env_server import create_fastapi_app +from openenv.core.env_server import create_app from ..models import FinRLAction, FinRLObservation from .finrl_environment import FinRLEnvironment @@ -116,11 +116,16 @@ def load_finrl_config(): # Load configuration finrl_env_class, finrl_config = load_finrl_config() -# Create the environment instance -env = FinRLEnvironment(finrl_env_class=finrl_env_class, finrl_env_config=finrl_config) + +# Factory function to create FinRLEnvironment instances +def create_finrl_environment(): + """Factory function that creates FinRLEnvironment with config.""" + return FinRLEnvironment(finrl_env_class=finrl_env_class, finrl_env_config=finrl_config) + # Create the FastAPI app with routes -app = create_fastapi_app(env, FinRLAction, FinRLObservation) +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_finrl_environment, FinRLAction, FinRLObservation, env_name="finrl_env") @app.get("/config") diff --git a/src/envs/finrl_env/server/build_docker.sh b/envs/finrl_env/server/build_docker.sh similarity index 100% rename from src/envs/finrl_env/server/build_docker.sh rename to envs/finrl_env/server/build_docker.sh diff --git a/src/envs/finrl_env/server/finrl_environment.py b/envs/finrl_env/server/finrl_environment.py similarity index 98% rename from src/envs/finrl_env/server/finrl_environment.py rename to envs/finrl_env/server/finrl_environment.py index 6cae2dbaa..d89b1c3c0 100644 --- a/src/envs/finrl_env/server/finrl_environment.py +++ b/envs/finrl_env/server/finrl_environment.py @@ -13,8 +13,8 @@ from uuid import uuid4 import numpy as np -from core.env_server.interfaces import Environment -from core.env_server.types import State +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.types import State from ..models import FinRLAction, FinRLObservation diff --git a/src/envs/git_env/README.md b/envs/git_env/README.md similarity index 99% rename from src/envs/git_env/README.md rename to envs/git_env/README.md index aed850ee0..5de057bb4 100644 --- a/src/envs/git_env/README.md +++ b/envs/git_env/README.md @@ -84,7 +84,7 @@ docker exec openenv-gitea curl -X POST \ # 3. Build Docker images docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . -docker build -t git-env:latest -f src/envs/git_env/server/Dockerfile . +docker build -t git-env:latest -f envs/git_env/server/Dockerfile . # 4. Install Python dependencies uv pip install -e . diff --git a/src/envs/git_env/__init__.py b/envs/git_env/__init__.py similarity index 100% rename from src/envs/git_env/__init__.py rename to envs/git_env/__init__.py diff --git a/src/envs/git_env/client.py b/envs/git_env/client.py similarity index 65% rename from src/envs/git_env/client.py rename to envs/git_env/client.py index 6857b0c23..efbf6182d 100644 --- a/src/envs/git_env/client.py +++ b/envs/git_env/client.py @@ -3,28 +3,30 @@ GitEnv Client ------------- Client-side wrapper for the Git environment server. -Talks HTTP to a single base_url exposing: /reset and /step. + +This client maintains a persistent WebSocket connection to the environment +server, enabling efficient multi-step interactions with lower latency. """ from __future__ import annotations from typing import TYPE_CHECKING -from core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient from .models import GitAction, GitObservation, GitState if TYPE_CHECKING: - from core.containers.runtime import ContainerProvider + from openenv.core.containers.runtime import ContainerProvider -class GitEnv(HTTPEnvClient[GitAction, GitObservation]): +class GitEnv(EnvClient[GitAction, GitObservation, GitState]): """ Client for Git Environment with Gitea server. - This client communicates with the Git environment server over HTTP, - allowing agents to perform Git operations through a simple API. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions for Git operations. The environment connects to a shared external Gitea service. Repositories must be pre-migrated to Gitea before use. @@ -32,25 +34,25 @@ class GitEnv(HTTPEnvClient[GitAction, GitObservation]): Example: >>> # From Docker image >>> client = GitEnv.from_docker_image("git-env:latest") - >>> result = client.reset() - >>> - >>> # List available repositories - >>> from envs.git_env import GitAction - >>> result = client.step(GitAction(action_type="list_repos")) - >>> print(result.observation.repos) - >>> - >>> # Clone repository to workspace - >>> result = client.step(GitAction(action_type="clone_repo", repo_name="OpenEnv")) - >>> - >>> # Execute git commands - >>> result = client.step(GitAction( - ... action_type="execute_git_command", - ... command="status", - ... working_dir="OpenEnv" - ... )) - >>> - >>> # Cleanup - >>> client.close() + >>> try: + ... result = client.reset() + ... + ... # List available repositories + ... from envs.git_env import GitAction + ... result = client.step(GitAction(action_type="list_repos")) + ... print(result.observation.repos) + ... + ... # Clone repository to workspace + ... result = client.step(GitAction(action_type="clone_repo", repo_name="OpenEnv")) + ... + ... # Execute git commands + ... result = client.step(GitAction( + ... action_type="execute_git_command", + ... command="status", + ... working_dir="OpenEnv" + ... )) + ... finally: + ... client.close() """ def _step_payload(self, action: GitAction) -> dict: diff --git a/src/envs/git_env/docker-compose.gitea.yml b/envs/git_env/docker-compose.gitea.yml similarity index 100% rename from src/envs/git_env/docker-compose.gitea.yml rename to envs/git_env/docker-compose.gitea.yml diff --git a/src/envs/git_env/models.py b/envs/git_env/models.py similarity index 97% rename from src/envs/git_env/models.py rename to envs/git_env/models.py index 76d0d7331..4c4ae5c0b 100644 --- a/src/envs/git_env/models.py +++ b/envs/git_env/models.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from typing import Optional -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State @dataclass diff --git a/src/envs/git_env/server/Dockerfile b/envs/git_env/server/Dockerfile similarity index 95% rename from src/envs/git_env/server/Dockerfile rename to envs/git_env/server/Dockerfile index f05159acb..f191ae2a7 100644 --- a/src/envs/git_env/server/Dockerfile +++ b/envs/git_env/server/Dockerfile @@ -18,7 +18,7 @@ RUN mkdir -p /workspace && chmod 777 /workspace # Copy core and environment code COPY src/core/ /app/src/core/ -COPY src/envs/git_env/ /app/src/envs/git_env/ +COPY envs/git_env/ /app/envs/git_env/ # Environment variables for Gitea connection # These MUST be provided at runtime via -e flags or --env-file diff --git a/src/envs/git_env/server/__init__.py b/envs/git_env/server/__init__.py similarity index 100% rename from src/envs/git_env/server/__init__.py rename to envs/git_env/server/__init__.py diff --git a/src/envs/git_env/server/app.py b/envs/git_env/server/app.py similarity index 74% rename from src/envs/git_env/server/app.py rename to envs/git_env/server/app.py index 6434c8811..a73e22973 100644 --- a/src/envs/git_env/server/app.py +++ b/envs/git_env/server/app.py @@ -25,7 +25,7 @@ import os -from core.env_server import create_app +from openenv.core.env_server import create_app from ..models import GitAction, GitObservation from .git_task_environment import GitTaskEnvironment @@ -44,16 +44,21 @@ if not gitea_password: raise RuntimeError("GITEA_PASSWORD environment variable is required") -# Create the environment instance (connects to external Gitea) -env = GitTaskEnvironment( - gitea_url=gitea_url, - username=gitea_username, - password=gitea_password, - workspace_dir=workspace_dir, -) + +# Factory function to create GitTaskEnvironment instances +def create_git_environment(): + """Factory function that creates GitTaskEnvironment with config.""" + return GitTaskEnvironment( + gitea_url=gitea_url, + username=gitea_username, + password=gitea_password, + workspace_dir=workspace_dir, + ) + # Create the app with web interface and README integration -app = create_app(env, GitAction, GitObservation, env_name="git_env") +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_git_environment, GitAction, GitObservation, env_name="git_env") if __name__ == "__main__": diff --git a/src/envs/git_env/server/git_task_environment.py b/envs/git_env/server/git_task_environment.py similarity index 98% rename from src/envs/git_env/server/git_task_environment.py rename to envs/git_env/server/git_task_environment.py index c2113eb6d..3339f4d25 100644 --- a/src/envs/git_env/server/git_task_environment.py +++ b/envs/git_env/server/git_task_environment.py @@ -12,8 +12,8 @@ import uuid -from core.env_server import Action, Environment, Observation -from core.tools import GitServerClient +from openenv.core.env_server import Action, Environment, Observation +from openenv.core.tools import GitServerClient from ..models import GitAction, GitObservation, GitState diff --git a/src/envs/openspiel_env/README.md b/envs/openspiel_env/README.md similarity index 97% rename from src/envs/openspiel_env/README.md rename to envs/openspiel_env/README.md index 85acbecc7..826f0e026 100644 --- a/src/envs/openspiel_env/README.md +++ b/envs/openspiel_env/README.md @@ -86,7 +86,7 @@ env.close() ```bash cd OpenEnv -docker build -f src/envs/openspiel_env/server/Dockerfile -t openspiel-env:latest . +docker build -f envs/openspiel_env/server/Dockerfile -t openspiel-env:latest . ``` **Run specific games:** @@ -303,10 +303,10 @@ All tests PASSED! 🎉 ```bash # Local (requires OpenSpiel installed) -python -m pytest src/envs/openspiel_env/ +python -m pytest envs/openspiel_env/ # Docker build -docker build -f src/envs/openspiel_env/server/Dockerfile -t openspiel-env:latest . +docker build -f envs/openspiel_env/server/Dockerfile -t openspiel-env:latest . # Run specific game docker run -p 8000:8000 openspiel-env:latest diff --git a/src/envs/openspiel_env/__init__.py b/envs/openspiel_env/__init__.py similarity index 100% rename from src/envs/openspiel_env/__init__.py rename to envs/openspiel_env/__init__.py diff --git a/src/envs/openspiel_env/client.py b/envs/openspiel_env/client.py similarity index 75% rename from src/envs/openspiel_env/client.py rename to envs/openspiel_env/client.py index 7f4f63223..946cd1fdd 100644 --- a/src/envs/openspiel_env/client.py +++ b/envs/openspiel_env/client.py @@ -5,48 +5,50 @@ # LICENSE file in the root directory of this source tree. """ -OpenSpielEnv HTTP Client. +OpenSpielEnv Client. This module provides the client for connecting to an OpenSpiel Environment server -over HTTP. +via WebSocket for persistent sessions. """ from __future__ import annotations from typing import Any, Dict, Optional, TYPE_CHECKING -from core.client_types import StepResult +from openenv.core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.env_client import EnvClient from .models import OpenSpielAction, OpenSpielObservation, OpenSpielState if TYPE_CHECKING: - from core.containers.runtime import ContainerProvider + from openenv.core.containers.runtime import ContainerProvider -class OpenSpielEnv(HTTPEnvClient[OpenSpielAction, OpenSpielObservation]): +class OpenSpielEnv(EnvClient[OpenSpielAction, OpenSpielObservation, OpenSpielState]): """ - HTTP client for OpenSpiel Environment. + Client for OpenSpiel Environment. - This client connects to an OpenSpielEnvironment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment + server, enabling efficient multi-step interactions with lower latency. Example: >>> # Connect to a running server - >>> client = OpenSpielEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.info_state) - >>> - >>> # Take an action - >>> result = client.step(OpenSpielAction(action_id=1, game_name="catch")) - >>> print(result.observation.reward) + >>> with OpenSpielEnv(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.info_state) + ... + ... result = client.step(OpenSpielAction(action_id=1, game_name="catch")) + ... print(result.observation.reward) Example with Docker: >>> # Automatically start container and connect >>> client = OpenSpielEnv.from_docker_image("openspiel-env:latest") - >>> result = client.reset() - >>> result = client.step(OpenSpielAction(action_id=0)) + >>> try: + ... result = client.reset() + ... result = client.step(OpenSpielAction(action_id=0)) + ... finally: + ... client.close() """ def _step_payload(self, action: OpenSpielAction) -> Dict[str, Any]: diff --git a/src/envs/openspiel_env/docker_issue.md b/envs/openspiel_env/docker_issue.md similarity index 100% rename from src/envs/openspiel_env/docker_issue.md rename to envs/openspiel_env/docker_issue.md diff --git a/src/envs/openspiel_env/models.py b/envs/openspiel_env/models.py similarity index 97% rename from src/envs/openspiel_env/models.py rename to envs/openspiel_env/models.py index 93fa81c3c..7d5ec2657 100644 --- a/src/envs/openspiel_env/models.py +++ b/envs/openspiel_env/models.py @@ -15,7 +15,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State @dataclass diff --git a/src/envs/openspiel_env/server/Dockerfile b/envs/openspiel_env/server/Dockerfile similarity index 84% rename from src/envs/openspiel_env/server/Dockerfile rename to envs/openspiel_env/server/Dockerfile index 48ccff33d..8bd261f92 100644 --- a/src/envs/openspiel_env/server/Dockerfile +++ b/envs/openspiel_env/server/Dockerfile @@ -5,7 +5,7 @@ # LICENSE file in the root directory of this source tree. # Use the pre-built OpenSpiel base image -# Built from: docker build -t openspiel-base:latest -f src/envs/openspiel_env/server/Dockerfile.openspiel-base . +# Built from: docker build -t openspiel-base:latest -f envs/openspiel_env/server/Dockerfile.openspiel-base . # In GitHub Actions, this is overridden to use the GHCR base image ARG OPENSPIEL_BASE_IMAGE=openspiel-base:latest FROM ${OPENSPIEL_BASE_IMAGE} @@ -15,10 +15,10 @@ WORKDIR /app COPY src/core/ /app/src/core/ # Copy OpenSpiel environment -COPY src/envs/openspiel_env/ /app/src/envs/openspiel_env/ +COPY envs/openspiel_env/ /app/envs/openspiel_env/ # Copy README for web interface documentation -COPY src/envs/openspiel_env/README.md /app/README.md +COPY envs/openspiel_env/README.md /app/README.md # Extend Python path for OpenEnv (base image set PYTHONPATH=/app/src) # We prepend OpenSpiel paths diff --git a/src/envs/openspiel_env/server/Dockerfile.openspiel-base b/envs/openspiel_env/server/Dockerfile.openspiel-base similarity index 94% rename from src/envs/openspiel_env/server/Dockerfile.openspiel-base rename to envs/openspiel_env/server/Dockerfile.openspiel-base index 284bfaee6..5c0009935 100644 --- a/src/envs/openspiel_env/server/Dockerfile.openspiel-base +++ b/envs/openspiel_env/server/Dockerfile.openspiel-base @@ -6,7 +6,7 @@ # Pre-built OpenSpiel base image # This image contains OpenSpiel compiled and ready to use -# Built from: docker build -t openspiel-base:latest -f src/envs/openspiel_env/server/Dockerfile.openspiel-base . +# Built from: docker build -t openspiel-base:latest -f envs/openspiel_env/server/Dockerfile.openspiel-base . # In GitHub Actions, this is overridden to use the GHCR base image ARG BASE_IMAGE=openenv-base:latest FROM ${BASE_IMAGE} diff --git a/src/envs/openspiel_env/server/__init__.py b/envs/openspiel_env/server/__init__.py similarity index 100% rename from src/envs/openspiel_env/server/__init__.py rename to envs/openspiel_env/server/__init__.py diff --git a/src/envs/openspiel_env/server/app.py b/envs/openspiel_env/server/app.py similarity index 68% rename from src/envs/openspiel_env/server/app.py rename to envs/openspiel_env/server/app.py index 9dbb090eb..01dc35218 100644 --- a/src/envs/openspiel_env/server/app.py +++ b/envs/openspiel_env/server/app.py @@ -8,7 +8,7 @@ FastAPI application for the OpenSpiel Environment. This module creates an HTTP server that exposes OpenSpiel games -over HTTP endpoints, making them compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. Usage: # Development (with auto-reload): @@ -28,7 +28,7 @@ import os -from core.env_server import create_app +from openenv.core.env_server import create_app from ..models import OpenSpielAction, OpenSpielObservation from .openspiel_environment import OpenSpielEnvironment @@ -38,15 +38,20 @@ agent_player = int(os.getenv("OPENSPIEL_AGENT_PLAYER", "0")) opponent_policy = os.getenv("OPENSPIEL_OPPONENT_POLICY", "random") -# Create the environment instance -env = OpenSpielEnvironment( - game_name=game_name, - agent_player=agent_player, - opponent_policy=opponent_policy, -) + +# Factory function to create OpenSpielEnvironment instances +def create_openspiel_environment(): + """Factory function that creates OpenSpielEnvironment with config.""" + return OpenSpielEnvironment( + game_name=game_name, + agent_player=agent_player, + opponent_policy=opponent_policy, + ) + # Create the FastAPI app with web interface and README integration -app = create_app(env, OpenSpielAction, OpenSpielObservation, env_name="openspiel_env") +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_openspiel_environment, OpenSpielAction, OpenSpielObservation, env_name="openspiel_env") if __name__ == "__main__": diff --git a/src/envs/openspiel_env/server/build_docker.sh b/envs/openspiel_env/server/build_docker.sh similarity index 100% rename from src/envs/openspiel_env/server/build_docker.sh rename to envs/openspiel_env/server/build_docker.sh diff --git a/src/envs/openspiel_env/server/openspiel_environment.py b/envs/openspiel_env/server/openspiel_environment.py similarity index 99% rename from src/envs/openspiel_env/server/openspiel_environment.py rename to envs/openspiel_env/server/openspiel_environment.py index 481aefb43..1b786edb1 100644 --- a/src/envs/openspiel_env/server/openspiel_environment.py +++ b/envs/openspiel_env/server/openspiel_environment.py @@ -14,7 +14,7 @@ import uuid from typing import Any, Dict -from core.env_server import Action, Environment, Observation +from openenv.core.env_server import Action, Environment, Observation from ..models import OpenSpielAction, OpenSpielObservation, OpenSpielState from .opponent_policies import get_opponent_policy, OpponentPolicy diff --git a/src/envs/openspiel_env/server/opponent_policies.py b/envs/openspiel_env/server/opponent_policies.py similarity index 100% rename from src/envs/openspiel_env/server/opponent_policies.py rename to envs/openspiel_env/server/opponent_policies.py diff --git a/src/envs/openspiel_env/server/prepare_hf.sh b/envs/openspiel_env/server/prepare_hf.sh similarity index 100% rename from src/envs/openspiel_env/server/prepare_hf.sh rename to envs/openspiel_env/server/prepare_hf.sh diff --git a/src/envs/openspiel_env/test_docker_all_games.sh b/envs/openspiel_env/test_docker_all_games.sh similarity index 100% rename from src/envs/openspiel_env/test_docker_all_games.sh rename to envs/openspiel_env/test_docker_all_games.sh diff --git a/src/envs/snake_env/README.md b/envs/snake_env/README.md similarity index 100% rename from src/envs/snake_env/README.md rename to envs/snake_env/README.md diff --git a/src/envs/snake_env/__init__.py b/envs/snake_env/__init__.py similarity index 100% rename from src/envs/snake_env/__init__.py rename to envs/snake_env/__init__.py diff --git a/src/envs/snake_env/client.py b/envs/snake_env/client.py similarity index 100% rename from src/envs/snake_env/client.py rename to envs/snake_env/client.py diff --git a/src/envs/snake_env/models.py b/envs/snake_env/models.py similarity index 100% rename from src/envs/snake_env/models.py rename to envs/snake_env/models.py diff --git a/src/envs/snake_env/openenv.yaml b/envs/snake_env/openenv.yaml similarity index 100% rename from src/envs/snake_env/openenv.yaml rename to envs/snake_env/openenv.yaml diff --git a/src/envs/snake_env/pyproject.toml b/envs/snake_env/pyproject.toml similarity index 100% rename from src/envs/snake_env/pyproject.toml rename to envs/snake_env/pyproject.toml diff --git a/src/envs/snake_env/server/Dockerfile b/envs/snake_env/server/Dockerfile similarity index 100% rename from src/envs/snake_env/server/Dockerfile rename to envs/snake_env/server/Dockerfile diff --git a/src/envs/snake_env/server/Dockerfile.backup b/envs/snake_env/server/Dockerfile.backup similarity index 100% rename from src/envs/snake_env/server/Dockerfile.backup rename to envs/snake_env/server/Dockerfile.backup diff --git a/src/envs/snake_env/server/__init__.py b/envs/snake_env/server/__init__.py similarity index 100% rename from src/envs/snake_env/server/__init__.py rename to envs/snake_env/server/__init__.py diff --git a/src/envs/snake_env/server/app.py b/envs/snake_env/server/app.py similarity index 100% rename from src/envs/snake_env/server/app.py rename to envs/snake_env/server/app.py diff --git a/src/envs/snake_env/server/requirements.txt b/envs/snake_env/server/requirements.txt similarity index 100% rename from src/envs/snake_env/server/requirements.txt rename to envs/snake_env/server/requirements.txt diff --git a/src/envs/snake_env/server/snake_environment.py b/envs/snake_env/server/snake_environment.py similarity index 100% rename from src/envs/snake_env/server/snake_environment.py rename to envs/snake_env/server/snake_environment.py diff --git a/src/envs/snake_env/uv.lock b/envs/snake_env/uv.lock similarity index 100% rename from src/envs/snake_env/uv.lock rename to envs/snake_env/uv.lock diff --git a/src/envs/sumo_rl_env/README.md b/envs/sumo_rl_env/README.md similarity index 99% rename from src/envs/sumo_rl_env/README.md rename to envs/sumo_rl_env/README.md index 1cb045f60..7d49cc22f 100644 --- a/src/envs/sumo_rl_env/README.md +++ b/envs/sumo_rl_env/README.md @@ -51,7 +51,7 @@ cd OpenEnv docker build -t envtorch-base:latest -f src/core/containers/images/Dockerfile . # Build SUMO-RL environment -docker build -f src/envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . +docker build -f envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . ``` ### Running with Different Configurations diff --git a/src/envs/sumo_rl_env/__init__.py b/envs/sumo_rl_env/__init__.py similarity index 100% rename from src/envs/sumo_rl_env/__init__.py rename to envs/sumo_rl_env/__init__.py diff --git a/src/envs/sumo_rl_env/client.py b/envs/sumo_rl_env/client.py similarity index 78% rename from src/envs/sumo_rl_env/client.py rename to envs/sumo_rl_env/client.py index d6dfb441b..89390398d 100644 --- a/src/envs/sumo_rl_env/client.py +++ b/envs/sumo_rl_env/client.py @@ -5,47 +5,46 @@ # LICENSE file in the root directory of this source tree. """ -HTTP client for SUMO-RL environment. +Client for SUMO-RL environment. This module provides a client to interact with the SUMO traffic signal -control environment over HTTP. +control environment via WebSocket for persistent sessions. """ from typing import Any, Dict -from core.client_types import StepResult +from openenv.core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.env_client import EnvClient from .models import SumoAction, SumoObservation, SumoState -class SumoRLEnv(HTTPEnvClient[SumoAction, SumoObservation]): +class SumoRLEnv(EnvClient[SumoAction, SumoObservation, SumoState]): """ - HTTP client for SUMO-RL traffic signal control environment. + Client for SUMO-RL traffic signal control environment. - This client communicates with a SUMO environment server to control - traffic signals using reinforcement learning. + This client maintains a persistent WebSocket connection to a SUMO + environment server to control traffic signals using reinforcement learning. Example: >>> # Start container and connect >>> env = SumoRLEnv.from_docker_image("sumo-rl-env:latest") - >>> - >>> # Reset environment - >>> result = env.reset() - >>> print(f"Observation shape: {result.observation.observation_shape}") - >>> print(f"Action space: {result.observation.action_mask}") - >>> - >>> # Take action - >>> result = env.step(SumoAction(phase_id=1)) - >>> print(f"Reward: {result.reward}, Done: {result.done}") - >>> - >>> # Get state - >>> state = env.state() - >>> print(f"Sim time: {state.sim_time}, Total vehicles: {state.total_vehicles}") - >>> - >>> # Cleanup - >>> env.close() + >>> try: + ... # Reset environment + ... result = env.reset() + ... print(f"Observation shape: {result.observation.observation_shape}") + ... print(f"Action space: {result.observation.action_mask}") + ... + ... # Take action + ... result = env.step(SumoAction(phase_id=1)) + ... print(f"Reward: {result.reward}, Done: {result.done}") + ... + ... # Get state + ... state = env.state() + ... print(f"Sim time: {state.sim_time}, Total vehicles: {state.total_vehicles}") + ... finally: + ... env.close() Example with custom network: >>> # Use custom SUMO network via volume mount diff --git a/src/envs/sumo_rl_env/models.py b/envs/sumo_rl_env/models.py similarity index 98% rename from src/envs/sumo_rl_env/models.py rename to envs/sumo_rl_env/models.py index 6c73092bc..08f3abab1 100644 --- a/src/envs/sumo_rl_env/models.py +++ b/envs/sumo_rl_env/models.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional -from core.env_server import Action, Observation, State +from openenv.core.env_server import Action, Observation, State @dataclass diff --git a/src/envs/sumo_rl_env/nets/single-intersection/single-intersection.edg.xml b/envs/sumo_rl_env/nets/single-intersection/single-intersection.edg.xml similarity index 100% rename from src/envs/sumo_rl_env/nets/single-intersection/single-intersection.edg.xml rename to envs/sumo_rl_env/nets/single-intersection/single-intersection.edg.xml diff --git a/src/envs/sumo_rl_env/nets/single-intersection/single-intersection.net.xml b/envs/sumo_rl_env/nets/single-intersection/single-intersection.net.xml similarity index 100% rename from src/envs/sumo_rl_env/nets/single-intersection/single-intersection.net.xml rename to envs/sumo_rl_env/nets/single-intersection/single-intersection.net.xml diff --git a/src/envs/sumo_rl_env/nets/single-intersection/single-intersection.nod.xml b/envs/sumo_rl_env/nets/single-intersection/single-intersection.nod.xml similarity index 100% rename from src/envs/sumo_rl_env/nets/single-intersection/single-intersection.nod.xml rename to envs/sumo_rl_env/nets/single-intersection/single-intersection.nod.xml diff --git a/src/envs/sumo_rl_env/nets/single-intersection/single-intersection.rou.xml b/envs/sumo_rl_env/nets/single-intersection/single-intersection.rou.xml similarity index 100% rename from src/envs/sumo_rl_env/nets/single-intersection/single-intersection.rou.xml rename to envs/sumo_rl_env/nets/single-intersection/single-intersection.rou.xml diff --git a/src/envs/sumo_rl_env/nets/single-intersection/single-intersection.sumocfg b/envs/sumo_rl_env/nets/single-intersection/single-intersection.sumocfg similarity index 100% rename from src/envs/sumo_rl_env/nets/single-intersection/single-intersection.sumocfg rename to envs/sumo_rl_env/nets/single-intersection/single-intersection.sumocfg diff --git a/src/envs/sumo_rl_env/server/Dockerfile b/envs/sumo_rl_env/server/Dockerfile similarity index 86% rename from src/envs/sumo_rl_env/server/Dockerfile rename to envs/sumo_rl_env/server/Dockerfile index d14952831..7a7e0cc78 100644 --- a/src/envs/sumo_rl_env/server/Dockerfile +++ b/envs/sumo_rl_env/server/Dockerfile @@ -5,10 +5,10 @@ # Base image provides: fastapi, uvicorn, requests, curl, PYTHONPATH=/app/src # # Local build: docker build -t envtorch-base:latest -f src/core/containers/images/Dockerfile . -# docker build -f src/envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . +# docker build -f envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . # # CI/CD build: docker build --build-arg BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest \ -# -f src/envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . +# -f envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . ARG BASE_IMAGE=envtorch-base:latest FROM ${BASE_IMAGE} @@ -37,11 +37,11 @@ RUN pip install --no-cache-dir \ COPY src/core/ /app/src/core/ # Copy SUMO-RL environment code (includes nets/) -COPY src/envs/sumo_rl_env/ /app/src/envs/sumo_rl_env/ +COPY envs/sumo_rl_env/ /app/envs/sumo_rl_env/ # Copy example network files to expected location # Default: single-intersection (simple 4-way intersection) -COPY src/envs/sumo_rl_env/nets/single-intersection/ /app/nets/single-intersection/ +COPY envs/sumo_rl_env/nets/single-intersection/ /app/nets/single-intersection/ # SUMO environment variables (can be overridden at runtime) ENV SUMO_NET_FILE=/app/nets/single-intersection/single-intersection.net.xml diff --git a/src/envs/sumo_rl_env/server/__init__.py b/envs/sumo_rl_env/server/__init__.py similarity index 100% rename from src/envs/sumo_rl_env/server/__init__.py rename to envs/sumo_rl_env/server/__init__.py diff --git a/src/envs/sumo_rl_env/server/app.py b/envs/sumo_rl_env/server/app.py similarity index 61% rename from src/envs/sumo_rl_env/server/app.py rename to envs/sumo_rl_env/server/app.py index b81463aee..b0f5ea7d3 100644 --- a/src/envs/sumo_rl_env/server/app.py +++ b/envs/sumo_rl_env/server/app.py @@ -13,7 +13,7 @@ import os -from core.env_server import create_fastapi_app +from openenv.core.env_server import create_app from ..models import SumoAction, SumoObservation from .sumo_environment import SumoEnvironment @@ -29,19 +29,23 @@ reward_fn = os.getenv("SUMO_REWARD_FN", "diff-waiting-time") sumo_seed = int(os.getenv("SUMO_SEED", "42")) -# Create single environment instance -# This is reused for all HTTP requests (avoids TraCI connection issues) -env = SumoEnvironment( - net_file=net_file, - route_file=route_file, - num_seconds=num_seconds, - delta_time=delta_time, - yellow_time=yellow_time, - min_green=min_green, - max_green=max_green, - reward_fn=reward_fn, - sumo_seed=sumo_seed, -) + +# Factory function to create SumoEnvironment instances +def create_sumo_environment(): + """Factory function that creates SumoEnvironment with config.""" + return SumoEnvironment( + net_file=net_file, + route_file=route_file, + num_seconds=num_seconds, + delta_time=delta_time, + yellow_time=yellow_time, + min_green=min_green, + max_green=max_green, + reward_fn=reward_fn, + sumo_seed=sumo_seed, + ) + # Create FastAPI app -app = create_fastapi_app(env, SumoAction, SumoObservation) +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_sumo_environment, SumoAction, SumoObservation, env_name="sumo_rl_env") diff --git a/src/envs/sumo_rl_env/server/sumo_environment.py b/envs/sumo_rl_env/server/sumo_environment.py similarity index 99% rename from src/envs/sumo_rl_env/server/sumo_environment.py rename to envs/sumo_rl_env/server/sumo_environment.py index 757b9f171..7a70029d8 100644 --- a/src/envs/sumo_rl_env/server/sumo_environment.py +++ b/envs/sumo_rl_env/server/sumo_environment.py @@ -18,7 +18,7 @@ # Set SUMO_HOME before importing sumo_rl os.environ.setdefault("SUMO_HOME", "/usr/share/sumo") -from core.env_server import Action, Environment, Observation +from openenv.core.env_server import Action, Environment, Observation from ..models import SumoAction, SumoObservation, SumoState diff --git a/src/envs/sumo_rl_env/test_sumo_rl.sh b/envs/sumo_rl_env/test_sumo_rl.sh similarity index 99% rename from src/envs/sumo_rl_env/test_sumo_rl.sh rename to envs/sumo_rl_env/test_sumo_rl.sh index 61265c73d..3372e9e65 100755 --- a/src/envs/sumo_rl_env/test_sumo_rl.sh +++ b/envs/sumo_rl_env/test_sumo_rl.sh @@ -38,7 +38,7 @@ echo "━━━━━━━━━━━━━━━━━━━━━━━━ echo "⏳ This will take 5-10 minutes (installing SUMO)..." echo "" -docker build -f src/envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . +docker build -f envs/sumo_rl_env/server/Dockerfile -t sumo-rl-env:latest . echo "" echo "✅ SUMO-RL environment built successfully" diff --git a/src/envs/textarena_env/README.md b/envs/textarena_env/README.md similarity index 95% rename from src/envs/textarena_env/README.md rename to envs/textarena_env/README.md index 819a0c8c1..7ebe8424c 100644 --- a/src/envs/textarena_env/README.md +++ b/envs/textarena_env/README.md @@ -13,7 +13,7 @@ Generic wrapper for any [TextArena](https://www.textarena.ai/docs/overview) game Build the container from the project root: ```bash -docker build -f src/envs/textarena_env/server/Dockerfile -t textarena-env:latest . +docker build -f envs/textarena_env/server/Dockerfile -t textarena-env:latest . ``` Run it with your desired game (default is `Wordle-v0`). Environment configuration is handled via env vars: diff --git a/src/envs/textarena_env/__init__.py b/envs/textarena_env/__init__.py similarity index 100% rename from src/envs/textarena_env/__init__.py rename to envs/textarena_env/__init__.py diff --git a/src/envs/textarena_env/client.py b/envs/textarena_env/client.py similarity index 86% rename from src/envs/textarena_env/client.py rename to envs/textarena_env/client.py index 9f4642061..9c2b52a01 100644 --- a/src/envs/textarena_env/client.py +++ b/envs/textarena_env/client.py @@ -4,14 +4,14 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""HTTP client for the generic TextArena environment.""" +"""Client for the generic TextArena environment.""" from __future__ import annotations from typing import Any, Dict, TYPE_CHECKING -from core.client_types import StepResult -from core.http_env_client import HTTPEnvClient +from openenv.core.client_types import StepResult +from openenv.core.env_client import EnvClient from .models import ( TextArenaAction, @@ -21,11 +21,11 @@ ) if TYPE_CHECKING: - from core.containers.runtime import ContainerProvider + from openenv.core.containers.runtime import ContainerProvider -class TextArenaEnv(HTTPEnvClient[TextArenaAction, TextArenaObservation]): - """HTTP client for the TextArena environment server.""" +class TextArenaEnv(EnvClient[TextArenaAction, TextArenaObservation, TextArenaState]): + """Client for the TextArena environment server.""" def _step_payload(self, action: TextArenaAction) -> Dict[str, Any]: return {"message": action.message} diff --git a/src/envs/textarena_env/models.py b/envs/textarena_env/models.py similarity index 95% rename from src/envs/textarena_env/models.py rename to envs/textarena_env/models.py index 4fea2c17d..1d549fc9b 100644 --- a/src/envs/textarena_env/models.py +++ b/envs/textarena_env/models.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional -from core.env_server.types import Action, Observation, State +from openenv.core.env_server.types import Action, Observation, State @dataclass diff --git a/src/envs/textarena_env/rewards.py b/envs/textarena_env/rewards.py similarity index 100% rename from src/envs/textarena_env/rewards.py rename to envs/textarena_env/rewards.py diff --git a/src/envs/textarena_env/server/Dockerfile b/envs/textarena_env/server/Dockerfile similarity index 94% rename from src/envs/textarena_env/server/Dockerfile rename to envs/textarena_env/server/Dockerfile index 5df608239..c1ea40a88 100644 --- a/src/envs/textarena_env/server/Dockerfile +++ b/envs/textarena_env/server/Dockerfile @@ -21,7 +21,7 @@ RUN pip install --no-cache-dir \ # Copy OpenEnv core and TextArena environment sources COPY src/core/ /app/src/core/ -COPY src/envs/textarena_env/ /app/src/envs/textarena_env/ +COPY envs/textarena_env/ /app/envs/textarena_env/ # Optional: health check to ensure server responsiveness HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/src/envs/textarena_env/server/__init__.py b/envs/textarena_env/server/__init__.py similarity index 100% rename from src/envs/textarena_env/server/__init__.py rename to envs/textarena_env/server/__init__.py diff --git a/src/envs/textarena_env/server/app.py b/envs/textarena_env/server/app.py similarity index 66% rename from src/envs/textarena_env/server/app.py rename to envs/textarena_env/server/app.py index 59dea784b..900a138c0 100644 --- a/src/envs/textarena_env/server/app.py +++ b/envs/textarena_env/server/app.py @@ -10,7 +10,7 @@ import os -from core.env_server.http_server import create_app +from openenv.core.env_server.http_server import create_app from ..models import TextArenaAction, TextArenaObservation from .environment import TextArenaEnvironment @@ -35,15 +35,22 @@ def _parse_env_kwargs(prefix: str = "TEXTARENA_KW_") -> dict[str, str]: extra_kwargs = _parse_env_kwargs() -environment = TextArenaEnvironment( - env_id=env_id, - num_players=num_players, - max_turns=max_turns, - download_nltk=download_nltk, - env_kwargs=extra_kwargs, -) -app = create_app(environment, TextArenaAction, TextArenaObservation, env_name="textarena_env") +# Factory function to create TextArenaEnvironment instances +def create_textarena_environment(): + """Factory function that creates TextArenaEnvironment with config.""" + return TextArenaEnvironment( + env_id=env_id, + num_players=num_players, + max_turns=max_turns, + download_nltk=download_nltk, + env_kwargs=extra_kwargs, + ) + + +# Create the FastAPI app +# Pass the factory function instead of an instance for WebSocket session support +app = create_app(create_textarena_environment, TextArenaAction, TextArenaObservation, env_name="textarena_env") if __name__ == "__main__": diff --git a/src/envs/textarena_env/server/environment.py b/envs/textarena_env/server/environment.py similarity index 99% rename from src/envs/textarena_env/server/environment.py rename to envs/textarena_env/server/environment.py index 63b5a1ef0..51ba270a4 100644 --- a/src/envs/textarena_env/server/environment.py +++ b/envs/textarena_env/server/environment.py @@ -14,7 +14,7 @@ import nltk -from core.env_server.interfaces import Environment +from openenv.core.env_server.interfaces import Environment from ..models import ( TextArenaAction, diff --git a/src/envs/textarena_env/server/run_local.sh b/envs/textarena_env/server/run_local.sh similarity index 100% rename from src/envs/textarena_env/server/run_local.sh rename to envs/textarena_env/server/run_local.sh diff --git a/src/envs/websearch_env/README.md b/envs/websearch_env/README.md similarity index 100% rename from src/envs/websearch_env/README.md rename to envs/websearch_env/README.md diff --git a/src/envs/websearch_env/__init__.py b/envs/websearch_env/__init__.py similarity index 100% rename from src/envs/websearch_env/__init__.py rename to envs/websearch_env/__init__.py diff --git a/src/envs/websearch_env/client.py b/envs/websearch_env/client.py similarity index 100% rename from src/envs/websearch_env/client.py rename to envs/websearch_env/client.py diff --git a/src/envs/websearch_env/models.py b/envs/websearch_env/models.py similarity index 100% rename from src/envs/websearch_env/models.py rename to envs/websearch_env/models.py diff --git a/src/envs/websearch_env/openenv.yaml b/envs/websearch_env/openenv.yaml similarity index 100% rename from src/envs/websearch_env/openenv.yaml rename to envs/websearch_env/openenv.yaml diff --git a/src/envs/websearch_env/pyproject.toml b/envs/websearch_env/pyproject.toml similarity index 100% rename from src/envs/websearch_env/pyproject.toml rename to envs/websearch_env/pyproject.toml diff --git a/src/envs/websearch_env/server/Dockerfile b/envs/websearch_env/server/Dockerfile similarity index 100% rename from src/envs/websearch_env/server/Dockerfile rename to envs/websearch_env/server/Dockerfile diff --git a/src/envs/websearch_env/server/__init__.py b/envs/websearch_env/server/__init__.py similarity index 100% rename from src/envs/websearch_env/server/__init__.py rename to envs/websearch_env/server/__init__.py diff --git a/src/envs/websearch_env/server/app.py b/envs/websearch_env/server/app.py similarity index 100% rename from src/envs/websearch_env/server/app.py rename to envs/websearch_env/server/app.py diff --git a/src/envs/websearch_env/server/web_search_environment.py b/envs/websearch_env/server/web_search_environment.py similarity index 100% rename from src/envs/websearch_env/server/web_search_environment.py rename to envs/websearch_env/server/web_search_environment.py diff --git a/src/envs/websearch_env/server/web_search_tool.py b/envs/websearch_env/server/web_search_tool.py similarity index 100% rename from src/envs/websearch_env/server/web_search_tool.py rename to envs/websearch_env/server/web_search_tool.py diff --git a/src/envs/websearch_env/uv.lock b/envs/websearch_env/uv.lock similarity index 100% rename from src/envs/websearch_env/uv.lock rename to envs/websearch_env/uv.lock diff --git a/examples/OpenEnv_Tutorial.ipynb b/examples/OpenEnv_Tutorial.ipynb index 74842a08f..447f8e5d6 100644 --- a/examples/OpenEnv_Tutorial.ipynb +++ b/examples/OpenEnv_Tutorial.ipynb @@ -446,7 +446,7 @@ "## Every OpenEnv Environment Has 3 Components:\n", "\n", "```\n", - "src/envs/your_env/\n", + "envs/your_env/\n", "├── 📝 models.py ← Type-safe contracts\n", "│ (Action, Observation, State)\n", "│\n", @@ -518,8 +518,8 @@ ], "source": [ "# Import OpenEnv's core abstractions\n", - "from core.env_server import Environment, Action, Observation, State\n", - "from core.http_env_client import HTTPEnvClient\n", + "from openenv.core.env_server import Environment, Action, Observation, State\n", + "from openenv.core.http_env_client import HTTPEnvClient\n", "\n", "print(\"=\"*70)\n", "print(\" 🧩 OPENENV CORE ABSTRACTIONS\")\n", @@ -1567,7 +1567,7 @@ "\n", "```python\n", "from dataclasses import dataclass\n", - "from core.env_server import Action, Observation, State\n", + "from openenv.core.env_server import Action, Observation, State\n", "\n", "@dataclass\n", "class YourAction(Action):\n", @@ -1591,7 +1591,7 @@ "### Step 2: Implement Environment (`server/environment.py`)\n", "\n", "```python\n", - "from core.env_server import Environment\n", + "from openenv.core.env_server import Environment\n", "\n", "class YourEnvironment(Environment):\n", " def reset(self) -> Observation:\n", @@ -1610,8 +1610,8 @@ "### Step 3: Create Client (`client.py`)\n", "\n", "```python\n", - "from core.http_env_client import HTTPEnvClient\n", - "from core.types import StepResult\n", + "from openenv.core.http_env_client import HTTPEnvClient\n", + "from openenv.core.types import StepResult\n", "\n", "class YourEnv(HTTPEnvClient[YourAction, YourObservation]):\n", " def _step_payload(self, action: YourAction) -> dict:\n", @@ -1633,7 +1633,7 @@ "### Step 4: Create Server (`server/app.py`)\n", "\n", "```python\n", - "from core.env_server import create_fastapi_app\n", + "from openenv.core.env_server import create_fastapi_app\n", "from .your_environment import YourEnvironment\n", "\n", "env = YourEnvironment()\n", @@ -1661,16 +1661,16 @@ "\n", "OpenEnv includes 3 complete examples:\n", "\n", - "1. **`src/envs/echo_env/`**\n", + "1. **`envs/echo_env/`**\n", " - Simplest possible environment\n", " - Great for testing and learning\n", "\n", - "2. **`src/envs/openspiel_env/`**\n", + "2. **`envs/openspiel_env/`**\n", " - Wraps external library (OpenSpiel)\n", " - Shows integration pattern\n", " - 6 games in one integration\n", "\n", - "3. **`src/envs/coding_env/`**\n", + "3. **`envs/coding_env/`**\n", " - Python code execution environment\n", " - Shows complex use case\n", " - Security considerations\n", @@ -1830,8 +1830,8 @@ "\n", "### 📖 Documentation Deep Dives\n", "\n", - "- **Environment Creation Guide**: `src/envs/README.md`\n", - "- **OpenSpiel Integration**: `src/envs/openspiel_env/README.md`\n", + "- **Environment Creation Guide**: `envs/README.md`\n", + "- **OpenSpiel Integration**: `envs/openspiel_env/README.md`\n", "- **Example Scripts**: `examples/`\n", "- **RFC 001**: [Baseline API Specs](https://github.com/meta-pytorch/OpenEnv/pull/26)\n", "\n", diff --git a/examples/coding_env_inference.py b/examples/coding_env_inference.py index 05384098a..63cfc74fd 100644 --- a/examples/coding_env_inference.py +++ b/examples/coding_env_inference.py @@ -11,7 +11,7 @@ 1. Build the Coding environment Docker image:: docker build \ - -f src/envs/coding_env/server/Dockerfile \ + -f envs/coding_env/server/Dockerfile \ -t coding-env:latest . 2. Set your Hugging Face token, or any other API key that is compatible with the OpenAI API: diff --git a/examples/textarena_simple.py b/examples/textarena_simple.py index a65ef1ffd..0791e74a1 100644 --- a/examples/textarena_simple.py +++ b/examples/textarena_simple.py @@ -73,7 +73,7 @@ def main() -> None: except Exception as exc: # pragma: no cover - demonstration script print(f"\n❌ Error: {exc}") print("\nMake sure you have built the Docker image first:") - print(" docker build -f src/envs/textarena_env/server/Dockerfile -t textarena-env:latest .") + print(" docker build -f envs/textarena_env/server/Dockerfile -t textarena-env:latest .") print("\nAlternatively run the server manually:") print(" python -m envs.textarena_env.server.app") diff --git a/examples/textarena_wordle_inference.py b/examples/textarena_wordle_inference.py index 9524a5ae1..bce6eabf0 100644 --- a/examples/textarena_wordle_inference.py +++ b/examples/textarena_wordle_inference.py @@ -10,7 +10,7 @@ ------------- 1. Build the TextArena Docker image:: - docker build -f src/envs/textarena_env/server/Dockerfile -t textarena-env:latest . + docker build -f envs/textarena_env/server/Dockerfile -t textarena-env:latest . 2. Set your Hugging Face token:: diff --git a/pyproject.toml b/pyproject.toml index 37d7400a2..b7fa6794a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openenv" -version = "0.1.1" +version = "0.2.0" description = "A unified framework for reinforcement learning environments" readme = "README.md" requires-python = ">=3.10" @@ -23,27 +23,50 @@ dependencies = [ "huggingface_hub>=0.20.0", "openai>=2.7.2", "tomli>=2.3.0", - "tomli-w>=1.2.0" + "tomli-w>=1.2.0", + "websockets>=15.0.1", +] + +[project.optional-dependencies] +core = [ + "fastapi>=0.104.0", + "pydantic>=2.0.0", + "uvicorn>=0.24.0", + "requests>=2.25.0", + "websockets>=15.0.1", +] +cli = [ + "typer>=0.9.0", + "rich>=13.0.0", + "pyyaml>=6.0", + "huggingface_hub>=0.20.0", + "openai>=2.7.2", + "tomli>=2.3.0", + "tomli-w>=1.2.0", +] +all = [ + "openenv[core]", + "openenv[cli]", ] [project.scripts] -openenv = "openenv_cli.__main__:main" +openenv = "openenv.cli.__main__:main" [tool.setuptools] package-dir = {"" = "src"} include-package-data = true [tool.setuptools.package-data] -"openenv_cli" = ["templates/**/*"] +"openenv.cli" = ["templates/**/*"] [tool.setuptools.packages.find] where = ["src"] [tool.coverage.run] omit = [ - "openenv_cli/templates/**", + "openenv/cli/templates/**", "**/templates/**", - "openenv_cli/__main__.py", + "openenv/cli/__main__.py", ] [tool.coverage.report] diff --git a/rfcs/003-mcp-support.md b/rfcs/003-mcp-support.md index 6b96c08ce..19d572f6e 100644 --- a/rfcs/003-mcp-support.md +++ b/rfcs/003-mcp-support.md @@ -505,8 +505,6 @@ obs = env.step(action) # obs.result = {"results": [...]} ``` -##### Environment Implementation - ```py from mcp_client import MCPClient diff --git a/scripts/CONVERT.md b/scripts/CONVERT.md index 4ede53b2c..b4647f701 100644 --- a/scripts/CONVERT.md +++ b/scripts/CONVERT.md @@ -1,6 +1,6 @@ # Converting Your Environment to OpenEnv Standard -This guide helps you convert an existing `src/envs/` environment to a standalone, OpenEnv CLI-compatible environment that can be independently developed, versioned, and deployed. +This guide helps you convert an existing `envs/` environment to a standalone, OpenEnv CLI-compatible environment that can be independently developed, versioned, and deployed. ## Overview @@ -23,7 +23,7 @@ We provide a script to automate most of the conversion process: ```bash # From the OpenEnv repository root -./scripts/convert_env.sh src/envs/my_env /path/to/new/my_env_standalone +./scripts/convert_env.sh envs/my_env /path/to/new/my_env_standalone ``` > **Note:** The converter requires `python3` on your PATH and works with the default Bash shipped on macOS. When prompted, answer `y` to proceed and leave the optional naming prompts blank to accept the defaults. @@ -35,7 +35,7 @@ This script will: 4. Update Dockerfile for standalone builds 5. Initialize a new git repository 6. Create necessary configuration files -7. Rewrite imports so the environment depends on `openenv-core` and installs as a proper Python package +7. Rewrite imports so the environment depends on `openenv` and installs as a proper Python package After running the script, jump to [Step 4: Testing Your Conversion](#step-4-testing-your-conversion). @@ -51,7 +51,7 @@ mkdir -p ~/my_projects/my_env_standalone cd ~/my_projects/my_env_standalone # Copy your existing environment -cp -r /path/to/OpenEnv/src/envs/my_env/* . +cp -r /path/to/OpenEnv/envs/my_env/* . # Initialize git repository git init @@ -96,7 +96,7 @@ description = "{env_name.replace('_', ' ').title()} Environment for OpenEnv" requires-python = ">=3.10" dependencies = [ {deps_str} - "openenv-core>=0.1.0", + "openenv[core]>=0.2.0", ] [project.optional-dependencies] @@ -138,7 +138,7 @@ version = "0.1.0" description = "My Environment for OpenEnv" requires-python = ">=3.10" dependencies = [ - "openenv-core>=0.1.0", + "openenv[core]>=0.2.0", "fastapi>=0.115.0", "pydantic>=2.0.0", "uvicorn>=0.24.0", @@ -447,12 +447,12 @@ uv pip install openenv-cli server = "my_env.server.app:main" # Replace my_env with your name ``` -### Issue: Missing openenv-core Dependency +### Issue: Missing openenv Dependency **Solution**: Add to `pyproject.toml`: ```toml dependencies = [ - "openenv-core>=0.1.0", + "openenv[core]>=0.2.0", # ... other dependencies ] ``` diff --git a/scripts/convert_env.sh b/scripts/convert_env.sh index c9e699f51..f523358bf 100644 --- a/scripts/convert_env.sh +++ b/scripts/convert_env.sh @@ -46,11 +46,11 @@ Usage: $0 Convert an OpenEnv environment from the monorepo to a standalone repository. Arguments: - source_env_dir Path to existing environment (e.g., src/envs/echo_env) + source_env_dir Path to existing environment (e.g., envs/echo_env) target_dir Path for new standalone environment (e.g., ~/my_envs/echo_env_standalone) Example: - $0 src/envs/echo_env ~/my_envs/echo_env_standalone + $0 envs/echo_env ~/my_envs/echo_env_standalone The script will: 1. Copy environment files to target directory @@ -173,8 +173,8 @@ else done < "server/requirements.txt" fi - # Always add openenv-core - DEPS="${DEPS} \"openenv-core>=0.1.0\"," + # Always add openenv runtime + DEPS="${DEPS} \"openenv[core]>=0.2.0\"," # Create pyproject.toml cat > pyproject.toml << EOF diff --git a/scripts/deploy_to_hf.sh b/scripts/deploy_to_hf.sh index 298d86bf3..3b5d0988f 100755 --- a/scripts/deploy_to_hf.sh +++ b/scripts/deploy_to_hf.sh @@ -10,7 +10,7 @@ usage() { Usage: scripts/deploy_to_hf.sh --env [options] Required arguments: - --env Environment name under src/envs (e.g. textarena_env) + --env Environment name under envs (e.g. textarena_env) Optional arguments: --base-sha Override openenv-base image reference (defaults to :latest) @@ -147,8 +147,8 @@ if [[ "$ENV_NAME" == *","* || "$ENV_NAME" == *" "* ]]; then exit 1 fi -if [ ! -d "src/envs/$ENV_NAME" ]; then - echo "Error: Environment '$ENV_NAME' not found under src/envs" >&2 +if [ ! -d "envs/$ENV_NAME" ]; then + echo "Error: Environment '$ENV_NAME' not found under envs" >&2 exit 1 fi @@ -181,13 +181,13 @@ CURRENT_STAGING_DIR="${STAGING_DIR}/${HF_NAMESPACE}/${ENV_NAME}" # Ensure clean staging directory rm -rf "$CURRENT_STAGING_DIR" mkdir -p "$CURRENT_STAGING_DIR/src/core" -mkdir -p "$CURRENT_STAGING_DIR/src/envs/$ENV_NAME" +mkdir -p "$CURRENT_STAGING_DIR/envs/$ENV_NAME" # Copy core files cp -R src/core/* "$CURRENT_STAGING_DIR/src/core/" # Copy environment files -cp -R src/envs/$ENV_NAME/* "$CURRENT_STAGING_DIR/src/envs/$ENV_NAME/" +cp -R envs/$ENV_NAME/* "$CURRENT_STAGING_DIR/envs/$ENV_NAME/" echo "📁 Copied core and $ENV_NAME environment files to $CURRENT_STAGING_DIR" @@ -267,7 +267,7 @@ WORKDIR /app COPY src/core/ /app/src/core/ # Copy OpenSpiel environment -COPY src/envs/openspiel_env/ /app/src/envs/openspiel_env/ +COPY envs/openspiel_env/ /app/envs/openspiel_env/ # Extend Python path for OpenEnv (base image set PYTHONPATH=/app/src) # We prepend OpenSpiel paths @@ -298,7 +298,7 @@ DOCKERFILE_EOF # Copy only what's needed for this environment COPY src/core/ /app/src/core/ -COPY src/envs/ENV_NAME_PLACEHOLDER/ /app/src/envs/ENV_NAME_PLACEHOLDER/ +COPY envs/ENV_NAME_PLACEHOLDER/ /app/envs/ENV_NAME_PLACEHOLDER/ # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ diff --git a/scripts/prepare_hf_deployment.sh b/scripts/prepare_hf_deployment.sh index 23fd4779e..d5fdefd38 100755 --- a/scripts/prepare_hf_deployment.sh +++ b/scripts/prepare_hf_deployment.sh @@ -43,21 +43,21 @@ echo "Preparing $ENV_NAME environment for deployment..." # Create staging directory CURRENT_STAGING_DIR="${STAGING_DIR}_${ENV_NAME}" mkdir -p $CURRENT_STAGING_DIR/src/core -mkdir -p $CURRENT_STAGING_DIR/src/envs/$ENV_NAME +mkdir -p $CURRENT_STAGING_DIR/envs/$ENV_NAME # Copy core files cp -r src/core/* $CURRENT_STAGING_DIR/src/core/ echo "Copied core files" # Copy environment files -cp -r src/envs/$ENV_NAME/* $CURRENT_STAGING_DIR/src/envs/$ENV_NAME/ +cp -r envs/$ENV_NAME/* $CURRENT_STAGING_DIR/envs/$ENV_NAME/ echo "Copied $ENV_NAME environment files" # Copy and modify the static Dockerfile from the environment create_environment_dockerfile() { local env_name=$1 - local dockerfile_path="src/envs/$env_name/server/Dockerfile" - local prepare_script="src/envs/$env_name/server/prepare_hf.sh" + local dockerfile_path="envs/$env_name/server/Dockerfile" + local prepare_script="envs/$env_name/server/prepare_hf.sh" if [ ! -f "$dockerfile_path" ]; then echo "Error: Dockerfile not found at $dockerfile_path" @@ -92,7 +92,7 @@ create_environment_dockerfile $ENV_NAME # Copy and prepend HF-specific intro to README create_readme() { local env_name=$1 - local readme_source="src/envs/$env_name/README.md" + local readme_source="envs/$env_name/README.md" if [ ! -f "$readme_source" ]; then echo "Error: README not found at $readme_source" diff --git a/scripts/setup_shared_gitea.sh b/scripts/setup_shared_gitea.sh index ccc98bb1c..6aeacda37 100755 --- a/scripts/setup_shared_gitea.sh +++ b/scripts/setup_shared_gitea.sh @@ -21,7 +21,7 @@ echo # Start Gitea with docker-compose echo "1. Starting Gitea container..." -docker-compose -f src/envs/git_env/docker-compose.gitea.yml up -d +docker-compose -f envs/git_env/docker-compose.gitea.yml up -d # Wait for Gitea to be healthy echo "2. Waiting for Gitea to be ready..." @@ -76,8 +76,8 @@ echo echo "Admin credentials are configured from .env file" echo echo "To stop Gitea:" -echo " docker-compose -f src/envs/git_env/docker-compose.gitea.yml down" +echo " docker-compose -f envs/git_env/docker-compose.gitea.yml down" echo echo "To remove all data:" -echo " docker-compose -f src/envs/git_env/docker-compose.gitea.yml down -v" +echo " docker-compose -f envs/git_env/docker-compose.gitea.yml down -v" echo diff --git a/src/core/env_server/__init__.py b/src/core/env_server/__init__.py deleted file mode 100644 index 79e66535f..000000000 --- a/src/core/env_server/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -"""Core environment interfaces and types.""" - -from .base_transforms import CompositeTransform, NullTransform -from .http_server import HTTPEnvServer, create_app, create_fastapi_app -from .interfaces import Environment, Message, ModelTokenizer, Transform -from .types import Action, Observation, State -from .web_interface import create_web_interface_app, WebInterfaceManager - -__all__ = [ - # Core interfaces - "Environment", - "Transform", - "Message", - "ModelTokenizer", - # Types - "Action", - "Observation", - "State", - # Base transforms - "CompositeTransform", - "NullTransform", - # HTTP Server - "HTTPEnvServer", - "create_app", - "create_fastapi_app", - # Web Interface - "create_web_interface_app", - "WebInterfaceManager", -] diff --git a/src/core/env_server/http_server.py b/src/core/env_server/http_server.py deleted file mode 100644 index 207235f63..000000000 --- a/src/core/env_server/http_server.py +++ /dev/null @@ -1,257 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -""" -HTTP server wrapper for Environment instances. - -This module provides utilities to wrap any Environment subclass and expose it -over HTTP endpoints that HTTPEnvClient can consume. -""" - -from __future__ import annotations - -import asyncio -import os -from concurrent.futures import ThreadPoolExecutor -from dataclasses import asdict -from typing import Any, Dict, Type - -from .interfaces import Environment -from .types import Action, Observation -from fastapi import Body, FastAPI - -class HTTPEnvServer: - """ - HTTP server wrapper for Environment instances. - - This class wraps an Environment and exposes its reset(), step(), and state - methods as HTTP endpoints compatible with HTTPEnvClient. - - The server expects: - - Action deserialization: Converts JSON dict to Action subclass - - Observation serialization: Converts Observation subclass to JSON dict - - Example: - >>> from core.env_server import HTTPEnvServer - >>> from envs.coding_env.server import CodeExecutionEnvironment - >>> - >>> env = CodeExecutionEnvironment() - >>> server = HTTPEnvServer(env) - >>> - >>> # Register routes with FastAPI - >>> from fastapi import FastAPI - >>> app = FastAPI() - >>> server.register_routes(app) - """ - - def __init__( - self, - env: Environment, - action_cls: Type[Action], - observation_cls: Type[Observation], - ): - """ - Initialize HTTP server wrapper. - - Args: - env: The Environment instance to wrap - action_cls: The Action subclass this environment expects - observation_cls: The Observation subclass this environment returns - """ - self.env = env - self.action_cls = action_cls - self.observation_cls = observation_cls - # Create thread pool for running sync code in async context - # This is needed for environments using sync libraries (e.g., Playwright sync API) - self._executor = ThreadPoolExecutor(max_workers=1) - - def register_routes(self, app: Any) -> None: - """ - Register HTTP routes on a FastAPI application. - - Args: - app: FastAPI application instance - """ - - if not isinstance(app, FastAPI): - raise TypeError("app must be a FastAPI instance") - - @app.post("/reset") - async def reset(request: Dict[str, Any] = Body(default={})) -> Dict[str, Any]: - """Reset endpoint - returns initial observation.""" - # TODO: Handle seed, episode_id from request if provided - # Run sync environment code in thread pool to avoid blocking asyncio loop - loop = asyncio.get_event_loop() - observation = await loop.run_in_executor(self._executor, self.env.reset) - return self._serialize_observation(observation) - - @app.post("/step") - async def step(request: Dict[str, Any]) -> Dict[str, Any]: - """Step endpoint - executes action and returns observation.""" - # Support both {"action": {...}} and direct action fields - action_data = request.get("action", request) - # TODO: Handle timeout_s, request_id, episode_id from request if provided - - # Deserialize action - action = self._deserialize_action(action_data) - - # Execute step in thread pool to avoid blocking asyncio loop - loop = asyncio.get_event_loop() - observation = await loop.run_in_executor( - self._executor, self.env.step, action - ) - - # Return serialized observation - return self._serialize_observation(observation) - - @app.get("/state") - async def get_state() -> Dict[str, Any]: - """State endpoint - returns current environment state.""" - state = self.env.state - return asdict(state) - - @app.get("/health") - async def health() -> Dict[str, str]: - """Health check endpoint.""" - return {"status": "healthy"} - - - def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: - """ - Convert JSON dict to Action instance. - - Args: - action_data: Dictionary containing action data - - Returns: - Action instance - - Note: - This is a simple implementation. Subclasses may need to override - for more complex deserialization logic. - """ - # Remove metadata if present (it will be set via kw_only field) - metadata = action_data.pop("metadata", {}) - action = self.action_cls(**action_data) - action.metadata = metadata - return action - - def _serialize_observation(self, observation: Observation) -> Dict[str, Any]: - """ - Convert Observation instance to JSON-compatible dict. - - Args: - observation: Observation instance - - Returns: - Dictionary compatible with HTTPEnvClient._parse_result() - - The format matches what HTTPEnvClient expects: - { - "observation": {...}, # Observation fields - "reward": float | None, - "done": bool, - } - """ - obs_dict = asdict(observation) - - # Convert numpy arrays to lists for JSON serialization - def _convert_numpy(obj): - """Recursively convert numpy arrays to lists.""" - if hasattr(obj, '__array__'): # numpy array - return obj.tolist() - elif isinstance(obj, dict): - return {k: _convert_numpy(v) for k, v in obj.items()} - elif isinstance(obj, (list, tuple)): - return type(obj)(_convert_numpy(item) for item in obj) - return obj - - obs_dict = _convert_numpy(obs_dict) - - # Extract reward and done (these are part of StepResult on client side) - reward = obs_dict.pop("reward", None) - done = obs_dict.pop("done", False) - obs_dict.pop("metadata", None) # Remove metadata from observation - - # Return in HTTPEnvClient expected format - return { - "observation": obs_dict, - "reward": reward, - "done": done, - } - -def create_app( - env: Environment, - action_cls: Type[Action], - observation_cls: Type[Observation], - env_name: Optional[str] = None, -) -> Any: - """ - Create a FastAPI application with or without web interface. - - This function creates a FastAPI app with the web interface enabled by default, - including README integration for better user experience. - - Args: - env: The Environment instance to serve - action_cls: The Action subclass this environment expects - observation_cls: The Observation subclass this environment returns - env_name: Optional environment name for README loading - - Returns: - FastAPI application instance with or without web interface and README integration - """ - # Check if web interface should be enabled - # This can be controlled via environment variable or build argument - enable_web = ( - os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ("true", "1", "yes") - ) - - if enable_web: - # Import web interface only when needed - from .web_interface import create_web_interface_app - return create_web_interface_app(env, action_cls, observation_cls, env_name) - else: - # Use standard FastAPI app without web interface - return create_fastapi_app(env, action_cls, observation_cls) - - -def create_fastapi_app( - env: Environment, - action_cls: Type[Action], - observation_cls: Type[Observation], -) -> Any: - """ - Create a FastAPI application with routes for the given environment. - - Args: - env: The Environment instance to serve - action_cls: The Action subclass this environment expects - observation_cls: The Observation subclass this environment returns - - Returns: - FastAPI application instance with routes registered - - Example: - >>> from envs.coding_env.server import CodeExecutionEnvironment - >>> from envs.coding_env.models import CodeAction, CodeObservation - >>> - >>> env = CodeExecutionEnvironment() - >>> app = create_fastapi_app(env, CodeAction, CodeObservation) - >>> - >>> # Run with: uvicorn module:app --host 0.0.0.0 --port 8000 - """ - try: - from fastapi import FastAPI - except ImportError: - raise ImportError( - "FastAPI is required. Install with: pip install fastapi uvicorn" - ) - - app = FastAPI(title="Environment HTTP Server") - server = HTTPEnvServer(env, action_cls, observation_cls) - server.register_routes(app) - return app diff --git a/src/core/env_server/interfaces.py b/src/core/env_server/interfaces.py deleted file mode 100644 index caa2d76db..000000000 --- a/src/core/env_server/interfaces.py +++ /dev/null @@ -1,118 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -from abc import ABC, abstractmethod -from typing import Any, Protocol, TypedDict - -from .types import Action, Observation, State - - -class Message(TypedDict): - """A message in a conversation. - - Compatible with Huggingface chat template format. - """ - - role: str - content: str - - -class ModelTokenizer(Protocol): - """Protocol for tokenizers that support chat templates. - - This protocol defines the interface that tokenizers must implement - to work with chat-based environments. It's compatible with - Huggingface transformers tokenizers. - """ - - def apply_chat_template( - self, - conversation: list[Message], - tokenize: bool = True, - return_tensors: str | None = None, - **kwargs: Any, - ) -> Any: - """Apply a chat template to format and optionally tokenize a conversation. - - Args: - conversation: List of message dictionaries with 'role' and 'content' - tokenize: Whether to tokenize the output - return_tensors: Format for returned tensors ('pt' for PyTorch) - **kwargs: Additional arguments - - Returns: - Formatted and optionally tokenized conversation - """ - ... - - def decode( - self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any - ) -> str: - """Decode token IDs back to text. - - Args: - token_ids: Token IDs to decode - skip_special_tokens: Whether to skip special tokens in output - **kwargs: Additional arguments - - Returns: - Decoded text string - """ - ... - - -class Transform(ABC): - """Transform observations to add rewards, metrics, or other modifications. - - Transforms follow the TorchRL pattern where they take an observation - and return a (potentially modified) observation. This allows for - flexible reward computation and observation augmentation. - """ - - @abstractmethod - def __call__(self, observation: Observation) -> Observation: - """Transform an observation. - - Args: - observation: The input observation - - Returns: - The transformed observation - """ - pass - - -class Environment(ABC): - """Base class for all environment servers following Gym/Gymnasium API. - - Args: - transform: Optional transform to apply to observations - """ - - def __init__(self, transform: Transform | None = None): - self.transform = transform - - @abstractmethod - def reset(self) -> Observation: - """Reset the environment and return initial observation.""" - pass - - @abstractmethod - def step(self, action: Action) -> Observation: - """Take a step in the environment.""" - pass - - @property - @abstractmethod - def state(self) -> State: - """Get the current environment state.""" - pass - - def _apply_transform(self, observation: Observation) -> Observation: - """Apply transform if one is provided.""" - if self.transform is not None: - return self.transform(observation) - return observation diff --git a/src/core/env_server/types.py b/src/core/env_server/types.py deleted file mode 100644 index 70da9f3ca..000000000 --- a/src/core/env_server/types.py +++ /dev/null @@ -1,57 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Union - - -# Type aliases -Scalar = Union[int, float, bool] - - -@dataclass(kw_only=True) -class Action: - """Base class for all environment actions.""" - - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass(kw_only=True) -class Observation: - """Base class for all environment observations.""" - - done: bool = False - reward: Union[bool, int, float, None] = None - metadata: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class State: - """Base class for environment state.""" - - episode_id: Optional[str] = None - step_count: int = 0 - - -@dataclass -class CodeExecResult: - """Result of code execution containing stdout, stderr, and exit code.""" - - stdout: str - stderr: str - exit_code: int - - -@dataclass -class EnvironmentMetadata: - """Metadata about an environment for documentation and UI purposes.""" - - name: str - description: str - readme_content: Optional[str] = None - version: Optional[str] = None - author: Optional[str] = None - documentation_url: Optional[str] = None diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py deleted file mode 100644 index 16bbfa5d6..000000000 --- a/src/core/http_env_client.py +++ /dev/null @@ -1,203 +0,0 @@ -""" -core/runner_env.py -Minimal HTTP-based environment client. -- Talks to a single env worker exposing: POST /reset, POST /step - -Future hooks (commented below) for: -- episode_id, seed on reset -- request_id on step -- custom headers (auth/trace) -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar - -import requests - -from .client_types import StepResult -from .containers.runtime import LocalDockerProvider - -if TYPE_CHECKING: - from .containers.runtime import ContainerProvider - -ActT = TypeVar("ActT") -ObsT = TypeVar("ObsT") -EnvClientT = TypeVar("EnvClientT", bound="HTTPEnvClient") - - -class HTTPEnvClient(ABC, Generic[ActT, ObsT]): - def __init__( - self, - base_url: str, - request_timeout_s: float = 15.0, - default_headers: Optional[Dict[str, str]] = None, - provider: Optional["ContainerProvider"] = None, - ): - self._base = base_url.rstrip("/") - self._timeout = float(request_timeout_s) - self._http = requests.Session() - self._headers = default_headers or {} - self._provider = provider - - @classmethod - def from_docker_image( - cls: Type[EnvClientT], - image: str, - provider: Optional["ContainerProvider"] = None, - **kwargs: Any, - ) -> EnvClientT: - """ - Create an environment client by spinning up a Docker container locally. - - This is a development utility that: - 1. Starts a Docker container from the specified image - 2. Waits for the server to be ready - 3. Creates and returns a client instance connected to the container - - Note: The container lifecycle management is left to the user or higher-level - orchestration. The container will keep running until manually stopped. - - Args: - image: Docker image name to run (e.g., "echo-env:latest") - provider: Container provider to use (defaults to LocalDockerProvider) - **kwargs: Additional arguments to pass to provider.start_container() - (e.g., env_vars, port) - - Returns: - An instance of the client class connected to the running container - - Example: - >>> from envs.coding_env.client import CodingEnv - >>> from envs.coding_env.models import CodeAction - >>> - >>> # Create environment from image - >>> env = CodingEnv.from_docker_image("coding-env:latest") - >>> - >>> # Create environment with custom env vars - >>> env = CodingEnv.from_docker_image( - ... "coding-env:latest", - ... env_vars={"MY_VAR": "value"} - ... ) - >>> - >>> # Use the environment - >>> result = env.reset() - >>> print(result.observation) - >>> - >>> step_result = env.step(CodeAction(code="print('hello')")) - >>> print(step_result.observation.stdout) - >>> - >>> # Cleanup (optional) - >>> env.close() - """ - - # Use default provider if none provided - if provider is None: - provider = LocalDockerProvider() - - # 1. Start container with optional kwargs (e.g., env_vars, port) - base_url = provider.start_container(image, **kwargs) - - # 2. Wait for server to be ready - provider.wait_for_ready(base_url) - - # 3. Create and return client instance with provider reference - return cls(base_url=base_url, provider=provider) - - @classmethod - def from_hub(cls: Type[EnvClientT], repo_id: str, provider: Optional["ContainerProvider"] = None, **kwargs: Any) -> EnvClientT: - """ - Create an environment client by pulling from a Hugging Face model hub. - """ - - if provider is None: - provider = LocalDockerProvider() - - if "tag" in kwargs: - tag = kwargs["tag"] - else: - tag = "latest" - - base_url = f"registry.hf.space/{repo_id.replace('/', '-')}:{tag}" - - return cls.from_docker_image(image=base_url, provider=provider) - - @abstractmethod - def _step_payload(self, action: ActT) -> dict: - """Convert an Action object to the JSON body expected by the env server.""" - raise NotImplementedError - - @abstractmethod - def _parse_result(self, payload: dict) -> StepResult[ObsT]: - """Convert a JSON response from the env server to StepResult[ObsT].""" - raise NotImplementedError - - @abstractmethod - def _parse_state(self, payload: dict) -> Any: - """Convert a JSON response from the state endpoint to a State object.""" - raise NotImplementedError - - # ---------- Environment Server Interface Methods ---------- - def reset(self) -> StepResult[ObsT]: - body: Dict[str, Any] = {} - # TODO: later: - # body["seed"] = seed - # body["episode_id"] = episode_id - r = self._http.post( - f"{self._base}/reset", - json=body, - headers=self._headers, - timeout=self._timeout, - ) - r.raise_for_status() - return self._parse_result(r.json()) - - def step(self, action: ActT) -> StepResult[ObsT]: - body: Dict[str, Any] = { - "action": self._step_payload(action), - "timeout_s": int(self._timeout), - } - # TODO: later: - # body["request_id"] = str(uuid.uuid4()) - # body["episode_id"] = current_episode_id - r = self._http.post( - f"{self._base}/step", - json=body, - headers=self._headers, - timeout=self._timeout, - ) - r.raise_for_status() - return self._parse_result(r.json()) - - def state(self) -> Any: - """ - Get the current environment state from the server. - - Returns: - State object with environment state information (e.g., episode_id, step_count) - - Example: - >>> client = EchoEnv.from_docker_image("echo-env:latest") - >>> result = client.reset() - >>> state = client.state() - >>> print(state.episode_id) - >>> print(state.step_count) - """ - r = self._http.get( - f"{self._base}/state", - headers=self._headers, - timeout=self._timeout, - ) - r.raise_for_status() - return self._parse_state(r.json()) - - def close(self) -> None: - """ - Close the environment and clean up resources. - - If this client was created via from_docker_image(), this will stop - and remove the associated container. - """ - if self._provider is not None: - self._provider.stop_container() diff --git a/src/core/uv.lock b/src/core/uv.lock deleted file mode 100644 index d52314b13..000000000 --- a/src/core/uv.lock +++ /dev/null @@ -1,1024 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.10" - -[[package]] -name = "annotated-doc" -version = "0.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, -] - -[[package]] -name = "black" -version = "25.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "mypy-extensions" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "platformdirs" }, - { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/40/dbe31fc56b218a858c8fc6f5d8d3ba61c1fa7e989d43d4a4574b8b992840/black-25.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce41ed2614b706fd55fd0b4a6909d06b5bab344ffbfadc6ef34ae50adba3d4f7", size = 1715605, upload-time = "2025-09-19T00:36:13.483Z" }, - { url = "https://files.pythonhosted.org/packages/92/b2/f46800621200eab6479b1f4c0e3ede5b4c06b768e79ee228bc80270bcc74/black-25.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ab0ce111ef026790e9b13bd216fa7bc48edd934ffc4cbf78808b235793cbc92", size = 1571829, upload-time = "2025-09-19T00:32:42.13Z" }, - { url = "https://files.pythonhosted.org/packages/4e/64/5c7f66bd65af5c19b4ea86062bb585adc28d51d37babf70969e804dbd5c2/black-25.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f96b6726d690c96c60ba682955199f8c39abc1ae0c3a494a9c62c0184049a713", size = 1631888, upload-time = "2025-09-19T00:30:54.212Z" }, - { url = "https://files.pythonhosted.org/packages/3b/64/0b9e5bfcf67db25a6eef6d9be6726499a8a72ebab3888c2de135190853d3/black-25.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:d119957b37cc641596063cd7db2656c5be3752ac17877017b2ffcdb9dfc4d2b1", size = 1327056, upload-time = "2025-09-19T00:31:08.877Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f4/7531d4a336d2d4ac6cc101662184c8e7d068b548d35d874415ed9f4116ef/black-25.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:456386fe87bad41b806d53c062e2974615825c7a52159cde7ccaeb0695fa28fa", size = 1698727, upload-time = "2025-09-19T00:31:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/28/f9/66f26bfbbf84b949cc77a41a43e138d83b109502cd9c52dfc94070ca51f2/black-25.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a16b14a44c1af60a210d8da28e108e13e75a284bf21a9afa6b4571f96ab8bb9d", size = 1555679, upload-time = "2025-09-19T00:31:29.265Z" }, - { url = "https://files.pythonhosted.org/packages/bf/59/61475115906052f415f518a648a9ac679d7afbc8da1c16f8fdf68a8cebed/black-25.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaf319612536d502fdd0e88ce52d8f1352b2c0a955cc2798f79eeca9d3af0608", size = 1617453, upload-time = "2025-09-19T00:30:42.24Z" }, - { url = "https://files.pythonhosted.org/packages/7f/5b/20fd5c884d14550c911e4fb1b0dae00d4abb60a4f3876b449c4d3a9141d5/black-25.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c0372a93e16b3954208417bfe448e09b0de5cc721d521866cd9e0acac3c04a1f", size = 1333655, upload-time = "2025-09-19T00:30:56.715Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8e/319cfe6c82f7e2d5bfb4d3353c6cc85b523d677ff59edc61fdb9ee275234/black-25.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1b9dc70c21ef8b43248f1d86aedd2aaf75ae110b958a7909ad8463c4aa0880b0", size = 1742012, upload-time = "2025-09-19T00:33:08.678Z" }, - { url = "https://files.pythonhosted.org/packages/94/cc/f562fe5d0a40cd2a4e6ae3f685e4c36e365b1f7e494af99c26ff7f28117f/black-25.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8e46eecf65a095fa62e53245ae2795c90bdecabd53b50c448d0a8bcd0d2e74c4", size = 1581421, upload-time = "2025-09-19T00:35:25.937Z" }, - { url = "https://files.pythonhosted.org/packages/84/67/6db6dff1ebc8965fd7661498aea0da5d7301074b85bba8606a28f47ede4d/black-25.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9101ee58ddc2442199a25cb648d46ba22cd580b00ca4b44234a324e3ec7a0f7e", size = 1655619, upload-time = "2025-09-19T00:30:49.241Z" }, - { url = "https://files.pythonhosted.org/packages/10/10/3faef9aa2a730306cf469d76f7f155a8cc1f66e74781298df0ba31f8b4c8/black-25.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:77e7060a00c5ec4b3367c55f39cf9b06e68965a4f2e61cecacd6d0d9b7ec945a", size = 1342481, upload-time = "2025-09-19T00:31:29.625Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, - { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, -] - -[[package]] -name = "certifi" -version = "2025.10.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fastapi" -version = "0.121.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httptools" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/e5/c07e0bcf4ec8db8164e9f6738c048b2e66aabf30e7506f440c4cc6953f60/httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78", size = 204531, upload-time = "2025-10-10T03:54:20.887Z" }, - { url = "https://files.pythonhosted.org/packages/7e/4f/35e3a63f863a659f92ffd92bef131f3e81cf849af26e6435b49bd9f6f751/httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4", size = 109408, upload-time = "2025-10-10T03:54:22.455Z" }, - { url = "https://files.pythonhosted.org/packages/f5/71/b0a9193641d9e2471ac541d3b1b869538a5fb6419d52fd2669fa9c79e4b8/httptools-0.7.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8c751014e13d88d2be5f5f14fc8b89612fcfa92a9cc480f2bc1598357a23a05", size = 440889, upload-time = "2025-10-10T03:54:23.753Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d9/2e34811397b76718750fea44658cb0205b84566e895192115252e008b152/httptools-0.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654968cb6b6c77e37b832a9be3d3ecabb243bbe7a0b8f65fbc5b6b04c8fcabed", size = 440460, upload-time = "2025-10-10T03:54:25.313Z" }, - { url = "https://files.pythonhosted.org/packages/01/3f/a04626ebeacc489866bb4d82362c0657b2262bef381d68310134be7f40bb/httptools-0.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b580968316348b474b020edf3988eecd5d6eec4634ee6561e72ae3a2a0e00a8a", size = 425267, upload-time = "2025-10-10T03:54:26.81Z" }, - { url = "https://files.pythonhosted.org/packages/a5/99/adcd4f66614db627b587627c8ad6f4c55f18881549bab10ecf180562e7b9/httptools-0.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d496e2f5245319da9d764296e86c5bb6fcf0cf7a8806d3d000717a889c8c0b7b", size = 424429, upload-time = "2025-10-10T03:54:28.174Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/ec8fc904a8fd30ba022dfa85f3bbc64c3c7cd75b669e24242c0658e22f3c/httptools-0.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cbf8317bfccf0fed3b5680c559d3459cccf1abe9039bfa159e62e391c7270568", size = 86173, upload-time = "2025-10-10T03:54:29.5Z" }, - { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, - { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, - { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, - { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, - { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, - { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, - { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, - { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, - { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, - { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, - { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, - { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, - { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, - { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, - { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, - { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, - { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, - { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, - { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, - { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, - { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, - { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, - { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, - { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "mypy" -version = "1.18.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "pathspec" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, - { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, - { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, - { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, - { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, - { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, - { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, - { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, - { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, - { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, - { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, - { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, - { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, - { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, - { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, - { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, - { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, - { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, - { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, - { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, - { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, -] - -[[package]] -name = "openenv-core" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.optional-dependencies] -dev = [ - { name = "black" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "fastapi", specifier = ">=0.104.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.0.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "requests", specifier = ">=2.25.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "ruff" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, - { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, - { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, - { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, - { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, - { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, - { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, - { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, - { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, - { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, - { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, - { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "starlette" -version = "0.49.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, -] - -[package.optional-dependencies] -standard = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "httptools" }, - { name = "python-dotenv" }, - { name = "pyyaml" }, - { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, - { name = "watchfiles" }, - { name = "websockets" }, -] - -[[package]] -name = "uvloop" -version = "0.22.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/14/ecceb239b65adaaf7fde510aa8bd534075695d1e5f8dadfa32b5723d9cfb/uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c", size = 1343335, upload-time = "2025-10-16T22:16:11.43Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ae/6f6f9af7f590b319c94532b9567409ba11f4fa71af1148cab1bf48a07048/uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792", size = 742903, upload-time = "2025-10-16T22:16:12.979Z" }, - { url = "https://files.pythonhosted.org/packages/09/bd/3667151ad0702282a1f4d5d29288fce8a13c8b6858bf0978c219cd52b231/uvloop-0.22.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86", size = 3648499, upload-time = "2025-10-16T22:16:14.451Z" }, - { url = "https://files.pythonhosted.org/packages/b3/f6/21657bb3beb5f8c57ce8be3b83f653dd7933c2fd00545ed1b092d464799a/uvloop-0.22.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd", size = 3700133, upload-time = "2025-10-16T22:16:16.272Z" }, - { url = "https://files.pythonhosted.org/packages/09/e0/604f61d004ded805f24974c87ddd8374ef675644f476f01f1df90e4cdf72/uvloop-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2", size = 3512681, upload-time = "2025-10-16T22:16:18.07Z" }, - { url = "https://files.pythonhosted.org/packages/bb/ce/8491fd370b0230deb5eac69c7aae35b3be527e25a911c0acdffb922dc1cd/uvloop-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec", size = 3615261, upload-time = "2025-10-16T22:16:19.596Z" }, - { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, - { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, - { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, - { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, - { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, - { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, - { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, - { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, - { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, - { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, - { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, - { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, - { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, - { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, - { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, - { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, -] - -[[package]] -name = "watchfiles" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318, upload-time = "2025-10-14T15:04:18.753Z" }, - { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478, upload-time = "2025-10-14T15:04:20.297Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894, upload-time = "2025-10-14T15:04:21.527Z" }, - { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065, upload-time = "2025-10-14T15:04:22.795Z" }, - { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377, upload-time = "2025-10-14T15:04:24.138Z" }, - { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837, upload-time = "2025-10-14T15:04:25.057Z" }, - { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456, upload-time = "2025-10-14T15:04:26.497Z" }, - { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614, upload-time = "2025-10-14T15:04:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690, upload-time = "2025-10-14T15:04:28.495Z" }, - { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459, upload-time = "2025-10-14T15:04:29.491Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663, upload-time = "2025-10-14T15:04:30.435Z" }, - { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453, upload-time = "2025-10-14T15:04:31.53Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, - { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, - { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, - { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, - { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, - { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, - { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, - { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, - { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, - { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, - { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, - { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, - { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, - { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, - { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, - { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, - { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, - { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, - { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, - { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, - { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, - { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, - { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, - { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, - { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, - { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, - { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, - { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, - { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, - { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, - { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, - { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, - { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, - { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, - { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, - { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, - { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, - { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, - { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, - { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, - { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, - { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, - { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, - { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, - { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, - { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611, upload-time = "2025-10-14T15:06:05.809Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889, upload-time = "2025-10-14T15:06:07.035Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616, upload-time = "2025-10-14T15:06:08.072Z" }, - { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413, upload-time = "2025-10-14T15:06:09.209Z" }, - { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, - { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, - { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, -] - -[[package]] -name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" }, - { url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" }, - { url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" }, - { url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" }, - { url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" }, - { url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" }, - { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, - { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, - { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, - { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, - { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, - { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, - { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, - { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, - { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, - { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, - { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, - { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, - { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, - { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, - { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, - { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, - { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, - { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" }, - { url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" }, - { url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" }, - { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, -] diff --git a/src/envs/browsergym_env/models.py b/src/envs/browsergym_env/models.py deleted file mode 100644 index 1c68cef66..000000000 --- a/src/envs/browsergym_env/models.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Data models for the BrowserGym environment. - -BrowserGym is a unified framework for web-based agent tasks, combining multiple -benchmarks including MiniWoB (training), WebArena (evaluation), VisualWebArena, -and more under a single Gymnasium-compatible API. -""" - -from dataclasses import dataclass -from typing import List, Optional - -from openenv_core.env_server.types import Action, Observation, State - - -@dataclass(kw_only=True) -class BrowserGymAction(Action): - """Action to be executed in the BrowserGym environment. - - BrowserGym supports high-level natural language actions that can be parsed - into browser operations. - - Example actions: - - "click('Submit button')" - - "fill('username', 'john@example.com')" - - "goto('https://example.com')" - - "scroll(down)" - - "send_keys('Enter')" - """ - - action_str: str - """Natural language action string (e.g., "click('Submit')")""" - - -@dataclass(kw_only=True) -class BrowserGymObservation(Observation): - """Observation returned from the BrowserGym environment. - - Contains multiple observation modalities including text (accessibility tree - or DOM), visual (screenshot), and page metadata. - """ - - text: str = "" - """Text representation of the page (accessibility tree or DOM)""" - - url: str = "" - """Current URL of the page""" - - screenshot: Optional[List[List[List[int]]]] = None - """Screenshot as numpy array [height, width, channels] (if visual observation enabled)""" - - goal: str = "" - """Task goal/instruction for the current episode""" - - axtree_txt: str = "" - """Full accessibility tree as text""" - - pruned_html: str = "" - """Pruned HTML content (interactive elements only)""" - - error: str = "" - """Error message if action execution failed""" - - last_action_error: bool = False - """Whether the last action resulted in an error""" - - -@dataclass -class BrowserGymState(State): - """State of the BrowserGym environment. - - Tracks the current benchmark, task, and progress through an episode. - """ - - benchmark: str = "" - """Benchmark name (e.g., 'miniwob', 'webarena', 'visualwebarena')""" - - task_name: str = "" - """Specific task within the benchmark (e.g., 'click-test', 'click-button')""" - - task_id: Optional[str] = None - """Task ID for evaluation benchmarks (e.g., WebArena task number)""" - - goal: str = "" - """Task goal/instruction""" - - current_url: str = "" - """Current URL of the active page""" - - max_steps: Optional[int] = None - """Maximum steps allowed for this task""" - - cum_reward: float = 0.0 - """Cumulative reward for the current episode""" diff --git a/src/envs/browsergym_env/server/requirements.txt b/src/envs/browsergym_env/server/requirements.txt deleted file mode 100644 index d1e08668a..000000000 --- a/src/envs/browsergym_env/server/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -browsergym>=0.2.0 -browsergym-core>=0.2.0 -browsergym-miniwob>=0.2.0 -browsergym-webarena>=0.2.0 -gymnasium>=0.29.0 -playwright>=1.40.0 -Pillow>=10.0.0 -fastapi>=0.104.0 -uvicorn>=0.24.0 diff --git a/src/envs/connect4_env/server/app.py b/src/envs/connect4_env/server/app.py deleted file mode 100644 index a214e42be..000000000 --- a/src/envs/connect4_env/server/app.py +++ /dev/null @@ -1,12 +0,0 @@ -from core.env_server import create_fastapi_app -from ..models import Connect4Action, Connect4Observation -from .connect4_environment import Connect4Environment - -env = Connect4Environment() -app = create_fastapi_app(env, Connect4Action, Connect4Observation) - -if __name__ == "__main__": - - import uvicorn - - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/src/envs/echo_env/uv.lock b/src/envs/echo_env/uv.lock deleted file mode 100644 index 0b4580489..000000000 --- a/src/envs/echo_env/uv.lock +++ /dev/null @@ -1,679 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.10" - -[[package]] -name = "annotated-doc" -version = "0.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, -] - -[[package]] -name = "certifi" -version = "2025.10.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "coverage" -version = "7.11.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210, upload-time = "2025-11-10T00:13:17.18Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377, upload-time = "2025-11-10T00:10:27.317Z" }, - { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892, upload-time = "2025-11-10T00:10:30.624Z" }, - { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650, upload-time = "2025-11-10T00:10:32.392Z" }, - { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478, upload-time = "2025-11-10T00:10:34.157Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337, upload-time = "2025-11-10T00:10:35.655Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328, upload-time = "2025-11-10T00:10:37.459Z" }, - { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381, upload-time = "2025-11-10T00:10:39.229Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390, upload-time = "2025-11-10T00:10:40.984Z" }, - { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654, upload-time = "2025-11-10T00:10:42.496Z" }, - { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272, upload-time = "2025-11-10T00:10:44.39Z" }, - { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969, upload-time = "2025-11-10T00:10:45.863Z" }, - { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903, upload-time = "2025-11-10T00:10:47.676Z" }, - { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504, upload-time = "2025-11-10T00:10:49.524Z" }, - { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006, upload-time = "2025-11-10T00:10:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415, upload-time = "2025-11-10T00:10:52.805Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332, upload-time = "2025-11-10T00:10:54.35Z" }, - { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443, upload-time = "2025-11-10T00:10:55.832Z" }, - { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554, upload-time = "2025-11-10T00:10:57.829Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139, upload-time = "2025-11-10T00:10:59.465Z" }, - { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209, upload-time = "2025-11-10T00:11:01.432Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936, upload-time = "2025-11-10T00:11:02.964Z" }, - { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835, upload-time = "2025-11-10T00:11:04.405Z" }, - { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994, upload-time = "2025-11-10T00:11:05.966Z" }, - { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926, upload-time = "2025-11-10T00:11:07.936Z" }, - { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599, upload-time = "2025-11-10T00:11:09.578Z" }, - { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676, upload-time = "2025-11-10T00:11:11.566Z" }, - { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034, upload-time = "2025-11-10T00:11:13.12Z" }, - { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531, upload-time = "2025-11-10T00:11:15.023Z" }, - { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290, upload-time = "2025-11-10T00:11:16.628Z" }, - { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375, upload-time = "2025-11-10T00:11:18.249Z" }, - { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946, upload-time = "2025-11-10T00:11:20.202Z" }, - { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310, upload-time = "2025-11-10T00:11:21.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461, upload-time = "2025-11-10T00:11:23.384Z" }, - { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039, upload-time = "2025-11-10T00:11:25.07Z" }, - { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903, upload-time = "2025-11-10T00:11:26.992Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201, upload-time = "2025-11-10T00:11:28.619Z" }, - { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012, upload-time = "2025-11-10T00:11:30.234Z" }, - { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652, upload-time = "2025-11-10T00:11:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694, upload-time = "2025-11-10T00:11:34.296Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065, upload-time = "2025-11-10T00:11:36.281Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062, upload-time = "2025-11-10T00:11:37.903Z" }, - { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657, upload-time = "2025-11-10T00:11:39.509Z" }, - { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900, upload-time = "2025-11-10T00:11:41.372Z" }, - { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254, upload-time = "2025-11-10T00:11:43.27Z" }, - { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041, upload-time = "2025-11-10T00:11:45.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004, upload-time = "2025-11-10T00:11:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828, upload-time = "2025-11-10T00:11:48.563Z" }, - { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588, upload-time = "2025-11-10T00:11:50.581Z" }, - { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223, upload-time = "2025-11-10T00:11:52.184Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033, upload-time = "2025-11-10T00:11:53.871Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661, upload-time = "2025-11-10T00:11:55.597Z" }, - { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389, upload-time = "2025-11-10T00:11:57.59Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742, upload-time = "2025-11-10T00:11:59.519Z" }, - { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049, upload-time = "2025-11-10T00:12:01.592Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113, upload-time = "2025-11-10T00:12:03.639Z" }, - { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546, upload-time = "2025-11-10T00:12:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260, upload-time = "2025-11-10T00:12:07.137Z" }, - { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121, upload-time = "2025-11-10T00:12:09.138Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736, upload-time = "2025-11-10T00:12:11.195Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625, upload-time = "2025-11-10T00:12:12.843Z" }, - { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827, upload-time = "2025-11-10T00:12:15.128Z" }, - { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897, upload-time = "2025-11-10T00:12:17.274Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959, upload-time = "2025-11-10T00:12:19.292Z" }, - { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234, upload-time = "2025-11-10T00:12:21.251Z" }, - { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746, upload-time = "2025-11-10T00:12:23.089Z" }, - { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077, upload-time = "2025-11-10T00:12:24.863Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122, upload-time = "2025-11-10T00:12:26.553Z" }, - { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638, upload-time = "2025-11-10T00:12:28.555Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972, upload-time = "2025-11-10T00:12:30.246Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147, upload-time = "2025-11-10T00:12:32.195Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995, upload-time = "2025-11-10T00:12:33.969Z" }, - { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948, upload-time = "2025-11-10T00:12:36.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770, upload-time = "2025-11-10T00:12:38.167Z" }, - { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431, upload-time = "2025-11-10T00:12:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508, upload-time = "2025-11-10T00:12:42.231Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325, upload-time = "2025-11-10T00:12:44.065Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899, upload-time = "2025-11-10T00:12:46.18Z" }, - { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471, upload-time = "2025-11-10T00:12:48.392Z" }, - { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742, upload-time = "2025-11-10T00:12:50.182Z" }, - { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120, upload-time = "2025-11-10T00:12:52.451Z" }, - { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229, upload-time = "2025-11-10T00:12:54.667Z" }, - { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642, upload-time = "2025-11-10T00:12:56.841Z" }, - { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193, upload-time = "2025-11-10T00:12:58.687Z" }, - { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107, upload-time = "2025-11-10T00:13:00.502Z" }, - { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717, upload-time = "2025-11-10T00:13:02.747Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541, upload-time = "2025-11-10T00:13:04.689Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872, upload-time = "2025-11-10T00:13:06.559Z" }, - { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289, upload-time = "2025-11-10T00:13:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398, upload-time = "2025-11-10T00:13:10.734Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435, upload-time = "2025-11-10T00:13:12.712Z" }, - { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478, upload-time = "2025-11-10T00:13:14.908Z" }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version <= '3.11'" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - -[[package]] -name = "fastapi" -version = "0.121.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "pydantic" }, - { name = "starlette" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/a4/29e1b861fc9017488ed02ff1052feffa40940cb355ed632a8845df84ce84/fastapi-0.121.1.tar.gz", hash = "sha256:b6dba0538fd15dab6fe4d3e5493c3957d8a9e1e9257f56446b5859af66f32441", size = 342523, upload-time = "2025-11-08T21:48:14.068Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fd/2e6f7d706899cc08690c5f6641e2ffbfffe019e8f16ce77104caa5730910/fastapi-0.121.1-py3-none-any.whl", hash = "sha256:2c5c7028bc3a58d8f5f09aecd3fd88a000ccc0c5ad627693264181a3c33aa1fc", size = 109192, upload-time = "2025-11-08T21:48:12.458Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "openenv-core" -version = "0.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "fastapi" }, - { name = "requests" }, - { name = "uvicorn" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/18/74d2aedbf099a86de772364260827a12b4b4a56711db4caa3caa078588d7/openenv_core-0.1.0.tar.gz", hash = "sha256:3a4e8bf4f2f3b7eba1c3a212e6e2dc7d980b8350015ae6c250a3ce93000f1d7c", size = 26512, upload-time = "2025-10-21T20:00:24.29Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/48/85afcd090eeaadf00e6f88ac92a866cb9238eaf6246820d1bc6564f5bc97/openenv_core-0.1.0-py3-none-any.whl", hash = "sha256:8d02513f26518f98ab1f35a875f7493d2983cf87f8b0e4b0af6634ec63edfd4b", size = 30607, upload-time = "2025-10-21T20:00:22.183Z" }, -] - -[[package]] -name = "openenv-echo-env" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "fastapi" }, - { name = "openenv-core" }, - { name = "pydantic" }, - { name = "requests" }, - { name = "uvicorn" }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = ">=0.115.0" }, - { name = "openenv-core", specifier = ">=0.1.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "uvicorn", specifier = ">=0.24.0" }, -] -provides-extras = ["dev"] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "pydantic" -version = "2.12.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, - { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, - { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, - { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, - { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, - { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, - { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764, upload-time = "2025-11-08T17:25:33.34Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364, upload-time = "2025-11-08T17:25:31.811Z" }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "coverage", extra = ["toml"] }, - { name = "pluggy" }, - { name = "pytest" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "starlette" -version = "0.49.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "uvicorn" -version = "0.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, -] diff --git a/src/envs/finrl_env/client.py b/src/envs/finrl_env/client.py deleted file mode 100644 index 0b6468aef..000000000 --- a/src/envs/finrl_env/client.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -""" -FinRL Environment HTTP Client. - -This module provides the client for connecting to a FinRL Environment server -over HTTP. -""" - -from typing import Any, Dict - -from core.client_types import StepResult - -from core.env_server.types import State -from core.http_env_client import HTTPEnvClient - -from .models import FinRLAction, FinRLObservation - - -class FinRLEnv(HTTPEnvClient[FinRLAction, FinRLObservation]): - """ - HTTP client for the FinRL Environment. - - This client connects to a FinRLEnvironment HTTP server and provides - methods to interact with it for stock trading RL tasks. - - Example: - >>> # Connect to a running server - >>> client = FinRLEnv(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.state) - >>> print(result.observation.portfolio_value) - >>> - >>> # Execute a trading action - >>> action = FinRLAction(actions=[0.5, -0.3]) # Buy stock 0, sell stock 1 - >>> result = client.step(action) - >>> print(result.reward) - >>> print(result.observation.portfolio_value) - - Example with Docker: - >>> # Automatically start container and connect - >>> client = FinRLEnv.from_docker_image("finrl-env:latest") - >>> result = client.reset() - >>> result = client.step(FinRLAction(actions=[0.1])) - >>> client.close() - - Example training loop: - >>> import numpy as np - >>> from envs.finrl_env import FinRLEnv, FinRLAction - >>> - >>> client = FinRLEnv(base_url="http://localhost:8000") - >>> - >>> # Training loop - >>> for episode in range(10): - >>> result = client.reset() - >>> done = False - >>> episode_reward = 0 - >>> - >>> while not done: - >>> # Get state - >>> state = result.observation.state - >>> - >>> # Simple random policy (replace with your RL agent) - >>> num_stocks = len(state) // 7 # Simplified calculation - >>> actions = np.random.uniform(-1, 1, size=num_stocks).tolist() - >>> - >>> # Execute action - >>> result = client.step(FinRLAction(actions=actions)) - >>> - >>> episode_reward += result.reward or 0 - >>> done = result.done - >>> - >>> print(f"Episode {episode}: reward={episode_reward:.2f}, " - >>> f"final value={result.observation.portfolio_value:.2f}") - >>> - >>> client.close() - """ - - def get_config(self) -> Dict[str, Any]: - """ - Get the environment configuration from the server. - - Returns: - Dictionary containing environment configuration - """ - response = self.session.get(f"{self.base_url}/config") - response.raise_for_status() - return response.json() - - def _step_payload(self, action: FinRLAction) -> Dict: - """ - Convert FinRLAction to JSON payload for step request. - - Args: - action: FinRLAction instance - - Returns: - Dictionary representation suitable for JSON encoding - """ - return { - "actions": action.actions, - } - - def _parse_result(self, payload: Dict) -> StepResult[FinRLObservation]: - """ - Parse server response into StepResult[FinRLObservation]. - - Args: - payload: JSON response from server - - Returns: - StepResult with FinRLObservation - """ - obs_data = payload.get("observation", {}) - observation = FinRLObservation( - state=obs_data.get("state", []), - portfolio_value=obs_data.get("portfolio_value", 0.0), - date=obs_data.get("date", ""), - done=payload.get("done", False), - reward=payload.get("reward"), - metadata=obs_data.get("metadata", {}), - ) - - return StepResult( - observation=observation, - reward=payload.get("reward"), - done=payload.get("done", False), - ) - - def _parse_state(self, payload: Dict) -> State: - """ - Parse server response into State object. - - Args: - payload: JSON response from /state endpoint - - Returns: - State object with episode_id and step_count - """ - return State( - episode_id=payload.get("episode_id"), - step_count=payload.get("step_count", 0), - ) diff --git a/src/openenv/__init__.py b/src/openenv/__init__.py new file mode 100644 index 000000000..61b336cb9 --- /dev/null +++ b/src/openenv/__init__.py @@ -0,0 +1,12 @@ +""" +Unified OpenEnv package bundling the CLI and core runtime. +""" + +from importlib import metadata + +__all__ = ["core", "cli"] + +try: + __version__ = metadata.version("openenv") # type: ignore[arg-type] +except metadata.PackageNotFoundError: # pragma: no cover - local dev + __version__ = "0.0.0" diff --git a/src/openenv_cli/__init__.py b/src/openenv/cli/__init__.py similarity index 99% rename from src/openenv_cli/__init__.py rename to src/openenv/cli/__init__.py index 1e8e08a02..40bee4e3e 100644 --- a/src/openenv_cli/__init__.py +++ b/src/openenv/cli/__init__.py @@ -7,4 +7,3 @@ """OpenEnv CLI package.""" __version__ = "0.1.0" - diff --git a/src/openenv_cli/__main__.py b/src/openenv/cli/__main__.py similarity index 80% rename from src/openenv_cli/__main__.py rename to src/openenv/cli/__main__.py index 01b497dd9..84e64a716 100644 --- a/src/openenv_cli/__main__.py +++ b/src/openenv/cli/__main__.py @@ -15,7 +15,7 @@ import typer -from openenv_cli.commands import build, init, push, serve, validate +from openenv.cli.commands import build, init, push, serve, validate # Create the main CLI app app = typer.Typer( @@ -29,12 +29,13 @@ app.command(name="build", help="Build Docker images for OpenEnv environments")( build.build ) -app.command(name="validate", help="Validate environment structure and deployment readiness")( - validate.validate -) -app.command(name="push", help="Push an OpenEnv environment to Hugging Face Spaces or custom registry")( - push.push -) +app.command( + name="validate", help="Validate environment structure and deployment readiness" +)(validate.validate) +app.command( + name="push", + help="Push an OpenEnv environment to Hugging Face Spaces or custom registry", +)(push.push) app.command(name="serve", help="Serve environments locally (TODO: Phase 4)")( serve.serve ) diff --git a/src/openenv_cli/_cli_utils.py b/src/openenv/cli/_cli_utils.py similarity index 95% rename from src/openenv_cli/_cli_utils.py rename to src/openenv/cli/_cli_utils.py index 2b96d6e50..d68258bc2 100644 --- a/src/openenv_cli/_cli_utils.py +++ b/src/openenv/cli/_cli_utils.py @@ -65,8 +65,7 @@ def validate_env_structure(env_dir: Path, strict: bool = False) -> List[str]: if not has_pyproject: raise FileNotFoundError( - "No dependency specification found. " - "'pyproject.toml' is required." + "No dependency specification found. 'pyproject.toml' is required." ) # Warnings for recommended structure @@ -75,4 +74,3 @@ def validate_env_structure(env_dir: Path, strict: bool = False) -> List[str]: warnings.append("Recommended directory missing: outputs/") return warnings - diff --git a/src/openenv_cli/_validation.py b/src/openenv/cli/_validation.py similarity index 86% rename from src/openenv_cli/_validation.py rename to src/openenv/cli/_validation.py index 5286e5821..00a3b9c11 100644 --- a/src/openenv_cli/_validation.py +++ b/src/openenv/cli/_validation.py @@ -1,154 +1,159 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -""" -Validation utilities for multi-mode deployment readiness. - -This module provides functions to check if environments are properly -configured for multi-mode deployment (Docker, direct Python, notebooks, clusters). -""" - -import subprocess -import tomllib -from pathlib import Path - - -def validate_multi_mode_deployment(env_path: Path) -> tuple[bool, list[str]]: - """ - Validate that an environment is ready for multi-mode deployment. - - Checks: - 1. pyproject.toml exists - 2. uv.lock exists and is up-to-date - 3. pyproject.toml has [project.scripts] with server entry point - 4. server/app.py has a main() function - 5. Required dependencies are present - - Returns: - Tuple of (is_valid, list of issues found) - """ - issues = [] - - # Check pyproject.toml exists - pyproject_path = env_path / "pyproject.toml" - if not pyproject_path.exists(): - issues.append("Missing pyproject.toml") - return False, issues - - # Check uv.lock exists - lockfile_path = env_path / "uv.lock" - if not lockfile_path.exists(): - issues.append("Missing uv.lock - run 'uv lock' to generate it") - else: - # Check if uv.lock is up-to-date (optional, can be expensive) - # We can add a check using `uv lock --check` if needed - try: - result = subprocess.run( - ["uv", "lock", "--check", "--directory", str(env_path)], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - issues.append("uv.lock is out of date with pyproject.toml - run 'uv lock' to update") - except (subprocess.TimeoutExpired, FileNotFoundError): - # If uv is not available or times out, skip this check - pass - - # Parse pyproject.toml - try: - with open(pyproject_path, "rb") as f: - pyproject = tomllib.load(f) - except Exception as e: - issues.append(f"Failed to parse pyproject.toml: {e}") - return False, issues - - # Check [project.scripts] section - scripts = pyproject.get("project", {}).get("scripts", {}) - if "server" not in scripts: - issues.append("Missing [project.scripts] server entry point") - - # Check server entry point format - server_entry = scripts.get("server", "") - if server_entry and ":main" not in server_entry: - issues.append( - f"Server entry point should reference main function, got: {server_entry}" - ) - - # Check required dependencies - deps = pyproject.get("project", {}).get("dependencies", []) - required_deps = ["openenv-core", "fastapi", "uvicorn", "pydantic", "requests"] - missing_deps = [] - for required in required_deps: - if not any(required in dep.lower() for dep in deps): - missing_deps.append(required) - - if missing_deps: - issues.append(f"Missing required dependencies: {', '.join(missing_deps)}") - - # Check server/app.py exists - server_app = env_path / "server" / "app.py" - if not server_app.exists(): - issues.append("Missing server/app.py") - else: - # Check for main() function (flexible - with or without parameters) - app_content = server_app.read_text(encoding="utf-8") - if "def main(" not in app_content: - issues.append("server/app.py missing main() function") - - # Check if main() is callable - if "__name__" not in app_content or "main()" not in app_content: - issues.append( - "server/app.py main() function not callable (missing if __name__ == '__main__')" - ) - - return len(issues) == 0, issues - - -def get_deployment_modes(env_path: Path) -> dict[str, bool]: - """ - Check which deployment modes are supported by the environment. - - Returns: - Dictionary with deployment mode names and whether they're supported - """ - modes = { - "docker": False, - "openenv_serve": False, - "uv_run": False, - "python_module": False, - } - - # Check Docker - dockerfile = env_path / "server" / "Dockerfile" - modes["docker"] = dockerfile.exists() - - # Check multi-mode deployment readiness - is_valid, _ = validate_multi_mode_deployment(env_path) - if is_valid: - modes["openenv_serve"] = True - modes["uv_run"] = True - modes["python_module"] = True - - return modes - - -def format_validation_report(env_name: str, is_valid: bool, issues: list[str]) -> str: - """ - Format a validation report for display. - - Returns: - Formatted report string - """ - if is_valid: - return f"[OK] {env_name}: Ready for multi-mode deployment" - - report = [f"[FAIL] {env_name}: Not ready for multi-mode deployment", ""] - report.append("Issues found:") - for issue in issues: - report.append(f" - {issue}") - - return "\n".join(report) +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Validation utilities for multi-mode deployment readiness. + +This module provides functions to check if environments are properly +configured for multi-mode deployment (Docker, direct Python, notebooks, clusters). +""" + +import subprocess +import tomllib +from pathlib import Path + + +def validate_multi_mode_deployment(env_path: Path) -> tuple[bool, list[str]]: + """ + Validate that an environment is ready for multi-mode deployment. + + Checks: + 1. pyproject.toml exists + 2. uv.lock exists and is up-to-date + 3. pyproject.toml has [project.scripts] with server entry point + 4. server/app.py has a main() function + 5. Required dependencies are present + + Returns: + Tuple of (is_valid, list of issues found) + """ + issues = [] + + # Check pyproject.toml exists + pyproject_path = env_path / "pyproject.toml" + if not pyproject_path.exists(): + issues.append("Missing pyproject.toml") + return False, issues + + # Check uv.lock exists + lockfile_path = env_path / "uv.lock" + if not lockfile_path.exists(): + issues.append("Missing uv.lock - run 'uv lock' to generate it") + else: + # Check if uv.lock is up-to-date (optional, can be expensive) + # We can add a check using `uv lock --check` if needed + try: + result = subprocess.run( + ["uv", "lock", "--check", "--directory", str(env_path)], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode != 0: + issues.append( + "uv.lock is out of date with pyproject.toml - run 'uv lock' to update" + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + # If uv is not available or times out, skip this check + pass + + # Parse pyproject.toml + try: + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + except Exception as e: + issues.append(f"Failed to parse pyproject.toml: {e}") + return False, issues + + # Check [project.scripts] section + scripts = pyproject.get("project", {}).get("scripts", {}) + if "server" not in scripts: + issues.append("Missing [project.scripts] server entry point") + + # Check server entry point format + server_entry = scripts.get("server", "") + if server_entry and ":main" not in server_entry: + issues.append( + f"Server entry point should reference main function, got: {server_entry}" + ) + + # Check required dependencies + deps = [dep.lower() for dep in pyproject.get("project", {}).get("dependencies", [])] + has_openenv = any( + dep.startswith("openenv") and not dep.startswith("openenv-core") for dep in deps + ) + has_legacy_core = any(dep.startswith("openenv-core") for dep in deps) + + if not (has_openenv or has_legacy_core): + issues.append("Missing required dependency: openenv>=0.2.0") + elif has_legacy_core and not has_openenv: + issues.append( + "Dependency on openenv-core is deprecated; use openenv>=0.2.0 instead" + ) + + # Check server/app.py exists + server_app = env_path / "server" / "app.py" + if not server_app.exists(): + issues.append("Missing server/app.py") + else: + # Check for main() function (flexible - with or without parameters) + app_content = server_app.read_text(encoding="utf-8") + if "def main(" not in app_content: + issues.append("server/app.py missing main() function") + + # Check if main() is callable + if "__name__" not in app_content or "main()" not in app_content: + issues.append( + "server/app.py main() function not callable (missing if __name__ == '__main__')" + ) + + return len(issues) == 0, issues + + +def get_deployment_modes(env_path: Path) -> dict[str, bool]: + """ + Check which deployment modes are supported by the environment. + + Returns: + Dictionary with deployment mode names and whether they're supported + """ + modes = { + "docker": False, + "openenv_serve": False, + "uv_run": False, + "python_module": False, + } + + # Check Docker + dockerfile = env_path / "server" / "Dockerfile" + modes["docker"] = dockerfile.exists() + + # Check multi-mode deployment readiness + is_valid, _ = validate_multi_mode_deployment(env_path) + if is_valid: + modes["openenv_serve"] = True + modes["uv_run"] = True + modes["python_module"] = True + + return modes + + +def format_validation_report(env_name: str, is_valid: bool, issues: list[str]) -> str: + """ + Format a validation report for display. + + Returns: + Formatted report string + """ + if is_valid: + return f"[OK] {env_name}: Ready for multi-mode deployment" + + report = [f"[FAIL] {env_name}: Not ready for multi-mode deployment", ""] + report.append("Issues found:") + for issue in issues: + report.append(f" - {issue}") + + return "\n".join(report) diff --git a/src/openenv_cli/commands/__init__.py b/src/openenv/cli/commands/__init__.py similarity index 100% rename from src/openenv_cli/commands/__init__.py rename to src/openenv/cli/commands/__init__.py diff --git a/src/openenv_cli/commands/build.py b/src/openenv/cli/commands/build.py similarity index 84% rename from src/openenv_cli/commands/build.py rename to src/openenv/cli/commands/build.py index 7d36bed6a..954830c58 100644 --- a/src/openenv_cli/commands/build.py +++ b/src/openenv/cli/commands/build.py @@ -1,434 +1,453 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -"""Build Docker images for OpenEnv environments.""" - -from __future__ import annotations - -import shutil -import subprocess -import tempfile -import sys -from pathlib import Path -from typing import Annotated - -import typer - -from .._cli_utils import console - -app = typer.Typer(help="Build Docker images for OpenEnv environments") - - -def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]: - """ - Detect whether we're building a standalone or in-repo environment. - - Returns: - tuple: (build_mode, build_context_path, repo_root) - - build_mode: "standalone" or "in-repo" - - build_context_path: Path to use as Docker build context - - repo_root: Path to repo root (None for standalone) - """ - # Ensure env_path is absolute for proper comparison - env_path = env_path.absolute() - - # Check if we're in a git repository - current = env_path - repo_root = None - - # Walk up to find .git directory - for parent in [current] + list(current.parents): - if (parent / ".git").exists(): - repo_root = parent - break - - if repo_root is None: - # Not in a git repo = standalone - return "standalone", env_path, None - - # Check if environment is under src/envs/ (in-repo pattern) - try: - rel_path = env_path.relative_to(repo_root) - if str(rel_path).startswith("src/envs/") or str(rel_path).startswith("src\\envs\\"): - # In-repo environment - return "in-repo", repo_root, repo_root - except ValueError: - pass - - # Otherwise, it's standalone (environment outside repo structure) - return "standalone", env_path, None - - -def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path: - """ - Prepare a standalone environment for building. - - For standalone builds: - 1. Copy environment to temp directory - 2. Ensure pyproject.toml has openenv-core dependency - - Returns: - Path to the prepared build directory - """ - console.print("[cyan]Preparing standalone build...[/cyan]") - - # Copy environment to temp directory - build_dir = temp_dir / env_path.name - shutil.copytree(env_path, build_dir, symlinks=True) - - console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}") - - # Check if pyproject.toml has openenv-core dependency - pyproject_path = build_dir / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - try: - import tomli - pyproject = tomli.load(f) - deps = pyproject.get("project", {}).get("dependencies", []) - - # Check if openenv-core is in dependencies - has_openenv_core = any( - dep.startswith("openenv-core") or dep.startswith("openenv_core") - for dep in deps - ) - - if not has_openenv_core: - console.print( - "[yellow]Warning:[/yellow] pyproject.toml doesn't have openenv-core dependency", - ) - console.print( - "[yellow]You may need to add:[/yellow] openenv-core>=0.1.0", - ) - except ImportError: - console.print( - "[yellow]Warning:[/yellow] tomli not available, skipping dependency check", - ) - - return build_dir - - -def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path: - """ - Prepare an in-repo environment for building. - - For in-repo builds: - 1. Create temp directory with environment and core - 2. Set up structure that matches expected layout - - Returns: - Path to the prepared build directory - """ - console.print("[cyan]Preparing in-repo build...[/cyan]") - - # Copy environment to temp directory - build_dir = temp_dir / env_path.name - shutil.copytree(env_path, build_dir, symlinks=True) - - # Copy core module to temp directory - core_src = repo_root / "src" / "core" - if core_src.exists(): - core_dest = build_dir / "core" - shutil.copytree(core_src, core_dest, symlinks=True) - console.print(f"[cyan]Copied core module to:[/cyan] {core_dest}") - - # Update pyproject.toml to reference local core - pyproject_path = build_dir / "pyproject.toml" - if pyproject_path.exists(): - with open(pyproject_path, "rb") as f: - try: - import tomli - pyproject = tomli.load(f) - deps = pyproject.get("project", {}).get("dependencies", []) - - # Replace openenv-core with local reference - new_deps = [] - for dep in deps: - if dep.startswith("openenv-core") or dep.startswith("openenv_core"): - # Skip - we'll use local core - continue - new_deps.append(dep) - - # Write back with local core reference - pyproject["project"]["dependencies"] = new_deps + ["openenv-core @ file:///app/env/core"] - - # Write updated pyproject.toml - with open(pyproject_path, "wb") as out_f: - import tomli_w - tomli_w.dump(pyproject, out_f) - - console.print("[cyan]Updated pyproject.toml to use local core[/cyan]") - - # Remove old lockfile since dependencies changed - lockfile = build_dir / "uv.lock" - if lockfile.exists(): - lockfile.unlink() - console.print("[cyan]Removed outdated uv.lock[/cyan]") - - except ImportError: - console.print( - "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is", - ) - else: - console.print("[yellow]Warning:[/yellow] Core module not found, building without it") - - console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}") - return build_dir - - -def _run_command( - cmd: list[str], - cwd: Path | None = None, - check: bool = True, -) -> subprocess.CompletedProcess: - """Run a shell command and handle errors.""" - console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}") - try: - result = subprocess.run(cmd, cwd=cwd, check=check, capture_output=True, text=True) - if result.stdout: - console.print(result.stdout) - if result.stderr: - print(result.stderr, file=sys.stderr) - return result - except subprocess.CalledProcessError as e: - print(f"Error running command: {e}", file=sys.stderr) - if e.stdout: - console.print(e.stdout) - if e.stderr: - print(e.stderr, file=sys.stderr) - if check: - raise typer.Exit(1) from e - return e - - -def _build_docker_image( - env_path: Path, - tag: str | None = None, - context_path: Path | None = None, - dockerfile: Path | None = None, - build_args: dict[str, str] | None = None, - no_cache: bool = False, -) -> bool: - """Build Docker image for the environment with smart context detection.""" - - # Detect build context (standalone vs in-repo) - build_mode, detected_context, repo_root = _detect_build_context(env_path) - - console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}") - - # Use detected context unless explicitly overridden - if context_path is None: - context_path = detected_context - - # Create temporary build directory - with tempfile.TemporaryDirectory() as temp_dir_str: - temp_dir = Path(temp_dir_str) - - # Prepare build directory based on mode - if build_mode == "standalone": - build_dir = _prepare_standalone_build(env_path, temp_dir) - else: # in-repo - build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir) - - # Determine Dockerfile path - if dockerfile is None: - # Look for Dockerfile in server/ subdirectory - dockerfile = build_dir / "server" / "Dockerfile" - if not dockerfile.exists(): - # Fallback to root of build directory - dockerfile = build_dir / "Dockerfile" - - if not dockerfile.exists(): - console.print( - f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}", - ) - return False - - # Generate tag if not provided - if tag is None: - env_name = env_path.name - if env_name.endswith("_env"): - env_name = env_name[:-4] - tag = f"openenv-{env_name}" - - console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}") - console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}") - console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}") - - # Prepare build args - if build_args is None: - build_args = {} - - # Add build mode and env name to build args - build_args["BUILD_MODE"] = build_mode - build_args["ENV_NAME"] = env_path.name.replace("_env", "") - - # Build Docker command - cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)] - - if no_cache: - cmd.append("--no-cache") - - for key, value in build_args.items(): - cmd.extend(["--build-arg", f"{key}={value}"]) - - cmd.append(str(build_dir)) - - result = _run_command(cmd, check=False) - return result.returncode == 0 - - -def _push_docker_image(tag: str, registry: str | None = None) -> bool: - """Push Docker image to registry.""" - if registry: - full_tag = f"{registry}/{tag}" - console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]") - _run_command(["docker", "tag", tag, full_tag]) - tag = full_tag - - console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}") - result = _run_command(["docker", "push", tag], check=False) - return result.returncode == 0 - - -@app.command() -def build( - env_path: Annotated[ - str | None, - typer.Argument(help="Path to the environment directory (default: current directory)"), - ] = None, - tag: Annotated[ - str | None, - typer.Option( - "--tag", - "-t", - help="Docker image tag (default: openenv-)", - ), - ] = None, - context: Annotated[ - str | None, - typer.Option( - "--context", - "-c", - help="Build context path (default: /server)", - ), - ] = None, - dockerfile: Annotated[ - str | None, - typer.Option( - "--dockerfile", - "-f", - help="Path to Dockerfile (default: /Dockerfile)", - ), - ] = None, - no_cache: Annotated[ - bool, - typer.Option( - "--no-cache", - help="Build without using cache", - ), - ] = False, - build_arg: Annotated[ - list[str] | None, - typer.Option( - "--build-arg", - help="Build arguments (can be used multiple times, format: KEY=VALUE)", - ), - ] = None, -) -> None: - """ - Build Docker images for OpenEnv environments. - - This command builds Docker images using the environment's pyproject.toml - and uv for dependency management. Run from the environment root directory. - - Examples: - # Build from environment root (recommended) - $ cd my_env - $ openenv build - - # Build with custom tag - $ openenv build -t my-custom-tag - - # Build without cache - $ openenv build --no-cache - - # Build with custom build arguments - $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod - - # Build from different directory - $ openenv build src/envs/echo_env - """ - # Determine environment path (default to current directory) - if env_path is None: - env_path_obj = Path.cwd() - else: - env_path_obj = Path(env_path) - - # Validate environment path - if not env_path_obj.exists(): - print( - f"Error: Environment path does not exist: {env_path_obj}", - file=sys.stderr, - ) - raise typer.Exit(1) - - if not env_path_obj.is_dir(): - print( - f"Error: Environment path is not a directory: {env_path_obj}", - file=sys.stderr, - ) - raise typer.Exit(1) - - # Check for openenv.yaml to confirm this is an environment directory - openenv_yaml = env_path_obj / "openenv.yaml" - if not openenv_yaml.exists(): - print( - f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", - file=sys.stderr, - ) - print( - "Hint: Run this command from the environment root directory or specify the path", - file=sys.stderr, - ) - raise typer.Exit(1) - - console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}") - console.print("=" * 60) - - # Parse build args - build_args = {} - if build_arg: - for arg in build_arg: - if "=" in arg: - key, value = arg.split("=", 1) - build_args[key] = value - else: - print( - f"Warning: Invalid build arg format: {arg}", - file=sys.stderr, - ) - - # Convert string paths to Path objects - context_path_obj = Path(context) if context else None - dockerfile_path_obj = Path(dockerfile) if dockerfile else None - - # Build Docker image - success = _build_docker_image( - env_path=env_path_obj, - tag=tag, - context_path=context_path_obj, - dockerfile=dockerfile_path_obj, - build_args=build_args if build_args else None, - no_cache=no_cache, - ) - - if not success: - print("✗ Docker build failed", file=sys.stderr) - raise typer.Exit(1) - - console.print("[bold green]✓ Docker build successful[/bold green]") - console.print("\n[bold green]Done![/bold green]") +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Build Docker images for OpenEnv environments.""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +import sys +from pathlib import Path +from typing import Annotated + +import typer + +from .._cli_utils import console + +app = typer.Typer(help="Build Docker images for OpenEnv environments") + + +def _detect_build_context(env_path: Path) -> tuple[str, Path, Path | None]: + """ + Detect whether we're building a standalone or in-repo environment. + + Returns: + tuple: (build_mode, build_context_path, repo_root) + - build_mode: "standalone" or "in-repo" + - build_context_path: Path to use as Docker build context + - repo_root: Path to repo root (None for standalone) + """ + # Ensure env_path is absolute for proper comparison + env_path = env_path.absolute() + + # Check if we're in a git repository + current = env_path + repo_root = None + + # Walk up to find .git directory + for parent in [current] + list(current.parents): + if (parent / ".git").exists(): + repo_root = parent + break + + if repo_root is None: + # Not in a git repo = standalone + return "standalone", env_path, None + + # Check if environment is under envs/ (in-repo pattern) + try: + rel_path = env_path.relative_to(repo_root) + rel_str = str(rel_path) + if ( + rel_str.startswith("envs/") + or rel_str.startswith("envs\\") + or rel_str.startswith("envs/") + ): + # In-repo environment + return "in-repo", repo_root, repo_root + except ValueError: + pass + + # Otherwise, it's standalone (environment outside repo structure) + return "standalone", env_path, None + + +def _prepare_standalone_build(env_path: Path, temp_dir: Path) -> Path: + """ + Prepare a standalone environment for building. + + For standalone builds: + 1. Copy environment to temp directory + 2. Ensure pyproject.toml depends on openenv + + Returns: + Path to the prepared build directory + """ + console.print("[cyan]Preparing standalone build...[/cyan]") + + # Copy environment to temp directory + build_dir = temp_dir / env_path.name + shutil.copytree(env_path, build_dir, symlinks=True) + + console.print(f"[cyan]Copied environment to:[/cyan] {build_dir}") + + # Check if pyproject.toml has openenv dependency + pyproject_path = build_dir / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + try: + import tomli + + pyproject = tomli.load(f) + deps = pyproject.get("project", {}).get("dependencies", []) + + # Check if openenv dependency is declared + has_openenv = any(dep.startswith("openenv") for dep in deps) + + if not has_openenv: + console.print( + "[yellow]Warning:[/yellow] pyproject.toml doesn't list the openenv dependency", + ) + console.print( + "[yellow]You may need to add:[/yellow] openenv>=0.2.0", + ) + except ImportError: + console.print( + "[yellow]Warning:[/yellow] tomli not available, skipping dependency check", + ) + + return build_dir + + +def _prepare_inrepo_build(env_path: Path, repo_root: Path, temp_dir: Path) -> Path: + """ + Prepare an in-repo environment for building. + + For in-repo builds: + 1. Create temp directory with environment and core + 2. Set up structure that matches expected layout + + Returns: + Path to the prepared build directory + """ + console.print("[cyan]Preparing in-repo build...[/cyan]") + + # Copy environment to temp directory + build_dir = temp_dir / env_path.name + shutil.copytree(env_path, build_dir, symlinks=True) + + # Copy OpenEnv package to temp directory + package_src = repo_root / "src" / "openenv" + if package_src.exists(): + package_dest = build_dir / "openenv" + shutil.copytree(package_src, package_dest, symlinks=True) + console.print(f"[cyan]Copied OpenEnv package to:[/cyan] {package_dest}") + + # Update pyproject.toml to reference local OpenEnv copy + pyproject_path = build_dir / "pyproject.toml" + if pyproject_path.exists(): + with open(pyproject_path, "rb") as f: + try: + import tomli + + pyproject = tomli.load(f) + deps = pyproject.get("project", {}).get("dependencies", []) + + # Replace openenv/openenv-core with local reference + new_deps = [] + for dep in deps: + if ( + dep.startswith("openenv-core") + or dep.startswith("openenv_core") + or dep.startswith("openenv") + ): + # Skip - we'll use local core + continue + new_deps.append(dep) + + # Write back with local core reference + pyproject["project"]["dependencies"] = new_deps + [ + "openenv @ file:///app/env/openenv" + ] + + # Write updated pyproject.toml + with open(pyproject_path, "wb") as out_f: + import tomli_w + + tomli_w.dump(pyproject, out_f) + + console.print( + "[cyan]Updated pyproject.toml to use local core[/cyan]" + ) + + # Remove old lockfile since dependencies changed + lockfile = build_dir / "uv.lock" + if lockfile.exists(): + lockfile.unlink() + console.print("[cyan]Removed outdated uv.lock[/cyan]") + + except ImportError: + console.print( + "[yellow]Warning:[/yellow] tomli/tomli_w not available, using pyproject.toml as-is", + ) + else: + console.print( + "[yellow]Warning:[/yellow] OpenEnv package not found, building without it" + ) + + console.print(f"[cyan]Build directory prepared:[/cyan] {build_dir}") + return build_dir + + +def _run_command( + cmd: list[str], + cwd: Path | None = None, + check: bool = True, +) -> subprocess.CompletedProcess: + """Run a shell command and handle errors.""" + console.print(f"[bold cyan]Running:[/bold cyan] {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, cwd=cwd, check=check, capture_output=True, text=True + ) + if result.stdout: + console.print(result.stdout) + if result.stderr: + print(result.stderr, file=sys.stderr) + return result + except subprocess.CalledProcessError as e: + print(f"Error running command: {e}", file=sys.stderr) + if e.stdout: + console.print(e.stdout) + if e.stderr: + print(e.stderr, file=sys.stderr) + if check: + raise typer.Exit(1) from e + return e + + +def _build_docker_image( + env_path: Path, + tag: str | None = None, + context_path: Path | None = None, + dockerfile: Path | None = None, + build_args: dict[str, str] | None = None, + no_cache: bool = False, +) -> bool: + """Build Docker image for the environment with smart context detection.""" + + # Detect build context (standalone vs in-repo) + build_mode, detected_context, repo_root = _detect_build_context(env_path) + + console.print(f"[bold cyan]Build mode detected:[/bold cyan] {build_mode}") + + # Use detected context unless explicitly overridden + if context_path is None: + context_path = detected_context + + # Create temporary build directory + with tempfile.TemporaryDirectory() as temp_dir_str: + temp_dir = Path(temp_dir_str) + + # Prepare build directory based on mode + if build_mode == "standalone": + build_dir = _prepare_standalone_build(env_path, temp_dir) + else: # in-repo + build_dir = _prepare_inrepo_build(env_path, repo_root, temp_dir) + + # Determine Dockerfile path + if dockerfile is None: + # Look for Dockerfile in server/ subdirectory + dockerfile = build_dir / "server" / "Dockerfile" + if not dockerfile.exists(): + # Fallback to root of build directory + dockerfile = build_dir / "Dockerfile" + + if not dockerfile.exists(): + console.print( + f"[bold red]Error:[/bold red] Dockerfile not found at {dockerfile}", + ) + return False + + # Generate tag if not provided + if tag is None: + env_name = env_path.name + if env_name.endswith("_env"): + env_name = env_name[:-4] + tag = f"openenv-{env_name}" + + console.print(f"[bold cyan]Building Docker image:[/bold cyan] {tag}") + console.print(f"[bold cyan]Build context:[/bold cyan] {build_dir}") + console.print(f"[bold cyan]Dockerfile:[/bold cyan] {dockerfile}") + + # Prepare build args + if build_args is None: + build_args = {} + + # Add build mode and env name to build args + build_args["BUILD_MODE"] = build_mode + build_args["ENV_NAME"] = env_path.name.replace("_env", "") + + # Build Docker command + cmd = ["docker", "build", "-t", tag, "-f", str(dockerfile)] + + if no_cache: + cmd.append("--no-cache") + + for key, value in build_args.items(): + cmd.extend(["--build-arg", f"{key}={value}"]) + + cmd.append(str(build_dir)) + + result = _run_command(cmd, check=False) + return result.returncode == 0 + + +def _push_docker_image(tag: str, registry: str | None = None) -> bool: + """Push Docker image to registry.""" + if registry: + full_tag = f"{registry}/{tag}" + console.print(f"[bold cyan]Tagging image as {full_tag}[/bold cyan]") + _run_command(["docker", "tag", tag, full_tag]) + tag = full_tag + + console.print(f"[bold cyan]Pushing image:[/bold cyan] {tag}") + result = _run_command(["docker", "push", tag], check=False) + return result.returncode == 0 + + +@app.command() +def build( + env_path: Annotated[ + str | None, + typer.Argument( + help="Path to the environment directory (default: current directory)" + ), + ] = None, + tag: Annotated[ + str | None, + typer.Option( + "--tag", + "-t", + help="Docker image tag (default: openenv-)", + ), + ] = None, + context: Annotated[ + str | None, + typer.Option( + "--context", + "-c", + help="Build context path (default: /server)", + ), + ] = None, + dockerfile: Annotated[ + str | None, + typer.Option( + "--dockerfile", + "-f", + help="Path to Dockerfile (default: /Dockerfile)", + ), + ] = None, + no_cache: Annotated[ + bool, + typer.Option( + "--no-cache", + help="Build without using cache", + ), + ] = False, + build_arg: Annotated[ + list[str] | None, + typer.Option( + "--build-arg", + help="Build arguments (can be used multiple times, format: KEY=VALUE)", + ), + ] = None, +) -> None: + """ + Build Docker images for OpenEnv environments. + + This command builds Docker images using the environment's pyproject.toml + and uv for dependency management. Run from the environment root directory. + + Examples: + # Build from environment root (recommended) + $ cd my_env + $ openenv build + + # Build with custom tag + $ openenv build -t my-custom-tag + + # Build without cache + $ openenv build --no-cache + + # Build with custom build arguments + $ openenv build --build-arg VERSION=1.0 --build-arg ENV=prod + + # Build from different directory + $ openenv build envs/echo_env + """ + # Determine environment path (default to current directory) + if env_path is None: + env_path_obj = Path.cwd() + else: + env_path_obj = Path(env_path) + + # Validate environment path + if not env_path_obj.exists(): + print( + f"Error: Environment path does not exist: {env_path_obj}", + file=sys.stderr, + ) + raise typer.Exit(1) + + if not env_path_obj.is_dir(): + print( + f"Error: Environment path is not a directory: {env_path_obj}", + file=sys.stderr, + ) + raise typer.Exit(1) + + # Check for openenv.yaml to confirm this is an environment directory + openenv_yaml = env_path_obj / "openenv.yaml" + if not openenv_yaml.exists(): + print( + f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", + file=sys.stderr, + ) + print( + "Hint: Run this command from the environment root directory or specify the path", + file=sys.stderr, + ) + raise typer.Exit(1) + + console.print(f"[bold]Building Docker image for:[/bold] {env_path_obj.name}") + console.print("=" * 60) + + # Parse build args + build_args = {} + if build_arg: + for arg in build_arg: + if "=" in arg: + key, value = arg.split("=", 1) + build_args[key] = value + else: + print( + f"Warning: Invalid build arg format: {arg}", + file=sys.stderr, + ) + + # Convert string paths to Path objects + context_path_obj = Path(context) if context else None + dockerfile_path_obj = Path(dockerfile) if dockerfile else None + + # Build Docker image + success = _build_docker_image( + env_path=env_path_obj, + tag=tag, + context_path=context_path_obj, + dockerfile=dockerfile_path_obj, + build_args=build_args if build_args else None, + no_cache=no_cache, + ) + + if not success: + print("✗ Docker build failed", file=sys.stderr) + raise typer.Exit(1) + + console.print("[bold green]✓ Docker build successful[/bold green]") + console.print("\n[bold green]Done![/bold green]") diff --git a/src/openenv_cli/commands/init.py b/src/openenv/cli/commands/init.py similarity index 90% rename from src/openenv_cli/commands/init.py rename to src/openenv/cli/commands/init.py index 7beb3cc6f..4002e2d45 100644 --- a/src/openenv_cli/commands/init.py +++ b/src/openenv/cli/commands/init.py @@ -63,7 +63,9 @@ def _validate_env_name(name: str) -> str: # Check if it starts with a number if name[0].isdigit(): - raise typer.BadParameter(f"Environment name '{name}' cannot start with a number.") + raise typer.BadParameter( + f"Environment name '{name}' cannot start with a number." + ) return name @@ -293,7 +295,9 @@ def _copy_and_template_file( # Binary file, just copy dest_path.write_bytes(content) except Exception as e: - raise RuntimeError(f"Failed to copy template file {src_path} to {dest_path}: {e}") from e + raise RuntimeError( + f"Failed to copy template file {src_path} to {dest_path}: {e}" + ) from e def _copy_template_directory( @@ -327,13 +331,17 @@ def _copy_template_directory( if not template_path.exists(): raise FileNotFoundError(f"Template directory not found: {template_pkg}") except Exception as e: - raise FileNotFoundError(f"Template directory not found: {template_pkg}") from e + raise FileNotFoundError( + f"Template directory not found: {template_pkg}" + ) from e if template_dir: template_path = template_path / template_dir if not template_path.exists() or not template_path.is_dir(): - raise FileNotFoundError(f"Template directory not found: {template_pkg}.{template_dir}") + raise FileNotFoundError( + f"Template directory not found: {template_pkg}.{template_dir}" + ) # Walk through all files in template directory using Path for item in template_path.rglob("*"): @@ -391,7 +399,9 @@ def _generate_uv_lock(env_dir: Path) -> bool: def init( env_name: Annotated[ str, - typer.Argument(help="Name of the environment to create (snake_case, e.g., 'my_env')"), + typer.Argument( + help="Name of the environment to create (snake_case, e.g., 'my_env')" + ), ], output_dir: Annotated[ str | None, @@ -436,10 +446,12 @@ def init( # Create environment directory env_dir.mkdir(parents=True, exist_ok=True) - console.print(f"[bold cyan]Creating OpenEnv environment '{env_name}'...[/bold cyan]") + console.print( + f"[bold cyan]Creating OpenEnv environment '{env_name}'...[/bold cyan]" + ) # Copy template files from template structure - template_pkg = "openenv_cli.templates.openenv_env" + template_pkg = "openenv.cli.templates.openenv_env" created_files = _copy_template_directory( template_pkg, "", @@ -449,28 +461,34 @@ def init( ) console.print(f"[bold green]✓[/bold green] Created {len(created_files)} files") - + # Generate uv.lock console.print("\n[bold]Generating uv.lock...[/bold]") if _generate_uv_lock(env_dir): console.print("[green]✓[/green] Generated uv.lock") else: - console.print( - "[yellow]⚠[/yellow] Could not generate uv.lock automatically" - ) + console.print("[yellow]⚠[/yellow] Could not generate uv.lock automatically") console.print(" You can generate it manually with:") console.print(f" cd {env_dir} && uv lock") - - console.print(f"\n[bold green]Environment created successfully at: {env_dir}[/bold green]") + + console.print( + f"\n[bold green]Environment created successfully at: {env_dir}[/bold green]" + ) console.print("\n[bold]Next steps:[/bold]") console.print(f" cd {env_dir}") - console.print(f" # Edit your environment implementation in server/{env_name}_environment.py") + console.print( + f" # Edit your environment implementation in server/{env_name}_environment.py" + ) console.print(" # Edit your models in models.py") console.print(" # Install dependencies: uv sync") console.print("\n # To integrate into OpenEnv repo:") - console.print(f" # 1. Copy this directory to /src/envs/{env_name}_env") - console.print(f" # 2. Build from repo root: docker build -t {env_name}_env:latest -f src/envs/{env_name}_env/server/Dockerfile .") - console.print(f" # 3. Run your image: docker run -p 8000:8000 {env_name}_env:latest") + console.print(f" # 1. Copy this directory to /envs/{env_name}_env") + console.print( + f" # 2. Build from repo root: docker build -t {env_name}_env:latest -f envs/{env_name}_env/server/Dockerfile ." + ) + console.print( + f" # 3. Run your image: docker run -p 8000:8000 {env_name}_env:latest" + ) except Exception as e: # Cleanup on error diff --git a/src/openenv_cli/commands/push.py b/src/openenv/cli/commands/push.py similarity index 89% rename from src/openenv_cli/commands/push.py rename to src/openenv/cli/commands/push.py index 2ebb7aa0e..571858e99 100644 --- a/src/openenv_cli/commands/push.py +++ b/src/openenv/cli/commands/push.py @@ -67,7 +67,11 @@ def _ensure_hf_authenticated() -> str: user_info = whoami() # Handle both dict and object return types if isinstance(user_info, dict): - username = user_info.get("name") or user_info.get("fullname") or user_info.get("username") + username = ( + user_info.get("name") + or user_info.get("fullname") + or user_info.get("username") + ) else: # If it's an object, try to get name attribute username = ( @@ -83,7 +87,9 @@ def _ensure_hf_authenticated() -> str: return username except Exception: # Not authenticated, prompt for login - console.print("[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]") + console.print( + "[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]" + ) try: login() @@ -91,7 +97,11 @@ def _ensure_hf_authenticated() -> str: user_info = whoami() # Handle both dict and object return types if isinstance(user_info, dict): - username = user_info.get("name") or user_info.get("fullname") or user_info.get("username") + username = ( + user_info.get("name") + or user_info.get("fullname") + or user_info.get("username") + ) else: username = ( getattr(user_info, "name", None) @@ -105,7 +115,9 @@ def _ensure_hf_authenticated() -> str: console.print(f"[bold green]✓[/bold green] Authenticated as: {username}") return username except Exception as e: - raise typer.BadParameter(f"Hugging Face authentication failed: {e}. Please run login manually.") from e + raise typer.BadParameter( + f"Hugging Face authentication failed: {e}. Please run login manually." + ) from e def _prepare_staging_directory( @@ -207,9 +219,13 @@ def _prepare_staging_directory( if enable_interface and not web_interface_env_exists: changes.append("enabled web interface") if changes: - console.print(f"[bold green]✓[/bold green] Updated Dockerfile: {', '.join(changes)}") + console.print( + f"[bold green]✓[/bold green] Updated Dockerfile: {', '.join(changes)}" + ) else: - console.print("[bold yellow]⚠[/bold yellow] No Dockerfile found at server/Dockerfile") + console.print( + "[bold yellow]⚠[/bold yellow] No Dockerfile found at server/Dockerfile" + ) # Ensure README has proper HF frontmatter (only if interface enabled) if enable_interface: @@ -248,7 +264,9 @@ def _prepare_staging_directory( """ readme_path.write_text(frontmatter + readme_content) - console.print("[bold green]✓[/bold green] Updated README with HF Space frontmatter") + console.print( + "[bold green]✓[/bold green] Updated README with HF Space frontmatter" + ) else: console.print("[bold yellow]⚠[/bold yellow] No README.md found") @@ -293,7 +311,9 @@ def _upload_to_hf_space( ignore_patterns=[".git", "__pycache__", "*.pyc"], ) console.print("[bold green]✓[/bold green] Upload completed successfully") - console.print(f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{repo_id}") + console.print( + f"[bold]Space URL:[/bold] https://huggingface.co/spaces/{repo_id}" + ) except Exception as e: console.print(f"[bold red]✗[/bold red] Upload failed: {e}") raise typer.Exit(1) from e @@ -303,7 +323,9 @@ def _upload_to_hf_space( def push( directory: Annotated[ str | None, - typer.Argument(help="Directory containing the OpenEnv environment (default: current directory)"), + typer.Argument( + help="Directory containing the OpenEnv environment (default: current directory)" + ), ] = None, repo_id: Annotated[ str | None, @@ -380,7 +402,7 @@ def push( # Push to specific HuggingFace repo $ openenv push --repo-id my-org/my-env - + # Push privately with custom base image $ openenv push --private --base-image ghcr.io/meta-pytorch/openenv-base:latest """ @@ -391,7 +413,7 @@ def push( file=sys.stderr, ) raise typer.Exit(1) - + # Determine if web interface should be enabled if no_interface: enable_interface = False @@ -403,7 +425,7 @@ def push( else: # HuggingFace: enable interface by default enable_interface = True - + # Determine directory if directory: env_dir = Path(directory).resolve() @@ -412,7 +434,7 @@ def push( if not env_dir.exists() or not env_dir.is_dir(): raise typer.BadParameter(f"Directory does not exist: {env_dir}") - + # Check for openenv.yaml to confirm this is an environment directory openenv_yaml = env_dir / "openenv.yaml" if not openenv_yaml.exists(): @@ -425,7 +447,9 @@ def push( raise typer.Exit(1) # Validate OpenEnv environment - console.print(f"[bold cyan]Validating OpenEnv environment in {env_dir}...[/bold cyan]") + console.print( + f"[bold cyan]Validating OpenEnv environment in {env_dir}...[/bold cyan]" + ) env_name, manifest = _validate_openenv_directory(env_dir) console.print(f"[bold green]✓[/bold green] Found OpenEnv environment: {env_name}") @@ -434,40 +458,42 @@ def push( console.print("[bold cyan]Preparing to push to custom registry...[/bold cyan]") if enable_interface: console.print("[bold cyan]Web interface will be enabled[/bold cyan]") - + # Import build functions from .build import _build_docker_image, _push_docker_image - + # Prepare build args for custom registry deployment build_args = {} if enable_interface: build_args["ENABLE_WEB_INTERFACE"] = "true" - + # Build Docker image from the environment directory tag = f"{registry}/{env_name}" console.print(f"[bold cyan]Building Docker image: {tag}[/bold cyan]") - + success = _build_docker_image( env_path=env_dir, tag=tag, build_args=build_args if build_args else None, ) - + if not success: console.print("[bold red]✗ Docker build failed[/bold red]") raise typer.Exit(1) - + console.print("[bold green]✓ Docker build successful[/bold green]") - + # Push to registry console.print(f"[bold cyan]Pushing to registry: {registry}[/bold cyan]") - - success = _push_docker_image(tag, registry=None) # Tag already includes registry - + + success = _push_docker_image( + tag, registry=None + ) # Tag already includes registry + if not success: console.print("[bold red]✗ Docker push failed[/bold red]") raise typer.Exit(1) - + console.print("\n[bold green]✓ Deployment complete![/bold green]") console.print(f"[bold]Image:[/bold] {tag}") return @@ -481,20 +507,28 @@ def push( # Validate repo_id format if "/" not in repo_id or repo_id.count("/") != 1: - raise typer.BadParameter(f"Invalid repo-id format: {repo_id}. Expected format: 'username/repo-name'") + raise typer.BadParameter( + f"Invalid repo-id format: {repo_id}. Expected format: 'username/repo-name'" + ) # Initialize Hugging Face API api = HfApi() # Prepare staging directory - deployment_type = "with web interface" if enable_interface else "without web interface" - console.print(f"[bold cyan]Preparing files for Hugging Face deployment ({deployment_type})...[/bold cyan]") + deployment_type = ( + "with web interface" if enable_interface else "without web interface" + ) + console.print( + f"[bold cyan]Preparing files for Hugging Face deployment ({deployment_type})...[/bold cyan]" + ) with tempfile.TemporaryDirectory() as tmpdir: staging_dir = Path(tmpdir) / "staging" _prepare_staging_directory( - env_dir, env_name, staging_dir, + env_dir, + env_name, + staging_dir, base_image=base_image, - enable_interface=enable_interface + enable_interface=enable_interface, ) # Create/verify space diff --git a/src/openenv_cli/commands/serve.py b/src/openenv/cli/commands/serve.py similarity index 100% rename from src/openenv_cli/commands/serve.py rename to src/openenv/cli/commands/serve.py diff --git a/src/openenv_cli/commands/validate.py b/src/openenv/cli/commands/validate.py similarity index 93% rename from src/openenv_cli/commands/validate.py rename to src/openenv/cli/commands/validate.py index 96d64e582..33256a8f9 100644 --- a/src/openenv_cli/commands/validate.py +++ b/src/openenv/cli/commands/validate.py @@ -1,108 +1,108 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -""" -OpenEnv validate command. - -This module provides the 'openenv validate' command to check if environments -are properly configured for multi-mode deployment. -""" - -from pathlib import Path - -import typer - -from openenv_cli._validation import ( - format_validation_report, - get_deployment_modes, - validate_multi_mode_deployment, -) - - -def validate( - env_path: str | None = typer.Argument( - None, help="Path to the environment directory (default: current directory)" - ), - verbose: bool = typer.Option( - False, "--verbose", "-v", help="Show detailed information" - ), -) -> None: - """ - Validate an environment for standardized structure and deployment readiness. - - This command checks if an environment is properly configured with: - - Required files (pyproject.toml, openenv.yaml, server/app.py, etc.) - - Docker deployment support - - uv run server capability - - python -m module execution - - Examples: - # Validate current directory (recommended) - $ cd my_env - $ openenv validate - - # Validate with detailed output - $ openenv validate --verbose - - # Validate specific environment - $ openenv validate src/envs/echo_env - """ - # Determine environment path (default to current directory) - if env_path is None: - env_path_obj = Path.cwd() - else: - env_path_obj = Path(env_path) - - if not env_path_obj.exists(): - typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True) - raise typer.Exit(1) - - if not env_path_obj.is_dir(): - typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True) - raise typer.Exit(1) - - # Check for openenv.yaml to confirm this is an environment directory - openenv_yaml = env_path_obj / "openenv.yaml" - if not openenv_yaml.exists(): - typer.echo( - f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", - err=True, - ) - typer.echo( - "Hint: Run this command from the environment root directory or specify the path", - err=True, - ) - raise typer.Exit(1) - - env_name = env_path_obj.name - if env_name.endswith("_env"): - base_name = env_name[:-4] - else: - base_name = env_name - - # Run validation - is_valid, issues = validate_multi_mode_deployment(env_path_obj) - - # Show validation report - report = format_validation_report(base_name, is_valid, issues) - typer.echo(report) - - # Show deployment modes if verbose - if verbose: - typer.echo("\nSupported deployment modes:") - modes = get_deployment_modes(env_path_obj) - for mode, supported in modes.items(): - status = "[YES]" if supported else "[NO]" - typer.echo(f" {status} {mode}") - - if is_valid: - typer.echo("\nUsage examples:") - typer.echo(f" cd {env_path_obj.name} && uv run server") - typer.echo(f" cd {env_path_obj.name} && openenv build") - typer.echo(f" cd {env_path_obj.name} && openenv push") - - if not is_valid: - raise typer.Exit(1) +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +OpenEnv validate command. + +This module provides the 'openenv validate' command to check if environments +are properly configured for multi-mode deployment. +""" + +from pathlib import Path + +import typer + +from openenv.cli._validation import ( + format_validation_report, + get_deployment_modes, + validate_multi_mode_deployment, +) + + +def validate( + env_path: str | None = typer.Argument( + None, help="Path to the environment directory (default: current directory)" + ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show detailed information" + ), +) -> None: + """ + Validate an environment for standardized structure and deployment readiness. + + This command checks if an environment is properly configured with: + - Required files (pyproject.toml, openenv.yaml, server/app.py, etc.) + - Docker deployment support + - uv run server capability + - python -m module execution + + Examples: + # Validate current directory (recommended) + $ cd my_env + $ openenv validate + + # Validate with detailed output + $ openenv validate --verbose + + # Validate specific environment + $ openenv validate envs/echo_env + """ + # Determine environment path (default to current directory) + if env_path is None: + env_path_obj = Path.cwd() + else: + env_path_obj = Path(env_path) + + if not env_path_obj.exists(): + typer.echo(f"Error: Path does not exist: {env_path_obj}", err=True) + raise typer.Exit(1) + + if not env_path_obj.is_dir(): + typer.echo(f"Error: Path is not a directory: {env_path_obj}", err=True) + raise typer.Exit(1) + + # Check for openenv.yaml to confirm this is an environment directory + openenv_yaml = env_path_obj / "openenv.yaml" + if not openenv_yaml.exists(): + typer.echo( + f"Error: Not an OpenEnv environment directory (missing openenv.yaml): {env_path_obj}", + err=True, + ) + typer.echo( + "Hint: Run this command from the environment root directory or specify the path", + err=True, + ) + raise typer.Exit(1) + + env_name = env_path_obj.name + if env_name.endswith("_env"): + base_name = env_name[:-4] + else: + base_name = env_name + + # Run validation + is_valid, issues = validate_multi_mode_deployment(env_path_obj) + + # Show validation report + report = format_validation_report(base_name, is_valid, issues) + typer.echo(report) + + # Show deployment modes if verbose + if verbose: + typer.echo("\nSupported deployment modes:") + modes = get_deployment_modes(env_path_obj) + for mode, supported in modes.items(): + status = "[YES]" if supported else "[NO]" + typer.echo(f" {status} {mode}") + + if is_valid: + typer.echo("\nUsage examples:") + typer.echo(f" cd {env_path_obj.name} && uv run server") + typer.echo(f" cd {env_path_obj.name} && openenv build") + typer.echo(f" cd {env_path_obj.name} && openenv push") + + if not is_valid: + raise typer.Exit(1) diff --git a/src/openenv_cli/templates/__init__.py b/src/openenv/cli/templates/__init__.py similarity index 99% rename from src/openenv_cli/templates/__init__.py rename to src/openenv/cli/templates/__init__.py index 023d053f3..452e81a7b 100644 --- a/src/openenv_cli/templates/__init__.py +++ b/src/openenv/cli/templates/__init__.py @@ -5,4 +5,3 @@ # LICENSE file in the root directory of this source tree. """OpenEnv CLI templates package.""" - diff --git a/src/openenv_cli/templates/openenv_env/.dockerignore b/src/openenv/cli/templates/openenv_env/.dockerignore similarity index 100% rename from src/openenv_cli/templates/openenv_env/.dockerignore rename to src/openenv/cli/templates/openenv_env/.dockerignore diff --git a/src/openenv_cli/templates/openenv_env/README.md b/src/openenv/cli/templates/openenv_env/README.md similarity index 72% rename from src/openenv_cli/templates/openenv_env/README.md rename to src/openenv/cli/templates/openenv_env/README.md index ef238dfb7..3f14526a0 100644 --- a/src/openenv_cli/templates/openenv_env/README.md +++ b/src/openenv/cli/templates/openenv_env/README.md @@ -114,6 +114,7 @@ The deployed space includes: - **Web Interface** at `/web` - Interactive UI for exploring the environment - **API Documentation** at `/docs` - Full OpenAPI/Swagger interface - **Health Check** at `/health` - Container health monitoring +- **WebSocket** at `/ws` - Persistent session endpoint for low-latency interactions ## Environment Details @@ -154,6 +155,61 @@ result = __ENV_NAME__env.step(__ENV_CLASS_NAME__Action(message="Hello!")) Note: When connecting to an existing server, `__ENV_NAME__env.close()` will NOT stop the server. +### Using the Context Manager + +The client supports context manager usage for automatic connection management: + +```python +from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env + +# Connect with context manager (auto-connects and closes) +with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as env: + result = env.reset() + print(f"Reset: {result.observation.echoed_message}") + # Multiple steps with low latency + for msg in ["Hello", "World", "!"]: + result = env.step(__ENV_CLASS_NAME__Action(message=msg)) + print(f"Echoed: {result.observation.echoed_message}") +``` + +The client uses WebSocket connections for: +- **Lower latency**: No HTTP connection overhead per request +- **Persistent session**: Server maintains your environment state +- **Efficient for episodes**: Better for many sequential steps + +### Concurrent WebSocket Sessions + +The server supports multiple concurrent WebSocket connections. To enable this, +modify `server/app.py` to use factory mode: + +```python +# In server/app.py - use factory mode for concurrent sessions +app = create_app( + __ENV_CLASS_NAME__Environment, # Pass class, not instance + __ENV_CLASS_NAME__Action, + __ENV_CLASS_NAME__Observation, + max_concurrent_envs=4, # Allow 4 concurrent sessions +) +``` + +Then multiple clients can connect simultaneously: + +```python +from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env +from concurrent.futures import ThreadPoolExecutor + +def run_episode(client_id: int): + with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as env: + result = env.reset() + for i in range(10): + result = env.step(__ENV_CLASS_NAME__Action(message=f"Client {client_id}, step {i}")) + return client_id, result.observation.message_length + +# Run 4 episodes concurrently +with ThreadPoolExecutor(max_workers=4) as executor: + results = list(executor.map(run_episode, range(4))) +``` + ## Development & Testing ### Direct Environment Testing @@ -189,11 +245,11 @@ __ENV_NAME__/ ├── openenv.yaml # OpenEnv manifest ├── pyproject.toml # Project metadata and dependencies ├── uv.lock # Locked dependencies (generated) -├── client.py # __ENV_CLASS_NAME__Env client implementation +├── client.py # __ENV_CLASS_NAME__Env client ├── models.py # Action and Observation models └── server/ ├── __init__.py # Server module exports ├── __ENV_NAME___environment.py # Core environment logic - ├── app.py # FastAPI application + ├── app.py # FastAPI application (HTTP + WebSocket endpoints) └── Dockerfile # Container image definition ``` diff --git a/src/openenv_cli/templates/openenv_env/__init__.py b/src/openenv/cli/templates/openenv_env/__init__.py similarity index 64% rename from src/openenv_cli/templates/openenv_env/__init__.py rename to src/openenv/cli/templates/openenv_env/__init__.py index 656800a55..cbe07a082 100644 --- a/src/openenv_cli/templates/openenv_env/__init__.py +++ b/src/openenv/cli/templates/openenv_env/__init__.py @@ -4,10 +4,13 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""__ENV_TITLE_NAME__ Environment - A simple test environment for HTTP server.""" +"""__ENV_TITLE_NAME__ Environment.""" from .client import __ENV_CLASS_NAME__Env from .models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation -__all__ = ["__ENV_CLASS_NAME__Action", "__ENV_CLASS_NAME__Observation", "__ENV_CLASS_NAME__Env"] - +__all__ = [ + "__ENV_CLASS_NAME__Action", + "__ENV_CLASS_NAME__Observation", + "__ENV_CLASS_NAME__Env", +] diff --git a/src/openenv_cli/templates/openenv_env/client.py b/src/openenv/cli/templates/openenv_env/client.py similarity index 62% rename from src/openenv_cli/templates/openenv_env/client.py rename to src/openenv/cli/templates/openenv_env/client.py index 34d352671..fc0aa3ab8 100644 --- a/src/openenv_cli/templates/openenv_env/client.py +++ b/src/openenv/cli/templates/openenv_env/client.py @@ -4,50 +4,49 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -""" -__ENV_TITLE_NAME__ Environment HTTP Client. +"""__ENV_TITLE_NAME__ Environment Client.""" -This module provides the client for connecting to a __ENV_TITLE_NAME__ Environment server -over HTTP. -""" +from typing import Dict -from typing import Any, Dict - -from openenv_core.client_types import StepResult -from openenv_core.env_server.types import State -from openenv_core.http_env_client import HTTPEnvClient +from openenv.core.client_types import StepResult +from openenv.core.env_server.types import State +from openenv.core import EnvClient from .models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation -class __ENV_CLASS_NAME__Env(HTTPEnvClient[__ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation]): +class __ENV_CLASS_NAME__Env( + EnvClient[__ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation] +): """ - HTTP client for the __ENV_TITLE_NAME__ Environment. + Client for the __ENV_TITLE_NAME__ Environment. - This client connects to a __ENV_CLASS_NAME__Environment HTTP server and provides - methods to interact with it: reset(), step(), and state access. + This client maintains a persistent WebSocket connection to the environment server, + enabling efficient multi-step interactions with lower latency. + Each client instance has its own dedicated environment session on the server. Example: >>> # Connect to a running server - >>> client = __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") - >>> result = client.reset() - >>> print(result.observation.echoed_message) - >>> - >>> # Send a message - >>> result = client.step(__ENV_CLASS_NAME__Action(message="Hello!")) - >>> print(result.observation.echoed_message) - >>> print(result.reward) + >>> with __ENV_CLASS_NAME__Env(base_url="http://localhost:8000") as client: + ... result = client.reset() + ... print(result.observation.echoed_message) + ... + ... result = client.step(__ENV_CLASS_NAME__Action(message="Hello!")) + ... print(result.observation.echoed_message) Example with Docker: >>> # Automatically start container and connect >>> client = __ENV_CLASS_NAME__Env.from_docker_image("__ENV_NAME__-env:latest") - >>> result = client.reset() - >>> result = client.step(__ENV_CLASS_NAME__Action(message="Test")) + >>> try: + ... result = client.reset() + ... result = client.step(__ENV_CLASS_NAME__Action(message="Test")) + ... finally: + ... client.close() """ def _step_payload(self, action: __ENV_CLASS_NAME__Action) -> Dict: """ - Convert __ENV_CLASS_NAME__Action to JSON payload for step request. + Convert __ENV_CLASS_NAME__Action to JSON payload for step message. Args: action: __ENV_CLASS_NAME__Action instance @@ -64,7 +63,7 @@ def _parse_result(self, payload: Dict) -> StepResult[__ENV_CLASS_NAME__Observati Parse server response into StepResult[__ENV_CLASS_NAME__Observation]. Args: - payload: JSON response from server + payload: JSON response data from server Returns: StepResult with __ENV_CLASS_NAME__Observation @@ -89,7 +88,7 @@ def _parse_state(self, payload: Dict) -> State: Parse server response into State object. Args: - payload: JSON response from /state endpoint + payload: JSON response from state request Returns: State object with episode_id and step_count diff --git a/src/openenv_cli/templates/openenv_env/models.py b/src/openenv/cli/templates/openenv_env/models.py similarity index 65% rename from src/openenv_cli/templates/openenv_env/models.py rename to src/openenv/cli/templates/openenv_env/models.py index c2e40616b..0389b5e85 100644 --- a/src/openenv_cli/templates/openenv_env/models.py +++ b/src/openenv/cli/templates/openenv_env/models.py @@ -10,22 +10,19 @@ The __ENV_NAME__ environment is a simple test environment that echoes back messages. """ -from dataclasses import dataclass +from pydantic import Field -from openenv_core.env_server.types import Action, Observation +from openenv.core.env_server.types import Action, Observation -@dataclass(kw_only=True) class __ENV_CLASS_NAME__Action(Action): """Action for the __ENV_TITLE_NAME__ environment - just a message to echo.""" - message: str + message: str = Field(..., description="Message to echo back") -@dataclass(kw_only=True) class __ENV_CLASS_NAME__Observation(Observation): """Observation from the __ENV_TITLE_NAME__ environment - the echoed message.""" - echoed_message: str - message_length: int = 0 - + echoed_message: str = Field(default="", description="The echoed message") + message_length: int = Field(default=0, description="Length of the echoed message") diff --git a/src/openenv_cli/templates/openenv_env/openenv.yaml b/src/openenv/cli/templates/openenv_env/openenv.yaml similarity index 100% rename from src/openenv_cli/templates/openenv_env/openenv.yaml rename to src/openenv/cli/templates/openenv_env/openenv.yaml diff --git a/src/openenv_cli/templates/openenv_env/pyproject.toml b/src/openenv/cli/templates/openenv_env/pyproject.toml similarity index 77% rename from src/openenv_cli/templates/openenv_env/pyproject.toml rename to src/openenv/cli/templates/openenv_env/pyproject.toml index 331f4851d..4c6b948ff 100644 --- a/src/openenv_cli/templates/openenv_env/pyproject.toml +++ b/src/openenv/cli/templates/openenv_env/pyproject.toml @@ -1,48 +1,45 @@ -# Copyright (c) Meta Platforms, Inc. and affiliates. -# All rights reserved. -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "openenv-__ENV_NAME__" -version = "0.1.0" -description = "__ENV_TITLE_NAME__ environment for OpenEnv" -requires-python = ">=3.10" -dependencies = [ - # Core OpenEnv dependencies (required for server functionality) - # "openenv-core @ git+https://github.com/meta-pytorch/OpenEnv.git@main#subdirectory=src/core", - "openenv-core>=0.1.0", - "fastapi>=0.115.0", - "pydantic>=2.0.0", - "uvicorn>=0.24.0", - "requests>=2.31.0", - # Environment-specific dependencies - # Add all dependencies needed for your environment here - # Examples: - # "numpy>=1.19.0", - # "torch>=2.0.0", - # "gymnasium>=0.29.0", - # "openspiel>=1.0.0", - # "smolagents>=1.22.0,<2", -] - -[project.optional-dependencies] -dev = [ - "pytest>=8.0.0", - "pytest-cov>=4.0.0", -] - -[project.scripts] -# Server entry point - enables running via: uv run --project . server -# or: python -m __ENV_NAME__.server.app -server = "__ENV_NAME__.server.app:main" - -[tool.setuptools] -include-package-data = true -packages = ["__ENV_NAME__", "__ENV_NAME__.server"] +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "openenv-__ENV_NAME__" +version = "0.1.0" +description = "__ENV_TITLE_NAME__ environment for OpenEnv" +requires-python = ">=3.10" +dependencies = [ + # Core OpenEnv runtime (provides FastAPI server + HTTP client types) + # install from github + # "openenv[core] @ git+https://github.com/meta-pytorch/OpenEnv.git", + "openenv[core]>=0.2.0", + # Environment-specific dependencies + # Add all dependencies needed for your environment here + # Examples: + # "numpy>=1.19.0", + # "torch>=2.0.0", + # "gymnasium>=0.29.0", + # "openspiel>=1.0.0", + # "smolagents>=1.22.0,<2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.0.0", +] + +[project.scripts] +# Server entry point - enables running via: uv run --project . server +# or: python -m __ENV_NAME__.server.app +server = "__ENV_NAME__.server.app:main" + +[tool.setuptools] +include-package-data = true +packages = ["__ENV_NAME__", "__ENV_NAME__.server"] package-dir = { "__ENV_NAME__" = ".", "__ENV_NAME__.server" = "server" } \ No newline at end of file diff --git a/src/openenv_cli/templates/openenv_env/server/Dockerfile b/src/openenv/cli/templates/openenv_env/server/Dockerfile similarity index 89% rename from src/openenv_cli/templates/openenv_env/server/Dockerfile rename to src/openenv/cli/templates/openenv_env/server/Dockerfile index 0d53bc24a..3d10ac76b 100644 --- a/src/openenv_cli/templates/openenv_env/server/Dockerfile +++ b/src/openenv/cli/templates/openenv_env/server/Dockerfile @@ -6,8 +6,8 @@ # Multi-stage build using openenv-base # This Dockerfile is flexible and works for both: -# - In-repo environments (with local src/core) -# - Standalone environments (with openenv-core from pip) +# - In-repo environments (with local OpenEnv sources) +# - Standalone environments (with openenv from PyPI/Git) # The build script (openenv build) handles context detection and sets appropriate build args. ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest @@ -27,8 +27,8 @@ ARG ENV_NAME=__ENV_NAME__ # Copy environment code (always at root of build context) COPY . /app/env -# For in-repo builds, openenv-core is already in the pyproject.toml dependencies -# For standalone builds, openenv-core will be installed from pip via pyproject.toml +# For in-repo builds, openenv is already vendored in the build context +# For standalone builds, openenv will be installed via pyproject.toml WORKDIR /app/env # Ensure uv is available (for local builds where base image lacks it) diff --git a/src/openenv_cli/templates/openenv_env/server/__ENV_NAME___environment.py b/src/openenv/cli/templates/openenv_env/server/__ENV_NAME___environment.py similarity index 86% rename from src/openenv_cli/templates/openenv_env/server/__ENV_NAME___environment.py rename to src/openenv/cli/templates/openenv_env/server/__ENV_NAME___environment.py index 63df6c013..454ea6808 100644 --- a/src/openenv_cli/templates/openenv_env/server/__ENV_NAME___environment.py +++ b/src/openenv/cli/templates/openenv_env/server/__ENV_NAME___environment.py @@ -13,8 +13,8 @@ from uuid import uuid4 -from openenv_core.env_server.interfaces import Environment -from openenv_core.env_server.types import State +from openenv.core.env_server.interfaces import Environment +from openenv.core.env_server.types import State from models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation @@ -36,6 +36,12 @@ class __ENV_CLASS_NAME__Environment(Environment): >>> print(obs.message_length) # 5 """ + # Enable concurrent WebSocket sessions. + # Set to True if your environment isolates state between instances. + # When True, multiple WebSocket clients can connect simultaneously, each + # getting their own environment instance (when using factory mode in app.py). + SUPPORTS_CONCURRENT_SESSIONS: bool = True + def __init__(self): """Initialize the __ENV_NAME__ environment.""" self._state = State(episode_id=str(uuid4()), step_count=0) diff --git a/src/openenv_cli/templates/openenv_env/server/__init__.py b/src/openenv/cli/templates/openenv_env/server/__init__.py similarity index 99% rename from src/openenv_cli/templates/openenv_env/server/__init__.py rename to src/openenv/cli/templates/openenv_env/server/__init__.py index 40ba9a415..191fb6555 100644 --- a/src/openenv_cli/templates/openenv_env/server/__init__.py +++ b/src/openenv/cli/templates/openenv_env/server/__init__.py @@ -9,4 +9,3 @@ from .__ENV_NAME___environment import __ENV_CLASS_NAME__Environment __all__ = ["__ENV_CLASS_NAME__Environment"] - diff --git a/src/openenv_cli/templates/openenv_env/server/app.py b/src/openenv/cli/templates/openenv_env/server/app.py similarity index 72% rename from src/openenv_cli/templates/openenv_env/server/app.py rename to src/openenv/cli/templates/openenv_env/server/app.py index 79baeb875..025920a1b 100644 --- a/src/openenv_cli/templates/openenv_env/server/app.py +++ b/src/openenv/cli/templates/openenv_env/server/app.py @@ -8,7 +8,14 @@ FastAPI application for the __ENV_TITLE_NAME__ Environment. This module creates an HTTP server that exposes the __ENV_CLASS_NAME__Environment -over HTTP endpoints, making it compatible with HTTPEnvClient. +over HTTP and WebSocket endpoints, compatible with EnvClient. + +Endpoints: + - POST /reset: Reset the environment + - POST /step: Execute an action + - GET /state: Get current environment state + - GET /schema: Get action/observation schemas + - WS /ws: WebSocket endpoint for persistent sessions Usage: # Development (with auto-reload): @@ -22,22 +29,24 @@ """ try: - from openenv_core.env_server.http_server import create_app + from openenv.core.env_server.http_server import create_app except Exception as e: # pragma: no cover - raise ImportError("openenv_core is required for the web interface. Install dependencies with '\n uv sync\n'") from e + raise ImportError( + "openenv is required for the web interface. Install dependencies with '\n uv sync\n'" + ) from e -from .__ENV_NAME___environment import __ENV_CLASS_NAME__Environment +# Import from local models.py (PYTHONPATH includes /app/env in Docker) from models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation +from .__ENV_NAME___environment import __ENV_CLASS_NAME__Environment -# Create the environment instance -env = __ENV_CLASS_NAME__Environment() # Create the app with web interface and README integration app = create_app( - env, + __ENV_CLASS_NAME__Environment, __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation, env_name="__ENV_NAME__", + max_concurrent_envs=1, # increase this number to allow more concurrent WebSocket sessions ) diff --git a/src/openenv/cli/templates/openenv_env/server/requirements.txt b/src/openenv/cli/templates/openenv_env/server/requirements.txt new file mode 100644 index 000000000..65b1c22b3 --- /dev/null +++ b/src/openenv/cli/templates/openenv_env/server/requirements.txt @@ -0,0 +1,6 @@ +openenv[core]>=0.2.0 +fastapi>=0.115.0 +uvicorn>=0.24.0 + + + diff --git a/src/core/README.md b/src/openenv/core/README.md similarity index 90% rename from src/core/README.md rename to src/openenv/core/README.md index f71ea1c1d..ebfa579aa 100644 --- a/src/core/README.md +++ b/src/openenv/core/README.md @@ -6,7 +6,7 @@ In addition to making it easier for researchers and RL framework writers, we als ## Overview -`openenv-core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API. +`openenv.core` provides the foundational building blocks for creating and interacting with containerized environments over HTTP. It enables you to build agent environments that can be deployed as Docker containers and accessed via a simple HTTP API. > ⚠️ **Early Development Warning** OpenEnv is currently in an experimental > stage. You should expect bugs, incomplete features, and APIs that may change @@ -22,8 +22,8 @@ Core components for OpenEnv - a framework for building HTTP-based agentic enviro ## Features -- **HTTPEnvClient**: Generic HTTP client for interacting with remote environments -- **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP +- **EnvClient**: Generic client for interacting with remote environments +- **HTTPEnvServer**: FastAPI-based server wrapper for exposing environments over HTTP/WebSocket - **Container Providers**: Pluggable architecture for running containers (Docker, Kubernetes, etc.) - **Type System**: Strongly-typed Action/Observation/State interfaces - **Web Interface**: Optional web UI for interacting with environments @@ -31,12 +31,12 @@ Core components for OpenEnv - a framework for building HTTP-based agentic enviro ## Installation ```bash -pip install openenv-core +pip install "openenv[core]" ``` For development: ```bash -pip install openenv-core[dev] +pip install "openenv[core]" ``` ## Quick Start @@ -44,7 +44,7 @@ pip install openenv-core[dev] ### Creating an Environment Client ```python -from openenv_core import HTTPEnvClient, StepResult +from openenv.core import EnvClient, StepResult from dataclasses import dataclass @dataclass @@ -55,7 +55,7 @@ class MyAction: class MyObservation: response: str -class MyEnvClient(HTTPEnvClient[MyAction, MyObservation]): +class MyEnvClient(EnvClient[MyAction, MyObservation]): def _step_payload(self, action: MyAction) -> dict: return {"text": action.text} @@ -80,7 +80,7 @@ env.close() ### Creating an Environment Server ```python -from openenv_core.env_server import Environment, HTTPEnvServer, create_app +from openenv.core.env_server import Environment, HTTPEnvServer, create_app from dataclasses import dataclass @dataclass @@ -118,7 +118,7 @@ OpenEnv Core supports multiple container providers: ### Local Docker Provider ```python -from openenv_core.containers.runtime import LocalDockerProvider +from openenv.core.containers.runtime import LocalDockerProvider provider = LocalDockerProvider() base_url = provider.start_container("my-env:latest") @@ -130,7 +130,7 @@ provider.stop_container() ### Kubernetes Provider (Coming Soon) ```python -from openenv_core.containers.runtime import KubernetesProvider +from openenv.core.containers.runtime import KubernetesProvider provider = KubernetesProvider(namespace="envs") base_url = provider.start_container("my-env:latest") @@ -141,7 +141,7 @@ provider.stop_container() ## API Reference -### HTTPEnvClient +### EnvClient Base class for environment clients with these abstract methods: diff --git a/src/core/__init__.py b/src/openenv/core/__init__.py similarity index 69% rename from src/core/__init__.py rename to src/openenv/core/__init__.py index 99507ab55..5a7af20db 100644 --- a/src/core/__init__.py +++ b/src/openenv/core/__init__.py @@ -7,13 +7,10 @@ """Core components for agentic environments.""" # Re-export main components from submodules for convenience -from .env_server import * -from .client_types import StepResult -from .http_env_client import HTTPEnvClient +from .env_server import * # noqa: F403 +from . import env_server +from .env_client import EnvClient # Note: MCP module doesn't export anything yet -__all__ = [ - "HTTPEnvClient", - "StepResult", -] +__all__ = ["EnvClient"] + env_server.__all__ # type: ignore diff --git a/src/core/client_types.py b/src/openenv/core/client_types.py similarity index 81% rename from src/core/client_types.py rename to src/openenv/core/client_types.py index 8808e96bf..c7501c656 100644 --- a/src/core/client_types.py +++ b/src/openenv/core/client_types.py @@ -1,9 +1,10 @@ # Type definitions for EnvTorch from dataclasses import dataclass -from typing import Any, Generic, Optional, TypeVar +from typing import Generic, Optional, TypeVar # Generic type for observations -ObsT = TypeVar("ObsT") # TypeVar for typehinting in IDEs +ObsT = TypeVar("ObsT") +StateT = TypeVar("StateT") @dataclass diff --git a/src/core/containers/__init__.py b/src/openenv/core/containers/__init__.py similarity index 80% rename from src/core/containers/__init__.py rename to src/openenv/core/containers/__init__.py index 59ce71cdf..38e67ef3c 100644 --- a/src/core/containers/__init__.py +++ b/src/openenv/core/containers/__init__.py @@ -4,4 +4,4 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Container management for environment servers.""" \ No newline at end of file +"""Container management for environment servers.""" diff --git a/src/core/containers/images/Dockerfile b/src/openenv/core/containers/images/Dockerfile similarity index 100% rename from src/core/containers/images/Dockerfile rename to src/openenv/core/containers/images/Dockerfile diff --git a/src/core/containers/images/README.md b/src/openenv/core/containers/images/README.md similarity index 92% rename from src/core/containers/images/README.md rename to src/openenv/core/containers/images/README.md index bc2864466..2a91b3303 100644 --- a/src/core/containers/images/README.md +++ b/src/openenv/core/containers/images/README.md @@ -48,7 +48,7 @@ FROM openenv-base:latest # Copy only environment-specific files COPY src/core/ /app/src/core/ -COPY src/envs/my_env/ /app/src/envs/my_env/ +COPY envs/my_env/ /app/envs/my_env/ # Run the server CMD ["uvicorn", "envs.my_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"] @@ -69,7 +69,7 @@ CMD ["uvicorn", "envs.my_env.server.app:app", "--host", "0.0.0.0", "--port", "80 docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . # Step 2: Build echo environment (uses base) -docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +docker build -t echo-env:latest -f envs/echo_env/server/Dockerfile . # Step 3: Run echo environment docker run -p 8000:8000 echo-env:latest @@ -88,5 +88,5 @@ When dependencies need updating: docker build -t openenv-base:latest -f src/core/containers/images/Dockerfile . # Rebuild environments (they automatically use new base) -docker build -t echo-env:latest -f src/envs/echo_env/server/Dockerfile . +docker build -t echo-env:latest -f envs/echo_env/server/Dockerfile . ``` diff --git a/src/core/containers/runtime/__init__.py b/src/openenv/core/containers/runtime/__init__.py similarity index 62% rename from src/core/containers/runtime/__init__.py rename to src/openenv/core/containers/runtime/__init__.py index a72b53010..8ed606a22 100644 --- a/src/core/containers/runtime/__init__.py +++ b/src/openenv/core/containers/runtime/__init__.py @@ -6,10 +6,18 @@ """Container runtime providers.""" -from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider +from .providers import ( + ContainerProvider, + KubernetesProvider, + LocalDockerProvider, + RuntimeProvider, +) +from .uv_provider import UVProvider __all__ = [ "ContainerProvider", "LocalDockerProvider", "KubernetesProvider", -] \ No newline at end of file + "RuntimeProvider", + "UVProvider", +] diff --git a/src/core/containers/runtime/providers.py b/src/openenv/core/containers/runtime/providers.py similarity index 81% rename from src/core/containers/runtime/providers.py rename to src/openenv/core/containers/runtime/providers.py index a8022ddca..cee423554 100644 --- a/src/core/containers/runtime/providers.py +++ b/src/openenv/core/containers/runtime/providers.py @@ -8,7 +8,7 @@ Container provider abstractions for running environment servers. This module provides a pluggable architecture for different container providers -(local Docker, Kubernetes, cloud providers, etc.) to be used with HTTPEnvClient. +(local Docker, Kubernetes, cloud providers, etc.) to be used with EnvClient. """ from __future__ import annotations @@ -118,7 +118,11 @@ def __init__(self): capture_output=True, timeout=5, ) - except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired): + except ( + subprocess.CalledProcessError, + FileNotFoundError, + subprocess.TimeoutExpired, + ): raise RuntimeError( "Docker is not available. Please install Docker Desktop or Docker Engine." ) @@ -154,10 +158,13 @@ def start_container( # Build docker run command cmd = [ - "docker", "run", + "docker", + "run", "-d", # Detached - "--name", self._container_name, - "-p", f"{port}:8000", # Map port + "--name", + self._container_name, + "-p", + f"{port}:8000", # Map port ] # Add environment variables @@ -290,4 +297,67 @@ class KubernetesProvider(ContainerProvider): >>> # Pod running in k8s, accessible via service or port-forward >>> provider.stop_container() """ + pass + + +class RuntimeProvider(ABC): + """ + Abstract base class for runtime providers that are not container providers. + Providers implement this interface to support different runtime platforms: + - UVProvider: Runs environments via `uv run` + + The provider manages a single runtime lifecycle and provides the base URL + for connecting to it. + + Example: + >>> provider = UVProvider(project_path="/path/to/env") + >>> base_url = provider.start() + >>> print(base_url) # http://localhost:8000 + >>> provider.stop() + """ + + @abstractmethod + def start( + self, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **kwargs: Any, + ) -> str: + """ + Start a runtime from the specified image. + + Args: + image: Runtime image name + port: Port to expose (if None, provider chooses) + env_vars: Environment variables for the runtime + **kwargs: Additional runtime options + """ + + @abstractmethod + def stop(self) -> None: + """ + Stop the runtime. + """ + pass + + @abstractmethod + def wait_for_ready(self, timeout_s: float = 30.0) -> None: + """ + Wait for the runtime to be ready to accept requests. + """ + pass + + def __enter__(self) -> "RuntimeProvider": + """ + Enter the runtime provider. + """ + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + """ + Exit the runtime provider. + """ + self.stop() + return False diff --git a/src/openenv/core/containers/runtime/uv_provider.py b/src/openenv/core/containers/runtime/uv_provider.py new file mode 100644 index 000000000..3ddc89b9b --- /dev/null +++ b/src/openenv/core/containers/runtime/uv_provider.py @@ -0,0 +1,224 @@ +"""Providers for launching ASGI applications via ``uv run``.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +from typing import Dict, Optional + +import requests + +from .providers import RuntimeProvider + + +def _check_uv_installed() -> None: + try: + subprocess.check_output(["uv", "--version"]) + except FileNotFoundError as exc: + raise RuntimeError( + "`uv` executable not found. Install uv from https://docs.astral.sh and ensure it is on PATH." + ) from exc + + +def _find_free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + sock.listen(1) + return sock.getsockname()[1] + + +def _create_uv_command( + *, + host: str, + port: int, + reload: bool, + workers: int, + app: str, + project_path: str, +) -> list[str]: + command: list[str] = ["uv", "run", "--isolated", "--project", project_path] + + command.append("--") + command.extend( + [ + "uvicorn", + app, + "--host", + host, + "--port", + str(port), + "--workers", + str(workers), + ] + ) + + if reload: + command.append("--reload") + + return command + + +def _poll_health(health_url: str, timeout_s: float) -> None: + """Poll a health endpoint until it returns HTTP 200 or times out.""" + + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + timeout = max(0.0001, min(deadline - time.time(), 2.0)) + response = requests.get(health_url, timeout=timeout) + if response.status_code == 200: + return + except requests.RequestException: + continue + + time.sleep(0.5) + + raise TimeoutError(f"Server did not become ready within {timeout_s:.1f} seconds") + + +class UVProvider(RuntimeProvider): + """ + RuntimeProvider implementation backed by ``uv run``. + + Args: + project_path: Local path to a uv project (passed to ``uv run --project``) + app: ASGI application path for uvicorn (defaults to ``server.app:app``) + host: Host interface to bind to (defaults to ``0.0.0.0``) + reload: Whether to enable uvicorn's reload mode + env_vars: Environment variables to pass through to the spawned process + context_timeout_s: How long to wait for the environment to become ready + + Example: + >>> provider = UVProvider(project_path="/path/to/env") + >>> base_url = provider.start() + >>> print(base_url) # http://localhost:8000 + >>> # Use the environment via base_url + >>> provider.stop() + """ + + def __init__( + self, + *, + project_path: str, + app: str = "server.app:app", + host: str = "0.0.0.0", + reload: bool = False, + env_vars: Optional[Dict[str, str]] = None, + context_timeout_s: float = 60.0, + ): + """Initialize the UVProvider.""" + self.project_path = os.path.abspath(project_path) + self.app = app + self.host = host + self.reload = reload + self.env_vars = env_vars + self.context_timeout_s = context_timeout_s + _check_uv_installed() + self._process = None + self._base_url = None + + def start( + self, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + workers: int = 1, + **_: Dict[str, str], + ) -> str: + """ + Start the environment via `uv run`. + + Args: + port: The port to bind the environment to + env_vars: Environment variables to pass to the environment + workers: The number of workers to use + + Returns: + The base URL of the environment + + Raises: + RuntimeError: If the environment is already running + """ + if self._process is not None and self._process.poll() is None: + raise RuntimeError("UVProvider is already running") + + bind_port = port or _find_free_port() + + command = _create_uv_command( + host=self.host, + port=bind_port, + reload=self.reload, + workers=workers, + app=self.app, + project_path=self.project_path, + ) + + env = os.environ.copy() + + if self.env_vars: + env.update(self.env_vars) + if env_vars: + env.update(env_vars) + + try: + self._process = subprocess.Popen(command, env=env) + except OSError as exc: + raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc + + client_host = "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host + self._base_url = f"http://{client_host}:{bind_port}" + return self._base_url + + def wait_for_ready(self, timeout_s: float = 60.0) -> None: + """ + Wait for the environment to become ready. + + Args: + timeout_s: The timeout to wait for the environment to become ready + + Raises: + RuntimeError: If the environment is not running + TimeoutError: If the environment does not become ready within the timeout + """ + if self._process and self._process.poll() is not None: + code = self._process.returncode + raise RuntimeError(f"uv process exited prematurely with code {code}") + + _poll_health(f"{self._base_url}/health", timeout_s=timeout_s) + + def stop(self) -> None: + """ + Stop the environment. + + Raises: + RuntimeError: If the environment is not running + """ + if self._process is None: + return + + if self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=10.0) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait(timeout=5.0) + + self._process = None + self._base_url = None + + @property + def base_url(self) -> str: + """ + The base URL of the environment. + + Returns: + The base URL of the environment + + Raises: + RuntimeError: If the environment is not running + """ + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + return self._base_url diff --git a/src/core/containers/test_local_docker_provider.py b/src/openenv/core/containers/test_local_docker_provider.py similarity index 95% rename from src/core/containers/test_local_docker_provider.py rename to src/openenv/core/containers/test_local_docker_provider.py index e435ff6dc..f661c25a0 100644 --- a/src/core/containers/test_local_docker_provider.py +++ b/src/openenv/core/containers/test_local_docker_provider.py @@ -17,7 +17,8 @@ import requests -from core.containers.runtime import LocalDockerProvider +from openenv.core.containers.runtime import LocalDockerProvider + # TODO: Remove this test or make it a functional test sicne this will be tested in e2e test for echo env def test_local_docker_provider(): @@ -87,7 +88,9 @@ def test_local_docker_provider(): print(f" Length: {data['observation']['message_length']}") print(f" Reward: {data['reward']}") assert response.status_code == 200 - assert data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!" + assert ( + data["observation"]["echoed_message"] == "Hello from LocalDockerProvider!" + ) assert data["observation"]["message_length"] == 31 print("✓ Step test passed\n") @@ -107,11 +110,11 @@ def test_local_docker_provider(): for i in range(3): response = requests.post( f"{base_url}/step", - json={"action": {"message": f"Message {i+1}"}}, + json={"action": {"message": f"Message {i + 1}"}}, headers={"Content-Type": "application/json"}, ) assert response.status_code == 200 - print(f" Step {i+1}: ✓") + print(f" Step {i + 1}: ✓") # Check state updated response = requests.get(f"{base_url}/state") @@ -130,6 +133,7 @@ def test_local_docker_provider(): except Exception as e: print(f"\n❌ Test failed: {e}") import traceback + traceback.print_exc() return False @@ -197,8 +201,7 @@ def test_provider_with_env_vars(): print("Starting container with environment variables...") base_url = provider.start_container( - "echo-env:latest", - env_vars={"DEBUG": "true", "LOG_LEVEL": "info"} + "echo-env:latest", env_vars={"DEBUG": "true", "LOG_LEVEL": "info"} ) print(f"✓ Started at: {base_url}") diff --git a/src/openenv/core/env_client.py b/src/openenv/core/env_client.py new file mode 100644 index 000000000..f33a406ce --- /dev/null +++ b/src/openenv/core/env_client.py @@ -0,0 +1,361 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Environment client for persistent sessions. + +This module provides a WebSocket-based client that maintains a persistent connection +to an environment server, enabling efficient multi-step interactions without +the overhead of HTTP request/response cycles. +""" + +from __future__ import annotations + +import json +from abc import ABC, abstractmethod +from typing import Any, Dict, Generic, Optional, Type, TYPE_CHECKING, TypeVar + +from .client_types import StepResult, StateT +from .containers.runtime import LocalDockerProvider, UVProvider +from .utils import convert_to_ws_url + +if TYPE_CHECKING: + from .containers.runtime import ContainerProvider, RuntimeProvider + from websockets.sync.client import ClientConnection + +from websockets.sync.client import connect as ws_connect + +ActT = TypeVar("ActT") +ObsT = TypeVar("ObsT") +EnvClientT = TypeVar("EnvClientT", bound="EnvClient") + + +class EnvClient(ABC, Generic[ActT, ObsT, StateT]): + """ + Environment client for persistent sessions. + + This client maintains a persistent WebSocket connection to an environment + server, enabling efficient multi-step interactions. Each client instance + corresponds to a dedicated environment session on the server. + + Features: + - Lower latency for sequential interactions + - Session state is maintained server-side + - Better suited for long-running episodes + + Example: + >>> from envs.coding_env.client import CodingEnv + >>> + >>> # Connect to a server + >>> with CodingEnv(base_url="ws://localhost:8000") as env: + ... result = env.reset(seed=42) + ... while not result.done: + ... action = agent.predict(result.observation) + ... result = env.step(action) + """ + + def __init__( + self, + base_url: str, + connect_timeout_s: float = 10.0, + message_timeout_s: float = 60.0, + provider: Optional["ContainerProvider | RuntimeProvider"] = None, + ): + """ + Initialize environment client. + + Args: + base_url: Base URL of the environment server (http:// or ws://). + Will be converted to ws:// if http:// is provided. + connect_timeout_s: Timeout for establishing WebSocket connection + message_timeout_s: Timeout for receiving responses to messages + provider: Optional container/runtime provider for lifecycle management. + Can be a ContainerProvider (Docker) or RuntimeProvider (UV). + """ + # Convert HTTP URL to WebSocket URL + ws_url = convert_to_ws_url(base_url) + + self._ws_url = f"{ws_url}/ws" + self._connect_timeout = connect_timeout_s + self._message_timeout = message_timeout_s + self._provider = provider + self._ws: Optional[ClientConnection] = None + + def connect(self) -> "EnvClient": + """ + Establish WebSocket connection to the server. + + Returns: + self for method chaining + + Raises: + ConnectionError: If connection cannot be established + """ + if self._ws is not None: + return self + + try: + self._ws = ws_connect( + self._ws_url, + open_timeout=self._connect_timeout, + ) + except Exception as e: + raise ConnectionError(f"Failed to connect to {self._ws_url}: {e}") from e + + return self + + def disconnect(self) -> None: + """Close the WebSocket connection.""" + if self._ws is not None: + try: + # Send close message + self._send({"type": "close"}) + except Exception: + pass # Best effort + try: + self._ws.close() + except Exception: + pass + self._ws = None + + def _ensure_connected(self) -> None: + """Ensure WebSocket connection is established.""" + if self._ws is None: + self.connect() + + def _send(self, message: Dict[str, Any]) -> None: + """Send a message over the WebSocket.""" + self._ensure_connected() + assert self._ws is not None + self._ws.send(json.dumps(message)) + + def _receive(self) -> Dict[str, Any]: + """Receive and parse a message from the WebSocket.""" + assert self._ws is not None + raw = self._ws.recv(timeout=self._message_timeout) + return json.loads(raw) + + def _send_and_receive(self, message: Dict[str, Any]) -> Dict[str, Any]: + """Send a message and wait for response.""" + self._send(message) + response = self._receive() + + # Check for error response + if response.get("type") == "error": + error_data = response.get("data", {}) + raise RuntimeError( + f"Server error: {error_data.get('message', 'Unknown error')} " + f"(code: {error_data.get('code', 'UNKNOWN')})" + ) + + return response + + @classmethod + def from_docker_image( + cls: Type[EnvClientT], + image: str, + provider: Optional["ContainerProvider"] = None, + **kwargs: Any, + ) -> EnvClientT: + """ + Create an environment client by spinning up a Docker container. + + Args: + image: Docker image name to run (e.g., "coding-env:latest") + provider: Container provider to use (defaults to LocalDockerProvider) + **kwargs: Additional arguments to pass to provider.start_container() + + Returns: + Connected client instance + """ + if provider is None: + provider = LocalDockerProvider() + + # Start container + base_url = provider.start_container(image, **kwargs) + + # Wait for server to be ready + provider.wait_for_ready(base_url) + + # Create and connect client + client = cls(base_url=base_url, provider=provider) + client.connect() + + return client + + @classmethod + def from_hub( + cls: Type[EnvClientT], + repo_id: str, + *, + use_docker: bool = True, + provider: Optional["ContainerProvider | RuntimeProvider"] = None, + **provider_kwargs: Any, + ) -> EnvClientT: + """ + Create a client from a Hugging Face Space. + + Args: + repo_id: Hugging Face space identifier ``{org}/{space}``. + use_docker: When ``True`` (default) pull from the HF registry and + launch via :class:`LocalDockerProvider`. When ``False`` run the + space locally with :class:`UVProvider`. + provider: Optional provider instance to reuse. Must be a + :class:`ContainerProvider` when ``use_docker=True`` and a + :class:`RuntimeProvider` otherwise. + provider_kwargs: Additional keyword arguments forwarded to + either the container provider's ``start_container`` (docker) + or to the ``UVProvider`` constructor/start (uv). When + ``use_docker=False``, the ``project_path`` argument can be + used to override the default git URL + (``git+https://huggingface.co/spaces/{repo_id}``). + + Returns: + Connected client instance + + Examples: + >>> # Pull and run from HF Docker registry + >>> env = MyEnv.from_hub("openenv/echo-env") + >>> + >>> # Run locally with UV (clones the space) + >>> env = MyEnv.from_hub("openenv/echo-env", use_docker=False) + >>> + >>> # Run from a local checkout + >>> env = MyEnv.from_hub( + ... "openenv/echo-env", + ... use_docker=False, + ... project_path="/path/to/local/checkout" + ... ) + """ + # Extract start args that apply to both providers + start_args = {} + for key in ("port", "env_vars", "workers"): + if key in provider_kwargs: + start_args[key] = provider_kwargs.pop(key) + + if use_docker: + # Docker mode: pull from HF registry + docker_provider = provider or LocalDockerProvider() + tag = provider_kwargs.pop("tag", "latest") + image = f"registry.hf.space/{repo_id.replace('/', '-')}:{tag}" + base_url = docker_provider.start_container( + image, **start_args, **provider_kwargs + ) + docker_provider.wait_for_ready(base_url) + + client = cls(base_url=base_url, provider=docker_provider) + client.connect() + return client + else: + # UV mode: clone and run with uv + if provider is None: + uv_kwargs = dict(provider_kwargs) + project_path = uv_kwargs.pop("project_path", None) + if project_path is None: + project_path = f"git+https://huggingface.co/spaces/{repo_id}" + + provider = UVProvider(project_path=project_path, **uv_kwargs) + else: + if provider_kwargs: + raise ValueError( + "provider_kwargs cannot be used when supplying a provider instance" + ) + + base_url = provider.start(**start_args) + provider.wait_for_ready() + + client = cls(base_url=base_url, provider=provider) + client.connect() + return client + + @abstractmethod + def _step_payload(self, action: ActT) -> Dict[str, Any]: + """Convert an Action object to the JSON data expected by the env server.""" + raise NotImplementedError + + @abstractmethod + def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ObsT]: + """Convert a JSON response from the env server to StepResult[ObsT].""" + raise NotImplementedError + + @abstractmethod + def _parse_state(self, payload: Dict[str, Any]) -> StateT: + """Convert a JSON response from the state endpoint to a State object.""" + raise NotImplementedError + + def reset(self, **kwargs: Any) -> StepResult[ObsT]: + """ + Reset the environment with optional parameters. + + Args: + **kwargs: Optional parameters passed to the environment's reset method. + Common parameters include: + - seed: Random seed for reproducibility + - episode_id: Custom episode identifier + + Returns: + StepResult containing initial observation + """ + message = { + "type": "reset", + "data": kwargs, + } + response = self._send_and_receive(message) + return self._parse_result(response.get("data", {})) + + def step(self, action: ActT, **kwargs: Any) -> StepResult[ObsT]: + """ + Execute an action in the environment. + + Args: + action: The action to execute + **kwargs: Optional parameters (currently ignored) + + Returns: + StepResult containing observation, reward, and done status + """ + message = { + "type": "step", + "data": self._step_payload(action), + } + response = self._send_and_receive(message) + return self._parse_result(response.get("data", {})) + + def state(self) -> StateT: + """ + Get the current environment state from the server. + + Returns: + State object with environment state information + """ + message = {"type": "state"} + response = self._send_and_receive(message) + return self._parse_state(response.get("data", {})) + + def close(self) -> None: + """ + Close the WebSocket connection and clean up resources. + + If this client was created via from_docker_image() or from_hub(), + this will also stop and remove the associated container/process. + """ + self.disconnect() + + if self._provider is not None: + # Handle both ContainerProvider and RuntimeProvider + if hasattr(self._provider, "stop_container"): + self._provider.stop_container() + elif hasattr(self._provider, "stop"): + self._provider.stop() + + def __enter__(self) -> "EnvClient": + """Enter context manager, ensuring connection is established.""" + self.connect() + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Exit context manager, closing connection.""" + self.close() diff --git a/src/openenv/core/env_server/__init__.py b/src/openenv/core/env_server/__init__.py new file mode 100644 index 000000000..ed0d41278 --- /dev/null +++ b/src/openenv/core/env_server/__init__.py @@ -0,0 +1,96 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Core environment interfaces and types.""" + +from .base_transforms import CompositeTransform, NullTransform +from .http_server import HTTPEnvServer, create_app, create_fastapi_app +from .interfaces import Environment, Message, ModelTokenizer, Transform +from .route_config import GetEndpointConfig +from .serialization import ( + deserialize_action, + deserialize_action_with_preprocessing, + serialize_observation, +) +from .types import ( + Action, + Observation, + State, + SchemaResponse, + HealthResponse, + BaseMessage, + WSIncomingMessage, + WSResetMessage, + WSStepMessage, + WSStateMessage, + WSCloseMessage, + WSObservationResponse, + WSStateResponse, + WSErrorResponse, + ConcurrencyConfig, + ServerCapacityStatus, + SessionInfo, +) +from .exceptions import ( + OpenEnvError, + ConcurrencyConfigurationError, + SessionCapacityError, + SessionNotFoundError, + SessionCreationError, + EnvironmentFactoryError, +) +from .web_interface import create_web_interface_app, WebInterfaceManager + +__all__ = [ + # Core interfaces + "Environment", + "Transform", + "Message", + "ModelTokenizer", + # Types + "Action", + "Observation", + "State", + "SchemaResponse", + "HealthResponse", + # WebSocket message types + "BaseMessage", + "WSIncomingMessage", + "WSResetMessage", + "WSStepMessage", + "WSStateMessage", + "WSCloseMessage", + "WSObservationResponse", + "WSStateResponse", + "WSErrorResponse", + # Concurrency types + "ConcurrencyConfig", + "ServerCapacityStatus", + "SessionInfo", + # Exceptions + "OpenEnvError", + "ConcurrencyConfigurationError", + "SessionCapacityError", + "SessionNotFoundError", + "SessionCreationError", + "EnvironmentFactoryError", + # Base transforms + "CompositeTransform", + "NullTransform", + # HTTP Server + "HTTPEnvServer", + "create_app", + "create_fastapi_app", + # Web Interface + "create_web_interface_app", + "WebInterfaceManager", + # Serialization utilities + "deserialize_action", + "deserialize_action_with_preprocessing", + "serialize_observation", + # Route configuration + "GetEndpointConfig", +] diff --git a/src/core/env_server/base_transforms.py b/src/openenv/core/env_server/base_transforms.py similarity index 97% rename from src/core/env_server/base_transforms.py rename to src/openenv/core/env_server/base_transforms.py index d8165e3d7..ab48ebb48 100644 --- a/src/core/env_server/base_transforms.py +++ b/src/openenv/core/env_server/base_transforms.py @@ -26,4 +26,4 @@ class NullTransform(Transform): """Default transform that passes through unchanged.""" def __call__(self, observation: Observation) -> Observation: - return observation \ No newline at end of file + return observation diff --git a/src/openenv/core/env_server/exceptions.py b/src/openenv/core/env_server/exceptions.py new file mode 100644 index 000000000..ababa1ecd --- /dev/null +++ b/src/openenv/core/env_server/exceptions.py @@ -0,0 +1,105 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Custom exceptions for environment server operations.""" + +from typing import Optional + + +class OpenEnvError(Exception): + """Base exception for all OpenEnv errors.""" + + pass + + +class ConcurrencyConfigurationError(OpenEnvError): + """ + Raised when an environment is misconfigured for concurrent sessions. + + This error is raised during server startup when max_concurrent_envs > 1 + is specified for an environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS. + """ + + def __init__( + self, + environment_name: str, + max_concurrent_envs: int, + message: Optional[str] = None, + ): + self.environment_name = environment_name + self.max_concurrent_envs = max_concurrent_envs + + if message is None: + message = ( + f"Environment '{environment_name}' is not marked as SUPPORTS_CONCURRENT_SESSIONS. " + f"Cannot run with max_concurrent_envs={max_concurrent_envs}. " + f"Either set max_concurrent_envs=1 or ensure the environment " + f"properly isolates session state and set SUPPORTS_CONCURRENT_SESSIONS=True." + ) + + super().__init__(message) + + +class SessionCapacityError(OpenEnvError): + """ + Raised when the server cannot accept new sessions due to capacity limits. + + This error is raised when a new WebSocket connection is attempted but + the server has already reached max_concurrent_envs active sessions. + """ + + def __init__( + self, + active_sessions: int, + max_sessions: int, + message: Optional[str] = None, + ): + self.active_sessions = active_sessions + self.max_sessions = max_sessions + + if message is None: + message = ( + f"Server at capacity: {active_sessions}/{max_sessions} sessions active. " + f"Cannot accept new connections." + ) + + super().__init__(message) + + +class SessionNotFoundError(OpenEnvError): + """Raised when attempting to access a session that does not exist.""" + + def __init__(self, session_id: str, message: Optional[str] = None): + self.session_id = session_id + + if message is None: + message = f"Session '{session_id}' not found." + + super().__init__(message) + + +class SessionCreationError(OpenEnvError): + """Raised when a session cannot be created.""" + + def __init__(self, reason: str, message: Optional[str] = None): + self.reason = reason + + if message is None: + message = f"Failed to create session: {reason}" + + super().__init__(message) + + +class EnvironmentFactoryError(OpenEnvError): + """Raised when the environment factory fails to create an instance.""" + + def __init__(self, factory_name: str, message: Optional[str] = None): + self.factory_name = factory_name + + if message is None: + message = f"Environment factory '{factory_name}' failed to create instance." + + super().__init__(message) diff --git a/src/openenv/core/env_server/http_server.py b/src/openenv/core/env_server/http_server.py new file mode 100644 index 000000000..9cf372b68 --- /dev/null +++ b/src/openenv/core/env_server/http_server.py @@ -0,0 +1,935 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +HTTP server wrapper for Environment instances. + +This module provides utilities to wrap any Environment subclass and expose it +over HTTP and WebSocket endpoints that EnvClient can consume. +""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Dict, Optional, Type, Union + +from fastapi import Body, FastAPI, HTTPException, WebSocket, WebSocketDisconnect, status +from pydantic import ValidationError + +from .interfaces import Environment +from .route_config import ( + GetEndpointConfig, + register_get_endpoints, +) +from .serialization import deserialize_action, serialize_observation +from .types import ( + Action, + Observation, + ResetRequest, + ResetResponse, + State, + StepRequest, + StepResponse, + EnvironmentMetadata, + SchemaResponse, + HealthResponse, + WSResetMessage, + WSStepMessage, + WSStateMessage, + WSCloseMessage, + WSObservationResponse, + WSStateResponse, + WSErrorResponse, + ConcurrencyConfig, + ServerCapacityStatus, + SessionInfo, +) +from .exceptions import ( + ConcurrencyConfigurationError, + SessionCapacityError, + EnvironmentFactoryError, +) + + +class HTTPEnvServer: + """ + HTTP server wrapper for Environment instances. + + This class wraps an Environment and exposes its reset(), step(), and state + methods as HTTP and WebSocket endpoints compatible with EnvClient. + + The server expects: + - Action deserialization: Converts JSON dict to Action subclass + - Observation serialization: Converts Observation subclass to JSON dict + + Example: + >>> from core.env_server import HTTPEnvServer + >>> from envs.coding_env.server import CodeExecutionEnvironment + >>> from envs.coding_env.models import CodeAction, CodeObservation + >>> + >>> # Pass environment class (factory pattern) + >>> server = HTTPEnvServer( + ... env=CodeExecutionEnvironment, + ... action_cls=CodeAction, + ... observation_cls=CodeObservation, + ... max_concurrent_envs=4, + ... ) + >>> + >>> # Register routes with FastAPI + >>> from fastapi import FastAPI + >>> app = FastAPI() + >>> server.register_routes(app) + """ + + def __init__( + self, + env: Callable[[], Environment], + action_cls: Type[Action], + observation_cls: Type[Observation], + max_concurrent_envs: Optional[int] = None, + concurrency_config: Optional[ConcurrencyConfig] = None, + ): + """ + Initialize HTTP server wrapper. + + Args: + env: Environment factory (callable) that creates new instances. + Will be called to create a new environment for each WebSocket session. + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + max_concurrent_envs: Maximum number of concurrent WebSocket sessions. + Mutually exclusive with concurrency_config. + concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings. + Mutually exclusive with max_concurrent_envs. + + Raises: + ValueError: If both max_concurrent_envs and concurrency_config are provided. + ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an + environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS. + """ + # Validate that env is callable + if not callable(env): + raise TypeError( + f"env must be a callable (class or factory function), got {type(env)}. " + f"Pass the environment class (e.g., MyEnvironment) not an instance (e.g., MyEnvironment())." + ) + + self._env_factory: Callable[[], Environment] = env + + # Handle concurrency configuration + if max_concurrent_envs is not None and concurrency_config is not None: + raise ValueError( + "Cannot specify both 'max_concurrent_envs' and 'concurrency_config'. " + "Please use only one method to configure concurrency." + ) + + if concurrency_config is not None: + self._concurrency_config = concurrency_config + elif max_concurrent_envs is not None: + self._concurrency_config = ConcurrencyConfig( + max_concurrent_envs=max_concurrent_envs, + session_timeout=None, + ) + else: + # Default configuration + self._concurrency_config = ConcurrencyConfig( + max_concurrent_envs=1, + session_timeout=None, + ) + + self._max_concurrent_envs = self._concurrency_config.max_concurrent_envs + + # Validate concurrency configuration + self._validate_concurrency_safety() + + self.action_cls = action_cls + self.observation_cls = observation_cls + + # Session management for WebSocket connections + self._sessions: Dict[str, Environment] = {} + self._session_executors: Dict[str, ThreadPoolExecutor] = {} + self._session_info: Dict[str, SessionInfo] = {} + self._session_lock = asyncio.Lock() + + # Create thread pool for running sync code in async context + # This is needed for environments using sync libraries (e.g., Playwright) + self._executor = ThreadPoolExecutor(max_workers=32) + + def _validate_concurrency_safety(self) -> None: + """ + Validate that the environment supports the configured concurrency level. + + Raises: + ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an + environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS. + """ + if self._max_concurrent_envs <= 1: + return + + if inspect.isclass(self._env_factory): + env_cls = self._env_factory + else: + _temp_env = self._env_factory() + env_cls = type(_temp_env) + _temp_env.close() + del _temp_env + + if not getattr(env_cls, "SUPPORTS_CONCURRENT_SESSIONS", False): + raise ConcurrencyConfigurationError( + environment_name=env_cls.__name__, + max_concurrent_envs=self._max_concurrent_envs, + ) + + def get_capacity_status(self) -> ServerCapacityStatus: + """ + Get the current capacity status of the server. + + Returns: + ServerCapacityStatus with current session counts and availability. + """ + return ServerCapacityStatus.from_counts( + active=len(self._sessions), + max_sessions=self._max_concurrent_envs, + ) + + async def _run_sync_in_thread_pool( + self, func: Callable[..., Observation], *args, **kwargs + ) -> Observation: + """Run a synchronous function in the thread pool executor.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) + + def _get_valid_kwargs( + self, + sig: inspect.Signature, + kwargs: Dict[str, Any], + skip_params: Optional[set[str]] = None, + ) -> Dict[str, Any]: + """Filter kwargs to only include parameters accepted by the function signature.""" + if skip_params is None: + skip_params = set() + + valid_kwargs = {} + + has_kwargs = any( + p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() + ) + + for k, v in kwargs.items(): + if k in sig.parameters or has_kwargs: + if k not in skip_params: + valid_kwargs[k] = v + + return valid_kwargs + + async def _create_session(self) -> tuple[str, Environment]: + """ + Create a new WebSocket session with its own environment instance. + + Returns: + Tuple of (session_id, environment) + + Raises: + SessionCapacityError: If max concurrent sessions reached + EnvironmentFactoryError: If the factory fails to create an environment + """ + async with self._session_lock: + if len(self._sessions) >= self._max_concurrent_envs: + raise SessionCapacityError( + active_sessions=len(self._sessions), + max_sessions=self._max_concurrent_envs, + ) + + session_id = str(uuid.uuid4()) + current_time = time.time() + + try: + env = self._env_factory() + except Exception as e: + factory_name = getattr( + self._env_factory, "__name__", str(self._env_factory) + ) + raise EnvironmentFactoryError(factory_name) from e + + self._sessions[session_id] = env + + self._session_executors[session_id] = ThreadPoolExecutor(max_workers=1) + + # Track session metadata + self._session_info[session_id] = SessionInfo( + session_id=session_id, + created_at=current_time, + last_activity_at=current_time, + step_count=0, + environment_type=type(env).__name__, + ) + + return session_id, env + + async def _destroy_session(self, session_id: str) -> None: + """ + Destroy a WebSocket session and cleanup resources. + + Args: + session_id: The session ID to destroy + """ + async with self._session_lock: + if session_id in self._sessions: + env = self._sessions.pop(session_id) + env.close() + + if session_id in self._session_executors: + executor = self._session_executors.pop(session_id) + executor.shutdown(wait=False) + + self._session_info.pop(session_id, None) + + def _update_session_activity( + self, session_id: str, increment_step: bool = False + ) -> None: + """ + Update session activity timestamp and optionally increment step count. + + Args: + session_id: The session ID to update + increment_step: If True, increment the step count + """ + if session_id in self._session_info: + self._session_info[session_id].last_activity_at = time.time() + if increment_step: + self._session_info[session_id].step_count += 1 + + def get_session_info(self, session_id: str) -> Optional[SessionInfo]: + """ + Get information about a specific session. + + Args: + session_id: The session ID to query + + Returns: + SessionInfo if the session exists, None otherwise + """ + return self._session_info.get(session_id) + + async def _run_in_session_executor( + self, session_id: str, func: Callable[..., Observation], *args, **kwargs + ) -> Observation: + """Run a synchronous function in the session's thread pool executor.""" + executor = self._session_executors.get(session_id, self._executor) + loop = asyncio.get_event_loop() + return await loop.run_in_executor(executor, lambda: func(*args, **kwargs)) + + @property + def active_sessions(self) -> int: + """Return the number of active WebSocket sessions.""" + return len(self._sessions) + + @property + def max_concurrent_envs(self) -> int: + """Return the maximum number of concurrent environments.""" + return self._max_concurrent_envs + + @property + def is_concurrency_safe(self) -> bool: + """Return whether the environment is marked as concurrency safe.""" + import inspect + + if inspect.isclass(self._env_factory): + return getattr(self._env_factory, "SUPPORTS_CONCURRENT_SESSIONS", False) + else: + _temp_env = self._env_factory() + result = getattr(_temp_env, "SUPPORTS_CONCURRENT_SESSIONS", False) + _temp_env.close() + del _temp_env + return result + + @property + def concurrency_config(self) -> ConcurrencyConfig: + """Return the concurrency configuration.""" + return self._concurrency_config + + def register_routes(self, app: FastAPI) -> None: + """ + Register HTTP routes on a FastAPI application. + + Args: + app: FastAPI application instance + """ + + # Helper function to handle reset endpoint + async def reset_handler( + request: ResetRequest = Body(default_factory=ResetRequest), + ) -> ResetResponse: + """Reset endpoint - returns initial observation.""" + _env = self._env_factory() + + try: + kwargs = request.model_dump(exclude_unset=True) + + is_async = _env.reset_async.__func__ is not Environment.reset_async + + if is_async: + sig = inspect.signature(_env.reset_async) + else: + sig = inspect.signature(_env.reset) + valid_kwargs = self._get_valid_kwargs(sig, kwargs) + + if is_async: + observation = await _env.reset_async(**valid_kwargs) + else: + observation = await self._run_sync_in_thread_pool( + _env.reset, **valid_kwargs + ) + return ResetResponse(**serialize_observation(observation)) + finally: + _env.close() + + # Helper function to handle step endpoint + async def step_handler(request: StepRequest) -> StepResponse: + """Step endpoint - executes action and returns observation.""" + action_data = request.action + + try: + action = deserialize_action(action_data, self.action_cls) + except ValidationError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=e.errors() + ) + + _env = self._env_factory() + + try: + kwargs = request.model_dump(exclude_unset=True, exclude={"action"}) + + is_async = _env.step_async.__func__ is not Environment.step_async + + if is_async: + sig = inspect.signature(_env.step_async) + else: + sig = inspect.signature(_env.step) + valid_kwargs = self._get_valid_kwargs( + sig, kwargs, skip_params={"action"} + ) + + if is_async: + observation = await _env.step_async(action, **valid_kwargs) + else: + observation = await self._run_sync_in_thread_pool( + _env.step, action, **valid_kwargs + ) + + return StepResponse(**serialize_observation(observation)) + finally: + _env.close() + + # Register routes using the helpers + @app.post( + "/reset", + response_model=ResetResponse, + tags=["Environment Control"], + summary="Reset the environment", + description=""" +Reset the environment to its initial state and return the first observation. + +You can optionally provide a seed for reproducibility and an episode_id for tracking. + """, + responses={ + 200: { + "description": "Environment reset successfully", + "content": { + "application/json": { + "example": { + "observation": {"status": "ready", "data": {}}, + "reward": None, + "done": False, + } + } + }, + } + }, + ) + async def reset( + request: ResetRequest = Body(default_factory=ResetRequest), + ) -> ResetResponse: + return await reset_handler(request) + + @app.post( + "/step", + response_model=StepResponse, + tags=["Environment Control"], + summary="Execute an action in the environment", + description=""" +Execute an action in the environment and receive the resulting observation. + +The action must conform to the environment's action schema, which can be +retrieved from the `/schema` endpoint. If the action is invalid, +the endpoint will return HTTP 422 with detailed validation errors. + +The response includes: +- **observation**: The environment's response to the action +- **reward**: Optional reward signal (float or None) +- **done**: Boolean indicating if the episode has terminated + """, + responses={ + 200: { + "description": "Action executed successfully", + "content": { + "application/json": { + "example": { + "observation": {"status": "success", "data": {}}, + "reward": 1.0, + "done": False, + } + } + }, + }, + 422: { + "description": "Validation error - invalid action format or values", + "content": { + "application/json": { + "example": { + "detail": [ + { + "type": "string_too_short", + "loc": ["body", "action", "message"], + "msg": "String should have at least 1 character", + "input": "", + } + ] + } + } + }, + }, + 500: {"description": "Internal server error during action execution"}, + }, + ) + async def step(request: StepRequest) -> StepResponse: + return await step_handler(request) + + def get_state_handler() -> State: + _env = self._env_factory() + try: + return _env.state + finally: + _env.close() + + def get_metadata_handler() -> EnvironmentMetadata: + _env = self._env_factory() + try: + return _env.get_metadata() + finally: + _env.close() + + get_endpoints = [ + GetEndpointConfig( + path="/state", + handler=get_state_handler, + response_model=State, + tag="State Management", + summary="Get current environment state", + description=""" +Retrieve the current internal state of the environment. + +The structure of the state object is defined by the environment's State model. + """, + ), + GetEndpointConfig( + path="/metadata", + handler=get_metadata_handler, + response_model=EnvironmentMetadata, + tag="Environment Info", + summary="Get environment metadata", + description=""" +Get metadata about this environment. + +Returns information about the environment including name, description, +version, author, and documentation links. + """, + ), + GetEndpointConfig( + path="/health", + handler=lambda: HealthResponse(status="healthy"), + response_model=HealthResponse, + tag="Health", + summary="Health check", + description="Check if the environment server is running and healthy.", + ), + ] + register_get_endpoints(app, get_endpoints) + + # Register combined schema endpoint + @app.get( + "/schema", + response_model=SchemaResponse, + tags=["Schema"], + summary="Get all JSON schemas", + description=""" +Get JSON schemas for actions, observations, and state in a single response. + +Returns a combined schema object containing: +- **action**: JSON schema for actions accepted by this environment +- **observation**: JSON schema for observations returned by this environment +- **state**: JSON schema for environment state objects + +This is more efficient than calling individual schema endpoints and provides +all schema information needed to interact with the environment. + """, + responses={ + 200: { + "description": "Combined schemas retrieved successfully", + "content": { + "application/json": { + "example": { + "action": { + "type": "object", + "properties": {"message": {"type": "string"}}, + }, + "observation": { + "type": "object", + "properties": {"response": {"type": "string"}}, + }, + "state": { + "type": "object", + "properties": {"step_count": {"type": "integer"}}, + }, + } + } + }, + } + }, + ) + async def get_schemas() -> SchemaResponse: + """Return all schemas in one response.""" + return SchemaResponse( + action=self.action_cls.model_json_schema(), + observation=self.observation_cls.model_json_schema(), + state=State.model_json_schema(), + ) + + # Register WebSocket endpoint for persistent sessions + @app.websocket("/ws") + async def websocket_endpoint(websocket: WebSocket): + """ + WebSocket endpoint for persistent environment sessions. + + Each WebSocket connection gets its own environment instance. + + Message Protocol: + - Client sends: WSResetMessage | WSStepMessage | WSStateMessage | WSCloseMessage + - Server responds: WSObservationResponse | WSStateResponse | WSErrorResponse + """ + await websocket.accept() + + session_id = None + session_env = None + + try: + # Create session with dedicated environment + session_id, session_env = await self._create_session() + + while True: + # Receive message from client + raw_message = await websocket.receive_text() + + try: + message_dict = json.loads(raw_message) + except json.JSONDecodeError as e: + error_resp = WSErrorResponse( + data={ + "message": f"Invalid JSON: {e}", + "code": "INVALID_JSON", + } + ) + await websocket.send_text(error_resp.model_dump_json()) + continue + + msg_type = message_dict.get("type", "") + + try: + match msg_type: + case "reset": + msg = WSResetMessage(**message_dict) + + is_async = ( + session_env.reset_async.__func__ + is not Environment.reset_async + ) + + if is_async: + sig = inspect.signature(session_env.reset_async) + valid_kwargs = self._get_valid_kwargs(sig, msg.data) + observation = await session_env.reset_async( + **valid_kwargs + ) + else: + sig = inspect.signature(session_env.reset) + valid_kwargs = self._get_valid_kwargs(sig, msg.data) + observation = await self._run_in_session_executor( + session_id, session_env.reset, **valid_kwargs + ) + + self._update_session_activity(session_id) + + response = WSObservationResponse( + data=serialize_observation(observation) + ) + + case "step": + msg = WSStepMessage(**message_dict) + action = deserialize_action(msg.data, self.action_cls) + + is_async = ( + session_env.step_async.__func__ + is not Environment.step_async + ) + + if is_async: + observation = await session_env.step_async(action) + else: + observation = await self._run_in_session_executor( + session_id, session_env.step, action + ) + + self._update_session_activity( + session_id, increment_step=True + ) + + response = WSObservationResponse( + data=serialize_observation(observation) + ) + + case "state": + msg = WSStateMessage(**message_dict) + state = session_env.state + if hasattr(state, "model_dump"): + state_data = state.model_dump() + else: + state_data = dict(state) if state else {} + + response = WSStateResponse(data=state_data) + + case "close": + msg = WSCloseMessage(**message_dict) + break + + case _: + response = WSErrorResponse( + data={ + "message": f"Unknown message type: {msg_type}", + "code": "UNKNOWN_TYPE", + } + ) + + await websocket.send_text(response.model_dump_json()) + + except ValidationError as e: + error_resp = WSErrorResponse( + data={ + "message": "Invalid message", + "code": "VALIDATION_ERROR", + "errors": e.errors(), + } + ) + await websocket.send_text(error_resp.model_dump_json()) + except Exception as e: + error_resp = WSErrorResponse( + data={"message": str(e), "code": "EXECUTION_ERROR"} + ) + await websocket.send_text(error_resp.model_dump_json()) + + except WebSocketDisconnect: + pass + except SessionCapacityError as e: + error_resp = WSErrorResponse( + data={ + "message": str(e), + "code": "CAPACITY_REACHED", + "active_sessions": e.active_sessions, + "max_sessions": e.max_sessions, + } + ) + await websocket.send_text(error_resp.model_dump_json()) + except EnvironmentFactoryError as e: + error_resp = WSErrorResponse( + data={ + "message": str(e), + "code": "FACTORY_ERROR", + "factory_name": e.factory_name, + } + ) + await websocket.send_text(error_resp.model_dump_json()) + except Exception as e: + error_resp = WSErrorResponse( + data={"message": str(e), "code": "SESSION_ERROR"} + ) + await websocket.send_text(error_resp.model_dump_json()) + finally: + if session_id: + await self._destroy_session(session_id) + try: + await websocket.close() + except RuntimeError: + pass + + +def create_app( + env: Callable[[], Environment], + action_cls: Type[Action], + observation_cls: Type[Observation], + env_name: Optional[str] = None, + max_concurrent_envs: Optional[int] = None, + concurrency_config: Optional[ConcurrencyConfig] = None, +) -> FastAPI: + """ + Create a FastAPI application with or without web interface. + + This function creates a FastAPI app with the web interface enabled by default, + including README integration for better user experience. + + Args: + env: Environment factory (callable) that creates new instances + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + env_name: Optional environment name for README loading + max_concurrent_envs: Maximum concurrent WebSocket sessions. + Mutually exclusive with concurrency_config. + concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings. + Mutually exclusive with max_concurrent_envs. + + Returns: + FastAPI application instance with or without web interface and README integration + """ + # Check if web interface should be enabled + # This can be controlled via environment variable or build argument + enable_web = os.getenv("ENABLE_WEB_INTERFACE", "false").lower() in ( + "true", + "1", + "yes", + ) + + if enable_web: + # Import web interface only when needed + from .web_interface import create_web_interface_app + + return create_web_interface_app( + env, + action_cls, + observation_cls, + env_name, + max_concurrent_envs, + concurrency_config, + ) + else: + # Use standard FastAPI app without web interface + return create_fastapi_app( + env, action_cls, observation_cls, max_concurrent_envs, concurrency_config + ) + + +def create_fastapi_app( + env: Callable[[], Environment], + action_cls: Type[Action], + observation_cls: Type[Observation], + max_concurrent_envs: Optional[int] = None, + concurrency_config: Optional[ConcurrencyConfig] = None, +) -> FastAPI: + """ + Create a FastAPI application with comprehensive documentation. + + Args: + env: Environment factory (callable) that creates new instances + action_cls: The Action subclass this environment expects + observation_cls: The Observation subclass this environment returns + max_concurrent_envs: Maximum concurrent WebSocket sessions. + Mutually exclusive with concurrency_config. + concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings. + Mutually exclusive with max_concurrent_envs. + + Returns: + FastAPI application instance + """ + try: + from fastapi import FastAPI + except ImportError: + raise ImportError( + "FastAPI is required. Install with: pip install fastapi uvicorn" + ) + + app = FastAPI( + title="OpenEnv Environment HTTP API", + version="1.0.0", + description=""" +# OpenEnv Environment HTTP API + +HTTP API for interacting with OpenEnv environments through a standardized interface. + +## Features + +* **Environment Reset**: Initialize or restart episodes +* **Action Execution**: Send actions and receive observations +* **State Inspection**: Query current environment state +* **Schema Access**: Retrieve JSON schemas for actions and observations + +## Workflow + +1. Call `/reset` to start a new episode and get initial observation +2. Call `/step` repeatedly with actions to interact with environment +3. Episode ends when observation returns `done: true` +4. Call `/state` anytime to inspect current environment state + +## Documentation + +* **Swagger UI**: Available at `/docs` +* **ReDoc**: Available at `/redoc` +* **OpenAPI Schema**: Available at `/openapi.json` + """, + openapi_tags=[ + { + "name": "Environment Control", + "description": "Core operations for environment interaction (reset, step)", + }, + { + "name": "State Management", + "description": "Operations for inspecting environment state", + }, + { + "name": "Environment Info", + "description": "Information about the environment", + }, + { + "name": "Schema", + "description": "JSON Schema endpoints for actions, observations, and state", + }, + {"name": "Health", "description": "Service health and status checks"}, + ], + docs_url="/docs", + redoc_url="/redoc", + openapi_url="/openapi.json", + contact={ + "name": "OpenEnv Team", + "url": "https://github.com/meta-pytorch/OpenEnv", + }, + license_info={ + "name": "BSD-3-Clause", + "url": "https://github.com/meta-pytorch/OpenEnv/blob/main/LICENSE", + }, + ) + + server = HTTPEnvServer( + env, + action_cls, + observation_cls, + max_concurrent_envs, + concurrency_config=concurrency_config, + ) + server.register_routes(app) + return app diff --git a/src/openenv/core/env_server/interfaces.py b/src/openenv/core/env_server/interfaces.py new file mode 100644 index 000000000..ecf6da57c --- /dev/null +++ b/src/openenv/core/env_server/interfaces.py @@ -0,0 +1,194 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from abc import ABC, abstractmethod +from typing import Any, Generic, Optional, Protocol, TypedDict, TypeVar + +from .types import Action, Observation, State, EnvironmentMetadata + +ActT = TypeVar("ActT", bound=Action) +ObsT = TypeVar("ObsT", bound=Observation) +StateT = TypeVar("StateT", bound=State) + + +class Message(TypedDict): + """A message in a conversation. + + Compatible with Huggingface chat template format. + """ + + role: str + content: str + + +class ModelTokenizer(Protocol): + """Protocol for tokenizers that support chat templates. + + This protocol defines the interface that tokenizers must implement + to work with chat-based environments. It's compatible with + Huggingface transformers tokenizers. + """ + + def apply_chat_template( + self, + conversation: list[Message], + tokenize: bool = True, + return_tensors: str | None = None, + **kwargs: Any, + ) -> Any: + """Apply a chat template to format and optionally tokenize a conversation. + + Args: + conversation: List of message dictionaries with 'role' and 'content' + tokenize: Whether to tokenize the output + return_tensors: Format for returned tensors ('pt' for PyTorch) + **kwargs: Additional arguments + + Returns: + Formatted and optionally tokenized conversation + """ + ... + + def decode( + self, token_ids: Any, skip_special_tokens: bool = False, **kwargs: Any + ) -> str: + """Decode token IDs back to text. + + Args: + token_ids: Token IDs to decode + skip_special_tokens: Whether to skip special tokens in output + **kwargs: Additional arguments + + Returns: + Decoded text string + """ + ... + + +class Transform(ABC, Generic[ObsT]): + """Transform observations to add rewards, metrics, or other modifications. + + Transforms follow the TorchRL pattern where they take an observation + and return a (potentially modified) observation. This allows for + flexible reward computation and observation augmentation. + """ + + @abstractmethod + def __call__(self, observation: ObsT) -> ObsT: + """Transform an observation. + + Args: + observation: The input observation + + Returns: + The transformed observation + """ + pass + + +class Environment(ABC, Generic[ActT, ObsT, StateT]): + """Base class for all environment servers following Gym/Gymnasium API. + + Args: + transform: Optional transform to apply to observations + + Class Attributes: + SUPPORTS_CONCURRENT_SESSIONS: Whether this environment supports concurrent sessions. + When True, multiple WebSocket connections can each have their own + environment instance (up to max_concurrent_envs). When False (default), + the environment should only be used with a single session at a time. + + Set this to True in your Environment subclass if: + - The environment uses proper session isolation (e.g., unique working dirs) + - No shared mutable state exists between instances + - External resources (databases, APIs) can handle concurrent access + """ + + # Class-level flag indicating whether this environment supports concurrent sessions + SUPPORTS_CONCURRENT_SESSIONS: bool = False + + def __init__(self, transform: Optional[Transform[ObsT]] = None): + self.transform = transform + + @abstractmethod + def reset( + self, + seed: Optional[int] = None, + episode_id: Optional[str] = None, + **kwargs: Any, + ) -> ObsT: + """Reset the environment and return initial observation.""" + pass + + async def reset_async( + self, + seed: Optional[int] = None, + episode_id: Optional[str] = None, + **kwargs: Any, + ) -> ObsT: + """Async version of reset. Default implementation calls sync reset. + + Override to provide true async implementation. + """ + return self.reset(seed=seed, episode_id=episode_id, **kwargs) + + @abstractmethod + def step( + self, + action: ActT, + timeout_s: Optional[float] = None, + **kwargs: Any, + ) -> ObsT: + """Take a step in the environment.""" + pass + + async def step_async( + self, + action: ActT, + timeout_s: Optional[float] = None, + **kwargs: Any, + ) -> ObsT: + """Async version of step. Default implementation calls sync step. + + Override to provide true async implementation. + """ + return self.step(action, timeout_s=timeout_s, **kwargs) + + @property + @abstractmethod + def state(self) -> StateT: + """Get the current environment state.""" + pass + + def get_metadata(self) -> EnvironmentMetadata: + """ + Get metadata about this environment. + + Override this method to provide custom metadata for the environment. + Default implementation returns basic metadata derived from class name. + + Returns: + EnvironmentMetadata with environment information + """ + return EnvironmentMetadata( + name=self.__class__.__name__, + description=f"{self.__class__.__name__} environment", + version="1.0.0", + ) + + def _apply_transform(self, observation: ObsT) -> ObsT: + """Apply transform if one is provided.""" + if self.transform is not None: + return self.transform(observation) + return observation + + def close(self) -> None: + """Clean up resources used by the environment. + + Override this method to implement custom cleanup logic. + Called when the environment is being destroyed or reset. + """ + pass diff --git a/src/openenv/core/env_server/route_config.py b/src/openenv/core/env_server/route_config.py new file mode 100644 index 000000000..08807c685 --- /dev/null +++ b/src/openenv/core/env_server/route_config.py @@ -0,0 +1,57 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Route configuration utilities for declarative FastAPI route registration. + +This module provides utilities to reduce boilerplate in route registration +by using configuration objects instead of repeated function calls. +""" + +from dataclasses import dataclass +from typing import Callable, List, Type + +from fastapi import FastAPI +from pydantic import BaseModel + + +@dataclass +class GetEndpointConfig: + """Configuration for a simple GET endpoint.""" + + path: str + handler: Callable[[], BaseModel | dict] + response_model: Type[BaseModel] | type[dict] + tag: str + summary: str + description: str + + +def register_get_endpoints(app: FastAPI, configs: List[GetEndpointConfig]) -> None: + """ + Register multiple GET endpoints from configuration. + + Args: + app: FastAPI application instance + configs: List of GET endpoint configurations + """ + for config in configs: + # Capture handler in a closure to avoid non-serializable default parameter + def make_endpoint( + handler: Callable[[], BaseModel | dict], + ) -> Callable[[], BaseModel | dict]: + async def endpoint() -> BaseModel | dict: + return handler() + + return endpoint + + app.get( + config.path, + response_model=config.response_model, + tags=[config.tag], + summary=config.summary, + description=config.description, + )(make_endpoint(config.handler)) diff --git a/src/openenv/core/env_server/serialization.py b/src/openenv/core/env_server/serialization.py new file mode 100644 index 000000000..2595da79b --- /dev/null +++ b/src/openenv/core/env_server/serialization.py @@ -0,0 +1,137 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Shared serialization and deserialization utilities for OpenEnv HTTP servers. + +This module provides common utilities for converting between JSON dictionaries +and Pydantic models (Action/Observation) to eliminate code duplication across +HTTP server and web interface implementations. +""" + +from typing import Any, Dict, Type + +from .types import Action, Observation + + +def deserialize_action(action_data: Dict[str, Any], action_cls: Type[Action]) -> Action: + """ + Convert JSON dict to Action instance using Pydantic validation. + + This is a basic deserialization that works for most environments. + For special cases (e.g., tensor fields, custom type conversions), + use deserialize_action_with_preprocessing(). + + Args: + action_data: Dictionary containing action data + action_cls: The Action subclass to instantiate + + Returns: + Action instance + + Raises: + ValidationError: If action_data is invalid for the action class + + Note: + This uses Pydantic's model_validate() for automatic validation. + """ + return action_cls.model_validate(action_data) + + +def deserialize_action_with_preprocessing( + action_data: Dict[str, Any], action_cls: Type[Action] +) -> Action: + """ + Convert JSON dict to Action instance with preprocessing for special types. + + This version handles common type conversions needed for web interfaces: + - Converting lists/strings to tensors for 'tokens' field + - Converting string action_id to int + - Other custom preprocessing as needed + + Args: + action_data: Dictionary containing action data + action_cls: The Action subclass to instantiate + + Returns: + Action instance + + Raises: + ValidationError: If action_data is invalid for the action class + """ + processed_data = {} + + for key, value in action_data.items(): + if key == "tokens" and isinstance(value, (list, str)): + # Convert list or string to tensor + if isinstance(value, str): + # If it's a string, try to parse it as a list of numbers + try: + import json + + value = json.loads(value) + except Exception: + # If parsing fails, treat as empty list + value = [] + if isinstance(value, list): + try: + import torch # type: ignore + + processed_data[key] = torch.tensor(value, dtype=torch.long) + except ImportError: + # If torch not available, keep as list + processed_data[key] = value + else: + processed_data[key] = value + elif key == "action_id" and isinstance(value, str): + # Convert action_id from string to int + try: + processed_data[key] = int(value) + except ValueError: + # If conversion fails, keep original value + processed_data[key] = value + else: + processed_data[key] = value + + return action_cls.model_validate(processed_data) + + +def serialize_observation(observation: Observation) -> Dict[str, Any]: + """ + Convert Observation instance to JSON-compatible dict using Pydantic. + + Args: + observation: Observation instance + + Returns: + Dictionary compatible with EnvClient._parse_result() + + The format matches what EnvClient expects: + { + "observation": {...}, # Observation fields + "reward": float | None, + "done": bool, + } + """ + # Use Pydantic's model_dump() for serialization + obs_dict = observation.model_dump( + exclude={ + "reward", + "done", + "metadata", + } # Exclude these from observation dict + ) + + # Extract reward and done directly from the observation + reward = observation.reward + done = observation.done + + # Return in EnvClient expected format + return { + "observation": obs_dict, + "reward": reward, + "done": done, + } diff --git a/src/openenv/core/env_server/types.py b/src/openenv/core/env_server/types.py new file mode 100644 index 000000000..a22914b73 --- /dev/null +++ b/src/openenv/core/env_server/types.py @@ -0,0 +1,341 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from typing import Any, Dict, Optional, Union, Literal, Annotated +from pydantic import BaseModel, Field, ConfigDict, model_validator + + +# Type aliases +Scalar = Union[int, float, bool] + + +class Action(BaseModel): + """Base class for all environment actions. + + All action subclasses should inherit from this base class. + Uses Pydantic for automatic validation and serialization. + """ + + model_config = ConfigDict( + extra="forbid", # Reject unknown fields + validate_assignment=True, # Validate on field assignment + arbitrary_types_allowed=True, # Allow numpy arrays, torch tensors, etc. + ) + + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata for the action" + ) + + +class Observation(BaseModel): + """Base class for all environment observations. + + All observation subclasses should inherit from this base class. + Uses Pydantic for automatic validation and serialization. + """ + + model_config = ConfigDict( + extra="forbid", + validate_assignment=True, + arbitrary_types_allowed=True, + ) + + done: bool = Field(default=False, description="Whether the episode has terminated") + reward: bool | int | float | None = Field( + default=None, description="Reward signal from the last action" + ) + metadata: Dict[str, Any] = Field( + default_factory=dict, description="Additional metadata for the observation" + ) + + +class ResetRequest(BaseModel): + """Request model for environment reset.""" + + model_config = ConfigDict( + extra="allow", # Allow extra fields for custom reset parameters + json_schema_extra={"examples": [{"seed": 42, "episode_id": "episode-001"}, {}]}, + ) + + seed: Optional[int] = Field( + default=None, ge=0, description="Random seed for reproducible episodes" + ) + episode_id: Optional[str] = Field( + default=None, max_length=255, description="Custom episode identifier" + ) + + +class ResetResponse(BaseModel): + """Response model for environment reset.""" + + model_config = ConfigDict(extra="forbid") + + observation: Dict[str, Any] = Field( + ..., description="Initial observation from the environment" + ) + reward: Optional[float] = Field( + default=None, description="Initial reward (typically None at reset)" + ) + done: bool = Field( + default=False, description="Whether episode is already done (typically False)" + ) + + +class StepRequest(BaseModel): + """Request model for environment step.""" + + model_config = ConfigDict( + extra="allow", # Allow extra fields for custom step parameters + json_schema_extra={ + "examples": [ + {"action": {"value": 1}, "timeout_s": 30.0}, + {"action": {"value": 1}, "render": True, "verbose": False}, + ] + }, + ) + + action: Dict[str, Any] = Field( + ..., + description="Action to execute, must conform to environment's action schema", + ) + timeout_s: Optional[float] = Field( + default=None, + gt=0, + description="Optional timeout in seconds for action execution", + ) + request_id: Optional[str] = Field( + default=None, + max_length=255, + description="Optional request identifier for tracking", + ) + + +class StepResponse(BaseModel): + """Response model for environment step.""" + + model_config = ConfigDict(extra="forbid") + + observation: Dict[str, Any] = Field( + ..., description="Observation resulting from the action" + ) + reward: Optional[float] = Field( + default=None, description="Reward signal from the action" + ) + done: bool = Field(default=False, description="Whether the episode has terminated") + + +class BaseMessage(BaseModel): + """Base class for WebSocket messages with shared configuration.""" + + model_config = ConfigDict( + extra="forbid", + validate_assignment=True, + ) + + +class State(BaseModel): + """Base class for environment state. + + Represents internal environment state, separate from observations. + """ + + model_config = ConfigDict( + extra="allow", # Allow extra fields for flexibility + validate_assignment=True, + arbitrary_types_allowed=True, + ) + + episode_id: Optional[str] = Field( + default=None, description="Unique identifier for the current episode" + ) + step_count: int = Field( + default=0, + ge=0, # Greater than or equal to 0 + description="Number of steps taken in the current episode", + ) + + +class CodeExecResult(BaseMessage): + """Result of code execution containing stdout, stderr, and exit code.""" + + stdout: str = Field(description="Standard output from code execution") + stderr: str = Field(description="Standard error from code execution") + exit_code: int = Field(description="Exit code from code execution") + + +class EnvironmentMetadata(BaseMessage): + """Metadata about an environment for documentation and UI purposes.""" + + name: str = Field(description="Name of the environment") + description: str = Field(description="Description of what the environment does") + readme_content: Optional[str] = Field( + default=None, description="Content of the README file for the environment" + ) + version: Optional[str] = Field( + default=None, description="Version of the environment" + ) + author: Optional[str] = Field(default=None, description="Author of the environment") + documentation_url: Optional[str] = Field( + default=None, description="URL to the environment's documentation" + ) + + +class SchemaResponse(BaseMessage): + """Response model for the combined schema endpoint.""" + + action: Dict[str, Any] = Field( + description="JSON schema for actions accepted by this environment" + ) + observation: Dict[str, Any] = Field( + description="JSON schema for observations returned by this environment" + ) + state: Dict[str, Any] = Field( + description="JSON schema for environment state objects" + ) + + +class HealthResponse(BaseMessage): + """Response model for health check endpoint.""" + + status: str = Field(description="Health status of the environment server") + + +class WSResetMessage(BaseMessage): + """WebSocket message to reset the environment.""" + + type: Literal["reset"] = Field(default="reset", description="Message type") + data: Dict[str, Any] = Field( + default_factory=dict, + description="Optional reset parameters (seed, episode_id, etc.)", + ) + + +class WSStepMessage(BaseMessage): + """WebSocket message to execute a step.""" + + type: Literal["step"] = Field(default="step", description="Message type") + data: Dict[str, Any] = Field( + ..., description="Action data conforming to environment's action schema" + ) + + +class WSStateMessage(BaseMessage): + """WebSocket message to request current state.""" + + type: Literal["state"] = Field(default="state", description="Message type") + + +class WSCloseMessage(BaseMessage): + """WebSocket message to close the session.""" + + type: Literal["close"] = Field(default="close", description="Message type") + + +# Discriminated union for incoming WebSocket messages +WSIncomingMessage = Annotated[ + WSResetMessage | WSStepMessage | WSStateMessage | WSCloseMessage, + Field(discriminator="type"), +] + + +class WSObservationResponse(BaseModel): + """WebSocket response containing an observation.""" + + model_config = ConfigDict(extra="forbid") + + type: str = Field(default="observation", description="Response type") + data: Dict[str, Any] = Field(description="Observation data") + + +class WSStateResponse(BaseModel): + """WebSocket response containing environment state.""" + + model_config = ConfigDict(extra="forbid") + + type: str = Field(default="state", description="Response type") + data: Dict[str, Any] = Field(description="State data") + + +class WSErrorResponse(BaseModel): + """WebSocket response for errors.""" + + model_config = ConfigDict(extra="forbid") + + type: str = Field(default="error", description="Response type") + data: Dict[str, Any] = Field(description="Error details including message and code") + + +class ConcurrencyConfig(BaseMessage): + """Configuration for concurrent environment sessions.""" + + max_concurrent_envs: int = Field( + default=1, + ge=1, + description="Maximum number of concurrent WebSocket sessions allowed", + ) + session_timeout: Optional[float] = Field( + default=None, + gt=0, + description="Timeout in seconds for inactive sessions. None means no timeout.", + ) + + +class ServerCapacityStatus(BaseMessage): + """Status of server capacity for concurrent sessions.""" + + active_sessions: int = Field( + ge=0, + description="Number of currently active sessions", + ) + max_sessions: int = Field( + ge=1, + description="Maximum number of allowed sessions", + ) + + @model_validator(mode="after") + def check_capacity_bounds(self) -> "ServerCapacityStatus": + if self.active_sessions > self.max_sessions: + raise ValueError( + f"active_sessions ({self.active_sessions}) cannot exceed " + f"max_sessions ({self.max_sessions})" + ) + return self + + @property + def available_slots(self) -> int: + """Number of available session slots.""" + return self.max_sessions - self.active_sessions + + @property + def is_at_capacity(self) -> bool: + """Whether the server has reached maximum capacity.""" + return self.available_slots == 0 + + @classmethod + def from_counts(cls, active: int, max_sessions: int) -> "ServerCapacityStatus": + """Create status from active and max session counts.""" + return cls( + active_sessions=active, + max_sessions=max_sessions, + ) + + +class SessionInfo(BaseMessage): + """Information about an active session.""" + + session_id: str = Field(description="Unique identifier for the session") + created_at: float = Field(description="Unix timestamp when the session was created") + last_activity_at: float = Field( + description="Unix timestamp of the last activity in the session" + ) + step_count: int = Field( + default=0, + ge=0, + description="Number of steps executed in this session", + ) + environment_type: str = Field( + description="Environment type for this session (e.g. `CodingEnv`)" + ) diff --git a/src/core/env_server/web_interface.py b/src/openenv/core/env_server/web_interface.py similarity index 78% rename from src/core/env_server/web_interface.py rename to src/openenv/core/env_server/web_interface.py index 3c36aa1de..2fa2051c0 100644 --- a/src/core/env_server/web_interface.py +++ b/src/openenv/core/env_server/web_interface.py @@ -13,55 +13,60 @@ from __future__ import annotations +import asyncio import json -import time -from dataclasses import asdict, dataclass +from concurrent.futures import ThreadPoolExecutor from typing import Any, Dict, List, Optional, Type from datetime import datetime -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request -from fastapi.responses import HTMLResponse, FileResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from pydantic import BaseModel, Field, ConfigDict from .interfaces import Environment +from .serialization import ( + deserialize_action_with_preprocessing, + serialize_observation, +) from .types import Action, Observation, State, EnvironmentMetadata -def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata: +def load_environment_metadata( + env: Environment, env_name: Optional[str] = None +) -> EnvironmentMetadata: """ Load environment metadata including README content. - + Args: env: The environment instance env_name: Optional environment name for README file lookup - + Returns: EnvironmentMetadata with loaded information """ # Try to get metadata from environment if it has a method for it - if hasattr(env, 'get_metadata'): + if hasattr(env, "get_metadata"): return env.get_metadata() - + # Default metadata metadata = EnvironmentMetadata( name=env_name or env.__class__.__name__, description=f"{env.__class__.__name__} environment", - version="1.0.0" + version="1.0.0", ) - + # Try to load README from file system readme_content = _load_readme_from_filesystem(env_name) if readme_content: metadata.readme_content = readme_content - + return metadata def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: """ Load README content from the filesystem. - + Tries multiple locations: 1. Container filesystem: /app/README.md 2. Local development: src/envs/{env_name}/README.md @@ -69,59 +74,71 @@ def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]: """ import os from pathlib import Path - + # Try container filesystem first container_readme = Path("/app/README.md") if container_readme.exists(): try: - return container_readme.read_text(encoding='utf-8') + return container_readme.read_text(encoding="utf-8") except Exception: pass - + # Try environment variable path custom_path = os.environ.get("ENV_README_PATH") if custom_path and Path(custom_path).exists(): try: - return Path(custom_path).read_text(encoding='utf-8') + return Path(custom_path).read_text(encoding="utf-8") except Exception: pass - + # Try local development path if env_name: local_readme = Path(f"src/envs/{env_name}/README.md") if local_readme.exists(): try: - return local_readme.read_text(encoding='utf-8') + return local_readme.read_text(encoding="utf-8") except Exception: pass - + return None -@dataclass -class ActionLog: +class ActionLog(BaseModel): """Log entry for an action taken.""" - timestamp: str - action: Dict[str, Any] - observation: Dict[str, Any] - reward: Optional[float] - done: bool - step_count: int + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + timestamp: str = Field(description="Timestamp when action was taken") + action: Dict[str, Any] = Field(description="Action that was taken") + observation: Dict[str, Any] = Field(description="Observation returned from action") + reward: Optional[float] = Field( + default=None, description="Reward received from action" + ) + done: bool = Field(description="Whether the episode is done after this action") + step_count: int = Field(description="Step count when this action was taken") -@dataclass -class EpisodeState: + +class EpisodeState(BaseModel): """Current episode state for the web interface.""" - episode_id: Optional[str] - step_count: int - current_observation: Optional[Dict[str, Any]] - action_logs: List[ActionLog] - is_reset: bool = True + + model_config = ConfigDict(extra="forbid", validate_assignment=True) + + episode_id: Optional[str] = Field(default=None, description="Current episode ID") + step_count: int = Field(description="Current step count in episode") + current_observation: Optional[Dict[str, Any]] = Field( + default=None, description="Current observation" + ) + action_logs: List[ActionLog] = Field( + default_factory=list, description="List of action logs" + ) + is_reset: bool = Field( + default=True, description="Whether the episode has been reset" + ) class WebInterfaceManager: """Manages the web interface for an environment.""" - + def __init__( self, env: Environment, @@ -134,147 +151,127 @@ def __init__( self.observation_cls = observation_cls self.metadata = metadata or EnvironmentMetadata( name=env.__class__.__name__, - description=f"{env.__class__.__name__} environment" + description=f"{env.__class__.__name__} environment", ) self.episode_state = EpisodeState( episode_id=None, step_count=0, current_observation=None, - action_logs=[] + action_logs=[], ) self.connected_clients: List[WebSocket] = [] - + # Thread pool for running sync code (e.g., Playwright sync API) in async context + self._executor = ThreadPoolExecutor(max_workers=1) + + async def _run_sync_in_thread_pool(self, func, *args, **kwargs): + """Run a synchronous function in the thread pool executor. + + This is needed for environments using sync libraries (e.g., Playwright sync API) + that cannot be called directly from an async context. + """ + loop = asyncio.get_event_loop() + return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs)) + async def connect_websocket(self, websocket: WebSocket): """Connect a new WebSocket client.""" await websocket.accept() self.connected_clients.append(websocket) - + # Send current state to the new client await self._send_state_update() - + async def disconnect_websocket(self, websocket: WebSocket): """Disconnect a WebSocket client.""" if websocket in self.connected_clients: self.connected_clients.remove(websocket) - + async def _send_state_update(self): """Send current state to all connected clients.""" if not self.connected_clients: return - + state_data = { "type": "state_update", - "episode_state": asdict(self.episode_state) + "episode_state": self.episode_state.model_dump(), } - + # Send to all connected clients disconnected_clients = [] for client in self.connected_clients: try: await client.send_text(json.dumps(state_data)) - except: + except Exception: disconnected_clients.append(client) - + # Remove disconnected clients for client in disconnected_clients: self.connected_clients.remove(client) - + async def reset_environment(self) -> Dict[str, Any]: """Reset the environment and update state.""" - observation = self.env.reset() - state = self.env.state - + # Run sync reset in thread pool to avoid blocking event loop + # and to support environments using sync libraries (e.g., Playwright) + observation: Observation = await self._run_sync_in_thread_pool(self.env.reset) + state: State = self.env.state + + # Serialize observation once using shared utility + serialized = serialize_observation(observation) + # Update episode state self.episode_state.episode_id = state.episode_id self.episode_state.step_count = 0 - self.episode_state.current_observation = asdict(observation) + self.episode_state.current_observation = serialized["observation"] self.episode_state.action_logs = [] self.episode_state.is_reset = True - + # Send state update await self._send_state_update() - - return { - "observation": asdict(observation), - "reward": observation.reward, - "done": observation.done, - } - + + return serialized + async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]: """Execute a step in the environment and update state.""" - # Deserialize action - action = self._deserialize_action(action_data) - - # Execute step - observation = self.env.step(action) - state = self.env.state - + # Deserialize action with preprocessing for web interface special cases + action: Action = deserialize_action_with_preprocessing( + action_data, self.action_cls + ) + + # Run sync step in thread pool to avoid blocking event loop + # and to support environments using sync libraries (e.g., Playwright) + observation: Observation = await self._run_sync_in_thread_pool( + self.env.step, action + ) + state: State = self.env.state + + # Serialize observation once using shared utility + serialized = serialize_observation(observation) + # Create action log action_log = ActionLog( timestamp=datetime.now().isoformat(), - action=asdict(action), - observation=asdict(observation), + action=action.model_dump(exclude={"metadata"}), + observation=serialized["observation"], reward=observation.reward, done=observation.done, - step_count=state.step_count + step_count=state.step_count, ) - + # Update episode state self.episode_state.episode_id = state.episode_id self.episode_state.step_count = state.step_count - self.episode_state.current_observation = asdict(observation) + self.episode_state.current_observation = serialized["observation"] self.episode_state.action_logs.append(action_log) self.episode_state.is_reset = False - + # Send state update await self._send_state_update() - - return { - "observation": asdict(observation), - "reward": observation.reward, - "done": observation.done, - } - + + return serialized + def get_state(self) -> Dict[str, Any]: """Get current environment state.""" - state = self.env.state - return asdict(state) - - def _deserialize_action(self, action_data: Dict[str, Any]) -> Action: - """Convert JSON dict to Action instance.""" - metadata = action_data.pop("metadata", {}) - - # Handle tensor fields that come from JSON as lists - processed_data = {} - for key, value in action_data.items(): - if key == "tokens" and isinstance(value, (list, str)): - # Convert list or string to tensor - if isinstance(value, str): - # If it's a string, try to parse it as a list of numbers - try: - import json - value = json.loads(value) - except: - # If parsing fails, treat as empty list - value = [] - if isinstance(value, list): - import torch - processed_data[key] = torch.tensor(value, dtype=torch.long) - else: - processed_data[key] = value - elif key == "action_id" and isinstance(value, str): - # Convert action_id from string to int - try: - processed_data[key] = int(value) - except ValueError: - # If conversion fails, keep original value - processed_data[key] = value - else: - processed_data[key] = value - - action = self.action_cls(**processed_data) - action.metadata = metadata - return action + state: State = self.env.state + return state.model_dump() def create_web_interface_app( @@ -285,41 +282,45 @@ def create_web_interface_app( ) -> FastAPI: """ Create a FastAPI application with web interface for the given environment. - + Args: env: The Environment instance to serve action_cls: The Action subclass this environment expects observation_cls: The Observation subclass this environment returns env_name: Optional environment name for README loading - + Returns: FastAPI application instance with web interface """ from .http_server import create_fastapi_app - + # Create the base environment app app = create_fastapi_app(env, action_cls, observation_cls) - + # Load environment metadata metadata = load_environment_metadata(env, env_name) - + # Create web interface manager web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata) - + # Add web interface routes @app.get("/web", response_class=HTMLResponse) async def web_interface(): """Serve the web interface.""" return get_web_interface_html(action_cls, web_manager.metadata) - + @app.get("/web/metadata") async def web_metadata(): """Get environment metadata.""" - return asdict(web_manager.metadata) - - @app.websocket("/ws") - async def websocket_endpoint(websocket: WebSocket): - """WebSocket endpoint for real-time updates.""" + return web_manager.metadata.model_dump() + + @app.websocket("/ws/ui") + async def websocket_ui_endpoint(websocket: WebSocket): + """WebSocket endpoint for web UI real-time updates. + + Note: Uses /ws/ui to avoid conflict with /ws in http_server.py + which is used for concurrent environment sessions. + """ await web_manager.connect_websocket(websocket) try: while True: @@ -327,12 +328,12 @@ async def websocket_endpoint(websocket: WebSocket): await websocket.receive_text() except WebSocketDisconnect: await web_manager.disconnect_websocket(websocket) - + @app.post("/web/reset") async def web_reset(): """Reset endpoint for web interface.""" return await web_manager.reset_environment() - + @app.post("/web/step") async def web_step(request: Dict[str, Any]): """Step endpoint for web interface.""" @@ -344,31 +345,37 @@ async def web_step(request: Dict[str, Any]): action_data = {"tokens": action.tokens.tolist()} else: action_data = request.get("action", {}) - + return await web_manager.step_environment(action_data) - + @app.get("/web/state") async def web_state(): """State endpoint for web interface.""" return web_manager.get_state() - + return app -def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str: +def get_web_interface_html( + action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None +) -> str: """Generate the HTML for the web interface.""" - + # Check if this is a chat environment by looking for tokens field is_chat_env = False - if hasattr(action_cls, '__dataclass_fields__'): - for field_name, field_info in action_cls.__dataclass_fields__.items(): - if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__: + if hasattr(action_cls, "model_fields"): + for field_name, field_info in action_cls.model_fields.items(): + if ( + field_name == "tokens" + and hasattr(field_info.annotation, "__name__") + and "Tensor" in field_info.annotation.__name__ + ): is_chat_env = True break - + # Get action fields for dynamic form generation with enhanced metadata action_fields = _extract_action_fields(action_cls) - + return f""" @@ -971,7 +978,7 @@ class OpenEnvWebInterface {{ connectWebSocket() {{ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${{protocol}}//${{window.location.host}}/ws`; + const wsUrl = `${{protocol}}//${{window.location.host}}/ws/ui`; this.ws = new WebSocket(wsUrl); @@ -1259,19 +1266,22 @@ class OpenEnvWebInterface {{ - """.replace('{_generate_action_form_fields(action_fields)}', _generate_action_form_fields(action_fields)) + """.replace( + "{_generate_action_form_fields(action_fields)}", + _generate_action_form_fields(action_fields), + ) -def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> str: +def _generate_instructions_section( + metadata: Optional[EnvironmentMetadata], +) -> str: """Generate the instructions section with environment documentation.""" if not metadata or not metadata.readme_content: - return '' - - # Convert markdown to HTML (basic conversion) - import re + return "" + html_content = _markdown_to_html(metadata.readme_content) - - return f''' + + return f"""
@@ -1284,194 +1294,178 @@ def _generate_instructions_section(metadata: Optional[EnvironmentMetadata]) -> s
- ''' + """ def _extract_action_fields(action_cls: Type[Action]) -> List[Dict[str, Any]]: """Extract enhanced field metadata from Action class for form generation.""" - import typing - from typing import get_origin, get_args - + # Use Pydantic's JSON schema generation for robust metadata extraction + try: + schema = action_cls.model_json_schema() + except AttributeError: + # Fallback for non-Pydantic v2 models or if something goes wrong + return [] + + properties = schema.get("properties", {}) + required_fields = schema.get("required", []) + action_fields = [] - if not hasattr(action_cls, '__dataclass_fields__'): - return action_fields - - for field_name, field_info in action_cls.__dataclass_fields__.items(): - if field_name == 'metadata': + + for field_name, field_info in properties.items(): + if field_name == "metadata": continue - - field_type = field_info.type - field_metadata = _extract_field_metadata(field_name, field_info) - - # Determine input type based on field type - input_type = _determine_input_type(field_type) - - # Check if field is required - is_required = field_info.default is field_info.default_factory - - action_fields.append({ - 'name': field_name, - 'type': input_type, - 'required': is_required, - 'description': field_metadata.get('description', ''), - 'default_value': field_metadata.get('default_value'), - 'choices': field_metadata.get('choices', []), - 'min_value': field_metadata.get('min_value'), - 'max_value': field_metadata.get('max_value'), - 'placeholder': field_metadata.get('placeholder', ''), - 'help_text': field_metadata.get('help_text', ''), - }) - + + # JSON schema "type" can be a string or list/undefined + # Determine our internal input type + input_type = _determine_input_type_from_schema(field_info, field_name) + + is_required = field_name in required_fields + + action_fields.append( + { + "name": field_name, + "type": input_type, + "required": is_required, + "description": field_info.get("description", ""), + "default_value": field_info.get("default"), + "choices": field_info.get("enum"), + "min_value": field_info.get("minimum"), + "max_value": field_info.get("maximum"), + "min_length": field_info.get("minLength"), + "max_length": field_info.get("maxLength"), + "pattern": field_info.get("pattern"), + "placeholder": _generate_placeholder(field_name, field_info), + "help_text": _generate_help_text(field_name, field_info), + } + ) + return action_fields -def _extract_field_metadata(field_name: str, field_info) -> Dict[str, Any]: - """Extract metadata from dataclass field including docstring and type hints.""" - import typing - from typing import get_origin, get_args, Literal, Union, Optional - - metadata = {} - - # Extract description from field docstring or annotation - if hasattr(field_info, 'metadata') and field_info.metadata: - # Check for custom metadata - for meta in field_info.metadata: - if isinstance(meta, dict): - metadata.update(meta) - - # Extract type information - field_type = field_info.type - origin = get_origin(field_type) - - # Handle Literal types for dropdown choices - if origin is Literal: - args = get_args(field_type) - metadata['choices'] = list(args) - - # Handle Optional types - if origin is Union: - args = get_args(field_type) - if len(args) == 2 and type(None) in args: - # This is Optional[SomeType] - non_none_type = args[0] if args[1] is type(None) else args[1] - metadata['optional'] = True - # Recursively check the non-None type for choices - if get_origin(non_none_type) is Literal: - metadata['choices'] = list(get_args(non_none_type)) - else: - # Regular Union type - metadata['choices'] = [str(arg) for arg in args if arg is not type(None)] - - # Handle numeric constraints - if field_type in (int, float): - # Check for common constraint patterns in field name - if 'count' in field_name.lower() or 'num' in field_name.lower(): - metadata['min_value'] = 0 - if 'id' in field_name.lower(): - metadata['min_value'] = 0 - - # Generate placeholder text - if 'message' in field_name.lower(): - metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' - elif 'code' in field_name.lower(): - metadata['placeholder'] = 'Enter Python code here...' - elif 'tokens' in field_name.lower(): - metadata['placeholder'] = 'Enter comma-separated token IDs (e.g., 1,2,3,4,5)' - else: - metadata['placeholder'] = f'Enter {field_name.replace("_", " ")}...' - - # Generate help text based on field name and type - if 'action_id' in field_name.lower(): - metadata['help_text'] = 'The action ID to execute in the environment' - elif 'game_name' in field_name.lower(): - metadata['help_text'] = 'Name of the game or environment' - elif 'tokens' in field_name.lower(): - metadata['help_text'] = 'Token IDs as a comma-separated list of integers' - elif 'code' in field_name.lower(): - metadata['help_text'] = 'Python code to execute in the environment' - elif 'message' in field_name.lower(): - metadata['help_text'] = 'Text message to send' - - return metadata +def _determine_input_type_from_schema( + field_info: Dict[str, Any], field_name: str +) -> str: + """Determine the appropriate HTML input type from JSON schema info.""" + schema_type = field_info.get("type") + # Check for specific tensor field convention + if "tokens" in field_name.lower(): + return "tensor" -def _determine_input_type(field_type) -> str: - """Determine the appropriate HTML input type for a field type.""" - import typing - from typing import get_origin, get_args, Literal, Union - - # Handle direct types - if field_type == str: - return "text" - elif field_type == int: - return "number" - elif field_type == float: - return "number" - elif field_type == bool: - return "checkbox" - - # Handle complex types - origin = get_origin(field_type) - - if origin is Literal: + if "enum" in field_info: return "select" - elif origin is Union: - args = get_args(field_type) - if len(args) == 2 and type(None) in args: - # Optional type - use the non-None type - non_none_type = args[0] if args[1] is type(None) else args[1] - return _determine_input_type(non_none_type) - elif all(isinstance(arg, str) for arg in args if arg is not type(None)): - return "select" - else: - return "text" - elif hasattr(field_type, '__name__') and 'Tensor' in field_type.__name__: - return "tensor" - else: + + if schema_type == "boolean": + return "checkbox" + + if schema_type == "integer" or schema_type == "number": + return "number" + + if schema_type == "string": + # Check if it should be a textarea + if ( + field_info.get("maxLength", 0) > 100 + or "message" in field_name.lower() + or "code" in field_name.lower() + ): + return "textarea" return "text" + # Default fallback + return "text" + + +def _generate_placeholder(field_name: str, field_info: Dict[str, Any]) -> str: + """Generate placeholder text.""" + if "message" in field_name.lower(): + return f"Enter {field_name.replace('_', ' ')}..." + elif "code" in field_name.lower(): + return "Enter Python code here..." + elif "tokens" in field_name.lower(): + return "Enter comma-separated token IDs (e.g., 1,2,3,4,5)" + else: + return f"Enter {field_name.replace('_', ' ')}..." + + +def _generate_help_text(field_name: str, field_info: Dict[str, Any]) -> str: + """Generate help text.""" + description = field_info.get("description", "") + if description: + return description + + if "action_id" in field_name.lower(): + return "The action ID to execute in environment" + elif "game_name" in field_name.lower(): + return "Name of game or environment" + elif "tokens" in field_name.lower(): + return "Token IDs as a comma-separated list of integers" + elif "code" in field_name.lower(): + return "Python code to execute in environment" + elif "message" in field_name.lower(): + return "Text message to send" + + return "" + def _markdown_to_html(markdown: str) -> str: """Convert basic markdown to HTML for README display.""" import html import re - + # Escape HTML first html_content = html.escape(markdown) - + # Convert headers - html_content = re.sub(r'^# (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) - html_content = re.sub(r'^## (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) - html_content = re.sub(r'^### (.*?)$', r'

\1

', html_content, flags=re.MULTILINE) - + html_content = re.sub( + r"^# (.*?)$", r"

\1

", html_content, flags=re.MULTILINE + ) + html_content = re.sub( + r"^## (.*?)$", r"

\1

", html_content, flags=re.MULTILINE + ) + html_content = re.sub( + r"^### (.*?)$", r"

\1

", html_content, flags=re.MULTILINE + ) + # Convert code blocks - html_content = re.sub(r'```(.*?)\n(.*?)\n```', r'
\2
', html_content, flags=re.DOTALL) - html_content = re.sub(r'`([^`]+)`', r'\1', html_content) - + html_content = re.sub( + r"```(.*?)\n(.*?)\n```", + r"
\2
", + html_content, + flags=re.DOTALL, + ) + html_content = re.sub(r"`([^`]+)`", r"\1", html_content) + # Convert bold and italic - html_content = re.sub(r'\*\*(.*?)\*\*', r'\1', html_content) - html_content = re.sub(r'\*(.*?)\*', r'\1', html_content) - + html_content = re.sub(r"\*\*(.*?)\*\*", r"\1", html_content) + html_content = re.sub(r"\*(.*?)\*", r"\1", html_content) + # Convert lists - html_content = re.sub(r'^- (.*?)$', r'
  • \1
  • ', html_content, flags=re.MULTILINE) - html_content = re.sub(r'(
  • .*
  • )', r'
      \1
    ', html_content, flags=re.DOTALL) - + html_content = re.sub( + r"^- (.*?)$", r"
  • \1
  • ", html_content, flags=re.MULTILINE + ) + html_content = re.sub( + r"(
  • .*
  • )", r"
      \1
    ", html_content, flags=re.DOTALL + ) + # Convert line breaks - html_content = html_content.replace('\n', '
    ') - + html_content = html_content.replace("\n", "
    ") + return html_content -def _generate_action_interface(action_fields: List[Dict[str, Any]], is_chat_env: bool) -> str: +def _generate_action_interface( + action_fields: List[Dict[str, Any]], is_chat_env: bool +) -> str: """Generate either a chat interface or action form based on environment type.""" if is_chat_env: return _generate_chat_interface() else: return _generate_action_form(action_fields) + def _generate_chat_interface() -> str: """Generate a chat-style interface for chat environments.""" - return ''' + return """

    Chat Interface

    @@ -1495,11 +1489,12 @@ def _generate_chat_interface() -> str:
    - ''' + """ + def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str: """Generate a traditional action form for non-chat environments.""" - return f''' + return f"""

    Take Action

    @@ -1508,106 +1503,119 @@ def _generate_action_form(action_fields: List[Dict[str, Any]]) -> str:
    - ''' + """ + def _generate_action_form_fields(action_fields: List[Dict[str, Any]]) -> str: """Generate HTML form fields for action input with enhanced metadata.""" if not action_fields: - return '

    No action fields available

    ' - + return "

    No action fields available

    " + fields_html = [] for field in action_fields: field_html = _generate_single_field(field) fields_html.append(field_html) - - return '\n'.join(fields_html) + + return "\n".join(fields_html) def _generate_single_field(field: Dict[str, Any]) -> str: """Generate HTML for a single form field with enhanced metadata.""" - field_name = field['name'] - field_type = field['type'] - required = field['required'] - placeholder = field.get('placeholder', '') - help_text = field.get('help_text', '') - choices = field.get('choices', []) - min_value = field.get('min_value') - max_value = field.get('max_value') - default_value = field.get('default_value') - + field_name = field["name"] + field_type = field["type"] + required = field["required"] + placeholder = field.get("placeholder", "") + help_text = field.get("help_text", "") + choices = field.get("choices", []) + min_value = field.get("min_value") + max_value = field.get("max_value") + default_value = field.get("default_value") + min_length = field.get("min_length") + max_length = field.get("max_length") + pattern = field.get("pattern") + # Build label with required indicator - label_text = field_name.replace('_', ' ').title() + label_text = field_name.replace("_", " ").title() if required: label_text += ' *' - + # Build input attributes input_attrs = [] if required: - input_attrs.append('required') + input_attrs.append("required") if placeholder: input_attrs.append(f'placeholder="{placeholder}"') if min_value is not None: input_attrs.append(f'min="{min_value}"') if max_value is not None: input_attrs.append(f'max="{max_value}"') + if min_length is not None: + input_attrs.append(f'minlength="{min_length}"') + if max_length is not None: + input_attrs.append(f'maxlength="{max_length}"') + if pattern is not None: + input_attrs.append(f'pattern="{pattern}"') if default_value is not None: input_attrs.append(f'value="{default_value}"') - - attrs_str = ' '.join(input_attrs) - - if field_type == 'checkbox': + + attrs_str = " ".join(input_attrs) + + if field_type == "checkbox": + checked = "checked" if default_value is True else "" return f'''
    - {f'{help_text}' if help_text else ''} + {f'{help_text}' if help_text else ""}
    ''' - - elif field_type == 'select': + + elif field_type == "select": options_html = [] if not required: options_html.append(f'') - + for choice in choices: - selected = 'selected' if str(choice) == str(default_value) else '' - options_html.append(f'') - + selected = "selected" if str(choice) == str(default_value) else "" + options_html.append( + f'' + ) + return f'''
    - {f'{help_text}' if help_text else ''} + {f'{help_text}' if help_text else ""}
    ''' - - elif field_type == 'tensor': + + elif field_type == "tensor": return f'''
    - {help_text or 'Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)'} + {help_text or "Enter token IDs as comma-separated integers (e.g., 1,2,3,4,5)"}
    ''' - - elif field_type == 'text' and ('message' in field_name.lower() or 'code' in field_name.lower()): + + elif field_type == "textarea": return f'''
    - - {f'{help_text}' if help_text else ''} + + {f'{help_text}' if help_text else ""}
    ''' - + else: return f'''
    - {f'{help_text}' if help_text else ''} + {f'{help_text}' if help_text else ""}
    ''' diff --git a/src/core/tools/__init__.py b/src/openenv/core/tools/__init__.py similarity index 99% rename from src/core/tools/__init__.py rename to src/openenv/core/tools/__init__.py index 034e7f068..071390a31 100644 --- a/src/core/tools/__init__.py +++ b/src/openenv/core/tools/__init__.py @@ -13,4 +13,4 @@ "PyExecutor", "GitServerClient", "RepoInfo", -] \ No newline at end of file +] diff --git a/src/core/tools/git_server_client.py b/src/openenv/core/tools/git_server_client.py similarity index 97% rename from src/core/tools/git_server_client.py rename to src/openenv/core/tools/git_server_client.py index 143bc363b..3dc3379f6 100644 --- a/src/core/tools/git_server_client.py +++ b/src/openenv/core/tools/git_server_client.py @@ -100,7 +100,9 @@ def _configure_git(self): gitconfig_path.write_text(git_config) # Git credentials - git_credentials = f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n" + git_credentials = ( + f"http://{self.username}:{self.password}@{self.domain}:{self.port}\n" + ) gitcreds_path = home_dir / ".git-credentials" gitcreds_path.write_text(git_credentials) gitcreds_path.chmod(0o600) @@ -272,7 +274,12 @@ def reset_workspace(self, repo_name: str, commit: str = "main") -> bool: raise RuntimeError(f"Checkout failed: {result.stderr}") result = subprocess.run( - ["git", "reset", "--hard", f"origin/{commit}" if commit != "main" else commit], + [ + "git", + "reset", + "--hard", + f"origin/{commit}" if commit != "main" else commit, + ], cwd=str(repo_path), capture_output=True, text=True, diff --git a/src/core/tools/local_python_executor.py b/src/openenv/core/tools/local_python_executor.py similarity index 93% rename from src/core/tools/local_python_executor.py rename to src/openenv/core/tools/local_python_executor.py index 1ebcf6b6a..472db5324 100644 --- a/src/core/tools/local_python_executor.py +++ b/src/openenv/core/tools/local_python_executor.py @@ -28,7 +28,7 @@ from smolagents import LocalPythonExecutor -from core.env_server.types import CodeExecResult +from openenv.core.env_server.types import CodeExecResult logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) @@ -69,7 +69,10 @@ def __init__(self, additional_imports: list[str] | None = None): except Exception: # If the LocalPythonExecutor implementation doesn't support # send_tools or fails, log and continue — the executor is still usable. - logger.debug("LocalPythonExecutor.send_tools failed; continuing without extra tools", exc_info=True) + logger.debug( + "LocalPythonExecutor.send_tools failed; continuing without extra tools", + exc_info=True, + ) def run(self, code: str) -> CodeExecResult: """Execute Python code and return a CodeExecResult. @@ -127,7 +130,11 @@ def run(self, code: str) -> CodeExecResult: # Determine exit code if provided try: if hasattr(exec_result, "exit_code"): - exit_code = int(exec_result.exit_code) if exec_result.exit_code is not None else 0 + exit_code = ( + int(exec_result.exit_code) + if exec_result.exit_code is not None + else 0 + ) elif hasattr(exec_result, "success"): # Some versions use `success` boolean exit_code = 0 if exec_result.success else 1 diff --git a/src/openenv/core/utils.py b/src/openenv/core/utils.py new file mode 100644 index 000000000..ba8fc1d12 --- /dev/null +++ b/src/openenv/core/utils.py @@ -0,0 +1,27 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Utility functions for OpenEnv core.""" + + +def convert_to_ws_url(url: str) -> str: + """ + Convert an HTTP/HTTPS URL to a WS/WSS URL. + + Args: + url: The URL to convert. + + Returns: + The converted WebSocket URL. + """ + ws_url = url.rstrip("/") + if ws_url.startswith("http://"): + ws_url = "ws://" + ws_url[7:] + elif ws_url.startswith("https://"): + ws_url = "wss://" + ws_url[8:] + elif not ws_url.startswith("ws://") and not ws_url.startswith("wss://"): + ws_url = "ws://" + ws_url + return ws_url diff --git a/src/openenv_core/__init__.py b/src/openenv_core/__init__.py new file mode 100644 index 000000000..c2df888d9 --- /dev/null +++ b/src/openenv_core/__init__.py @@ -0,0 +1,46 @@ +""" +Compatibility shim for the historical ``openenv_core`` package. + +The core runtime now lives under ``openenv.core``. Importing from the old +package path will continue to work but emits a ``DeprecationWarning`` so +downstream users can migrate at their own pace. +""" + +from __future__ import annotations + +import importlib +import sys +import warnings +from types import ModuleType +from typing import Dict + +_TARGET_PREFIX = "openenv.core" +_TARGET_MODULE = importlib.import_module(_TARGET_PREFIX) + +warnings.warn( + "openenv_core is deprecated; import from openenv.core instead.", + DeprecationWarning, + stacklevel=2, +) + +__all__ = getattr(_TARGET_MODULE, "__all__", []) + + +def __getattr__(name: str): + return getattr(_TARGET_MODULE, name) + + +def __dir__(): + return sorted(set(dir(_TARGET_MODULE))) + + +def _alias(name: str) -> None: + target = f"{_TARGET_PREFIX}.{name}" + sys.modules[f"{__name__}.{name}"] = importlib.import_module(target) + + +for _child in ("client_types", "containers", "env_server", "http_env_client", "tools"): + try: + _alias(_child) + except ModuleNotFoundError: # pragma: no cover - defensive + continue diff --git a/src/pyproject.toml b/src/pyproject.toml deleted file mode 100644 index 067237115..000000000 --- a/src/pyproject.toml +++ /dev/null @@ -1,61 +0,0 @@ -[build-system] -requires = ["setuptools>=45", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "openenv-core" -version = "0.1.1" -description = "Core components and cli for OpenEnv - HTTP-based agentic environments" -readme = "./core/README.md" -requires-python = ">=3.10" -license = {text = "BSD-3-Clause"} -authors = [ - {name = "Meta Platforms, Inc.", email = "opensource@meta.com"} -] -keywords = ["environment", "agent", "http", "docker", "fastapi"] - -dependencies = [ - "fastapi>=0.104.0", - "pydantic>=2.0.0", - "uvicorn[standard]>=0.24.0", - "requests>=2.25.0", - "typer>=0.12.0", - "pyyaml>=6.0.0", - "huggingface_hub>=0.24.0", - "rich>=13.0.0", - "tomli>=2.0.1", - "tomli-w>=1.0.0", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "black>=23.0.0", - "ruff>=0.1.0", - "mypy>=1.0.0", -] - -[project.scripts] -openenv = "openenv_cli.__main__:main" - -[project.urls] -Homepage = "https://github.com/facebookresearch/OpenEnv" -Repository = "https://github.com/facebookresearch/OpenEnv" -Documentation = "https://github.com/facebookresearch/OpenEnv/blob/main/README.md" -"Bug Tracker" = "https://github.com/facebookresearch/OpenEnv/issues" - -[tool.setuptools] -package-dir = { "openenv_core" = "core", "openenv_cli" = "openenv_cli"} -packages = [ - "openenv_core", - "openenv_core.containers", - "openenv_core.containers.runtime", - "openenv_core.env_server", - "openenv_core.tools", - "openenv_cli", - "openenv_cli.commands", - "openenv_cli.templates" -] - -[tool.setuptools.package-data] -"openenv_cli" = ["templates/**/*"] diff --git a/tests/envs/test_browsergym_environment.py b/tests/envs/test_browsergym_environment.py index 4a5234c6e..cf4b22753 100644 --- a/tests/envs/test_browsergym_environment.py +++ b/tests/envs/test_browsergym_environment.py @@ -1,15 +1,24 @@ """Unit tests for BrowserGym environment server.""" import os +import shutil import sys import subprocess import time import requests import pytest +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + from envs.browsergym_env.client import BrowserGymEnv from envs.browsergym_env.models import BrowserGymAction +# Skip all tests if gunicorn is not installed +pytestmark = pytest.mark.skipif( + shutil.which("gunicorn") is None, reason="gunicorn not installed" +) + @pytest.fixture(scope="module") def server(): @@ -32,9 +41,12 @@ def server(): gunicorn_command = [ "gunicorn", - "-w", "1", # Single worker for testing - "-k", "uvicorn.workers.UvicornWorker", - "-b", f"0.0.0.0:{PORT}", + "-w", + "1", # Single worker for testing + "-k", + "uvicorn.workers.UvicornWorker", + "-b", + f"0.0.0.0:{PORT}", "envs.browsergym_env.server.app:app", ] @@ -57,7 +69,7 @@ def server(): print("✅ Server is running and healthy!") break except requests.exceptions.RequestException: - print(f"Attempt {i+1}/12: Server not ready, waiting 10 seconds...") + print(f"Attempt {i + 1}/12: Server not ready, waiting 10 seconds...") time.sleep(10) if not is_healthy: @@ -199,8 +211,7 @@ def test_action_with_metadata(server): env.reset() action = BrowserGymAction( - action_str="click('button')", - metadata={"test": "value", "number": 42} + action_str="click('button')", metadata={"test": "value", "number": 42} ) result = env.step(action) diff --git a/tests/envs/test_browsergym_models.py b/tests/envs/test_browsergym_models.py index 6aebcf449..d8a735da2 100644 --- a/tests/envs/test_browsergym_models.py +++ b/tests/envs/test_browsergym_models.py @@ -1,5 +1,11 @@ """Unit tests for BrowserGym models.""" +import os +import sys + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + from envs.browsergym_env.models import ( BrowserGymAction, BrowserGymObservation, @@ -18,7 +24,7 @@ def test_browser_gym_action_with_metadata(): """Test creating a BrowserGymAction with metadata.""" action = BrowserGymAction( action_str="fill('username', 'john')", - metadata={"user": "test", "timestamp": 123456} + metadata={"user": "test", "timestamp": 123456}, ) assert action.action_str == "fill('username', 'john')" assert action.metadata["user"] == "test" diff --git a/tests/envs/test_connect4_env.py b/tests/envs/test_connect4_env.py index d68cd7387..3ad230d32 100644 --- a/tests/envs/test_connect4_env.py +++ b/tests/envs/test_connect4_env.py @@ -4,32 +4,49 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. -"""Test that PythonCodeActEnv.reset() properly resets executor state.""" +"""Test Connect4 environment client and server integration. +NOTE: This is a legacy test file using unittest patterns with manual server lifecycle. +For comprehensive Connect4 tests, see test_websockets.py::TestConnect4Environment. +""" + +import os import sys from pathlib import Path -import sys, os -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) +import pytest + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) -from envs.connect4_env import Connect4Action, Connect4Observation, Connect4State, Connect4Env +from envs.connect4_env import ( + Connect4Action, + Connect4Observation, + Connect4State, + Connect4Env, +) import subprocess -# subprocess.run(["python", "-m", "envs.connect4_env.server.app"], check=True,stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) import unittest import time import requests import signal + +# Skip this legacy test file - comprehensive tests in test_websockets.py +pytestmark = pytest.mark.skip( + reason="Legacy test file - see test_websockets.py for comprehensive Connect4 tests" +) + + class TestConnect4(unittest.TestCase): - def __init__(self, methodName = "runTest"): + def __init__(self, methodName="runTest"): self.client = None self.actions = [] super().__init__(methodName) - - def test_setup_server(self): + def test_setup_server(self): self.server_process = subprocess.Popen( ["python", "-m", "envs.connect4_env.server.app"], stdin=subprocess.PIPE, @@ -38,101 +55,100 @@ def test_setup_server(self): ) # Give it a few seconds to start time.sleep(3) - def check_server_running(self): + def check_server_running(self): try: # Attempt to ping the server - response = requests.get("http://127.0.0.1:8000/health") # or "/" depending on your app + response = requests.get( + "http://127.0.0.1:8000/health" + ) # or "/" depending on your app self.assertEqual(response.status_code, 200) except requests.ConnectionError: self.fail("Server did not start or is unreachable") def test_connect4_env_client(self): - self.test_setup_server() self.check_server_running() self.client = Connect4Env(base_url="http://127.0.0.1:8000") assert isinstance(self.client, Connect4Env) - - def test_connect4_initial_state(self): - self.test_connect4_env_client() - + result = self.client.reset() - observation= result.observation - + observation = result.observation assert isinstance(observation, Connect4Observation) assert isinstance(observation.board, list) - assert isinstance(observation.legal_actions, list) + assert isinstance(observation.legal_actions, list) assert isinstance(observation.done, bool) assert isinstance(observation.reward, float) assert len(observation.board) == 6 # 6 rows assert all(len(row) == 7 for row in observation.board) # 7 columns - assert len(observation.legal_actions) == 7 # All columns should be legal at start + assert ( + len(observation.legal_actions) == 7 + ) # All columns should be legal at start assert observation.done == False assert observation.reward == 0.0 if isinstance(observation.legal_actions, float): - - self.actions=observation.legal_actions - - + self.actions = observation.legal_actions def check_valid_action(self, action): - legal_actions = self.actions - if self.assertIn(action, legal_actions, f"Action {action} is not legal in the current state."): + if self.assertIn( + action, legal_actions, f"Action {action} is not legal in the current state." + ): return True - - return False - - - def step_action(self, column): + return False - valid=self.check_valid_action(column) + def step_action(self, column): + valid = self.check_valid_action(column) - assert isinstance(valid,bool) + assert isinstance(valid, bool) if valid: - action = Connect4Action(column=column) result = self.client.step(action) assert isinstance(result, object) - observation= result.observation + observation = result.observation assert isinstance(observation, Connect4Observation) assert isinstance(observation.board, list) - assert isinstance(observation.legal_actions, list) + assert isinstance(observation.legal_actions, list) assert isinstance(observation.done, bool) assert isinstance(observation.reward, float) return result + def tearDown(self): - if self.server_process: - # Try terminating the process gracefully - self.server_process.terminate() - try: - self.server_process.wait(timeout=5) - except subprocess.TimeoutExpired: - os.kill(self.server_process.pid, signal.SIGKILL) - - # Close the pipes to avoid ResourceWarnings - for stream in [self.server_process.stdin, self.server_process.stdout, self.server_process.stderr]: - if stream and not stream.closed: - stream.close() - + if self.server_process: + # Try terminating the process gracefully + self.server_process.terminate() + try: + self.server_process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.kill(self.server_process.pid, signal.SIGKILL) + + # Close the pipes to avoid ResourceWarnings + for stream in [ + self.server_process.stdin, + self.server_process.stdout, + self.server_process.stderr, + ]: + if stream and not stream.closed: + stream.close() + + if __name__ == "__main__": unittest.main() diff --git a/tests/envs/test_dipg_client.py b/tests/envs/test_dipg_client.py index 00a6a3eeb..2cc880bfd 100644 --- a/tests/envs/test_dipg_client.py +++ b/tests/envs/test_dipg_client.py @@ -1,26 +1,35 @@ +import os +import sys import pytest -import requests + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + from envs.dipg_safety_env.client import DIPGSafetyEnv from envs.dipg_safety_env.models import DIPGAction + def test_invalid_url(): """Test that the client raises an error for an invalid URL.""" - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises(ConnectionError): env = DIPGSafetyEnv(base_url="http://invalid-url:9999") env.reset() + def test_server_not_running(): """Test that the client raises an error when the server is not running.""" - with pytest.raises(requests.exceptions.ConnectionError): + with pytest.raises(ConnectionError): env = DIPGSafetyEnv(base_url="http://localhost:9999") env.reset() + def test_invalid_action(): """Test that the client raises an error for an invalid action.""" # This test requires a running server, so we'll skip it for now. pass + def test_server_timeout(): """Test that the client raises an error for a server timeout.""" # This test requires a running server that can be made to hang, so we'll skip it for now. - pass \ No newline at end of file + pass diff --git a/tests/envs/test_dipg_environment.py b/tests/envs/test_dipg_environment.py index 22576fcf0..44013b663 100644 --- a/tests/envs/test_dipg_environment.py +++ b/tests/envs/test_dipg_environment.py @@ -1,14 +1,23 @@ -#tests/envs/test_dipg_environment.py +# tests/envs/test_dipg_environment.py import os +import shutil import sys import subprocess import time import requests import pytest +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + from envs.dipg_safety_env.client import DIPGSafetyEnv from envs.dipg_safety_env.models import DIPGAction +# Skip all tests if gunicorn is not installed +pytestmark = pytest.mark.skipif( + shutil.which("gunicorn") is None, reason="gunicorn not installed" +) + @pytest.fixture(scope="module") def server(): @@ -16,7 +25,9 @@ def server(): # --- Define Absolute Paths & Port --- ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) SRC_PATH = os.path.join(ROOT_DIR, "src") - DATASET_SOURCE_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "mock_dataset.jsonl")) + DATASET_SOURCE_PATH = os.path.abspath( + os.path.join(os.path.dirname(__file__), "mock_dataset.jsonl") + ) PORT = 8009 # --- Launch the Server using Gunicorn --- @@ -31,15 +42,20 @@ def server(): gunicorn_command = [ "gunicorn", - "-w", "4", - "-k", "uvicorn.workers.UvicornWorker", - "-b", f"0.0.0.0:{PORT}", + "-w", + "4", + "-k", + "uvicorn.workers.UvicornWorker", + "-b", + f"0.0.0.0:{PORT}", "envs.dipg_safety_env.server.app:app", ] openenv_process = subprocess.Popen( gunicorn_command, env=server_env, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) # --- Wait and Verify --- @@ -53,7 +69,7 @@ def server(): print("✅ Server is running and healthy!") break except requests.exceptions.RequestException: - print(f"Attempt {i+1}/12: Server not ready, waiting 10 seconds...") + print(f"Attempt {i + 1}/12: Server not ready, waiting 10 seconds...") time.sleep(10) if not is_healthy: @@ -76,6 +92,7 @@ def server(): except ProcessLookupError: print("✅ Server process was already killed.") + def test_reset(server): """Test that reset() returns a valid observation.""" env = DIPGSafetyEnv(base_url=server, timeout=300) @@ -83,15 +100,19 @@ def test_reset(server): obs2 = env.reset() assert obs1.observation.question != obs2.observation.question + def test_step(server): """Test that step() returns a valid result.""" env = DIPGSafetyEnv(base_url=server, timeout=300) env.reset() - action = DIPGAction(llm_response="<|channel|>analysis<|message|>This is an analysis.<|end|>\n<|channel|>final<|message|>This is the final answer.<|end|>") + action = DIPGAction( + llm_response="<|channel|>analysis<|message|>This is an analysis.<|end|>\n<|channel|>final<|message|>This is the final answer.<|end|>" + ) result = env.step(action) assert isinstance(result.reward, float) assert result.done is True + def test_malformed_step(server): """Test that a malformed step() does not crash the server.""" env = DIPGSafetyEnv(base_url=server, timeout=300) @@ -99,4 +120,4 @@ def test_malformed_step(server): action = DIPGAction(llm_response="This is a malformed response") result = env.step(action) assert isinstance(result.reward, float) - assert result.done is True \ No newline at end of file + assert result.done is True diff --git a/tests/envs/test_dipg_reward_functions.py b/tests/envs/test_dipg_reward_functions.py index a5f894e9f..98f459f67 100644 --- a/tests/envs/test_dipg_reward_functions.py +++ b/tests/envs/test_dipg_reward_functions.py @@ -1,13 +1,22 @@ -# tests/envs/test_dipg_reward_functions.py +import os +import sys import pytest + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# Skip entire module if langdetect is not installed (required by DIPG) +pytest.importorskip("langdetect", reason="langdetect not installed") + from envs.dipg_safety_env.server.dipg_environment import DIPGEnvironment + @pytest.fixture def env_v3(tmp_path): """Provides a V3 (format-first) environment instance for testing.""" dataset_path = tmp_path / "dataset.jsonl" dataset_path.touch() - + # Parameters match the V3 format-first curriculum return DIPGEnvironment( dataset_path=str(dataset_path), @@ -37,6 +46,7 @@ def env_v3(tmp_path): channel_end="<|end|>", ) + class TestFormatFirstRewards: # Define constants for channels to make tests readable ANALYSIS_START = "<|channel|>analysis<|message|>" @@ -45,26 +55,38 @@ class TestFormatFirstRewards: END = "<|end|>" CONTEXT = "Drug A is effective. Dr. Smith conducted the trial." - GROUND_TRUTH_SYNTHESIS = {"final": "Drug A is effective.", "proof": "Drug A is effective."} - GROUND_TRUTH_ABSTENTION = {"final": "The provided sources present conflicting information.", "proof": "Source A says X, Source B says Y."} + GROUND_TRUTH_SYNTHESIS = { + "final": "Drug A is effective.", + "proof": "Drug A is effective.", + } + GROUND_TRUTH_ABSTENTION = { + "final": "The provided sources present conflicting information.", + "proof": "Source A says X, Source B says Y.", + } def test_imperfect_format_returns_large_penalty(self, env_v3): """If format is not perfect, a large penalty is returned immediately.""" # Case 1: Missing a channel llm_response_missing = f"{self.ANALYSIS_START}Analysis.{self.END}\n{self.FINAL_START}Final answer.{self.END}" - reward = env_v3.calculate_total_reward(llm_response_missing, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS) + reward = env_v3.calculate_total_reward( + llm_response_missing, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS + ) assert reward == env_v3.format_mismatch_penalty # Case 2: Wrong order llm_response_wrong_order = f"{self.FINAL_START}Final.{self.END}\n{self.PROOF_START}Proof.{self.END}\n{self.ANALYSIS_START}Analysis.{self.END}" - reward = env_v3.calculate_total_reward(llm_response_wrong_order, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS) + reward = env_v3.calculate_total_reward( + llm_response_wrong_order, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS + ) assert reward == env_v3.format_mismatch_penalty def test_hallucinated_trace_with_perfect_format(self, env_v3): """Perfect format but hallucinated proof results in format reward + hallucination penalty.""" proof = "This is a fabricated proof." llm_response = f"{self.ANALYSIS_START}A.{self.END}\n{self.PROOF_START}{proof}{self.END}\n{self.FINAL_START}F.{self.END}" - reward = env_v3.calculate_total_reward(llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS) + reward = env_v3.calculate_total_reward( + llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS + ) expected = env_v3.exact_format_reward + env_v3.hallucinated_trace_penalty assert reward == expected @@ -77,28 +99,32 @@ def test_perfect_response_synthesis(self, env_v3): f"{self.PROOF_START}{proof}{self.END}\n" f"{self.FINAL_START}{final}{self.END}" ) - reward = env_v3.calculate_total_reward(llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS) + reward = env_v3.calculate_total_reward( + llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS + ) expected = ( - env_v3.exact_format_reward + - env_v3.verifiable_trace_reward + - env_v3.correct_synthesis_reward + env_v3.exact_format_reward + + env_v3.verifiable_trace_reward + + env_v3.correct_synthesis_reward ) assert reward == expected def test_perfect_format_but_incorrect_answer(self, env_v3): """Perfect format and valid proof, but the final answer is wrong.""" proof = "Drug A is effective." - final = "Drug B is better." # Incorrect conclusion + final = "Drug B is better." # Incorrect conclusion llm_response = ( f"{self.ANALYSIS_START}Analysis.{self.END}\n" f"{self.PROOF_START}{proof}{self.END}\n" f"{self.FINAL_START}{final}{self.END}" ) - reward = env_v3.calculate_total_reward(llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS) + reward = env_v3.calculate_total_reward( + llm_response, self.CONTEXT, self.GROUND_TRUTH_SYNTHESIS + ) expected = ( - env_v3.exact_format_reward + - env_v3.verifiable_trace_reward + # Trace was good - env_v3.incorrect_answer_penalty # But answer was bad + env_v3.exact_format_reward + + env_v3.verifiable_trace_reward # Trace was good + + env_v3.incorrect_answer_penalty # But answer was bad ) assert reward == expected @@ -112,10 +138,12 @@ def test_perfect_format_correct_abstention(self, env_v3): f"{self.PROOF_START}{proof}{self.END}\n" f"{self.FINAL_START}{final}{self.END}" ) - reward = env_v3.calculate_total_reward(llm_response, context_conflict, self.GROUND_TRUTH_ABSTENTION) + reward = env_v3.calculate_total_reward( + llm_response, context_conflict, self.GROUND_TRUTH_ABSTENTION + ) expected = ( - env_v3.exact_format_reward + - env_v3.verifiable_trace_reward + - env_v3.correct_abstention_reward + env_v3.exact_format_reward + + env_v3.verifiable_trace_reward + + env_v3.correct_abstention_reward ) assert reward == expected diff --git a/tests/envs/test_python_codeact_reset.py b/tests/envs/test_python_codeact_reset.py index 553dc5c53..baa95d05a 100644 --- a/tests/envs/test_python_codeact_reset.py +++ b/tests/envs/test_python_codeact_reset.py @@ -6,15 +6,22 @@ """Test that PythonCodeActEnv.reset() properly resets executor state.""" +import os import sys from pathlib import Path -from envs.coding_env.models import CodeAction -from envs.coding_env.server.python_codeact_env import PythonCodeActEnv +import pytest -# Add src to path +# Add the project root and src to the path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +# Skip entire module if smolagents is not installed +pytest.importorskip("smolagents", reason="smolagents is not installed") + +from envs.coding_env.models import CodeAction +from envs.coding_env.server.python_codeact_env import PythonCodeActEnv + def test_reset_clears_executor_state(): """Test that reset() clears functions and variables defined in diff --git a/tests/envs/test_textarena_environment.py b/tests/envs/test_textarena_environment.py index b64921cff..6ab7cde60 100644 --- a/tests/envs/test_textarena_environment.py +++ b/tests/envs/test_textarena_environment.py @@ -1,3 +1,13 @@ +import os +import sys +import pytest + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# Skip entire module if nltk is not installed (required by textarena_env) +pytest.importorskip("nltk", reason="nltk not installed") + from envs.textarena_env.server.environment import TextArenaEnvironment from envs.textarena_env.models import TextArenaMessage, TextArenaAction @@ -23,12 +33,17 @@ def test_convert_messages_coalesces_consecutive_characters(): ] +@pytest.mark.skipif( + not pytest.importorskip("textarena", reason="textarena not installed"), + reason="textarena not installed", +) def test_wordle_reset_clears_accumulated_state(): """Test that resetting Wordle environment clears accumulated observation state. This test verifies the workaround for TextArena's LLMObservationWrapper, which accumulates observations in self.full_observations across resets. """ + pytest.importorskip("textarena", reason="textarena not installed") env = TextArenaEnvironment( env_id="Wordle-v0", num_players=1, diff --git a/tests/envs/test_websearch_environment.py b/tests/envs/test_websearch_environment.py index 9c9bdf1e4..5bbe5ace6 100644 --- a/tests/envs/test_websearch_environment.py +++ b/tests/envs/test_websearch_environment.py @@ -1,15 +1,18 @@ import os +import sys +import pytest + +# Add the project root to the path for envs imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + from envs.websearch_env.server import WebSearchEnvironment from envs.websearch_env.models import WebSearchAction, WebSearchObservation -def test_websearch_environment(): - - # Check if the SERPER_API_KEY is set - api_key = os.environ.get("SERPER_API_KEY") - if not api_key: - import pytest - pytest.skip("Skipping websearch environment test because SERPER_API_KEY is not set.") +@pytest.mark.skipif( + not os.environ.get("SERPER_API_KEY"), reason="SERPER_API_KEY not set" +) +def test_websearch_environment(): # Create the environment env = WebSearchEnvironment() @@ -19,11 +22,13 @@ def test_websearch_environment(): assert obs.content == "" # Step the environment - obs: WebSearchObservation = env.step(WebSearchAction(query="What is the capital of France?")) + obs: WebSearchObservation = env.step( + WebSearchAction(query="What is the capital of France?") + ) if not obs.metadata.get("error"): assert obs.web_contents != [] assert len(obs.web_contents) == 5 assert obs.metadata == {"query": "What is the capital of France?"} else: assert obs.web_contents == [] - assert "[ERROR]" in obs.content \ No newline at end of file + assert "[ERROR]" in obs.content diff --git a/tests/envs/test_websockets.py b/tests/envs/test_websockets.py new file mode 100644 index 000000000..0cb565fb1 --- /dev/null +++ b/tests/envs/test_websockets.py @@ -0,0 +1,466 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +""" +Integration tests for OpenEnv environments. + +This module tests the new WebSocket-based client architecture and factory pattern +to ensure all environments work correctly after the migration from HTTPEnvClient. + +Test Categories: +- Smoke: Factory pattern validation and basic server startup +- Protocol: WebSocket and HTTP endpoint verification +- Concurrency: Multiple simultaneous session handling + +Run with: pytest tests/envs/test_websockets.py -v +Run specific category: pytest tests/envs/test_websockets.py -v -k "smoke" +""" + +import asyncio +import json +import os +import signal +import subprocess +import sys +import time +from contextlib import contextmanager +from typing import Generator, Tuple, Type, Callable +from unittest.mock import patch + +import pytest +import requests + +# Add the project root to the path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + + +# ============================================================================= +# Test Fixtures and Utilities +# ============================================================================= + + +@contextmanager +def run_server( + module_path: str, + port: int = 8000, + startup_timeout: float = 10.0, + env_vars: dict = None, +) -> Generator[subprocess.Popen, None, None]: + """ + Context manager to start and stop a server process. + + Args: + module_path: Python module path (e.g., "envs.echo_env.server.app") + port: Port to run the server on + startup_timeout: Max seconds to wait for server startup + env_vars: Additional environment variables + + Yields: + The subprocess.Popen instance + """ + env = os.environ.copy() + if env_vars: + env.update(env_vars) + + # Start the server + process = subprocess.Popen( + [ + sys.executable, + "-m", + "uvicorn", + f"{module_path}:app", + "--host", + "127.0.0.1", + "--port", + str(port), + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + try: + # Wait for server to be ready + start_time = time.time() + while time.time() - start_time < startup_timeout: + try: + response = requests.get(f"http://127.0.0.1:{port}/health", timeout=1) + if response.status_code == 200: + break + except requests.exceptions.ConnectionError: + time.sleep(0.5) + else: + # Print stderr for debugging + stderr = process.stderr.read().decode() if process.stderr else "" + raise TimeoutError( + f"Server failed to start within {startup_timeout}s. Stderr: {stderr}" + ) + + yield process + + finally: + # Clean shutdown + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + # Close pipes + for stream in [process.stdin, process.stdout, process.stderr]: + if stream and not stream.closed: + stream.close() + + +def wait_for_server(base_url: str, timeout: float = 10.0) -> bool: + """Wait for a server to be ready.""" + start_time = time.time() + while time.time() - start_time < timeout: + try: + response = requests.get(f"{base_url}/health", timeout=1) + if response.status_code == 200: + return True + except requests.exceptions.ConnectionError: + time.sleep(0.5) + return False + + +# ============================================================================= +# Smoke Tests - Factory Pattern and Basic Functionality +# ============================================================================= + + +class TestSmokeFactoryPattern: + """Test that the factory pattern works correctly for all environments.""" + + def test_smoke_echo_env_factory_pattern(self): + """Test that EchoEnvironment can be created via factory.""" + from envs.echo_env.server.echo_environment import EchoEnvironment + + # Should be callable + env = EchoEnvironment() + assert env is not None + + # Test basic operations + obs = env.reset() + assert obs is not None + + env.close() + + def test_smoke_connect4_env_factory_pattern(self): + """Test that Connect4Environment can be created via factory.""" + from envs.connect4_env.server.connect4_environment import Connect4Environment + + env = Connect4Environment() + assert env is not None + + obs = env.reset() + assert obs is not None + + env.close() + + def test_smoke_create_app_accepts_class(self): + """Test that create_app accepts a class (not instance).""" + from openenv.core.env_server.http_server import create_app + from envs.echo_env.server.echo_environment import EchoEnvironment + from envs.echo_env.models import EchoAction, EchoObservation + + # Should not raise TypeError + app = create_app(EchoEnvironment, EchoAction, EchoObservation, env_name="test") + assert app is not None + + def test_smoke_create_app_accepts_factory_function(self): + """Test that create_app accepts a factory function.""" + from openenv.core.env_server.http_server import create_app + from envs.echo_env.server.echo_environment import EchoEnvironment + from envs.echo_env.models import EchoAction, EchoObservation + + def create_echo_env(): + return EchoEnvironment() + + # Should not raise TypeError + app = create_app(create_echo_env, EchoAction, EchoObservation, env_name="test") + assert app is not None + + def test_smoke_create_app_rejects_instance(self): + """Test that create_app rejects an instance (not callable).""" + from openenv.core.env_server.http_server import create_app + from envs.echo_env.server.echo_environment import EchoEnvironment + from envs.echo_env.models import EchoAction, EchoObservation + + # Create an instance (wrong pattern) + instance = EchoEnvironment() + + # Should raise TypeError + with pytest.raises(TypeError, match="must be a callable"): + create_app(instance, EchoAction, EchoObservation, env_name="test") + + instance.close() + + +# ============================================================================= +# Protocol Tests - WebSocket and HTTP Endpoints +# ============================================================================= + + +class TestProtocolHttpEndpoints: + """Test that HTTP endpoints work correctly.""" + + @pytest.fixture + def echo_server(self): + """Start echo environment server.""" + with run_server("envs.echo_env.server.app", port=8100) as proc: + yield "http://127.0.0.1:8100" + + def test_protocol_health_endpoint(self, echo_server): + """Test /health endpoint.""" + response = requests.get(f"{echo_server}/health") + assert response.status_code == 200 + data = response.json() + assert data.get("status") == "healthy" + + def test_protocol_schema_endpoint(self, echo_server): + """Test /schema endpoint.""" + response = requests.get(f"{echo_server}/schema") + assert response.status_code == 200 + data = response.json() + assert "action" in data + assert "observation" in data + + def test_protocol_reset_endpoint(self, echo_server): + """Test /reset endpoint.""" + response = requests.post(f"{echo_server}/reset", json={}) + assert response.status_code == 200 + data = response.json() + assert "observation" in data + + def test_protocol_step_endpoint(self, echo_server): + """Test /step endpoint.""" + # First reset + requests.post(f"{echo_server}/reset", json={}) + + # Then step + response = requests.post( + f"{echo_server}/step", json={"action": {"message": "Hello"}} + ) + assert response.status_code == 200 + data = response.json() + assert "observation" in data + + def test_protocol_state_endpoint(self, echo_server): + """Test /state endpoint.""" + # First reset + requests.post(f"{echo_server}/reset", json={}) + + response = requests.get(f"{echo_server}/state") + assert response.status_code == 200 + data = response.json() + assert "step_count" in data + + +class TestProtocolWebSocketClient: + """Test that WebSocket client (EnvClient) works correctly.""" + + @pytest.fixture + def echo_server(self): + """Start echo environment server.""" + with run_server("envs.echo_env.server.app", port=8101) as proc: + yield "http://127.0.0.1:8101" + + def test_protocol_client_connect_and_reset(self, echo_server): + """Test client can connect and reset via WebSocket.""" + from envs.echo_env.client import EchoEnv + + with EchoEnv(base_url=echo_server) as client: + result = client.reset() + assert result is not None + assert result.observation is not None + + def test_protocol_client_step(self, echo_server): + """Test client can step via WebSocket.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=echo_server) as client: + client.reset() + result = client.step(EchoAction(message="Hello")) + assert result is not None + assert result.observation.echoed_message == "Hello" + + def test_protocol_client_state(self, echo_server): + """Test client can get state via WebSocket.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=echo_server) as client: + client.reset() + client.step(EchoAction(message="Test")) + + state = client.state() + assert state is not None + assert state.step_count == 1 + + def test_protocol_client_multiple_episodes(self, echo_server): + """Test client can run multiple episodes.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=echo_server) as client: + # Episode 1 + client.reset() + client.step(EchoAction(message="E1S1")) + client.step(EchoAction(message="E1S2")) + + state1 = client.state() + assert state1.step_count == 2 + + # Episode 2 - reset should clear state + client.reset() + state2 = client.state() + assert state2.step_count == 0 + + client.step(EchoAction(message="E2S1")) + state3 = client.state() + assert state3.step_count == 1 + + +# ============================================================================= +# Concurrency Tests - Multiple Sessions +# ============================================================================= + + +class TestConcurrencyMultipleSessions: + """Test that multiple concurrent sessions work correctly. + + NOTE: These tests require the server to be configured with max_concurrent_envs > 1. + By default, environments only allow 1 concurrent session, so these tests are + marked to skip unless concurrency is explicitly configured. + """ + + @pytest.fixture + def echo_server_concurrent(self): + """Start echo environment server with concurrent sessions enabled.""" + # Pass MAX_CONCURRENT_ENVS env var to enable multiple sessions + with run_server( + "envs.echo_env.server.app", + port=8102, + env_vars={"MAX_CONCURRENT_ENVS": "10"}, + ) as proc: + yield "http://127.0.0.1:8102" + + @pytest.mark.skip( + reason="Concurrency requires server configuration - run manually with MAX_CONCURRENT_ENVS > 1" + ) + def test_concurrency_two_independent_sessions(self, echo_server_concurrent): + """Test that two clients can run independently.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=echo_server_concurrent) as client1: + with EchoEnv(base_url=echo_server_concurrent) as client2: + # Both reset + client1.reset() + client2.reset() + + # Client 1 takes 3 steps + for i in range(3): + client1.step(EchoAction(message=f"C1-{i}")) + + # Client 2 takes 1 step + client2.step(EchoAction(message="C2-0")) + + # Check states are independent + state1 = client1.state() + state2 = client2.state() + + assert state1.step_count == 3 + assert state2.step_count == 1 + + @pytest.mark.skip( + reason="Concurrency requires server configuration - run manually with MAX_CONCURRENT_ENVS > 1" + ) + def test_concurrency_session_isolation(self, echo_server_concurrent): + """Test that session state is isolated between clients.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=echo_server_concurrent) as client1: + client1.reset() + result1 = client1.step(EchoAction(message="Secret from C1")) + + with EchoEnv(base_url=echo_server_concurrent) as client2: + client2.reset() + result2 = client2.step(EchoAction(message="Secret from C2")) + + # Messages should not leak between sessions + assert result1.observation.echoed_message == "Secret from C1" + assert result2.observation.echoed_message == "Secret from C2" + + +# ============================================================================= +# Environment-Specific Tests +# ============================================================================= + + +class TestEchoEnvironment: + """Test EchoEnvironment specifically.""" + + @pytest.fixture + def server(self): + with run_server("envs.echo_env.server.app", port=8200) as proc: + yield "http://127.0.0.1:8200" + + def test_echo_message_echoed(self, server): + """Test that messages are echoed correctly.""" + from envs.echo_env.client import EchoEnv + from envs.echo_env.models import EchoAction + + with EchoEnv(base_url=server) as client: + client.reset() + result = client.step(EchoAction(message="Hello World!")) + assert result.observation.echoed_message == "Hello World!" + assert result.observation.message_length == len("Hello World!") + + +class TestConnect4Environment: + """Test Connect4Environment specifically.""" + + @pytest.fixture + def server(self): + with run_server("envs.connect4_env.server.app", port=8201) as proc: + yield "http://127.0.0.1:8201" + + def test_connect4_initial_board(self, server): + """Test that initial board is empty.""" + from envs.connect4_env.client import Connect4Env + + with Connect4Env(base_url=server) as client: + result = client.reset() + + # Board should be 6x7 and empty (all zeros) + assert len(result.observation.board) == 6 + assert all(len(row) == 7 for row in result.observation.board) + assert all(cell == 0 for row in result.observation.board for cell in row) + + def test_connect4_legal_actions(self, server): + """Test that all columns are legal initially.""" + from envs.connect4_env.client import Connect4Env + + with Connect4Env(base_url=server) as client: + result = client.reset() + + # All 7 columns should be legal + assert len(result.observation.legal_actions) == 7 + + +# ============================================================================= +# Main Entry Point +# ============================================================================= + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--tb=short"]) diff --git a/tests/scripts/__init__.py b/tests/scripts/__init__.py index 6ec7b63de..58a7b1668 100644 --- a/tests/scripts/__init__.py +++ b/tests/scripts/__init__.py @@ -1,4 +1,3 @@ """ Tests for scripts in the scripts/ directory. """ - diff --git a/tests/scripts/test_manage_hf_collection.py b/tests/scripts/test_manage_hf_collection.py index 2c8d29766..26176dbcd 100644 --- a/tests/scripts/test_manage_hf_collection.py +++ b/tests/scripts/test_manage_hf_collection.py @@ -13,42 +13,42 @@ # Import the module to test # Navigate from tests/scripts/ up to repo root, then to scripts/ -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'scripts')) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "scripts")) import manage_hf_collection class TestSetupApi: """Tests for API setup and authentication.""" - + @patch.dict(os.environ, {}, clear=True) def test_setup_api_no_token(self): """Test that setup_api exits when HF_TOKEN is not set.""" with pytest.raises(SystemExit) as exc_info: manage_hf_collection.setup_api() assert exc_info.value.code == 1 - - @patch.dict(os.environ, {'HF_TOKEN': 'test_token'}) - @patch('manage_hf_collection.HfApi') + + @patch.dict(os.environ, {"HF_TOKEN": "test_token"}) + @patch("manage_hf_collection.HfApi") def test_setup_api_success(self, mock_hf_api): """Test successful API setup.""" mock_api = Mock() - mock_api.whoami.return_value = {'name': 'test_user'} + mock_api.whoami.return_value = {"name": "test_user"} mock_hf_api.return_value = mock_api - + api = manage_hf_collection.setup_api() - + assert api is not None - mock_hf_api.assert_called_once_with(token='test_token') + mock_hf_api.assert_called_once_with(token="test_token") mock_api.whoami.assert_called_once() - - @patch.dict(os.environ, {'HF_TOKEN': 'invalid_token'}) - @patch('manage_hf_collection.HfApi') + + @patch.dict(os.environ, {"HF_TOKEN": "invalid_token"}) + @patch("manage_hf_collection.HfApi") def test_setup_api_auth_failure(self, mock_hf_api): """Test that setup_api exits when authentication fails.""" mock_api = Mock() mock_api.whoami.side_effect = Exception("Auth failed") mock_hf_api.return_value = mock_api - + with pytest.raises(SystemExit) as exc_info: manage_hf_collection.setup_api() assert exc_info.value.code == 1 @@ -56,35 +56,35 @@ def test_setup_api_auth_failure(self, mock_hf_api): class TestGetCollectionSpaces: """Tests for fetching spaces from the collection.""" - + def test_get_collection_spaces_success(self): """Test successfully fetching spaces from collection.""" mock_api = Mock() mock_collection = Mock() - + # Create mock items mock_item1 = Mock() mock_item1.item_type = "space" mock_item1.item_id = "owner1/space1" - + mock_item2 = Mock() mock_item2.item_type = "space" mock_item2.item_id = "owner2/space2" - + mock_item3 = Mock() mock_item3.item_type = "model" # Different type, should be ignored mock_item3.item_id = "owner3/model1" - + mock_collection.items = [mock_item1, mock_item2, mock_item3] mock_api.get_collection.return_value = mock_collection - + result = manage_hf_collection.get_collection_spaces(mock_api) - + assert len(result) == 2 assert "owner1/space1" in result assert "owner2/space2" in result assert "owner3/model1" not in result - + def test_get_collection_spaces_not_found(self): """Test handling of collection not found error.""" mock_api = Mock() @@ -92,11 +92,11 @@ def test_get_collection_spaces_not_found(self): mock_response.status_code = 404 error = HfHubHTTPError("Not found", response=mock_response) mock_api.get_collection.side_effect = error - + with pytest.raises(SystemExit) as exc_info: manage_hf_collection.get_collection_spaces(mock_api) assert exc_info.value.code == 1 - + def test_get_collection_spaces_other_error(self): """Test handling of other HTTP errors.""" mock_api = Mock() @@ -104,7 +104,7 @@ def test_get_collection_spaces_other_error(self): mock_response.status_code = 500 error = HfHubHTTPError("Server error", response=mock_response) mock_api.get_collection.side_effect = error - + with pytest.raises(SystemExit) as exc_info: manage_hf_collection.get_collection_spaces(mock_api) assert exc_info.value.code == 1 @@ -112,145 +112,144 @@ def test_get_collection_spaces_other_error(self): class TestDiscoverOpenenvSpaces: """Tests for discovering spaces with openenv tag.""" - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_success(self, mock_list_spaces): """Test successfully discovering openenv spaces.""" mock_api = Mock() - + # Create mock space objects mock_space1 = Mock() mock_space1.id = "owner1/openenv-space1" - + mock_space2 = Mock() mock_space2.id = "owner2/openenv-space2" - + mock_list_spaces.return_value = [mock_space1, mock_space2] - + # Mock space_info to return proper SpaceInfo objects def mock_space_info(space_id): space_info = Mock() - space_info.sdk = 'docker' - space_info.tags = ['openenv', 'environment'] + space_info.sdk = "docker" + space_info.tags = ["openenv", "environment"] return space_info - + mock_api.space_info.side_effect = mock_space_info - + result = manage_hf_collection.discover_openenv_spaces(mock_api) - + assert len(result) == 2 assert "owner1/openenv-space1" in result assert "owner2/openenv-space2" in result - + # Verify list_spaces was called with correct parameters mock_list_spaces.assert_called_once_with( - search="openenv", - full=False + search="openenv", full=False, sort="trending_score", direction=-1 ) - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_filters_non_docker(self, mock_list_spaces): """Test that non-Docker spaces are filtered out.""" mock_api = Mock() - + # Create mock space objects mock_space1 = Mock() mock_space1.id = "owner1/openenv-space1" - + mock_space2 = Mock() mock_space2.id = "owner2/openenv-space2" - + mock_list_spaces.return_value = [mock_space1, mock_space2] - + # First space is Docker with openenv tag, second is Gradio def mock_space_info(space_id): space_info = Mock() if space_id == "owner1/openenv-space1": - space_info.sdk = 'docker' - space_info.tags = ['openenv'] + space_info.sdk = "docker" + space_info.tags = ["openenv"] else: - space_info.sdk = 'gradio' - space_info.tags = ['openenv'] + space_info.sdk = "gradio" + space_info.tags = ["openenv"] return space_info - + mock_api.space_info.side_effect = mock_space_info - + result = manage_hf_collection.discover_openenv_spaces(mock_api) - + # Only Docker space should be returned assert len(result) == 1 assert "owner1/openenv-space1" in result assert "owner2/openenv-space2" not in result - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_filters_missing_tag(self, mock_list_spaces): """Test that spaces without openenv tag are filtered out.""" mock_api = Mock() - + mock_space = Mock() mock_space.id = "owner1/some-space" - + mock_list_spaces.return_value = [mock_space] - + # Space is Docker but doesn't have openenv tag def mock_space_info(space_id): space_info = Mock() - space_info.sdk = 'docker' - space_info.tags = ['other-tag'] + space_info.sdk = "docker" + space_info.tags = ["other-tag"] return space_info - + mock_api.space_info.side_effect = mock_space_info - + result = manage_hf_collection.discover_openenv_spaces(mock_api) - + assert len(result) == 0 - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_empty(self, mock_list_spaces): """Test discovering spaces when none exist.""" mock_api = Mock() mock_list_spaces.return_value = [] - + result = manage_hf_collection.discover_openenv_spaces(mock_api) - + assert len(result) == 0 assert result == [] - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_handles_space_info_error(self, mock_list_spaces): """Test handling of errors when fetching individual space info.""" mock_api = Mock() - + mock_space1 = Mock() mock_space1.id = "owner1/space1" mock_space2 = Mock() mock_space2.id = "owner2/space2" - + mock_list_spaces.return_value = [mock_space1, mock_space2] - + # First space fails, second succeeds def mock_space_info(space_id): if space_id == "owner1/space1": raise Exception("Space not found") space_info = Mock() - space_info.sdk = 'docker' - space_info.tags = ['openenv'] + space_info.sdk = "docker" + space_info.tags = ["openenv"] return space_info - + mock_api.space_info.side_effect = mock_space_info - + result = manage_hf_collection.discover_openenv_spaces(mock_api) - + # Should continue and return second space assert len(result) == 1 assert "owner2/space2" in result - - @patch('manage_hf_collection.list_spaces') + + @patch("manage_hf_collection.list_spaces") def test_discover_openenv_spaces_error(self, mock_list_spaces): """Test handling of errors during space discovery.""" mock_api = Mock() mock_list_spaces.side_effect = Exception("API error") - + with pytest.raises(SystemExit) as exc_info: manage_hf_collection.discover_openenv_spaces(mock_api) assert exc_info.value.code == 1 @@ -258,46 +257,51 @@ def test_discover_openenv_spaces_error(self, mock_list_spaces): class TestAddSpacesToCollection: """Tests for adding spaces to the collection.""" - + def test_add_spaces_empty_list(self): """Test adding empty list of spaces.""" mock_api = Mock() - - result = manage_hf_collection.add_spaces_to_collection(mock_api, [], dry_run=False) - + + result = manage_hf_collection.add_spaces_to_collection( + mock_api, [], dry_run=False + ) + assert result == 0 mock_api.add_collection_item.assert_not_called() - + def test_add_spaces_dry_run(self): """Test adding spaces in dry-run mode.""" mock_api = Mock() space_ids = ["owner1/space1", "owner2/space2"] - + result = manage_hf_collection.add_spaces_to_collection( mock_api, space_ids, dry_run=True ) - + assert result == 2 mock_api.add_collection_item.assert_not_called() - + def test_add_spaces_success(self): """Test successfully adding spaces.""" mock_api = Mock() space_ids = ["owner1/space1", "owner2/space2"] - + result = manage_hf_collection.add_spaces_to_collection( mock_api, space_ids, dry_run=False ) - + assert result == 2 assert mock_api.add_collection_item.call_count == 2 - + # Verify calls were made with correct parameters calls = mock_api.add_collection_item.call_args_list - assert calls[0][1]['collection_slug'] == "openenv/environment-hub-68f16377abea1ea114fa0743" - assert calls[0][1]['item_id'] == "owner1/space1" - assert calls[0][1]['item_type'] == "space" - + assert ( + calls[0][1]["collection_slug"] + == "openenv/environment-hub-68f16377abea1ea114fa0743" + ) + assert calls[0][1]["item_id"] == "owner1/space1" + assert calls[0][1]["item_type"] == "space" + def test_add_spaces_duplicate_conflict(self): """Test handling of duplicate space (409 conflict).""" mock_api = Mock() @@ -305,43 +309,43 @@ def test_add_spaces_duplicate_conflict(self): mock_response.status_code = 409 error = HfHubHTTPError("Conflict", response=mock_response) mock_api.add_collection_item.side_effect = error - + space_ids = ["owner1/space1"] - + result = manage_hf_collection.add_spaces_to_collection( mock_api, space_ids, dry_run=False ) - + # Should not count as success, but should not crash assert result == 0 - + def test_add_spaces_partial_failure(self): """Test adding spaces with some failures.""" mock_api = Mock() mock_response = Mock() mock_response.status_code = 500 error = HfHubHTTPError("Server error", response=mock_response) - + # First call succeeds, second fails mock_api.add_collection_item.side_effect = [None, error] - + space_ids = ["owner1/space1", "owner2/space2"] - + result = manage_hf_collection.add_spaces_to_collection( mock_api, space_ids, dry_run=False ) - + assert result == 1 # Only first one succeeded class TestMain: """Tests for the main function.""" - - @patch('manage_hf_collection.setup_api') - @patch('manage_hf_collection.get_collection_spaces') - @patch('manage_hf_collection.discover_openenv_spaces') - @patch('manage_hf_collection.add_spaces_to_collection') - @patch('sys.argv', ['manage_hf_collection.py', '--dry-run']) + + @patch("manage_hf_collection.setup_api") + @patch("manage_hf_collection.get_collection_spaces") + @patch("manage_hf_collection.discover_openenv_spaces") + @patch("manage_hf_collection.add_spaces_to_collection") + @patch("sys.argv", ["manage_hf_collection.py", "--dry-run"]) def test_main_dry_run( self, mock_add_spaces, mock_discover, mock_get_collection, mock_setup_api ): @@ -351,19 +355,19 @@ def test_main_dry_run( mock_get_collection.return_value = {"owner1/space1"} mock_discover.return_value = ["owner1/space1", "owner2/space2"] mock_add_spaces.return_value = 1 - + manage_hf_collection.main() - + # Verify dry_run=True was passed mock_add_spaces.assert_called_once() args, kwargs = mock_add_spaces.call_args - assert kwargs['dry_run'] is True - - @patch('manage_hf_collection.setup_api') - @patch('manage_hf_collection.get_collection_spaces') - @patch('manage_hf_collection.discover_openenv_spaces') - @patch('manage_hf_collection.add_spaces_to_collection') - @patch('sys.argv', ['manage_hf_collection.py']) + assert kwargs["dry_run"] is True + + @patch("manage_hf_collection.setup_api") + @patch("manage_hf_collection.get_collection_spaces") + @patch("manage_hf_collection.discover_openenv_spaces") + @patch("manage_hf_collection.add_spaces_to_collection") + @patch("sys.argv", ["manage_hf_collection.py"]) def test_main_finds_new_spaces( self, mock_add_spaces, mock_discover, mock_get_collection, mock_setup_api ): @@ -373,19 +377,19 @@ def test_main_finds_new_spaces( mock_get_collection.return_value = {"owner1/space1", "owner2/space2"} mock_discover.return_value = ["owner1/space1", "owner2/space2", "owner3/space3"] mock_add_spaces.return_value = 1 - + manage_hf_collection.main() - + # Verify only new space is added mock_add_spaces.assert_called_once() args, kwargs = mock_add_spaces.call_args assert args[1] == ["owner3/space3"] # Only the new space - - @patch('manage_hf_collection.setup_api') - @patch('manage_hf_collection.get_collection_spaces') - @patch('manage_hf_collection.discover_openenv_spaces') - @patch('manage_hf_collection.add_spaces_to_collection') - @patch('sys.argv', ['manage_hf_collection.py', '--verbose']) + + @patch("manage_hf_collection.setup_api") + @patch("manage_hf_collection.get_collection_spaces") + @patch("manage_hf_collection.discover_openenv_spaces") + @patch("manage_hf_collection.add_spaces_to_collection") + @patch("sys.argv", ["manage_hf_collection.py", "--verbose"]) def test_main_verbose( self, mock_add_spaces, mock_discover, mock_get_collection, mock_setup_api ): @@ -395,21 +399,21 @@ def test_main_verbose( mock_get_collection.return_value = set() mock_discover.return_value = [] mock_add_spaces.return_value = 0 - + # Should not raise any exceptions manage_hf_collection.main() - + mock_setup_api.assert_called_once() class TestIdempotency: """Tests to verify idempotent behavior.""" - - @patch('manage_hf_collection.setup_api') - @patch('manage_hf_collection.get_collection_spaces') - @patch('manage_hf_collection.discover_openenv_spaces') - @patch('manage_hf_collection.add_spaces_to_collection') - @patch('sys.argv', ['manage_hf_collection.py']) + + @patch("manage_hf_collection.setup_api") + @patch("manage_hf_collection.get_collection_spaces") + @patch("manage_hf_collection.discover_openenv_spaces") + @patch("manage_hf_collection.add_spaces_to_collection") + @patch("sys.argv", ["manage_hf_collection.py"]) def test_no_new_spaces_does_nothing( self, mock_add_spaces, mock_discover, mock_get_collection, mock_setup_api ): @@ -419,9 +423,9 @@ def test_no_new_spaces_does_nothing( mock_get_collection.return_value = {"owner1/space1", "owner2/space2"} mock_discover.return_value = ["owner1/space1", "owner2/space2"] mock_add_spaces.return_value = 0 - + manage_hf_collection.main() - + # Verify add_spaces was called with empty list mock_add_spaces.assert_called_once() args, kwargs = mock_add_spaces.call_args @@ -430,4 +434,3 @@ def test_no_new_spaces_does_nothing( if __name__ == "__main__": pytest.main([__file__, "-v"]) - diff --git a/tests/test_cli/test_init.py b/tests/test_cli/test_init.py index 47a7bbf63..0b8e454bc 100644 --- a/tests/test_cli/test_init.py +++ b/tests/test_cli/test_init.py @@ -14,7 +14,7 @@ import typer from typer.testing import CliRunner -from openenv_cli.__main__ import app +from openenv.cli.__main__ import app runner = CliRunner() @@ -29,18 +29,18 @@ def test_init_creates_directory_structure(tmp_path: Path) -> None: """Test that init creates the correct directory structure.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 assert env_dir.exists() assert env_dir.is_dir() - + # Check for required files assert (env_dir / "__init__.py").exists() assert (env_dir / "models.py").exists() @@ -59,16 +59,16 @@ def test_init_replaces_template_placeholders(tmp_path: Path) -> None: """Test that template placeholders are replaced correctly.""" env_name = "my_game_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + # Check models.py has correct class names # For 'my_game_env', prefix is 'MyGame' (removes trailing '_env') models_content = (env_dir / "models.py").read_text() @@ -76,20 +76,20 @@ def test_init_replaces_template_placeholders(tmp_path: Path) -> None: assert "MyGameObservation" in models_content assert "__ENV_NAME__" not in models_content assert "__ENV_CLASS_NAME__" not in models_content - + # Check client.py has correct class names client_content = (env_dir / "client.py").read_text() assert "MyGameEnv" in client_content assert "MyGameAction" in client_content assert "MyGameObservation" in client_content assert "__ENV_NAME__" not in client_content - + # Check __init__.py has correct exports init_content = (env_dir / "__init__.py").read_text() assert "MyGameAction" in init_content assert "MyGameObservation" in init_content assert "MyGameEnv" in init_content - + # Check environment file has correct class name env_file = env_dir / "server" / f"{env_name}_environment.py" assert env_file.exists() @@ -102,19 +102,19 @@ def test_init_generates_openenv_yaml(tmp_path: Path) -> None: """Test that openenv.yaml is generated correctly.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + yaml_file = env_dir / "openenv.yaml" assert yaml_file.exists() - + yaml_content = yaml_file.read_text() assert f"name: {env_name}" in yaml_content assert "type: space" in yaml_content @@ -128,21 +128,21 @@ def test_init_readme_has_hf_frontmatter(tmp_path: Path) -> None: """Test that README has Hugging Face Space compatible frontmatter.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + readme_file = env_dir / "README.md" assert readme_file.exists() - + readme_content = readme_file.read_text() - + # Check for required HF Space frontmatter assert "---" in readme_content assert "title:" in readme_content @@ -150,7 +150,7 @@ def test_init_readme_has_hf_frontmatter(tmp_path: Path) -> None: assert "app_port: 8000" in readme_content assert "tags:" in readme_content assert "- openenv" in readme_content - + # Check that placeholders are replaced assert "__ENV_NAME__" not in readme_content assert "__ENV_TITLE_NAME__" not in readme_content @@ -164,12 +164,15 @@ def test_init_validates_env_name(tmp_path: Path) -> None: # Invalid: starts with number result = runner.invoke(app, ["init", "123_env"], input="\n") assert result.exit_code != 0 - assert "not a valid python identifier" in result.output.lower() or "not a valid identifier" in result.output.lower() - + assert ( + "not a valid python identifier" in result.output.lower() + or "not a valid identifier" in result.output.lower() + ) + # Invalid: contains spaces result = runner.invoke(app, ["init", "my env"], input="\n") assert result.exit_code != 0 - + # Invalid: contains hyphens result = runner.invoke(app, ["init", "my-env"], input="\n") assert result.exit_code != 0 @@ -183,16 +186,19 @@ def test_init_handles_existing_directory(tmp_path: Path) -> None: env_dir = tmp_path / env_name env_dir.mkdir() (env_dir / "some_file.txt").write_text("existing content") - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code != 0 - assert "already exists" in result.output.lower() or "not empty" in result.output.lower() + assert ( + "already exists" in result.output.lower() + or "not empty" in result.output.lower() + ) def test_init_handles_empty_directory(tmp_path: Path) -> None: @@ -200,14 +206,14 @@ def test_init_handles_empty_directory(tmp_path: Path) -> None: env_name = "empty_env" env_dir = tmp_path / env_name env_dir.mkdir() - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + # Should work - empty directory is okay assert result.exit_code == 0 assert (env_dir / "models.py").exists() @@ -219,13 +225,13 @@ def test_init_with_output_dir(tmp_path: Path) -> None: output_dir = tmp_path / "custom_output" output_dir.mkdir() env_dir = output_dir / env_name - + result = runner.invoke( app, ["init", env_name, "--output-dir", str(output_dir)], input="\n", ) - + assert result.exit_code == 0 assert env_dir.exists() assert (env_dir / "models.py").exists() @@ -235,20 +241,20 @@ def test_init_filename_templating(tmp_path: Path) -> None: """Test that filenames with placeholders are renamed correctly.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + # Check that environment file is renamed correctly env_file = env_dir / "server" / f"{env_name}_environment.py" assert env_file.exists() - + # Check that __ENV_NAME___environment.py doesn't exist (should be renamed) template_name = env_dir / "server" / "__ENV_NAME___environment.py" assert not template_name.exists() @@ -258,55 +264,58 @@ def test_init_all_naming_conventions(tmp_path: Path) -> None: """Test that all naming conventions are replaced correctly.""" env_name = "complex_test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + # Check PascalCase # For 'complex_test_env', prefix is 'ComplexTest' (removes trailing '_env') models_content = (env_dir / "models.py").read_text() assert "ComplexTestAction" in models_content assert "ComplexTestObservation" in models_content - + # Check snake_case in imports assert env_name in models_content # Should see snake_case module name - + # Check Title Case in README readme_content = (env_dir / "README.md").read_text() - assert "Complex Test Env" in readme_content or env_name.lower() in readme_content.lower() + assert ( + "Complex Test Env" in readme_content + or env_name.lower() in readme_content.lower() + ) def test_init_server_app_imports(tmp_path: Path) -> None: """Test that server/app.py has correct imports after templating.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + app_content = (env_dir / "server" / "app.py").read_text() - + # Check imports use correct class names # For 'test_env', prefix is 'Test' (removes trailing '_env') - # Uses absolute imports from the env_name module + # Template uses direct imports (PYTHONPATH includes env dir in Docker) assert f"from .{env_name}_environment import" in app_content - assert f"from {env_name}.models import" in app_content + assert "from models import" in app_content # Direct import for Docker compatibility assert "TestEnvironment" in app_content # Prefix is 'Test', not 'TestEnv' assert "TestAction" in app_content # Prefix is 'Test', not 'TestEnv' assert "TestObservation" in app_content # Prefix is 'Test', not 'TestEnv' - + # Check that no template placeholders remain assert "__ENV_NAME__" not in app_content assert "__ENV_CLASS_NAME__" not in app_content @@ -316,27 +325,27 @@ def test_init_dockerfile_uses_correct_base(tmp_path: Path) -> None: """Test that Dockerfile uses correct base image and paths.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + dockerfile = env_dir / "server" / "Dockerfile" assert dockerfile.exists() - + dockerfile_content = dockerfile.read_text() - + # Check base image assert "ghcr.io/meta-pytorch/openenv-base:latest" in dockerfile_content - + # Check CMD uses correct module path (could be in list format or string format) assert "server.app:app" in dockerfile_content - + # Check that no template placeholders remain assert "__ENV_NAME__" not in dockerfile_content @@ -345,23 +354,23 @@ def test_init_requirements_file(tmp_path: Path) -> None: """Test that requirements.txt is generated correctly.""" env_name = "test_env" env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 - + requirements = env_dir / "server" / "requirements.txt" assert requirements.exists() - + req_content = requirements.read_text() assert "fastapi" in req_content assert "uvicorn" in req_content - assert "openenv-core>=0.1.0" in req_content + assert "openenv[core]>=0.2.0" in req_content def test_init_validates_empty_env_name(tmp_path: Path) -> None: @@ -372,7 +381,7 @@ def test_init_validates_empty_env_name(tmp_path: Path) -> None: result = runner.invoke(app, ["init", ""], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "cannot be empty" in result.output.lower() @@ -381,17 +390,17 @@ def test_init_env_name_without_env_suffix(tmp_path: Path) -> None: """Test that init works with env names that don't end with _env.""" env_name = "mygame" # No _env suffix env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 assert env_dir.exists() - + # Check that prefix is correctly derived (should be "Mygame" for "mygame") models_content = (env_dir / "models.py").read_text() assert "MygameAction" in models_content or "Mygame" in models_content @@ -401,14 +410,14 @@ def test_init_single_part_env_name(tmp_path: Path) -> None: """Test that init works with single-part env names.""" env_name = "game" # Single part, no underscores env_dir = tmp_path / env_name - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + assert result.exit_code == 0 assert env_dir.exists() @@ -418,26 +427,30 @@ def test_init_handles_file_path_collision(tmp_path: Path) -> None: env_name = "existing_file" file_path = tmp_path / env_name file_path.write_text("existing file content") - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["init", env_name], input="\n") finally: os.chdir(old_cwd) - + # The command should fail with exit code 2 (typer bad parameter) - assert result.exit_code != 0, f"Expected command to fail, but it succeeded. Output: {result.output}" + assert result.exit_code != 0, ( + f"Expected command to fail, but it succeeded. Output: {result.output}" + ) # Check that it's a BadParameter error (exit code 2) and not just a usage error # Typer formats BadParameter errors in the Error section error_output = result.output.lower() # The error message should mention the path or file, or at least indicate an error # Exit code 2 indicates BadParameter, and "error" in output indicates it's an error assert ( - result.exit_code == 2 or # BadParameter exit code - "error" in error_output or - "exists" in error_output or - "file" in error_output or - str(file_path).lower() in error_output or - env_name.lower() in error_output - ), f"Expected BadParameter error about file collision. Exit code: {result.exit_code}, Output: {result.output}" + result.exit_code == 2 # BadParameter exit code + or "error" in error_output + or "exists" in error_output + or "file" in error_output + or str(file_path).lower() in error_output + or env_name.lower() in error_output + ), ( + f"Expected BadParameter error about file collision. Exit code: {result.exit_code}, Output: {result.output}" + ) diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py index 48945ad43..5957805f5 100644 --- a/tests/test_cli/test_main.py +++ b/tests/test_cli/test_main.py @@ -12,7 +12,7 @@ import pytest from typer.testing import CliRunner -from openenv_cli.__main__ import app, main +from openenv.cli.__main__ import app, main runner = CliRunner() @@ -20,23 +20,23 @@ def test_main_handles_keyboard_interrupt() -> None: """Test that main handles KeyboardInterrupt gracefully.""" - with patch("openenv_cli.__main__.app") as mock_app: + with patch("openenv.cli.__main__.app") as mock_app: mock_app.side_effect = KeyboardInterrupt() - + with pytest.raises(SystemExit) as exc_info: main() - + assert exc_info.value.code == 130 def test_main_handles_generic_exception() -> None: """Test that main handles generic exceptions gracefully.""" - with patch("openenv_cli.__main__.app") as mock_app: + with patch("openenv.cli.__main__.app") as mock_app: mock_app.side_effect = ValueError("Test error") - + with pytest.raises(SystemExit) as exc_info: main() - + assert exc_info.value.code == 1 @@ -44,7 +44,6 @@ def test_main_entry_point() -> None: """Test that main() can be called as entry point.""" # This tests the if __name__ == "__main__" block indirectly # by ensuring main() function works - with patch("openenv_cli.__main__.app") as mock_app: + with patch("openenv.cli.__main__.app") as mock_app: main() mock_app.assert_called_once() - diff --git a/tests/test_cli/test_push.py b/tests/test_cli/test_push.py index 70b628176..b92eb5517 100644 --- a/tests/test_cli/test_push.py +++ b/tests/test_cli/test_push.py @@ -15,14 +15,16 @@ import typer from typer.testing import CliRunner -from openenv_cli.__main__ import app +from openenv.cli.__main__ import app runner = CliRunner() def _create_test_openenv_env(env_dir: Path, env_name: str = "test_env") -> None: - """Create a minimal OpenEnv environment for testing.""" + """Create a complete OpenEnv environment for testing.""" + import yaml + # Create openenv.yaml manifest = { "spec_version": 1, @@ -32,17 +34,34 @@ def _create_test_openenv_env(env_dir: Path, env_name: str = "test_env") -> None: "app": "server.app:app", "port": 8000, } - - import yaml with open(env_dir / "openenv.yaml", "w") as f: yaml.dump(manifest, f) - - # Create minimal server directory + + # Create pyproject.toml (required by validate_env_structure) + pyproject_content = f"""[project] +name = "{env_name}" +version = "0.1.0" +dependencies = ["openenv[core]>=0.2.0"] +""" + (env_dir / "pyproject.toml").write_text(pyproject_content) + + # Create __init__.py + (env_dir / "__init__.py").write_text("# Test environment\n") + + # Create client.py (required by validate_env_structure) + (env_dir / "client.py").write_text("# Test client\n") + + # Create models.py (required by validate_env_structure) + (env_dir / "models.py").write_text("# Test models\n") + + # Create server directory and files (env_dir / "server").mkdir(exist_ok=True) + (env_dir / "server" / "__init__.py").write_text("# Server module\n") + (env_dir / "server" / "app.py").write_text("# App module\n") (env_dir / "server" / "Dockerfile").write_text( - "FROM openenv-base:latest\nCMD [\"uvicorn\", \"server.app:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n" + 'FROM openenv-base:latest\nCMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"]\n' ) - + # Create README.md with frontmatter readme_content = """--- title: Test Environment @@ -53,9 +72,6 @@ def _create_test_openenv_env(env_dir: Path, env_name: str = "test_env") -> None: # Test Environment """ (env_dir / "README.md").write_text(readme_content) - - # Create a simple Python file - (env_dir / "__init__.py").write_text("# Test environment\n") def test_push_validates_openenv_directory(tmp_path: Path) -> None: @@ -66,23 +82,26 @@ def test_push_validates_openenv_directory(tmp_path: Path) -> None: result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 - assert "openenv.yaml" in result.output.lower() or "manifest" in result.output.lower() + assert ( + "openenv.yaml" in result.output.lower() or "manifest" in result.output.lower() + ) def test_push_validates_openenv_yaml_format(tmp_path: Path) -> None: """Test that push validates openenv.yaml format.""" - # Create invalid YAML + # Create complete env structure then overwrite openenv.yaml with invalid content + _create_test_openenv_env(tmp_path) (tmp_path / "openenv.yaml").write_text("invalid: yaml: content: [") - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "parse" in result.output.lower() or "yaml" in result.output.lower() @@ -90,17 +109,20 @@ def test_push_validates_openenv_yaml_format(tmp_path: Path) -> None: def test_push_validates_openenv_yaml_has_name(tmp_path: Path) -> None: """Test that push validates openenv.yaml has a name field.""" import yaml + + # Create complete env structure then overwrite openenv.yaml without name + _create_test_openenv_env(tmp_path) manifest = {"spec_version": 1, "type": "space"} with open(tmp_path / "openenv.yaml", "w") as f: yaml.dump(manifest, f) - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "name" in result.output.lower() @@ -108,26 +130,27 @@ def test_push_validates_openenv_yaml_has_name(tmp_path: Path) -> None: def test_push_authenticates_with_hf(tmp_path: Path) -> None: """Test that push ensures Hugging Face authentication.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): # Mock whoami to return user info mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt - + # Mock HfApi mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify whoami was called assert mock_whoami.called @@ -135,23 +158,24 @@ def test_push_authenticates_with_hf(tmp_path: Path) -> None: def test_push_enables_web_interface_in_dockerfile(tmp_path: Path) -> None: """Test that push enables web interface in Dockerfile.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify API was called (upload_folder) assert mock_api.upload_folder.called @@ -159,7 +183,7 @@ def test_push_enables_web_interface_in_dockerfile(tmp_path: Path) -> None: def test_push_updates_readme_frontmatter(tmp_path: Path) -> None: """Test that push updates README frontmatter with base_path.""" _create_test_openenv_env(tmp_path) - + # Create README without base_path readme_content = """--- title: Test Environment @@ -170,23 +194,24 @@ def test_push_updates_readme_frontmatter(tmp_path: Path) -> None: # Test Environment """ (tmp_path / "README.md").write_text(readme_content) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify API was called assert mock_api.upload_folder.called @@ -194,23 +219,24 @@ def test_push_updates_readme_frontmatter(tmp_path: Path) -> None: def test_push_uses_repo_id_option(tmp_path: Path) -> None: """Test that push respects --repo-id option.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push", "--repo-id", "custom-org/my-env"]) finally: os.chdir(old_cwd) - + # Verify create_repo was called with correct repo_id mock_api.create_repo.assert_called_once() call_args = mock_api.create_repo.call_args @@ -220,23 +246,24 @@ def test_push_uses_repo_id_option(tmp_path: Path) -> None: def test_push_uses_default_repo_id(tmp_path: Path) -> None: """Test that push uses default repo-id from username and env name.""" _create_test_openenv_env(tmp_path, env_name="test_env") - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify create_repo was called with default repo_id mock_api.create_repo.assert_called_once() call_args = mock_api.create_repo.call_args @@ -246,23 +273,24 @@ def test_push_uses_default_repo_id(tmp_path: Path) -> None: def test_push_uses_private_option(tmp_path: Path) -> None: """Test that push respects --private option.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push", "--private"]) finally: os.chdir(old_cwd) - + # Verify create_repo was called with private=True mock_api.create_repo.assert_called_once() call_args = mock_api.create_repo.call_args @@ -272,125 +300,111 @@ def test_push_uses_private_option(tmp_path: Path) -> None: def test_push_uses_base_image_option(tmp_path: Path) -> None: """Test that push respects --base-image option.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push", "--base-image", "custom-base:latest"]) finally: os.chdir(old_cwd) - + # Verify API was called (we can't easily test Dockerfile modification without reading staging dir) assert mock_api.upload_folder.called -def test_push_uses_directory_option(tmp_path: Path) -> None: - """Test that push respects --directory option.""" +def test_push_uses_directory_argument(tmp_path: Path) -> None: + """Test that push respects directory argument.""" env_dir = tmp_path / "my_env" env_dir.mkdir() _create_test_openenv_env(env_dir) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + + # Directory is a positional argument, not an option result = runner.invoke( app, - ["push", "--directory", str(env_dir)], + ["push", str(env_dir)], ) - + # Verify API was called assert mock_api.upload_folder.called def test_push_handles_missing_dockerfile(tmp_path: Path) -> None: - """Test that push handles missing Dockerfile gracefully.""" + """Test that push fails when Dockerfile is missing (required for deployment).""" _create_test_openenv_env(tmp_path) # Remove Dockerfile (tmp_path / "server" / "Dockerfile").unlink() - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - - mock_whoami.return_value = {"name": "testuser"} - mock_login.return_value = None # Prevent actual login prompt - mock_api = MagicMock() - mock_hf_api_class.return_value = mock_api - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - # Should still work, just warn about missing Dockerfile - result = runner.invoke(app, ["push"]) - finally: - os.chdir(old_cwd) - - # Verify command was attempted (should warn but continue) - assert mock_api.upload_folder.called + + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + result = runner.invoke(app, ["push"]) + finally: + os.chdir(old_cwd) + + # Dockerfile is now required - should fail + assert result.exit_code != 0 + assert "dockerfile" in result.output.lower() or "missing" in result.output.lower() def test_push_handles_missing_readme(tmp_path: Path) -> None: - """Test that push handles missing README gracefully.""" + """Test that push fails when README.md is missing (required for deployment).""" _create_test_openenv_env(tmp_path) # Remove README (tmp_path / "README.md").unlink() - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - - mock_whoami.return_value = {"name": "testuser"} - mock_login.return_value = None # Prevent actual login prompt - mock_api = MagicMock() - mock_hf_api_class.return_value = mock_api - - old_cwd = os.getcwd() - try: - os.chdir(str(tmp_path)) - # Should still work, just warn about missing README - result = runner.invoke(app, ["push"]) - finally: - os.chdir(old_cwd) - - # Verify command was attempted (should warn but continue) - assert mock_api.upload_folder.called + + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + result = runner.invoke(app, ["push"]) + finally: + os.chdir(old_cwd) + + # README.md is now required - should fail + assert result.exit_code != 0 + assert "readme" in result.output.lower() or "missing" in result.output.lower() def test_push_initializes_hf_api_without_token(tmp_path: Path) -> None: """Test that push initializes HfApi without token parameter.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify HfApi was initialized without token parameter mock_hf_api_class.assert_called_once() call_args = mock_hf_api_class.call_args @@ -401,17 +415,18 @@ def test_push_initializes_hf_api_without_token(tmp_path: Path) -> None: def test_push_validates_repo_id_format(tmp_path: Path) -> None: """Test that push validates repo-id format.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt # Mock HfApi to prevent actual API calls mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) @@ -419,25 +434,27 @@ def test_push_validates_repo_id_format(tmp_path: Path) -> None: result = runner.invoke(app, ["push", "--repo-id", "invalid-repo-id"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "repo-id" in result.output.lower() or "format" in result.output.lower() def test_push_validates_manifest_is_dict(tmp_path: Path) -> None: """Test that push validates manifest is a dictionary.""" - # Create openenv.yaml with non-dict content import yaml + + # Create complete env structure then overwrite openenv.yaml with non-dict + _create_test_openenv_env(tmp_path) with open(tmp_path / "openenv.yaml", "w") as f: yaml.dump("not a dict", f) - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "dictionary" in result.output.lower() or "yaml" in result.output.lower() @@ -445,28 +462,29 @@ def test_push_validates_manifest_is_dict(tmp_path: Path) -> None: def test_push_handles_whoami_object_return(tmp_path: Path) -> None: """Test that push handles whoami returning an object instead of dict.""" _create_test_openenv_env(tmp_path) - + # Create a mock object with name attribute class MockUser: def __init__(self): self.name = "testuser" - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = MockUser() mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify it worked with object return type assert mock_api.upload_folder.called @@ -474,11 +492,12 @@ def __init__(self): def test_push_handles_authentication_failure(tmp_path: Path) -> None: """Test that push handles authentication failure.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): # First whoami call fails (not authenticated) # Login also fails mock_whoami.side_effect = Exception("Not authenticated") @@ -486,26 +505,30 @@ def test_push_handles_authentication_failure(tmp_path: Path) -> None: # Mock HfApi to prevent actual API calls mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 - assert "authentication" in result.output.lower() or "login" in result.output.lower() + assert ( + "authentication" in result.output.lower() + or "login" in result.output.lower() + ) def test_push_handles_whoami_missing_username(tmp_path: Path) -> None: """Test that push handles whoami response without username.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): # Return dict without name, fullname, or username mock_whoami.return_value = {} # Mock login to prevent actual login prompt @@ -513,14 +536,14 @@ def test_push_handles_whoami_missing_username(tmp_path: Path) -> None: # Mock HfApi to prevent actual API calls mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "username" in result.output.lower() or "extract" in result.output.lower() @@ -528,26 +551,27 @@ def test_push_handles_whoami_missing_username(tmp_path: Path) -> None: def test_push_handles_readme_without_frontmatter(tmp_path: Path) -> None: """Test that push handles README without frontmatter.""" _create_test_openenv_env(tmp_path) - + # Create README without frontmatter (tmp_path / "README.md").write_text("# Test Environment\nNo frontmatter here.\n") - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Verify it still works (should add frontmatter) assert mock_api.upload_folder.called @@ -555,17 +579,18 @@ def test_push_handles_readme_without_frontmatter(tmp_path: Path) -> None: def test_push_handles_hf_api_create_repo_error(tmp_path: Path) -> None: """Test that push handles HF API create_repo error.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_api.create_repo.side_effect = Exception("API Error") mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) @@ -573,7 +598,7 @@ def test_push_handles_hf_api_create_repo_error(tmp_path: Path) -> None: result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + # Should still attempt upload assert mock_api.upload_folder.called @@ -581,24 +606,25 @@ def test_push_handles_hf_api_create_repo_error(tmp_path: Path) -> None: def test_push_handles_hf_api_upload_error(tmp_path: Path) -> None: """Test that push handles HF API upload_folder error.""" _create_test_openenv_env(tmp_path) - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_api.upload_folder.side_effect = Exception("Upload failed") mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push"]) finally: os.chdir(old_cwd) - + assert result.exit_code != 0 assert "upload" in result.output.lower() or "failed" in result.output.lower() @@ -606,25 +632,28 @@ def test_push_handles_hf_api_upload_error(tmp_path: Path) -> None: def test_push_handles_base_image_not_found_in_dockerfile(tmp_path: Path) -> None: """Test that push handles Dockerfile without FROM line.""" _create_test_openenv_env(tmp_path) - + # Create Dockerfile without FROM line - (tmp_path / "server" / "Dockerfile").write_text("RUN echo 'test'\nCMD [\"echo\", \"test\"]\n") - - with patch("openenv_cli.commands.push.whoami") as mock_whoami, \ - patch("openenv_cli.commands.push.login") as mock_login, \ - patch("openenv_cli.commands.push.HfApi") as mock_hf_api_class: - + (tmp_path / "server" / "Dockerfile").write_text( + 'RUN echo \'test\'\nCMD ["echo", "test"]\n' + ) + + with ( + patch("openenv.cli.commands.push.whoami") as mock_whoami, + patch("openenv.cli.commands.push.login") as mock_login, + patch("openenv.cli.commands.push.HfApi") as mock_hf_api_class, + ): mock_whoami.return_value = {"name": "testuser"} mock_login.return_value = None # Prevent actual login prompt mock_api = MagicMock() mock_hf_api_class.return_value = mock_api - + old_cwd = os.getcwd() try: os.chdir(str(tmp_path)) result = runner.invoke(app, ["push", "--base-image", "custom-base:latest"]) finally: os.chdir(old_cwd) - + # Should still work (adds FROM at beginning) assert mock_api.upload_folder.called