From 939de3c3d04f6c52c1331515f6c4a99d297e4c8b Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 7 Nov 2025 09:51:55 +0100 Subject: [PATCH 01/12] implement a uv subprocess provider based on docker provider --- src/core/containers/runtime/uv_provider.py | 183 +++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/core/containers/runtime/uv_provider.py diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py new file mode 100644 index 00000000..caa669f4 --- /dev/null +++ b/src/core/containers/runtime/uv_provider.py @@ -0,0 +1,183 @@ +"""Providers for launching Hugging Face Spaces via ``uv run``.""" + +from __future__ import annotations + +import os +import socket +import subprocess +import time +from dataclasses import dataclass, field +from typing import Dict, Optional + +import requests + +from .providers import ContainerProvider + + +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: + response = requests.get(health_url, timeout=2.0) + if response.status_code == 200: + return + except requests.RequestException: + pass + + time.sleep(0.5) + + raise TimeoutError( + f"Server did not become ready within {timeout_s:.1f} seconds" + ) + + +def _create_uv_command( + repo_id: str, + host: str, + port: int, + reload: bool, + project_url: Optional[str] = None, +) -> list[str]: + command = [ + "uv", + "run", + "--project", + project_url or f"git+https://huggingface.co/spaces/{repo_id}", + "--", + "server", + "--host", + host, + "--port", + str(port), + ] + if reload: + command.append("--reload") + return command + + +@dataclass +class UVProvider(ContainerProvider): + """ContainerProvider implementation backed by ``uv run``.""" + + repo_id: str + host: str = "0.0.0.0" + port: Optional[int] = None + reload: bool = False + project_url: Optional[str] = None + connect_host: Optional[str] = None + extra_env: Optional[Dict[str, str]] = None + context_timeout_s: float = 60.0 + + _process: subprocess.Popen | None = field(init=False, default=None) + _base_url: str | None = field(init=False, default=None) + + def start_container( + self, + image: str, + port: Optional[int] = None, + env_vars: Optional[Dict[str, str]] = None, + **_: Dict[str, str], + ) -> str: + if self._process is not None and self._process.poll() is None: + raise RuntimeError("UVProvider is already running") + + self.repo_id = image or self.repo_id + + bind_port = port or self.port or self._find_free_port() + + command = _create_uv_command( + self.repo_id, + self.host, + bind_port, + self.reload, + project_url=self.project_url, + ) + + env = os.environ.copy() + if self.extra_env: + env.update(self.extra_env) + if env_vars: + env.update(env_vars) + + try: + self._process = subprocess.Popen(command, env=env) + except FileNotFoundError as exc: + raise RuntimeError( + "`uv` executable not found. Install uv from " + "https://github.com/astral-sh/uv and ensure it is on PATH." + ) from exc + except OSError as exc: + raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc + + client_host = self.connect_host or ( + "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host + ) + self._base_url = f"http://{client_host}:{bind_port}" + self.port = bind_port + return self._base_url + + def wait_for_ready(self, base_url: str, timeout_s: float = 60.0) -> None: + 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"{base_url}/health", timeout_s) + + def stop_container(self) -> None: + 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 + + def start(self) -> str: + return self.start_container(self.repo_id, port=self.port) + + def stop(self) -> None: + self.stop_container() + + def wait_for_ready_default(self, timeout_s: float | None = None) -> None: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + self.wait_for_ready( + self._base_url, + timeout_s or self.context_timeout_s, + ) + + def close(self) -> None: + self.stop_container() + + def __enter__(self) -> "UVProvider": + if self._base_url is None: + base_url = self.start_container(self.repo_id, port=self.port) + self.wait_for_ready(base_url, timeout_s=self.context_timeout_s) + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.stop_container() + + def _find_free_port(self) -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + sock.listen(1) + return sock.getsockname()[1] + + @property + def base_url(self) -> str: + if self._base_url is None: + raise RuntimeError("UVProvider has not been started") + return self._base_url + + From 1108d8d29115c4f9761584d27b59932373cb768c Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 7 Nov 2025 09:52:10 +0100 Subject: [PATCH 02/12] add uv provider to http client --- src/core/http_env_client.py | 80 +++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 16 deletions(-) diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py index 16bbfa5d..c3ac4625 100644 --- a/src/core/http_env_client.py +++ b/src/core/http_env_client.py @@ -17,7 +17,7 @@ import requests from .client_types import StepResult -from .containers.runtime import LocalDockerProvider +from .containers.runtime import LocalDockerProvider, UVProvider if TYPE_CHECKING: from .containers.runtime import ContainerProvider @@ -106,22 +106,70 @@ def from_docker_image( 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. + def from_hub( + cls: Type[EnvClientT], + repo_id: str, + *, + use_docker: bool = False, + provider: Optional["ContainerProvider"] = None, + host: str = "0.0.0.0", + port: Optional[int] = None, + reload: bool = False, + timeout_s: float = 60.0, + runner: Optional[UVProvider] = None, + project_url: Optional[str] = None, + connect_host: Optional[str] = None, + extra_env: Optional[Dict[str, str]] = None, + **provider_kwargs: Any, + ) -> EnvClientT: + """Create a client from a Hugging Face Space. + + Set ``use_docker=True`` to launch the registry image with a container + provider. The default ``use_docker=False`` runs the Space locally using + ``uv run`` through :class:`UVProvider`. """ - - 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) + + if use_docker: + if provider is None: + provider = LocalDockerProvider() + + tag = provider_kwargs.pop("tag", "latest") + image = provider_kwargs.pop( + "image", + f"registry.hf.space/{repo_id.replace('/', '-')}:" f"{tag}", + ) + + base_url = provider.start_container(image, **provider_kwargs) + provider.wait_for_ready(base_url, timeout_s=timeout_s) + return cls(base_url=base_url, provider=provider) + + uv_runner = runner or UVProvider( + repo_id=repo_id, + host=host, + port=port, + reload=reload, + project_url=project_url, + connect_host=connect_host, + extra_env=extra_env, + ) + + non_docker_kwargs = dict(provider_kwargs) + env_vars = non_docker_kwargs.pop("env_vars", None) + + base_url = uv_runner.start_container( + repo_id, + port=port, + env_vars=env_vars, + **non_docker_kwargs, + ) + + try: + uv_runner.wait_for_ready(base_url, timeout_s=timeout_s) + except Exception: + uv_runner.stop_container() + raise + + return cls(base_url=base_url, provider=uv_runner) @abstractmethod def _step_payload(self, action: ActT) -> dict: From 01a583538df5a2909cef4f146e9309fa9fd0e900 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Fri, 7 Nov 2025 09:52:23 +0100 Subject: [PATCH 03/12] expose uv provider --- src/core/containers/runtime/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/containers/runtime/__init__.py b/src/core/containers/runtime/__init__.py index a72b5301..5cc6cf49 100644 --- a/src/core/containers/runtime/__init__.py +++ b/src/core/containers/runtime/__init__.py @@ -7,9 +7,11 @@ """Container runtime providers.""" from .providers import ContainerProvider, KubernetesProvider, LocalDockerProvider +from .uv_provider import UVProvider __all__ = [ "ContainerProvider", "LocalDockerProvider", "KubernetesProvider", + "UVProvider", ] \ No newline at end of file From b1d213df5216b3a9234a6036efea2fee7d7e3b0d Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 12:13:47 +0100 Subject: [PATCH 04/12] use isolated and with in uv command --- src/core/containers/runtime/uv_provider.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py index caa669f4..9a2f7db6 100644 --- a/src/core/containers/runtime/uv_provider.py +++ b/src/core/containers/runtime/uv_provider.py @@ -28,9 +28,7 @@ def _poll_health(health_url: str, timeout_s: float) -> None: time.sleep(0.5) - raise TimeoutError( - f"Server did not become ready within {timeout_s:.1f} seconds" - ) + raise TimeoutError(f"Server did not become ready within {timeout_s:.1f} seconds") def _create_uv_command( @@ -43,7 +41,8 @@ def _create_uv_command( command = [ "uv", "run", - "--project", + "--isolated", + "--with", project_url or f"git+https://huggingface.co/spaces/{repo_id}", "--", "server", @@ -121,9 +120,7 @@ def start_container( def wait_for_ready(self, base_url: str, timeout_s: float = 60.0) -> None: if self._process and self._process.poll() is not None: code = self._process.returncode - raise RuntimeError( - f"uv process exited prematurely with code {code}" - ) + raise RuntimeError(f"uv process exited prematurely with code {code}") _poll_health(f"{base_url}/health", timeout_s) @@ -179,5 +176,3 @@ def base_url(self) -> str: if self._base_url is None: raise RuntimeError("UVProvider has not been started") return self._base_url - - From ec693dc7d07c96b6436847f3d0df8bfd6bbbbd28 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 12:34:08 +0100 Subject: [PATCH 05/12] Update src/core/containers/runtime/uv_provider.py Co-authored-by: Lucain --- src/core/containers/runtime/uv_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py index 9a2f7db6..b50dad50 100644 --- a/src/core/containers/runtime/uv_provider.py +++ b/src/core/containers/runtime/uv_provider.py @@ -20,11 +20,12 @@ def _poll_health(health_url: str, timeout_s: float) -> None: deadline = time.time() + timeout_s while time.time() < deadline: try: - response = requests.get(health_url, timeout=2.0) + 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: - pass + continue time.sleep(0.5) From f79eef8b276277cc20fc6cf87a51a74233bfc281 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 12:59:35 +0100 Subject: [PATCH 06/12] add shim for none container runtimes --- src/core/containers/runtime/providers.py | 65 ++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/core/containers/runtime/providers.py b/src/core/containers/runtime/providers.py index a8022ddc..6ea44953 100644 --- a/src/core/containers/runtime/providers.py +++ b/src/core/containers/runtime/providers.py @@ -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,54 @@ 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() + >>> base_url = provider.start_container("echo-env:latest") + >>> print(base_url) # http://localhost:8000 + >>> # Use the environment via base_url + >>> provider.stop_container() + """ + + @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 From eb6a7feee72b19a864ebe0295fa3227e77e30a6a Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:00:16 +0100 Subject: [PATCH 07/12] refactor to use shim and simplify signatures --- src/core/containers/runtime/uv_provider.py | 182 ++++++++++++--------- 1 file changed, 108 insertions(+), 74 deletions(-) diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py index b50dad50..a8a7f190 100644 --- a/src/core/containers/runtime/uv_provider.py +++ b/src/core/containers/runtime/uv_provider.py @@ -6,12 +6,11 @@ import socket import subprocess import time -from dataclasses import dataclass, field from typing import Dict, Optional import requests -from .providers import ContainerProvider +from .providers import RuntimeProvider def _poll_health(health_url: str, timeout_s: float) -> None: @@ -37,14 +36,13 @@ def _create_uv_command( host: str, port: int, reload: bool, - project_url: Optional[str] = None, ) -> list[str]: command = [ "uv", "run", "--isolated", "--with", - project_url or f"git+https://huggingface.co/spaces/{repo_id}", + f"git+https://huggingface.co/spaces/{repo_id}", "--", "server", "--host", @@ -57,75 +55,134 @@ def _create_uv_command( return command -@dataclass -class UVProvider(ContainerProvider): - """ContainerProvider implementation backed by ``uv run``.""" - - repo_id: str - host: str = "0.0.0.0" - port: Optional[int] = None - reload: bool = False - project_url: Optional[str] = None - connect_host: Optional[str] = None - extra_env: Optional[Dict[str, str]] = None - context_timeout_s: float = 60.0 - - _process: subprocess.Popen | None = field(init=False, default=None) - _base_url: str | None = field(init=False, default=None) +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] + + +class UVProvider(RuntimeProvider): + """ + RuntimeProvider implementation backed by ``uv run``. + + Args: + repo_id: The repository ID of the environment to run + host: The host to bind the environment to + port: The port to bind the environment to + reload: Whether to reload the environment on code changes + env_vars: Environment variables to pass to the environment + context_timeout_s: The timeout to wait for the environment to become ready + + Example: + >>> provider = UVProvider(repo_id="burtenshaw/echo-cli") + >>> base_url = provider.start() + >>> print(base_url) # http://localhost:8000 + >>> # Use the environment via base_url + >>> provider.stop() + """ + + def __init__( + self, + repo_id: str, + host: str = "0.0.0.0", + port: Optional[int] = None, + reload: bool = False, + env_vars: Optional[Dict[str, str]] = None, + context_timeout_s: float = 60.0, + ): + """Initialize the UVProvider.""" + self.repo_id = repo_id + self.host = host + self.port = port + 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_container( + def start( self, - image: str, port: Optional[int] = None, env_vars: Optional[Dict[str, str]] = None, **_: 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 + + 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") - self.repo_id = image or self.repo_id - - bind_port = port or self.port or self._find_free_port() + bind_port = port or self.port or _find_free_port() command = _create_uv_command( - self.repo_id, - self.host, - bind_port, - self.reload, - project_url=self.project_url, + repo_id=self.repo_id, + host=self.host, + port=bind_port, + reload=self.reload, ) env = os.environ.copy() - if self.extra_env: - env.update(self.extra_env) + + 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 FileNotFoundError as exc: - raise RuntimeError( - "`uv` executable not found. Install uv from " - "https://github.com/astral-sh/uv and ensure it is on PATH." - ) from exc except OSError as exc: raise RuntimeError(f"Failed to launch `uv run`: {exc}") from exc - client_host = self.connect_host or ( - "127.0.0.1" if self.host in {"0.0.0.0", "::"} else self.host - ) + 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}" self.port = bind_port return self._base_url - def wait_for_ready(self, base_url: str, timeout_s: float = 60.0) -> None: + 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"{base_url}/health", timeout_s) + _poll_health(f"{self._base_url}/health", timeout_s=timeout_s) - def stop_container(self) -> None: + def stop(self) -> None: + """ + Stop the environment. + + Raises: + RuntimeError: If the environment is not running + """ if self._process is None: return @@ -140,40 +197,17 @@ def stop_container(self) -> None: self._process = None self._base_url = None - def start(self) -> str: - return self.start_container(self.repo_id, port=self.port) - - def stop(self) -> None: - self.stop_container() - - def wait_for_ready_default(self, timeout_s: float | None = None) -> None: - if self._base_url is None: - raise RuntimeError("UVProvider has not been started") - self.wait_for_ready( - self._base_url, - timeout_s or self.context_timeout_s, - ) - - def close(self) -> None: - self.stop_container() - - def __enter__(self) -> "UVProvider": - if self._base_url is None: - base_url = self.start_container(self.repo_id, port=self.port) - self.wait_for_ready(base_url, timeout_s=self.context_timeout_s) - return self - - def __exit__(self, exc_type, exc, tb) -> None: - self.stop_container() - - def _find_free_port(self) -> int: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("", 0)) - sock.listen(1) - return sock.getsockname()[1] - @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 From 857ed4b23da3eae19b5bdd330a9965820b08c1cd Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:08:01 +0100 Subject: [PATCH 08/12] simplify uv provider even further --- src/core/containers/runtime/uv_provider.py | 36 ++++++++-------------- 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py index a8a7f190..4cff1640 100644 --- a/src/core/containers/runtime/uv_provider.py +++ b/src/core/containers/runtime/uv_provider.py @@ -33,7 +33,6 @@ def _poll_health(health_url: str, timeout_s: float) -> None: def _create_uv_command( repo_id: str, - host: str, port: int, reload: bool, ) -> list[str]: @@ -46,7 +45,7 @@ def _create_uv_command( "--", "server", "--host", - host, + "0.0.0.0", "--port", str(port), ] @@ -74,15 +73,13 @@ def _find_free_port() -> int: class UVProvider(RuntimeProvider): """ RuntimeProvider implementation backed by ``uv run``. - + Args: repo_id: The repository ID of the environment to run - host: The host to bind the environment to - port: The port to bind the environment to reload: Whether to reload the environment on code changes env_vars: Environment variables to pass to the environment context_timeout_s: The timeout to wait for the environment to become ready - + Example: >>> provider = UVProvider(repo_id="burtenshaw/echo-cli") >>> base_url = provider.start() @@ -94,16 +91,12 @@ class UVProvider(RuntimeProvider): def __init__( self, repo_id: str, - host: str = "0.0.0.0", - port: Optional[int] = None, reload: bool = False, env_vars: Optional[Dict[str, str]] = None, context_timeout_s: float = 60.0, ): """Initialize the UVProvider.""" self.repo_id = repo_id - self.host = host - self.port = port self.reload = reload self.env_vars = env_vars self.context_timeout_s = context_timeout_s @@ -119,25 +112,24 @@ def start( ) -> 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 - + 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 self.port or _find_free_port() + bind_port = port or _find_free_port() command = _create_uv_command( repo_id=self.repo_id, - host=self.host, port=bind_port, reload=self.reload, ) @@ -154,18 +146,16 @@ def start( 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}" - self.port = bind_port + self._base_url = f"http://localhost:{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 @@ -179,7 +169,7 @@ def wait_for_ready(self, timeout_s: float = 60.0) -> None: def stop(self) -> None: """ Stop the environment. - + Raises: RuntimeError: If the environment is not running """ @@ -201,10 +191,10 @@ def stop(self) -> None: 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 """ From 8ebfdeb8016efa1f22bbae341afcb2b7937f1470 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:12:02 +0100 Subject: [PATCH 09/12] add context handling to rundtime provider abc --- src/core/containers/runtime/providers.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/core/containers/runtime/providers.py b/src/core/containers/runtime/providers.py index 6ea44953..03633516 100644 --- a/src/core/containers/runtime/providers.py +++ b/src/core/containers/runtime/providers.py @@ -348,3 +348,17 @@ 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 \ No newline at end of file From ea3b1ec6d4cd15b24138dbf8bce8c35f395c60df Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:14:35 +0100 Subject: [PATCH 10/12] improve order of utils in uv_provider --- src/core/containers/runtime/uv_provider.py | 54 +++++++++++----------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/core/containers/runtime/uv_provider.py b/src/core/containers/runtime/uv_provider.py index 4cff1640..a26def0b 100644 --- a/src/core/containers/runtime/uv_provider.py +++ b/src/core/containers/runtime/uv_provider.py @@ -13,23 +13,21 @@ from .providers import RuntimeProvider -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) +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 - raise TimeoutError(f"Server did not become ready within {timeout_s:.1f} seconds") +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( repo_id: str, @@ -54,20 +52,22 @@ def _create_uv_command( return command -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 _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) -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] + raise TimeoutError(f"Server did not become ready within {timeout_s:.1f} seconds") class UVProvider(RuntimeProvider): From f4ca8c7f7d6c5db70c70f67eab3b09f2bdc1516a Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:29:52 +0100 Subject: [PATCH 11/12] simplify from_hub interface in http client --- src/core/http_env_client.py | 59 +++++++++---------------------------- 1 file changed, 14 insertions(+), 45 deletions(-) diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py index c3ac4625..327a1cb7 100644 --- a/src/core/http_env_client.py +++ b/src/core/http_env_client.py @@ -20,7 +20,7 @@ from .containers.runtime import LocalDockerProvider, UVProvider if TYPE_CHECKING: - from .containers.runtime import ContainerProvider + from .containers.runtime import ContainerProvider, RuntimeProvider ActT = TypeVar("ActT") ObsT = TypeVar("ObsT") @@ -110,16 +110,11 @@ def from_hub( cls: Type[EnvClientT], repo_id: str, *, - use_docker: bool = False, - provider: Optional["ContainerProvider"] = None, - host: str = "0.0.0.0", - port: Optional[int] = None, + use_docker: bool = True, reload: bool = False, timeout_s: float = 60.0, - runner: Optional[UVProvider] = None, - project_url: Optional[str] = None, - connect_host: Optional[str] = None, - extra_env: Optional[Dict[str, str]] = None, + provider: Optional["ContainerProvider" | "RuntimeProvider"] = None, + env_vars: Optional[Dict[str, str]] = None, **provider_kwargs: Any, ) -> EnvClientT: """Create a client from a Hugging Face Space. @@ -130,47 +125,21 @@ def from_hub( """ if use_docker: - if provider is None: - provider = LocalDockerProvider() - tag = provider_kwargs.pop("tag", "latest") - image = provider_kwargs.pop( - "image", - f"registry.hf.space/{repo_id.replace('/', '-')}:" f"{tag}", + image = f"registry.hf.space/{repo_id.replace('/', '-')}:{tag}" + return cls.from_docker_image(image, provider=provider, **provider_kwargs) + else: + provider: RuntimeProvider = UVProvider( + repo_id=repo_id, + reload=reload, + env_vars=env_vars, + context_timeout_s=timeout_s, ) + base_url = provider.start() + provider.wait_for_ready(base_url=provider.base_url, timeout_s=timeout_s) - base_url = provider.start_container(image, **provider_kwargs) - provider.wait_for_ready(base_url, timeout_s=timeout_s) return cls(base_url=base_url, provider=provider) - uv_runner = runner or UVProvider( - repo_id=repo_id, - host=host, - port=port, - reload=reload, - project_url=project_url, - connect_host=connect_host, - extra_env=extra_env, - ) - - non_docker_kwargs = dict(provider_kwargs) - env_vars = non_docker_kwargs.pop("env_vars", None) - - base_url = uv_runner.start_container( - repo_id, - port=port, - env_vars=env_vars, - **non_docker_kwargs, - ) - - try: - uv_runner.wait_for_ready(base_url, timeout_s=timeout_s) - except Exception: - uv_runner.stop_container() - raise - - return cls(base_url=base_url, provider=uv_runner) - @abstractmethod def _step_payload(self, action: ActT) -> dict: """Convert an Action object to the JSON body expected by the env server.""" From 1e626c5a05c3822f64619d8bd56120a8d6222227 Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Mon, 10 Nov 2025 13:42:36 +0100 Subject: [PATCH 12/12] rely on provider kwargs instead of specifics --- src/core/http_env_client.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/core/http_env_client.py b/src/core/http_env_client.py index 327a1cb7..25faab46 100644 --- a/src/core/http_env_client.py +++ b/src/core/http_env_client.py @@ -111,10 +111,7 @@ def from_hub( repo_id: str, *, use_docker: bool = True, - reload: bool = False, - timeout_s: float = 60.0, provider: Optional["ContainerProvider" | "RuntimeProvider"] = None, - env_vars: Optional[Dict[str, str]] = None, **provider_kwargs: Any, ) -> EnvClientT: """Create a client from a Hugging Face Space. @@ -131,11 +128,10 @@ def from_hub( else: provider: RuntimeProvider = UVProvider( repo_id=repo_id, - reload=reload, - env_vars=env_vars, - context_timeout_s=timeout_s, + **provider_kwargs, ) base_url = provider.start() + timeout_s = provider_kwargs.pop("timeout_s", 60.0) provider.wait_for_ready(base_url=provider.base_url, timeout_s=timeout_s) return cls(base_url=base_url, provider=provider)