Team Ai
Apppublic

openenv/coding_env

sourceHugging Faceupdated 3mo agoView on Hugging Face
21likes
_validation.py595 linesDownload Raw Back to cli
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"""8Validation utilities for multi-mode deployment readiness.9 10This module provides functions to check if environments are properly11configured for multi-mode deployment (Docker, direct Python, notebooks, clusters).12"""13 14from pathlib import Path15from typing import Any16from urllib.parse import urlparse17 18import requests19 20try:21    import tomllib22except ModuleNotFoundError:23    import tomli as tomllib24 25 26def _make_criterion(27    criterion_id: str,28    description: str,29    passed: bool,30    *,31    required: bool = True,32    details: str | None = None,33    expected: Any | None = None,34    actual: Any | None = None,35) -> dict[str, Any]:36    """Create a standard criterion result payload."""37    criterion: dict[str, Any] = {38        "id": criterion_id,39        "description": description,40        "passed": passed,41        "required": required,42    }43    if details is not None:44        criterion["details"] = details45    if expected is not None:46        criterion["expected"] = expected47    if actual is not None:48        criterion["actual"] = actual49    return criterion50 51 52def _normalize_runtime_url(base_url: str) -> str:53    """Normalize and validate a runtime target URL."""54    target = base_url.strip()55    if not target:56        raise ValueError("Runtime URL cannot be empty")57 58    if "://" not in target:59        target = f"http://{target}"60 61    parsed = urlparse(target)62    if not parsed.scheme or not parsed.netloc:63        raise ValueError(f"Invalid runtime URL: {base_url}")64 65    return target.rstrip("/")66 67 68def _runtime_standard_profile(api_version: str) -> str:69    """Resolve the runtime standard profile for an API version."""70    if api_version.startswith("1."):71        return "openenv-http/1.x"72    return "openenv-http/unknown"73 74 75def _build_summary(criteria: list[dict[str, Any]]) -> dict[str, Any]:76    """Build a compact pass/fail summary for a criteria list."""77    total_count = len(criteria)78    passed_count = sum(1 for criterion in criteria if criterion.get("passed", False))79    failed_criteria = [80        criterion.get("id", "unknown")81        for criterion in criteria82        if not criterion.get("passed", False)83    ]84    required_criteria = [85        criterion for criterion in criteria if criterion.get("required", True)86    ]87    required_total_count = len(required_criteria)88    required_passed_count = sum(89        1 for criterion in required_criteria if criterion.get("passed", False)90    )91 92    return {93        "passed_count": passed_count,94        "total_count": total_count,95        "failed_criteria": failed_criteria,96        "required_passed_count": required_passed_count,97        "required_total_count": required_total_count,98    }99 100 101def validate_running_environment(102    base_url: str, timeout_s: float = 5.0103) -> dict[str, Any]:104    """105    Validate a running OpenEnv server against runtime API standards.106 107    The returned JSON report contains an overall pass/fail result and108    per-criterion outcomes that can be consumed in CI.109    """110    normalized_url = _normalize_runtime_url(base_url)111    criteria: list[dict[str, Any]] = []112 113    report: dict[str, Any] = {114        "target": normalized_url,115        "validation_type": "running_environment",116        "standard_version": "unknown",117        "standard_profile": "openenv-http/unknown",118        "mode": "unknown",119        "passed": False,120        "summary": {},121        "criteria": criteria,122    }123 124    openapi_paths: dict[str, Any] = {}125    api_version = "unknown"126 127    # Criterion: OpenAPI endpoint reachable with a declared version.128    try:129        openapi_response = requests.get(130            f"{normalized_url}/openapi.json", timeout=timeout_s131        )132    except requests.RequestException as exc:133        criteria.append(134            _make_criterion(135                "openapi_version_available",136                "GET /openapi.json returns OpenAPI info.version",137                False,138                details=f"Request failed: {type(exc).__name__}: {exc}",139                expected={"status_code": 200, "info.version": "string"},140            )141        )142    else:143        try:144            openapi_json = openapi_response.json()145        except ValueError:146            openapi_json = None147 148        openapi_ok = (149            openapi_response.status_code == 200150            and isinstance(openapi_json, dict)151            and isinstance(openapi_json.get("info"), dict)152            and isinstance(openapi_json["info"].get("version"), str)153        )154 155        if openapi_ok:156            api_version = str(openapi_json["info"]["version"])157            openapi_paths = openapi_json.get("paths", {})158            criteria.append(159                _make_criterion(160                    "openapi_version_available",161                    "GET /openapi.json returns OpenAPI info.version",162                    True,163                    expected={"status_code": 200, "info.version": "string"},164                    actual={165                        "status_code": openapi_response.status_code,166                        "info.version": api_version,167                    },168                )169            )170        else:171            criteria.append(172                _make_criterion(173                    "openapi_version_available",174                    "GET /openapi.json returns OpenAPI info.version",175                    False,176                    details="Response missing required OpenAPI info.version field",177                    expected={"status_code": 200, "info.version": "string"},178                    actual={179                        "status_code": openapi_response.status_code,180                        "body_type": (181                            type(openapi_json).__name__182                            if openapi_json is not None183                            else "non_json"184                        ),185                    },186                )187            )188 189    report["standard_version"] = api_version190    report["standard_profile"] = _runtime_standard_profile(api_version)191 192    # Criterion: Health endpoint.193    try:194        health_response = requests.get(f"{normalized_url}/health", timeout=timeout_s)195    except requests.RequestException as exc:196        criteria.append(197            _make_criterion(198                "health_endpoint",199                "GET /health returns healthy status",200                False,201                details=f"Request failed: {type(exc).__name__}: {exc}",202                expected={"status_code": 200, "status": "healthy"},203            )204        )205    else:206        try:207            health_json = health_response.json()208        except ValueError:209            health_json = None210 211        health_ok = (212            health_response.status_code == 200213            and isinstance(health_json, dict)214            and health_json.get("status") == "healthy"215        )216        criteria.append(217            _make_criterion(218                "health_endpoint",219                "GET /health returns healthy status",220                health_ok,221                expected={"status_code": 200, "status": "healthy"},222                actual={223                    "status_code": health_response.status_code,224                    "status": (225                        health_json.get("status")226                        if isinstance(health_json, dict)227                        else None228                    ),229                },230            )231        )232 233    # Criterion: Metadata endpoint has required fields.234    try:235        metadata_response = requests.get(236            f"{normalized_url}/metadata", timeout=timeout_s237        )238    except requests.RequestException as exc:239        criteria.append(240            _make_criterion(241                "metadata_endpoint",242                "GET /metadata returns name and description",243                False,244                details=f"Request failed: {type(exc).__name__}: {exc}",245                expected={"status_code": 200, "fields": ["name", "description"]},246            )247        )248    else:249        try:250            metadata_json = metadata_response.json()251        except ValueError:252            metadata_json = None253 254        metadata_ok = (255            metadata_response.status_code == 200256            and isinstance(metadata_json, dict)257            and isinstance(metadata_json.get("name"), str)258            and isinstance(metadata_json.get("description"), str)259        )260        criteria.append(261            _make_criterion(262                "metadata_endpoint",263                "GET /metadata returns name and description",264                metadata_ok,265                expected={"status_code": 200, "fields": ["name", "description"]},266                actual={267                    "status_code": metadata_response.status_code,268                    "name": (269                        metadata_json.get("name")270                        if isinstance(metadata_json, dict)271                        else None272                    ),273                    "description": (274                        metadata_json.get("description")275                        if isinstance(metadata_json, dict)276                        else None277                    ),278                },279            )280        )281 282    # Criterion: Schema endpoint returns action/observation/state.283    try:284        schema_response = requests.get(f"{normalized_url}/schema", timeout=timeout_s)285    except requests.RequestException as exc:286        criteria.append(287            _make_criterion(288                "schema_endpoint",289                "GET /schema returns action, observation, and state schemas",290                False,291                details=f"Request failed: {type(exc).__name__}: {exc}",292                expected={293                    "status_code": 200,294                    "fields": ["action", "observation", "state"],295                },296            )297        )298    else:299        try:300            schema_json = schema_response.json()301        except ValueError:302            schema_json = None303 304        schema_ok = (305            schema_response.status_code == 200306            and isinstance(schema_json, dict)307            and isinstance(schema_json.get("action"), dict)308            and isinstance(schema_json.get("observation"), dict)309            and isinstance(schema_json.get("state"), dict)310        )311        criteria.append(312            _make_criterion(313                "schema_endpoint",314                "GET /schema returns action, observation, and state schemas",315                schema_ok,316                expected={317                    "status_code": 200,318                    "fields": ["action", "observation", "state"],319                },320                actual={321                    "status_code": schema_response.status_code,322                    "has_action": (323                        isinstance(schema_json.get("action"), dict)324                        if isinstance(schema_json, dict)325                        else False326                    ),327                    "has_observation": (328                        isinstance(schema_json.get("observation"), dict)329                        if isinstance(schema_json, dict)330                        else False331                    ),332                    "has_state": (333                        isinstance(schema_json.get("state"), dict)334                        if isinstance(schema_json, dict)335                        else False336                    ),337                },338            )339        )340 341    # Criterion: MCP endpoint is reachable.342    try:343        mcp_response = requests.post(344            f"{normalized_url}/mcp", json={}, timeout=timeout_s345        )346    except requests.RequestException as exc:347        criteria.append(348            _make_criterion(349                "mcp_endpoint",350                "POST /mcp is reachable and returns JSON-RPC payload",351                False,352                details=f"Request failed: {type(exc).__name__}: {exc}",353                expected={"status_code": 200, "jsonrpc": "2.0"},354            )355        )356    else:357        try:358            mcp_json = mcp_response.json()359        except ValueError:360            mcp_json = None361 362        mcp_ok = (363            mcp_response.status_code == 200364            and isinstance(mcp_json, dict)365            and mcp_json.get("jsonrpc") == "2.0"366        )367        criteria.append(368            _make_criterion(369                "mcp_endpoint",370                "POST /mcp is reachable and returns JSON-RPC payload",371                mcp_ok,372                expected={"status_code": 200, "jsonrpc": "2.0"},373                actual={374                    "status_code": mcp_response.status_code,375                    "jsonrpc": (376                        mcp_json.get("jsonrpc") if isinstance(mcp_json, dict) else None377                    ),378                },379            )380        )381 382    # Criterion: mode endpoint contract consistency via OpenAPI paths.383    if isinstance(openapi_paths, dict) and openapi_paths:384        has_reset = "/reset" in openapi_paths385        has_step = "/step" in openapi_paths386        has_state = "/state" in openapi_paths387 388        if has_reset:389            report["mode"] = "simulation"390            mode_ok = has_step and has_state391            expected_paths = {"/reset": True, "/step": True, "/state": True}392        else:393            report["mode"] = "production"394            mode_ok = not has_step and not has_state395            expected_paths = {"/reset": False, "/step": False, "/state": False}396 397        criteria.append(398            _make_criterion(399                "mode_endpoint_consistency",400                "OpenAPI endpoint set matches OpenEnv mode contract",401                mode_ok,402                expected=expected_paths,403                actual={404                    "/reset": has_reset,405                    "/step": has_step,406                    "/state": has_state,407                },408            )409        )410    else:411        criteria.append(412            _make_criterion(413                "mode_endpoint_consistency",414                "OpenAPI endpoint set matches OpenEnv mode contract",415                False,416                details="Cannot determine mode without OpenAPI paths",417                expected={"openapi.paths": "present"},418                actual={"openapi.paths": "missing"},419            )420        )421 422    report["passed"] = all(423        criterion["passed"] for criterion in criteria if criterion.get("required", True)424    )425    report["summary"] = _build_summary(criteria)426    return report427 428 429def validate_multi_mode_deployment(env_path: Path) -> tuple[bool, list[str]]:430    """431    Validate that an environment is ready for multi-mode deployment.432 433    Checks:434    1. pyproject.toml exists435    2. uv.lock exists436    3. pyproject.toml has [project.scripts] with server entry point437    4. server/app.py has a main() function438    5. Required dependencies are present439 440    Returns:441        Tuple of (is_valid, list of issues found)442    """443    issues = []444 445    # Check pyproject.toml exists446    pyproject_path = env_path / "pyproject.toml"447    if not pyproject_path.exists():448        issues.append("Missing pyproject.toml")449        return False, issues450 451    # Check uv.lock exists452    lockfile_path = env_path / "uv.lock"453    if not lockfile_path.exists():454        issues.append("Missing uv.lock - run 'uv lock' to generate it")455 456    # Parse pyproject.toml457    try:458        with open(pyproject_path, "rb") as f:459            pyproject = tomllib.load(f)460    except Exception as e:461        issues.append(f"Failed to parse pyproject.toml: {e}")462        return False, issues463 464    # Check [project.scripts] section465    scripts = pyproject.get("project", {}).get("scripts", {})466    if "server" not in scripts:467        issues.append("Missing [project.scripts] server entry point")468 469    # Check server entry point format470    server_entry = scripts.get("server", "")471    if server_entry and ":main" not in server_entry:472        issues.append(473            f"Server entry point should reference main function, got: {server_entry}"474        )475 476    # Check required dependencies477    deps = [dep.lower() for dep in pyproject.get("project", {}).get("dependencies", [])]478    has_openenv = any(479        dep.startswith("openenv") and not dep.startswith("openenv-core") for dep in deps480    )481    has_legacy_core = any(dep.startswith("openenv-core") for dep in deps)482 483    if not (has_openenv or has_legacy_core):484        issues.append(485            "Missing required dependency: openenv-core>=0.2.0 (or openenv>=0.2.0)"486        )487 488    # Check server/app.py exists489    server_app = env_path / "server" / "app.py"490    if not server_app.exists():491        issues.append("Missing server/app.py")492    else:493        # Check for main() function (flexible - with or without parameters)494        app_content = server_app.read_text(encoding="utf-8")495        if "def main(" not in app_content:496            issues.append("server/app.py missing main() function")497 498        # Check if main() is callable499        if "__name__" not in app_content or "main()" not in app_content:500            issues.append(501                "server/app.py main() function not callable (missing if __name__ == '__main__')"502            )503 504    return len(issues) == 0, issues505 506 507def get_deployment_modes(env_path: Path) -> dict[str, bool]:508    """509    Check which deployment modes are supported by the environment.510 511    Returns:512        Dictionary with deployment mode names and whether they're supported513    """514    modes = {515        "docker": False,516        "openenv_serve": False,517        "uv_run": False,518        "python_module": False,519    }520 521    # Check Docker (Dockerfile may be in server/ or at env root)522    modes["docker"] = (env_path / "server" / "Dockerfile").exists() or (523        env_path / "Dockerfile"524    ).exists()525 526    # Check multi-mode deployment readiness527    is_valid, _ = validate_multi_mode_deployment(env_path)528    if is_valid:529        modes["openenv_serve"] = True530        modes["uv_run"] = True531        modes["python_module"] = True532 533    return modes534 535 536def format_validation_report(env_name: str, is_valid: bool, issues: list[str]) -> str:537    """538    Format a validation report for display.539 540    Returns:541        Formatted report string542    """543    if is_valid:544        return f"[OK] {env_name}: Ready for multi-mode deployment"545 546    report = [f"[FAIL] {env_name}: Not ready for multi-mode deployment", ""]547    report.append("Issues found:")548    for issue in issues:549        report.append(f"  - {issue}")550 551    return "\n".join(report)552 553 554def build_local_validation_json_report(555    env_name: str,556    env_path: Path,557    is_valid: bool,558    issues: list[str],559    deployment_modes: dict[str, bool] | None = None,560) -> dict[str, Any]:561    """Build a JSON report for local environment validation."""562    criteria = [563        _make_criterion(564            "multi_mode_deployment_readiness",565            "Environment structure is ready for multi-mode deployment",566            is_valid,567            details="No issues found" if is_valid else f"{len(issues)} issue(s) found",568            actual={"issues": issues},569        )570    ]571 572    if deployment_modes:573        for mode, supported in deployment_modes.items():574            criteria.append(575                _make_criterion(576                    f"deployment_mode_{mode}",577                    f"Deployment mode '{mode}' is supported",578                    supported,579                    required=False,580                )581            )582 583    return {584        "target": str(env_path),585        "environment": env_name,586        "validation_type": "local_environment",587        "standard_version": "local",588        "standard_profile": "openenv-local",589        "passed": is_valid,590        "summary": _build_summary(criteria),591        "criteria": criteria,592        "issues": issues,593        "deployment_modes": deployment_modes or {},594    }595