diff --git a/README.md b/README.md index c4cd0a08..95a21f76 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ OpenEnv provides a standard for interacting with agentic execution environments In addition to making it easier for researchers and RL framework writers, we also provide tools for environment creators making it easier for them to create richer environments and make them available over familar protocols like HTTP and packaged using canonical technologies like docker. Environment creators can use the OpenEnv framework to create environments that are isolated, secure, and easy to deploy and use. +The OpenEnv CLI (`openenv`) provides commands to initialize new environments and deploy them to Hugging Face Spaces. + > ⚠️ **Early Development Warning** OpenEnv is currently in an experimental > stage. You should expect bugs, incomplete features, and APIs that may change > in future versions. The project welcomes bugfixes, but to make sure things are @@ -117,14 +119,21 @@ Type-safe data structures: ### For Environment Creators -When building a new environment, create the following structure: +Use the CLI to quickly scaffold a new environment: + +```bash +openenv init my_env +``` + +This creates the following structure: ``` -src/envs/your_env/ +my_env/ ├── __init__.py # Export YourAction, YourObservation, YourEnv ├── models.py # Define Action, Observation, State dataclasses ├── client.py # Implement YourEnv(HTTPEnvClient) ├── README.md # Document your environment +├── openenv.yaml # Environment manifest └── server/ ├── your_environment.py # Implement YourEnvironment(Environment) ├── app.py # Create FastAPI app @@ -143,6 +152,26 @@ To use an environment: See example scripts in `examples/` directory. +## CLI Commands + +The OpenEnv CLI provides commands to manage environments: + +- **`openenv init `** - Initialize a new environment from template +- **`openenv push [--repo-id ] [--private]`** - Deploy environment to Hugging Face Spaces + +### Quick Start + +```bash +# Create a new environment +openenv init my_game_env + +# Deploy to Hugging Face (will prompt for login if needed) +cd my_game_env +openenv push +``` + +For detailed options: `openenv init --help` and `openenv push --help`. + ## Design Principles 1. **Separation of Concerns**: Clear client-server boundaries diff --git a/pyproject.toml b/pyproject.toml index 6c9fb689..40b359f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,11 +12,35 @@ dependencies = [ "requests>=2.25.0", "fastapi>=0.104.0", "uvicorn>=0.24.0", - "smolagents>=1.22.0,<2" + "smolagents>=1.22.0,<2", + "typer>=0.9.0", + "rich>=13.0.0", + "pyyaml>=6.0", + "huggingface_hub>=0.20.0" ] +[project.scripts] +openenv = "openenv_cli.__main__:main" + [tool.setuptools] package-dir = {"" = "src"} [tool.setuptools.packages.find] where = ["src"] + +[tool.coverage.run] +omit = [ + "openenv_cli/templates/**", + "**/templates/**", + "openenv_cli/__main__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/src/openenv_cli/__init__.py b/src/openenv_cli/__init__.py new file mode 100644 index 00000000..1e8e08a0 --- /dev/null +++ b/src/openenv_cli/__init__.py @@ -0,0 +1,10 @@ +# 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 CLI package.""" + +__version__ = "0.1.0" + diff --git a/src/openenv_cli/__main__.py b/src/openenv_cli/__main__.py new file mode 100644 index 00000000..f12f182b --- /dev/null +++ b/src/openenv_cli/__main__.py @@ -0,0 +1,47 @@ +# 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 CLI entry point. + +This module provides the main entry point for the OpenEnv command-line interface, +following the Hugging Face CLI pattern. +""" + +import sys + +import typer + +from openenv_cli.commands import init +from openenv_cli.commands import push + +# Create the main CLI app +app = typer.Typer( + name="openenv", + help="OpenEnv - An e2e framework for creating, deploying and using isolated execution environments for agentic RL training", + no_args_is_help=True, +) + +# Register commands +app.command(name="init", help="Initialize a new OpenEnv environment")(init.init) +app.command(name="push", help="Push an OpenEnv environment to Hugging Face Spaces")(push.push) + + +# Entry point for setuptools +def main() -> None: + """Main entry point for the CLI.""" + try: + app() + except KeyboardInterrupt: + print("\nOperation cancelled by user.") + sys.exit(130) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/openenv_cli/_cli_utils.py b/src/openenv_cli/_cli_utils.py new file mode 100644 index 00000000..236dcbe8 --- /dev/null +++ b/src/openenv_cli/_cli_utils.py @@ -0,0 +1,13 @@ +# 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. + +"""CLI utilities for OpenEnv command-line interface.""" + +from rich.console import Console + +# Create a console instance for CLI output +console = Console() + diff --git a/src/openenv_cli/commands/__init__.py b/src/openenv_cli/commands/__init__.py new file mode 100644 index 00000000..e68fe04d --- /dev/null +++ b/src/openenv_cli/commands/__init__.py @@ -0,0 +1,12 @@ +# 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 CLI commands.""" + +from openenv_cli.commands import init, push + +__all__ = ["init", "push"] + diff --git a/src/openenv_cli/commands/init.py b/src/openenv_cli/commands/init.py new file mode 100644 index 00000000..ee151222 --- /dev/null +++ b/src/openenv_cli/commands/init.py @@ -0,0 +1,329 @@ +"""Initialize a new OpenEnv environment.""" + +from __future__ import annotations + +import os +import random +import shutil +from pathlib import Path +from typing import Annotated, Dict, List, Tuple + +import typer +from importlib import resources + +from .._cli_utils import console + + +app = typer.Typer(help="Initialize a new OpenEnv environment") + + +def _snake_to_pascal(snake_str: str) -> str: + """Convert snake_case to PascalCase (e.g., 'my_env' -> 'MyEnv').""" + return "".join(word.capitalize() for word in snake_str.split("_")) + + +def _get_env_prefix(env_name: str) -> str: + """Extract the prefix for class names (e.g., 'my_env' -> 'My', 'test_env' -> 'Test').""" + # Remove trailing '_env' if present + if env_name.endswith("_env"): + base = env_name[:-4] # Remove '_env' + else: + base = env_name + + # If empty or just one part, use the whole thing + if not base or "_" not in base: + return base.capitalize() if base else env_name.capitalize() + + # PascalCase all parts except the last + parts = base.split("_") + return "".join(word.capitalize() for word in parts) + + +def _snake_to_camel(snake_str: str) -> str: + """Convert snake_case to camelCase (e.g., 'my_env' -> 'myEnv').""" + parts = snake_str.split("_") + return parts[0] + "".join(word.capitalize() for word in parts[1:]) + + +def _snake_to_title(snake_str: str) -> str: + """Convert snake_case to Title Case (e.g., 'my_env' -> 'My Env').""" + return " ".join(word.capitalize() for word in snake_str.split("_")) + + +def _validate_env_name(name: str) -> str: + """Validate environment name (must be valid Python identifier in snake_case).""" + if not name: + raise typer.BadParameter("Environment name cannot be empty") + + # Check if it's a valid Python identifier + if not name.isidentifier(): + raise typer.BadParameter( + f"Environment name '{name}' is not a valid Python identifier. " + "Use snake_case (e.g., 'my_env', 'game_env')." + ) + + # Check if it starts with a number + if name[0].isdigit(): + raise typer.BadParameter( + f"Environment name '{name}' cannot start with a number." + ) + + return name + + +def _get_random_hf_space_config() -> Dict[str, str]: + """ + Get random Hugging Face Space configuration values. + + Returns: + Dictionary with 'emoji', 'colorFrom', and 'colorTo' keys + """ + # Valid emojis (emoji-only characters) + emojis = [ + "🎮", "🎯", "🚀", "🌟", "🎨", "🎪", "🎭", "🎬", "🎤", "🎧", + "🎵", "🎶", "🎸", "🎹", "🥁", "🎺", "🎻", "🎼", "🎯", "🎲", + "🎳", "🎰", "🎴", "🃏", "🀄", "🎴", "🎨", "🖼️", "🎬", "🎭", + "🎪", "🎤", "🎧", "🎵", "🎶", "🎸", "🎹", "🎺", "🎻", "🥁", + "🎯", "🎲", "🎳", "🎰", "🏀", "⚽", "🏈", "⚾", "🎾", "🏐", + "🏉", "🎱", "🏓", "🏸", "🥅", "🏒", "🏑", "🏏", "⛳", "🏹", + "🎣", "🥊", "🥋", "🎽", "🏅", "🎖️", "🏆", "🥇", "🥈", "🥉", + "🔊", "🔉", "🔈", "🔇", "📢", "📣", "📯", "🔔", "🔕", "📻", + "📡", "💻", "🖥️", "🖨️", "⌨️", "🖱️", "🖲️", "🕹️", "🗜️", "💾", + "💿", "📀", "📼", "📷", "📸", "📹", "🎥", "📽️", "🎞️", "📞", + "☎️", "📟", "📠", "📺", "📻", "🎙️", "🎚️", "🎛️", "⏱️", "⏲️", + "⏰", "🕰️", "⌚", "📱", "📲", "💻", "⌨️", "🖥️", "🖨️", "🖱️", + ] + + # Valid colors from HF Spaces config reference + colors = ["red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"] + + return { + "emoji": random.choice(emojis), + "colorFrom": random.choice(colors), + "colorTo": random.choice(colors), + } + + +def _create_template_replacements(env_name: str) -> Dict[str, str]: + """ + Create comprehensive template replacement dictionary. + + Supports all naming conventions: + - PascalCase for class names + - camelCase for variable names + - snake_case for module names, file paths + """ + env_pascal = _snake_to_pascal(env_name) + env_prefix = _get_env_prefix(env_name) + env_camel = _snake_to_camel(env_name) + env_title = _snake_to_title(env_name) + + # Get random HF Space config values + hf_config = _get_random_hf_space_config() + + replacements = { + # Template placeholders (MUST come first - full class names before partial) + "__ENV_CLASS_NAME__Environment": f"{env_prefix}Environment", + "__ENV_CLASS_NAME__Action": f"{env_prefix}Action", + "__ENV_CLASS_NAME__Observation": f"{env_prefix}Observation", + "__ENV_CLASS_NAME__Env": f"{env_prefix}Env", + + # Template placeholders (partial - must come after full replacements) + "__ENV_NAME__": env_name, + "__ENV_CLASS_NAME__": env_prefix, # Use prefix, not full PascalCase + "__ENV_TITLE_NAME__": env_title, + "__ENV_CAMEL_NAME__": env_camel, + + # Hugging Face Space config placeholders + "__HF_EMOJI__": hf_config["emoji"], + "__HF_COLOR_FROM__": hf_config["colorFrom"], + "__HF_COLOR_TO__": hf_config["colorTo"], + } + + return replacements + + +def _replace_in_content(content: str, replacements: Dict[str, str]) -> str: + """Replace all occurrences in content using case-sensitive replacements.""" + result = content + # Sort by length (longest first) to avoid partial replacements + for old, new in sorted(replacements.items(), key=lambda x: len(x[0]), reverse=True): + result = result.replace(old, new) + return result + + +def _should_rename_file(filename: str, env_name: str) -> Tuple[bool, str]: + """ + Check if a file should be renamed and return the new name. + + Handles template placeholders in filenames like: + - `__ENV_NAME___environment.py` → `_environment.py` + """ + # Check for template placeholder + if "__ENV_NAME__" in filename: + new_name = filename.replace("__ENV_NAME__", env_name) + return True, new_name + + return False, filename + + +def _copy_and_template_file( + src_path: Path, + dest_path: Path, + replacements: Dict[str, str], +) -> None: + """Copy a file and apply template replacements.""" + dest_path.parent.mkdir(parents=True, exist_ok=True) + + try: + # Read source file + content = src_path.read_bytes() + + # Try to decode as text and apply replacements + try: + text = content.decode("utf-8") + text = _replace_in_content(text, replacements) + dest_path.write_text(text, encoding="utf-8") + except UnicodeDecodeError: + # 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 + + +def _copy_template_directory( + template_pkg: str, + template_dir: str, + dest_dir: Path, + replacements: Dict[str, str], + env_name: str, +) -> List[Path]: + """Recursively copy template directory and apply replacements.""" + created_files: List[Path] = [] + + # Get the package path using importlib.resources but avoid importing the template package + # We'll use the package's __file__ to get the directory path + import importlib + try: + # Import the parent package (not the template package itself) + if '.' in template_pkg: + parent_pkg = '.'.join(template_pkg.split('.')[:-1]) + pkg = importlib.import_module(parent_pkg) + template_path = Path(pkg.__file__).parent / template_pkg.split('.')[-1] + else: + pkg = importlib.import_module(template_pkg.split('.')[0]) + template_path = Path(pkg.__file__).parent / template_pkg.split('.')[-1] + except Exception: + # Fallback: try to use resources.files but handle import errors + try: + base = resources.files(template_pkg.split('.')[0]) + template_path = base.joinpath(*template_pkg.split('.')[1:]) + 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 + + 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}") + + # Walk through all files in template directory using Path + for item in template_path.rglob("*"): + if item.is_file(): + rel_path = item.relative_to(template_path) + dest_path = dest_dir / rel_path + + # Apply filename templating + should_rename, new_name = _should_rename_file(dest_path.name, env_name) + if should_rename: + dest_path = dest_path.parent / new_name + + # Copy and apply replacements + _copy_and_template_file(item, dest_path, replacements) + created_files.append(dest_path) + + return created_files + + +@app.command() +def init( + env_name: Annotated[ + str, + typer.Argument(help="Name of the environment to create (snake_case, e.g., 'my_env')"), + ], + output_dir: Annotated[ + str | None, + typer.Option( + "--output-dir", + "-o", + help="Output directory (defaults to current working directory)", + ), + ] = None, +) -> None: + """ + Initialize a new OpenEnv environment. + + Creates a new directory with the environment name and generates all necessary + files based on the OpenEnv template structure. + + Example: + $ openenv init my_game_env + $ openenv init my_env --output-dir /path/to/projects + """ + # Validate environment name + env_name = _validate_env_name(env_name) + + # Determine output directory + base_dir = Path(output_dir).resolve() if output_dir else Path.cwd().resolve() + env_dir = base_dir / env_name + + # Check if directory already exists + if env_dir.exists(): + if env_dir.is_file(): + raise typer.BadParameter(f"Path '{env_dir}' exists and is a file") + if any(env_dir.iterdir()): + raise typer.BadParameter( + f"Directory '{env_dir}' already exists and is not empty. " + "Please choose a different name or remove the existing directory." + ) + + try: + # Create template replacements + replacements = _create_template_replacements(env_name) + + # Create environment directory + env_dir.mkdir(parents=True, exist_ok=True) + + 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" + created_files = _copy_template_directory( + template_pkg, + "", + env_dir, + replacements, + env_name, + ) + + console.print(f"[bold green]✓[/bold green] Created {len(created_files)} files") + console.print(f"[bold green]Environment created successfully at: {env_dir}[/bold green]") + console.print(f"\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 models in models.py") + console.print(f" # Build your docker environment: docker build -t {env_name}:latest -f server/Dockerfile .") + console.print(f" # Run your image: docker run {env_name}:latest") + + except Exception as e: + # Cleanup on error + if env_dir.exists() and env_dir.is_dir(): + try: + shutil.rmtree(env_dir) + except Exception: + pass + + console.print(f"[bold red]Error:[/bold red] {e}") + raise typer.Exit(1) from e diff --git a/src/openenv_cli/commands/push.py b/src/openenv_cli/commands/push.py new file mode 100644 index 00000000..afe1f2f1 --- /dev/null +++ b/src/openenv_cli/commands/push.py @@ -0,0 +1,363 @@ +# 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. + +"""Push an OpenEnv environment to Hugging Face Spaces.""" + +from __future__ import annotations + +import shutil +import tempfile +from pathlib import Path +from typing import Annotated + +import typer +import yaml +from huggingface_hub import HfApi, login, whoami + +from .._cli_utils import console + + +app = typer.Typer(help="Push an OpenEnv environment to Hugging Face Spaces") + + +def _validate_openenv_directory(directory: Path) -> tuple[str, dict]: + """ + Validate that the directory is an OpenEnv environment. + + Returns: + Tuple of (env_name, manifest_data) + """ + manifest_path = directory / "openenv.yaml" + + if not manifest_path.exists(): + raise typer.BadParameter( + f"No openenv.yaml manifest found in {directory}. " + "This directory doesn't appear to be an OpenEnv environment." + ) + + try: + with open(manifest_path, "r") as f: + manifest = yaml.safe_load(f) + except Exception as e: + raise typer.BadParameter( + f"Failed to parse openenv.yaml: {e}" + ) from e + + if not isinstance(manifest, dict): + raise typer.BadParameter("openenv.yaml must be a YAML dictionary") + + env_name = manifest.get("name") + if not env_name: + raise typer.BadParameter("openenv.yaml must contain a 'name' field") + + return env_name, manifest + + +def _ensure_hf_authenticated() -> str: + """ + Ensure user is authenticated with Hugging Face. + + Returns: + Username of authenticated user + """ + try: + # Try to get current user + 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") + else: + # If it's an object, try to get name attribute + username = getattr(user_info, "name", None) or getattr(user_info, "fullname", None) or getattr(user_info, "username", None) + + if not username: + raise ValueError("Could not extract username from whoami response") + + console.print(f"[bold green]✓[/bold green] Authenticated as: {username}") + return username + except Exception: + # Not authenticated, prompt for login + console.print("[bold yellow]Not authenticated with Hugging Face. Please login...[/bold yellow]") + + try: + login() + # Verify login worked + 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") + else: + username = getattr(user_info, "name", None) or getattr(user_info, "fullname", None) or getattr(user_info, "username", None) + + if not username: + raise ValueError("Could not extract username from whoami response") + + 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 + + +def _prepare_staging_directory( + env_dir: Path, + env_name: str, + staging_dir: Path, + base_image: str | None = None, +) -> None: + """ + Prepare files for Hugging Face deployment. + + This includes: + - Copying necessary files + - Modifying Dockerfile to enable web interface and update base image + - Ensuring README has proper HF frontmatter + """ + # Create staging directory structure + staging_dir.mkdir(parents=True, exist_ok=True) + + # Copy all files from env directory + for item in env_dir.iterdir(): + # Skip hidden files and common ignore patterns + if item.name.startswith(".") or item.name in ["__pycache__", ".git"]: + continue + + dest = staging_dir / item.name + if item.is_dir(): + shutil.copytree(item, dest, dirs_exist_ok=True) + else: + shutil.copy2(item, dest) + + # Modify Dockerfile to enable web interface and optionally update base image + dockerfile_path = staging_dir / "server" / "Dockerfile" + if dockerfile_path.exists(): + dockerfile_content = dockerfile_path.read_text() + lines = dockerfile_content.split("\n") + new_lines = [] + cmd_found = False + base_image_updated = False + web_interface_enabled = "ENABLE_WEB_INTERFACE=true" in dockerfile_content + + for line in lines: + # Update base image if specified + if base_image and line.strip().startswith("FROM") and not base_image_updated: + # Replace the FROM line with new base image + new_lines.append(f"FROM {base_image}") + base_image_updated = True + continue + + # Add ENABLE_WEB_INTERFACE=true before CMD + if line.strip().startswith("CMD") and not cmd_found: + # Add ENV line before CMD + if not web_interface_enabled: + new_lines.append("ENV ENABLE_WEB_INTERFACE=true") + web_interface_enabled = True + cmd_found = True + + new_lines.append(line) + + # Add ENABLE_WEB_INTERFACE if CMD not found + if not cmd_found and not web_interface_enabled: + new_lines.append("ENV ENABLE_WEB_INTERFACE=true") + web_interface_enabled = True + + # If base image was specified but FROM line wasn't found, add it at the beginning + if base_image and not base_image_updated: + new_lines.insert(0, f"FROM {base_image}") + + dockerfile_path.write_text("\n".join(new_lines)) + + changes = [] + if base_image and base_image_updated: + changes.append("updated base image") + if web_interface_enabled and "ENABLE_WEB_INTERFACE=true" not in dockerfile_content: + changes.append("enabled web interface") + if 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") + + # Ensure README has proper HF frontmatter + readme_path = staging_dir / "README.md" + if readme_path.exists(): + readme_content = readme_path.read_text() + if "base_path: /web" not in readme_content: + # Check if frontmatter exists + if readme_content.startswith("---"): + # Add base_path to existing frontmatter + lines = readme_content.split("\n") + new_lines = [] + in_frontmatter = True + for i, line in enumerate(lines): + new_lines.append(line) + if line.strip() == "---" and i > 0: + # End of frontmatter, add base_path before this line + if "base_path:" not in "\n".join(new_lines): + new_lines.insert(-1, "base_path: /web") + in_frontmatter = False + readme_path.write_text("\n".join(new_lines)) + else: + # No frontmatter, add it + frontmatter = f"""--- +title: {env_name.replace('_', ' ').title()} Environment Server +emoji: 🔊 +colorFrom: '#00C9FF' +colorTo: '#1B2845' +sdk: docker +pinned: false +app_port: 8000 +base_path: /web +tags: + - openenv +--- + +""" + readme_path.write_text(frontmatter + readme_content) + console.print("[bold green]✓[/bold green] Updated README with HF Space frontmatter") + else: + console.print("[bold yellow]⚠[/bold yellow] No README.md found") + + +def _create_hf_space( + repo_id: str, + api: HfApi, + private: bool = False, +) -> None: + """Create a Hugging Face Space if it doesn't exist.""" + console.print(f"[bold cyan]Creating/verifying space: {repo_id}[/bold cyan]") + + try: + api.create_repo( + repo_id=repo_id, + repo_type="space", + space_sdk="docker", + private=private, + exist_ok=True, + ) + console.print(f"[bold green]✓[/bold green] Space {repo_id} is ready") + except Exception as e: + # Space might already exist, which is okay with exist_ok=True + # But if there's another error, log it + console.print(f"[bold yellow]⚠[/bold yellow] Space creation: {e}") + + +def _upload_to_hf_space( + repo_id: str, + staging_dir: Path, + api: HfApi, + private: bool = False, +) -> None: + """Upload files to Hugging Face Space.""" + console.print(f"[bold cyan]Uploading files to {repo_id}...[/bold cyan]") + + try: + api.upload_folder( + folder_path=str(staging_dir), + repo_id=repo_id, + repo_type="space", + ignore_patterns=[".git", "__pycache__", "*.pyc"], + ) + console.print(f"[bold green]✓[/bold green] Upload completed successfully") + 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 + + +@app.command() +def push( + directory: Annotated[ + str | None, + typer.Option( + "--directory", + "-d", + help="Directory containing the OpenEnv environment (defaults to current directory)", + ), + ] = None, + repo_id: Annotated[ + str | None, + typer.Option( + "--repo-id", + "-r", + help="Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml)", + ), + ] = None, + base_image: Annotated[ + str | None, + typer.Option( + "--base-image", + "-b", + help="Base Docker image to use (overrides Dockerfile FROM)", + ), + ] = None, + private: Annotated[ + bool, + typer.Option( + "--private", + help="Deploy the space as private", + ), + ] = False, +) -> None: + """ + Push an OpenEnv environment to Hugging Face Spaces. + + This command: + 1. Validates that the directory is an OpenEnv environment (openenv.yaml present) + 2. Prepares a custom build for Hugging Face Docker space (enables web interface) + 3. Uploads to Hugging Face (ensuring user is logged in) + + Examples: + $ openenv push + $ openenv push --repo-id my-org/my-env + $ openenv push --private --base-image ghcr.io/meta-pytorch/openenv-base:latest + """ + # Determine directory + if directory: + env_dir = Path(directory).resolve() + else: + env_dir = Path.cwd().resolve() + + if not env_dir.exists() or not env_dir.is_dir(): + raise typer.BadParameter(f"Directory does not exist: {env_dir}") + + # Validate OpenEnv environment + 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}") + + # Ensure authentication + username = _ensure_hf_authenticated() + + # Determine repo_id + if not repo_id: + repo_id = f"{username}/{env_name}" + + # 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'" + ) + + # Initialize Hugging Face API + api = HfApi() + + # Prepare staging directory + console.print("[bold cyan]Preparing files for Hugging Face deployment...[/bold cyan]") + with tempfile.TemporaryDirectory() as tmpdir: + staging_dir = Path(tmpdir) / "staging" + _prepare_staging_directory(env_dir, env_name, staging_dir, base_image=base_image) + + # Create/verify space + _create_hf_space(repo_id, api, private=private) + + # Upload files + _upload_to_hf_space(repo_id, staging_dir, api, private=private) + + console.print(f"\n[bold green]✓ Deployment complete![/bold green]") + console.print(f"Visit your space at: https://huggingface.co/spaces/{repo_id}") diff --git a/src/openenv_cli/templates/__init__.py b/src/openenv_cli/templates/__init__.py new file mode 100644 index 00000000..023d053f --- /dev/null +++ b/src/openenv_cli/templates/__init__.py @@ -0,0 +1,8 @@ +# 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 CLI templates package.""" + diff --git a/src/openenv_cli/templates/openenv_env/README.md b/src/openenv_cli/templates/openenv_env/README.md new file mode 100644 index 00000000..0c786e7d --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/README.md @@ -0,0 +1,197 @@ +--- +title: __ENV_TITLE_NAME__ Environment Server +emoji: __HF_EMOJI__ +colorFrom: __HF_COLOR_FROM__ +colorTo: __HF_COLOR_TO__ +sdk: docker +pinned: false +app_port: 8000 +base_path: /web +tags: + - openenv +--- + +# __ENV_TITLE_NAME__ Environment + +A simple test environment that echoes back messages. Perfect for testing the env APIs as well as demonstrating environment usage patterns. + +## Quick Start + +The simplest way to use the __ENV_TITLE_NAME__ environment is through the `__ENV_CLASS_NAME__Env` class: + +```python +from __ENV_NAME__ import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Env + +try: + # Create environment from Docker image + __ENV_NAME__env = __ENV_CLASS_NAME__Env.from_docker_image("__ENV_NAME__-env:latest") + + # Reset + result = __ENV_NAME__env.reset() + print(f"Reset: {result.observation.echoed_message}") + + # Send multiple messages + messages = ["Hello, World!", "Testing echo", "Final message"] + + for msg in messages: + result = __ENV_NAME__env.step(__ENV_CLASS_NAME__Action(message=msg)) + print(f"Sent: '{msg}'") + print(f" → Echoed: '{result.observation.echoed_message}'") + print(f" → Length: {result.observation.message_length}") + print(f" → Reward: {result.reward}") + +finally: + # Always clean up + __ENV_NAME__env.close() +``` + +That's it! The `__ENV_CLASS_NAME__Env.from_docker_image()` method handles: +- Starting the Docker container +- Waiting for the server to be ready +- Connecting to the environment +- Container cleanup when you call `close()` + +## Building the Docker Image + +Before using the environment, you need to build the Docker image: + +```bash +# From project root +docker build -t __ENV_NAME__-env:latest -f server/Dockerfile . +``` + +## Deploying to Hugging Face Spaces + +You can easily deploy your OpenEnv environment to Hugging Face Spaces using the `openenv push` command: + +```bash +# From the environment directory (where openenv.yaml is located) +openenv push + +# Or specify options +openenv push --namespace my-org --private +``` + +The `openenv push` command will: +1. Validate that the directory is an OpenEnv environment (checks for `openenv.yaml`) +2. Prepare a custom build for Hugging Face Docker space (enables web interface) +3. Upload to Hugging Face (ensuring you're logged in) + +### Prerequisites + +- Authenticate with Hugging Face: The command will prompt for login if not already authenticated + +### Options + +- `--directory`, `-d`: Directory containing the OpenEnv environment (defaults to current directory) +- `--repo-id`, `-r`: Repository ID in format 'username/repo-name' (defaults to 'username/env-name' from openenv.yaml) +- `--base-image`, `-b`: Base Docker image to use (overrides Dockerfile FROM) +- `--private`: Deploy the space as private (default: public) + +### Examples + +```bash +# Push to your personal namespace (defaults to username/env-name from openenv.yaml) +openenv push + +# Push to a specific repository +openenv push --repo-id my-org/my-env + +# Push with a custom base image +openenv push --base-image ghcr.io/meta-pytorch/openenv-base:latest + +# Push as a private space +openenv push --private + +# Combine options +openenv push --repo-id my-org/my-env --base-image custom-base:latest --private +``` + +After deployment, your space will be available at: +`https://huggingface.co/spaces/` + +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 + +## Environment Details + +### Action +**__ENV_CLASS_NAME__Action**: Contains a single field +- `message` (str) - The message to echo back + +### Observation +**__ENV_CLASS_NAME__Observation**: Contains the echo response and metadata +- `echoed_message` (str) - The message echoed back +- `message_length` (int) - Length of the message +- `reward` (float) - Reward based on message length (length × 0.1) +- `done` (bool) - Always False for echo environment +- `metadata` (dict) - Additional info like step count + +### Reward +The reward is calculated as: `message_length × 0.1` +- "Hi" → reward: 0.2 +- "Hello, World!" → reward: 1.3 +- Empty message → reward: 0.0 + +## Advanced Usage + +### Connecting to an Existing Server + +If you already have a __ENV_TITLE_NAME__ environment server running, you can connect directly: + +```python +from __ENV_NAME__ import __ENV_CLASS_NAME__Env + +# Connect to existing server +__ENV_NAME__env = __ENV_CLASS_NAME__Env(base_url="") + +# Use as normal +result = __ENV_NAME__env.reset() +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. + +## Development & Testing + +### Direct Environment Testing + +Test the environment logic directly without starting the HTTP server: + +```bash +# From the server directory +python3 server/__ENV_NAME___environment.py +``` + +This verifies that: +- Environment resets correctly +- Step executes actions properly +- State tracking works +- Rewards are calculated correctly + +### Running Locally + +Run the server locally for development: + +```bash +uvicorn server.app:app --reload +``` + +## Project Structure + +``` +__ENV_NAME__/ +├── __init__.py # Module exports +├── README.md # This file +├── openenv.yaml # OpenEnv manifest +├── client.py # __ENV_CLASS_NAME__Env client implementation +├── models.py # Action and Observation models +└── server/ + ├── __init__.py # Server module exports + ├── __ENV_NAME___environment.py # Core environment logic + ├── app.py # FastAPI application + ├── Dockerfile # Container image definition + └── requirements.txt # Python dependencies +``` diff --git a/src/openenv_cli/templates/openenv_env/__init__.py b/src/openenv_cli/templates/openenv_env/__init__.py new file mode 100644 index 00000000..656800a5 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/__init__.py @@ -0,0 +1,13 @@ +# 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. + +"""__ENV_TITLE_NAME__ Environment - A simple test environment for HTTP server.""" + +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"] + diff --git a/src/openenv_cli/templates/openenv_env/client.py b/src/openenv_cli/templates/openenv_env/client.py new file mode 100644 index 00000000..34d35267 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/client.py @@ -0,0 +1,100 @@ +# 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. + +""" +__ENV_TITLE_NAME__ Environment HTTP Client. + +This module provides the client for connecting to a __ENV_TITLE_NAME__ Environment server +over HTTP. +""" + +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 .models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation + + +class __ENV_CLASS_NAME__Env(HTTPEnvClient[__ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation]): + """ + HTTP 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. + + 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) + + 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")) + """ + + def _step_payload(self, action: __ENV_CLASS_NAME__Action) -> Dict: + """ + Convert __ENV_CLASS_NAME__Action to JSON payload for step request. + + Args: + action: __ENV_CLASS_NAME__Action instance + + Returns: + Dictionary representation suitable for JSON encoding + """ + return { + "message": action.message, + } + + def _parse_result(self, payload: Dict) -> StepResult[__ENV_CLASS_NAME__Observation]: + """ + Parse server response into StepResult[__ENV_CLASS_NAME__Observation]. + + Args: + payload: JSON response from server + + Returns: + StepResult with __ENV_CLASS_NAME__Observation + """ + obs_data = payload.get("observation", {}) + observation = __ENV_CLASS_NAME__Observation( + echoed_message=obs_data.get("echoed_message", ""), + message_length=obs_data.get("message_length", 0), + 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_cli/templates/openenv_env/models.py b/src/openenv_cli/templates/openenv_env/models.py new file mode 100644 index 00000000..c2e40616 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/models.py @@ -0,0 +1,31 @@ +# 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. + +""" +Data models for the __ENV_TITLE_NAME__ Environment. + +The __ENV_NAME__ environment is a simple test environment that echoes back messages. +""" + +from dataclasses import dataclass + +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 + + +@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 + diff --git a/src/openenv_cli/templates/openenv_env/openenv.yaml b/src/openenv_cli/templates/openenv_env/openenv.yaml new file mode 100644 index 00000000..828cc53b --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/openenv.yaml @@ -0,0 +1,7 @@ +spec_version: 1 +name: __ENV_NAME__ +type: space +runtime: fastapi +app: server.app:app +port: 8000 + diff --git a/src/openenv_cli/templates/openenv_env/server/Dockerfile b/src/openenv_cli/templates/openenv_env/server/Dockerfile new file mode 100644 index 00000000..e0366239 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/server/Dockerfile @@ -0,0 +1,23 @@ +# 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. + +# Use the standard openenv base image +ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest +FROM ${BASE_IMAGE} + +# Copy only what's needed for this environment +COPY . /app/src/envs/__ENV_NAME__/ + +# Install dependencies +RUN pip install --no-cache-dir -r /app/src/envs/__ENV_NAME__/server/requirements.txt && rm /app/src/envs/__ENV_NAME__/server/requirements.txt + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# Run the FastAPI server +CMD ["uvicorn", "envs.__ENV_NAME__.server.app:app", "--host", "0.0.0.0", "--port", "8000"] + 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 new file mode 100644 index 00000000..f2f39c9c --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/server/__ENV_NAME___environment.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. + +""" +__ENV_TITLE_NAME__ Environment Implementation. + +A simple test environment that echoes back messages sent to it. +Perfect for testing HTTP server infrastructure. +""" + +from uuid import uuid4 + +from openenv_core.env_server.interfaces import Environment +from openenv_core.env_server.types import State + +from __ENV_NAME__.models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation + + +class __ENV_CLASS_NAME__Environment(Environment): + """ + A simple echo environment that echoes back messages. + + This environment is designed for testing the HTTP server infrastructure. + It maintains minimal state and simply echoes back whatever message it receives. + + Example: + >>> env = __ENV_CLASS_NAME__Environment() + >>> obs = env.reset() + >>> print(obs.echoed_message) # "__ENV_TITLE_NAME__ environment ready!" + >>> + >>> obs = env.step(__ENV_CLASS_NAME__Action(message="Hello")) + >>> print(obs.echoed_message) # "Hello" + >>> print(obs.message_length) # 5 + """ + + def __init__(self): + """Initialize the __ENV_NAME__ environment.""" + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count = 0 + + def reset(self) -> __ENV_CLASS_NAME__Observation: + """ + Reset the environment. + + Returns: + __ENV_CLASS_NAME__Observation with a ready message + """ + self._state = State(episode_id=str(uuid4()), step_count=0) + self._reset_count += 1 + + return __ENV_CLASS_NAME__Observation( + echoed_message="__ENV_TITLE_NAME__ environment ready!", + message_length=0, + done=False, + reward=0.0, + ) + + def step(self, action: __ENV_CLASS_NAME__Action) -> __ENV_CLASS_NAME__Observation: # type: ignore[override] + """ + Execute a step in the environment by echoing the message. + + Args: + action: __ENV_CLASS_NAME__Action containing the message to echo + + Returns: + __ENV_CLASS_NAME__Observation with the echoed message and its length + """ + self._state.step_count += 1 + + message = action.message + length = len(message) + + # Simple reward: longer messages get higher rewards + reward = length * 0.1 + + return __ENV_CLASS_NAME__Observation( + echoed_message=message, + message_length=length, + done=False, + reward=reward, + metadata={"original_message": message, "step": self._state.step_count}, + ) + + @property + def state(self) -> State: + """ + Get the current environment state. + + Returns: + Current State with episode_id and step_count + """ + return self._state + diff --git a/src/openenv_cli/templates/openenv_env/server/__init__.py b/src/openenv_cli/templates/openenv_env/server/__init__.py new file mode 100644 index 00000000..40ba9a41 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/server/__init__.py @@ -0,0 +1,12 @@ +# 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. + +"""__ENV_TITLE_NAME__ environment server components.""" + +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 new file mode 100644 index 00000000..7eb90a60 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/server/app.py @@ -0,0 +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. + +""" +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. + +Usage: + # Development (with auto-reload): + uvicorn server.app:app --reload --host 0.0.0.0 --port 8000 + + # Production: + uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4 + + # Or run directly: + python -m server.app +""" + +try: + 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 template deps with '\n" + " pip install -r server/requirements.txt\n'" + ) from e + +from .__ENV_NAME___environment import __ENV_CLASS_NAME__Environment +from __ENV_NAME__.models import __ENV_CLASS_NAME__Action, __ENV_CLASS_NAME__Observation + +# 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__Action, __ENV_CLASS_NAME__Observation, env_name="__ENV_NAME__") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) 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 00000000..dc78bc86 --- /dev/null +++ b/src/openenv_cli/templates/openenv_env/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn +openenv-core>=0.1.0 diff --git a/tests/test_cli/test_init.py b/tests/test_cli/test_init.py new file mode 100644 index 00000000..47a7bbf6 --- /dev/null +++ b/tests/test_cli/test_init.py @@ -0,0 +1,443 @@ +# 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. + +"""Tests for the openenv init command.""" + +import os +import tempfile +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from openenv_cli.__main__ import app + + +runner = CliRunner() + + +def _snake_to_pascal(snake_str: str) -> str: + """Helper function matching the one in init.py""" + return "".join(word.capitalize() for word in snake_str.split("_")) + + +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() + assert (env_dir / "client.py").exists() + assert (env_dir / "README.md").exists() + assert (env_dir / "openenv.yaml").exists() + assert (env_dir / "server").exists() + assert (env_dir / "server" / "__init__.py").exists() + assert (env_dir / "server" / "app.py").exists() + assert (env_dir / "server" / f"{env_name}_environment.py").exists() + assert (env_dir / "server" / "Dockerfile").exists() + assert (env_dir / "server" / "requirements.txt").exists() + + +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() + assert "MyGameAction" in models_content + 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() + env_content = env_file.read_text() + assert "MyGameEnvironment" in env_content + assert "__ENV_CLASS_NAME__" not in env_content + + +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 + assert "runtime: fastapi" in yaml_content + assert "app: server.app:app" in yaml_content + assert "port: 8000" in yaml_content + assert "__ENV_NAME__" not in yaml_content + + +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 + assert "sdk: docker" in readme_content + 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 + + +def test_init_validates_env_name(tmp_path: Path) -> None: + """Test that invalid environment names are rejected.""" + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + # 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() + + # 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 + finally: + os.chdir(old_cwd) + + +def test_init_handles_existing_directory(tmp_path: Path) -> None: + """Test that init fails gracefully when directory exists.""" + env_name = "existing_env" + 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() + + +def test_init_handles_empty_directory(tmp_path: Path) -> None: + """Test that init works when directory exists but is empty.""" + 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() + + +def test_init_with_output_dir(tmp_path: Path) -> None: + """Test that init works with custom output directory.""" + env_name = "output_env" + 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() + + +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() + + +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() + + +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 + assert f"from .{env_name}_environment import" in app_content + assert f"from {env_name}.models import" in app_content + 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 + + +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 + + +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 + + +def test_init_validates_empty_env_name(tmp_path: Path) -> None: + """Test that init validates empty environment name.""" + old_cwd = os.getcwd() + try: + os.chdir(str(tmp_path)) + 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() + + +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 + + +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() + + +def test_init_handles_file_path_collision(tmp_path: Path) -> None: + """Test that init fails when path exists as a file.""" + 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}" + # 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}" diff --git a/tests/test_cli/test_main.py b/tests/test_cli/test_main.py new file mode 100644 index 00000000..48945ad4 --- /dev/null +++ b/tests/test_cli/test_main.py @@ -0,0 +1,50 @@ +# 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. + +"""Tests for the openenv __main__ module.""" + +import sys +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from openenv_cli.__main__ import app, main + + +runner = CliRunner() + + +def test_main_handles_keyboard_interrupt() -> None: + """Test that main handles KeyboardInterrupt gracefully.""" + 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: + mock_app.side_effect = ValueError("Test error") + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 1 + + +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: + main() + mock_app.assert_called_once() + diff --git a/tests/test_cli/test_push.py b/tests/test_cli/test_push.py new file mode 100644 index 00000000..70b62817 --- /dev/null +++ b/tests/test_cli/test_push.py @@ -0,0 +1,630 @@ +# 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. + +"""Tests for the openenv push command.""" + +import os +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer +from typer.testing import CliRunner + +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 openenv.yaml + manifest = { + "spec_version": 1, + "name": env_name, + "type": "space", + "runtime": "fastapi", + "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 + (env_dir / "server").mkdir(exist_ok=True) + (env_dir / "server" / "Dockerfile").write_text( + "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 +sdk: docker +app_port: 8000 +--- + +# 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: + """Test that push validates openenv.yaml is present.""" + 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 "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 + (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() + + +def test_push_validates_openenv_yaml_has_name(tmp_path: Path) -> None: + """Test that push validates openenv.yaml has a name field.""" + import yaml + 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() + + +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: + + # 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 + + +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: + + 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 + + +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 +sdk: docker +app_port: 8000 +--- + +# 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: + + 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 + + +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: + + 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 + assert call_args.kwargs["repo_id"] == "custom-org/my-env" + + +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: + + 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 + assert call_args.kwargs["repo_id"] == "testuser/test_env" + + +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: + + 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 + assert call_args.kwargs["private"] is True + + +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: + + 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.""" + 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: + + 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 + + result = runner.invoke( + app, + ["push", "--directory", 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.""" + _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 + + +def test_push_handles_missing_readme(tmp_path: Path) -> None: + """Test that push handles missing README gracefully.""" + _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 + + +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: + + 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 + # Should not have token in kwargs + assert "token" not in (call_args.kwargs or {}) + + +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: + + 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)) + # Invalid format (no slash) + 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 + 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() + + +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: + + 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 + + +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: + + # First whoami call fails (not authenticated) + # Login also fails + mock_whoami.side_effect = Exception("Not authenticated") + mock_login.side_effect = Exception("Login failed") + # 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() + + +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: + + # Return dict without name, fullname, or username + mock_whoami.return_value = {} + # Mock login to prevent actual login prompt + mock_login.return_value = 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() + + +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: + + 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 + + +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: + + 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)) + # Should continue despite error (warns but doesn't fail) + result = runner.invoke(app, ["push"]) + finally: + os.chdir(old_cwd) + + # Should still attempt upload + assert mock_api.upload_folder.called + + +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: + + 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() + + +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: + + 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