Team Ai
Apppublic

openenv/coding_env

sourceHugging Faceupdated 3mo agoView on Hugging Face
21likes
_discovery.py585 linesDownload Raw Back to auto
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""8Environment Auto-Discovery System9==================================10 11This module provides automatic discovery of OpenEnv environments by:121. Discovering installed openenv-* packages using importlib.metadata132. Loading manifests (openenv.yaml) from package resources143. Caching results for performance154. Supporting HuggingFace Hub downloads16 17This enables AutoEnv to work without coupling to src/envs/ directory.18"""19 20import importlib21import importlib.metadata22import importlib.resources23import json24import logging25import re26import tempfile27from dataclasses import asdict, dataclass28from pathlib import Path29from typing import Any, Dict, Optional, Type30 31import yaml32 33logger = logging.getLogger(__name__)34 35 36@dataclass37class EnvironmentInfo:38    """39    Rich information about a discovered environment.40 41    Attributes:42        env_key: Environment key (e.g., "echo", "coding")43        name: Full environment name (e.g., "echo_env")44        package_name: Package name (e.g., "openenv-echo_env")45        version: Version string46        description: Human-readable description47        client_module_path: Full module path to client (e.g., "echo_env.client")48        client_class_name: Client class name (e.g., "EchoEnv")49        action_class_name: Action class name (e.g., "EchoAction")50        observation_class_name: Observation class name (e.g., "EchoObservation")51        default_image: Default Docker image name (e.g., "echo-env:latest")52        spec_version: OpenEnv spec version (from openenv.yaml)53        manifest: Original manifest data54    """55 56    env_key: str57    name: str58    package_name: str59    version: str60    description: str61    client_module_path: str62    client_class_name: str63    action_class_name: str64    observation_class_name: str65    default_image: str66    spec_version: Optional[int] = None67    manifest: Optional[Dict[str, Any]] = None68 69    def get_client_class(self) -> Type:70        """71        Dynamically import and return the client class.72 73        Returns:74            Client class (e.g., EchoEnv)75 76        Raises:77            ImportError: If module or class cannot be imported78        """79        try:80            module = importlib.import_module(self.client_module_path)81            return getattr(module, self.client_class_name)82        except ImportError as e:83            raise ImportError(84                f"Failed to import {self.client_class_name} from {self.client_module_path}: {e}\n"85                f"Make sure the package '{self.package_name}' is installed: "86                f"pip install {self.package_name}"87            ) from e88        except AttributeError as e:89            raise ImportError(90                f"Class {self.client_class_name} not found in {self.client_module_path}: {e}"91            ) from e92 93    def get_action_class(self) -> Type:94        """95        Dynamically import and return the action class.96 97        Returns:98            Action class (e.g., EchoAction)99 100        Raises:101            ImportError: If module or class cannot be imported102        """103        try:104            module = importlib.import_module(self.client_module_path)105            return getattr(module, self.action_class_name)106        except ImportError as e:107            raise ImportError(108                f"Failed to import {self.action_class_name} from {self.client_module_path}: {e}\n"109                f"Make sure the package '{self.package_name}' is installed: "110                f"pip install {self.package_name}"111            ) from e112        except AttributeError as e:113            raise ImportError(114                f"Class {self.action_class_name} not found in {self.client_module_path}: {e}"115            ) from e116 117    def get_observation_class(self) -> Type:118        """119        Dynamically import and return the observation class.120 121        Returns:122            Observation class (e.g., EchoObservation)123 124        Raises:125            ImportError: If module or class cannot be imported126        """127        try:128            module = importlib.import_module(self.client_module_path)129            return getattr(module, self.observation_class_name)130        except ImportError as e:131            raise ImportError(132                f"Failed to import {self.observation_class_name} from {self.client_module_path}: {e}\n"133                f"Make sure the package '{self.package_name}' is installed: "134                f"pip install {self.package_name}"135            ) from e136        except AttributeError as e:137            raise ImportError(138                f"Class {self.observation_class_name} not found in {self.client_module_path}: {e}"139            ) from e140 141 142def _normalize_env_name(name: str) -> str:143    """144    Normalize environment name to standard format.145 146    Args:147        name: Input name (e.g., "echo", "echo-env", "echo_env")148 149    Returns:150        Normalized name (e.g., "echo_env")151 152    Examples:153        >>> _normalize_env_name("echo")154        'echo_env'155        >>> _normalize_env_name("echo-env")156        'echo_env'157        >>> _normalize_env_name("echo_env")158        'echo_env'159    """160    # Remove common suffixes161    name = re.sub(r"[-_]env$", "", name)162    # Convert hyphens to underscores163    name = name.replace("-", "_")164    # Add _env suffix if not present165    if not name.endswith("_env"):166        name = f"{name}_env"167    return name168 169 170def _is_hub_url(name: str) -> bool:171    """172    Check if name is a HuggingFace Hub URL or repo ID.173 174    Args:175        name: Input name176 177    Returns:178        True if it looks like a Hub URL179 180    Examples:181        >>> _is_hub_url("meta-pytorch/echo_env")182        True183        >>> _is_hub_url("https://huggingface.co/meta-pytorch/echo_env")184        True185        >>> _is_hub_url("echo")186        False187    """188    # Contains org/repo pattern or huggingface.co domain189    return "/" in name or "huggingface.co" in name190 191 192def _infer_class_name(env_name: str, class_type: str) -> str:193    """194    Infer class name from environment name using simple conventions.195 196    Args:197        env_name: Environment name (e.g., "echo_env")198        class_type: Type of class ("client", "action", "observation")199 200    Returns:201        Inferred class name202 203    Examples:204        >>> _infer_class_name("echo_env", "client")205        'EchoEnv'206        >>> _infer_class_name("echo_env", "action")207        'EchoAction'208    """209    # Remove _env suffix for base name210    base_name = env_name.replace("_env", "")211 212    # Convert to PascalCase213    pascal_name = "".join(word.capitalize() for word in base_name.split("_"))214 215    # Add suffix based on type216    if class_type == "client":217        return f"{pascal_name}Env"218    elif class_type == "action":219        return f"{pascal_name}Action"220    elif class_type == "observation":221        return f"{pascal_name}Observation"222    else:223        raise ValueError(f"Unknown class type: {class_type}")224 225 226def _load_manifest_from_package(227    package_name: str, module_name: str228) -> Optional[Dict[str, Any]]:229    """230    Load openenv.yaml manifest from an installed package.231 232    Args:233        package_name: Package name (e.g., "openenv-echo_env")234        module_name: Module name (e.g., "echo_env")235 236    Returns:237        Parsed manifest dictionary, or None if not found238 239    """240    try:241        # Try to read openenv.yaml from package242        if hasattr(importlib.resources, "files"):243            # Python 3.9+244            package_files = importlib.resources.files(module_name)245            if (package_files / "openenv.yaml").is_file():246                manifest_text = (package_files / "openenv.yaml").read_text()247                return yaml.safe_load(manifest_text)248        else:249            # Python 3.7-3.8 fallback250            with importlib.resources.open_text(module_name, "openenv.yaml") as f:251                return yaml.safe_load(f)252    except (FileNotFoundError, ModuleNotFoundError, AttributeError):253        logger.debug(f"No openenv.yaml found in {module_name}")254        return None255    except Exception as e:256        logger.warning(f"Failed to load openenv.yaml from {module_name}: {e}")257        return None258 259 260def _create_env_info_from_package(261    package_name: str, module_name: str, version: str262) -> Optional[EnvironmentInfo]:263    """264    Create EnvironmentInfo from an installed package.265 266    Args:267        package_name: Package name (e.g., "openenv-echo_env")268        module_name: Module name (e.g., "echo_env")269        version: Package version270 271    Returns:272        EnvironmentInfo instance, or None if invalid273    """274    # Load manifest275    manifest = _load_manifest_from_package(package_name, module_name)276 277    # Get environment name278    if manifest and "name" in manifest:279        env_name = manifest["name"]280    else:281        # Infer from module name282        env_name = module_name283 284    # Normalize to ensure _env suffix285    if not env_name.endswith("_env"):286        env_name = f"{env_name}_env"287 288    # Determine env_key (e.g., "echo_env" → "echo")289    env_key = env_name.replace("_env", "") if env_name.endswith("_env") else env_name290 291    # Get description292    description = (293        manifest.get("description", f"{env_name} environment")294        if manifest295        else f"{env_name} environment"296    )297 298    # Get spec version299    spec_version = manifest.get("spec_version") if manifest else None300 301    # Determine class names302    # Check if manifest has custom class names (custom format)303    if manifest and "action" in manifest and "observation" in manifest:304        # Custom format (like coding_env)305        client_class_name = _infer_class_name(env_name, "client")306        action_class_name = manifest.get(307            "action", _infer_class_name(env_name, "action")308        )309        observation_class_name = manifest.get(310            "observation", _infer_class_name(env_name, "observation")311        )312    else:313        # Use conventions314        client_class_name = _infer_class_name(env_name, "client")315        action_class_name = _infer_class_name(env_name, "action")316        observation_class_name = _infer_class_name(env_name, "observation")317 318    # Module path is just module_name.client319    client_module_path = f"{module_name}.client"320 321    # Determine default Docker image name322    image_name = env_name.replace("_", "-")323    default_image = f"{image_name}:latest"324 325    return EnvironmentInfo(326        env_key=env_key,327        name=env_name,328        package_name=package_name,329        version=version,330        description=description,331        client_module_path=client_module_path,332        client_class_name=client_class_name,333        action_class_name=action_class_name,334        observation_class_name=observation_class_name,335        default_image=default_image,336        spec_version=spec_version,337        manifest=manifest,338    )339 340 341class EnvironmentDiscovery:342    """343    Auto-discovery system for OpenEnv environments using installed packages.344 345    This class discovers installed openenv-* packages and loads their metadata.346    """347 348    def __init__(self):349        """Initialize discovery system."""350        self._cache: Optional[Dict[str, EnvironmentInfo]] = None351        self._cache_file = Path(tempfile.gettempdir()) / "openenv_discovery_cache.json"352 353    def _discover_installed_packages(self) -> Dict[str, EnvironmentInfo]:354        """355        Discover all installed openenv-* packages.356 357        Returns:358            Dictionary mapping env_key to EnvironmentInfo359        """360        environments = {}361 362        # Invalidate import caches to ensure we pick up newly installed packages363        importlib.invalidate_caches()364 365        # Get all installed packages366        try:367            distributions = importlib.metadata.distributions()368        except Exception as e:369            logger.warning(f"Failed to get installed packages: {e}")370            return environments371 372        # Filter for openenv-* packages (exclude openenv-core)373        for dist in distributions:374            package_name = dist.metadata["Name"]375 376            if not package_name.startswith("openenv-"):377                continue378 379            if package_name == "openenv-core":380                continue381 382            # Get module name (e.g., "openenv-echo_env" → "echo_env")383            module_name = package_name.replace("openenv-", "").replace("-", "_")384 385            # Get version386            version = dist.version387 388            try:389                # Create environment info390                env_info = _create_env_info_from_package(391                    package_name, module_name, version392                )393 394                if env_info:395                    environments[env_info.env_key] = env_info396                    logger.debug(397                        f"Discovered environment: {env_info.env_key} ({package_name})"398                    )399 400            except Exception as e:401                logger.warning(f"Failed to load environment from {package_name}: {e}")402                continue403 404        return environments405 406    def _load_cache(self) -> Optional[Dict[str, EnvironmentInfo]]:407        """408        Load cached discovery results.409 410        Returns:411            Dictionary of env_key -> EnvironmentInfo, or None if cache invalid412        """413        if not self._cache_file.exists():414            return None415 416        try:417            with open(self._cache_file, "r") as f:418                cache_data = json.load(f)419 420            # Reconstruct EnvironmentInfo objects421            cache = {}422            for env_key, env_data in cache_data.items():423                cache[env_key] = EnvironmentInfo(**env_data)424 425            return cache426        except Exception as e:427            logger.warning(f"Failed to load discovery cache: {e}")428            return None429 430    def _save_cache(self, environments: Dict[str, EnvironmentInfo]) -> None:431        """432        Save discovery results to cache.433 434        Args:435            environments: Dictionary of env_key -> EnvironmentInfo436        """437        try:438            cache_data = {}439            for env_key, env_info in environments.items():440                cache_data[env_key] = asdict(env_info)441 442            with open(self._cache_file, "w") as f:443                json.dump(cache_data, f, indent=2)444 445        except Exception as e:446            logger.warning(f"Failed to save discovery cache: {e}")447 448    def discover(self, use_cache: bool = True) -> Dict[str, EnvironmentInfo]:449        """450        Discover all installed OpenEnv environments.451 452        Args:453            use_cache: If True, try to load from cache first454 455        Returns:456            Dictionary mapping env_key to EnvironmentInfo457 458        Examples:459            >>> discovery = EnvironmentDiscovery()460            >>> envs = discovery.discover()461            >>> print(envs.keys())462            dict_keys(['echo', 'coding', ...])463        """464        # Try to load from memory cache first465        if use_cache and self._cache is not None:466            return self._cache467 468        # Try to load from file cache469        if use_cache:470            cached = self._load_cache()471            if cached is not None:472                self._cache = cached473                return self._cache474 475        # Discover from installed packages476        environments = self._discover_installed_packages()477 478        # Save to cache479        self._save_cache(environments)480        self._cache = environments481 482        return environments483 484    def get_environment(self, env_key: str) -> Optional[EnvironmentInfo]:485        """486        Get information about a specific environment.487 488        Args:489            env_key: Environment key (e.g., "echo", "coding")490 491        Returns:492            EnvironmentInfo if found, None otherwise493 494        Examples:495            >>> discovery = EnvironmentDiscovery()496            >>> env = discovery.get_environment("echo")497            >>> print(env.client_class_name)498            'EchoEnv'499        """500        environments = self.discover()501        return environments.get(env_key)502 503    def get_environment_by_name(self, name: str) -> Optional[EnvironmentInfo]:504        """505        Get environment info by flexible name matching.506 507        Args:508            name: Environment name (e.g., "echo", "echo-env", "echo_env")509 510        Returns:511            EnvironmentInfo if found, None otherwise512        """513        # Normalize name to env_key514        normalized = _normalize_env_name(name)515        env_key = normalized.replace("_env", "")516 517        return self.get_environment(env_key)518 519    def list_environments(self) -> None:520        """521        Print a formatted list of all discovered environments.522 523        Examples:524            >>> discovery = EnvironmentDiscovery()525            >>> discovery.list_environments()526            Available OpenEnv Environments:527            ----------------------------------------------------------------------528              echo           : Echo Environment (v0.1.0) - openenv-echo_env529              coding         : Coding Environment (v0.1.0) - openenv-coding_env530              ...531        """532        environments = self.discover()533 534        print("Available OpenEnv Environments:")535        print("-" * 70)536 537        if not environments:538            print("  No OpenEnv environments found.")539            print("  Install environments with: pip install openenv-<env-name>")540        else:541            for env_key in sorted(environments.keys()):542                env = environments[env_key]543                print(f"  {env_key:<15}: {env.description} (v{env.version})")544                print(f"                   Package: {env.package_name}")545 546        print("-" * 70)547        print(f"Total: {len(environments)} environments")548 549    def clear_cache(self) -> None:550        """Clear the discovery cache."""551        if self._cache_file.exists():552            self._cache_file.unlink()553        self._cache = None554 555 556# Global discovery instance557_global_discovery: Optional[EnvironmentDiscovery] = None558 559 560def get_discovery() -> EnvironmentDiscovery:561    """562    Get or create the global discovery instance.563 564    Returns:565        Global EnvironmentDiscovery instance566 567    Examples:568        >>> discovery = get_discovery()569        >>> envs = discovery.discover()570    """571    global _global_discovery572 573    if _global_discovery is None:574        _global_discovery = EnvironmentDiscovery()575 576    return _global_discovery577 578 579def reset_discovery() -> None:580    """Reset the global discovery instance (useful for testing)."""581    global _global_discovery582    if _global_discovery is not None:583        _global_discovery.clear_cache()584    _global_discovery = None585