| """Release doctor checks for DataForge public-surface gates."""
|
|
|
| from __future__ import annotations
|
|
|
| import json
|
| import shutil
|
| import subprocess
|
| import sys
|
| import tempfile
|
| import tomllib
|
| from dataclasses import asdict, dataclass
|
| from importlib import metadata as importlib_metadata
|
| from pathlib import Path
|
| from typing import Any
|
|
|
| EXPECTED_HF_USER = "Praneshrajan15"
|
| DEFAULT_KAGGLE_CREDENTIALS = Path.home() / ".kaggle" / "credentials.json"
|
| STALE_KAGGLE_JSON = Path.home() / ".kaggle" / "kaggle.json"
|
| CORE_DISTRIBUTION = "dataforge_07"
|
|
|
|
|
| @dataclass(frozen=True)
|
| class DoctorCheck:
|
| """One release doctor check result."""
|
|
|
| name: str
|
| ok: bool
|
| detail: str
|
| metadata: dict[str, Any]
|
|
|
|
|
| @dataclass(frozen=True)
|
| class DoctorReport:
|
| """Machine-readable release doctor report."""
|
|
|
| ok: bool
|
| checks: list[DoctorCheck]
|
| scopes: list[str]
|
| secrets_printed: bool = False
|
|
|
| def to_dict(self) -> dict[str, Any]:
|
| """Return a JSON-serializable report."""
|
| return asdict(self)
|
|
|
|
|
| def _check_hugging_face() -> DoctorCheck:
|
| try:
|
| from huggingface_hub import HfApi, get_token
|
| except ImportError as exc:
|
| return DoctorCheck("hugging_face", False, f"huggingface_hub missing: {exc}", {})
|
|
|
| token_present = bool(get_token())
|
| if not token_present:
|
| return DoctorCheck(
|
| "hugging_face",
|
| False,
|
| "No cached Hugging Face token found.",
|
| {"expected_user": EXPECTED_HF_USER, "token_present": False},
|
| )
|
| try:
|
| info = HfApi(token=get_token()).whoami()
|
| except Exception as exc:
|
| return DoctorCheck(
|
| "hugging_face",
|
| False,
|
| f"Could not resolve Hugging Face identity: {exc}",
|
| {"expected_user": EXPECTED_HF_USER, "token_present": True},
|
| )
|
| user = str(info.get("name", ""))
|
| return DoctorCheck(
|
| "hugging_face",
|
| user == EXPECTED_HF_USER,
|
| "Authenticated with expected Hugging Face user."
|
| if user == EXPECTED_HF_USER
|
| else f"Authenticated as {user!r}, expected {EXPECTED_HF_USER!r}.",
|
| {"user": user, "expected_user": EXPECTED_HF_USER, "token_present": True},
|
| )
|
|
|
|
|
| def _load_kaggle_oauth(path: Path) -> dict[str, Any]:
|
| if path.name == "kaggle.json":
|
| raise RuntimeError(
|
| f"Refusing to read stale legacy Kaggle API key file: {path}. "
|
| f"Use OAuth credentials at {DEFAULT_KAGGLE_CREDENTIALS}."
|
| )
|
| if not path.exists():
|
| raise RuntimeError(f"Missing Kaggle OAuth credentials: {path}")
|
| payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
| if not isinstance(payload, dict):
|
| raise RuntimeError("Kaggle OAuth credentials must be a JSON object.")
|
| required = {"refresh_token", "access_token", "access_token_expiration", "username", "scopes"}
|
| missing = sorted(required - set(payload))
|
| if missing:
|
| raise RuntimeError("Kaggle OAuth credentials missing fields: " + ", ".join(missing))
|
| if not isinstance(payload.get("username"), str) or not payload["username"]:
|
| raise RuntimeError("Kaggle OAuth credentials are missing username.")
|
| scopes = payload.get("scopes")
|
| if not isinstance(scopes, list) or not scopes:
|
| raise RuntimeError("Kaggle OAuth credentials are missing scopes.")
|
| return payload
|
|
|
|
|
| def _check_kaggle_oauth(credentials_path: Path = DEFAULT_KAGGLE_CREDENTIALS) -> DoctorCheck:
|
| try:
|
| payload = _load_kaggle_oauth(credentials_path)
|
| except Exception as exc:
|
| return DoctorCheck(
|
| "kaggle_oauth", False, str(exc), {"credential_path": str(credentials_path)}
|
| )
|
| return DoctorCheck(
|
| "kaggle_oauth",
|
| True,
|
| "Kaggle OAuth credentials are present and legacy key is ignored.",
|
| {
|
| "credential_path": str(credentials_path),
|
| "username": payload["username"],
|
| "scopes_count": len(payload["scopes"]),
|
| "legacy_kaggle_json_exists": STALE_KAGGLE_JSON.exists(),
|
| "legacy_kaggle_json_used": False,
|
| "tokens_printed": False,
|
| },
|
| )
|
|
|
|
|
| def _check_kaggle_cli_clean_config(
|
| credentials_path: Path = DEFAULT_KAGGLE_CREDENTIALS,
|
| ) -> DoctorCheck:
|
| """Verify Kaggle CLI auth through OAuth under a clean config directory."""
|
| script = _project_root() / "scripts" / "preflight" / "check_kaggle_auth.py"
|
| if not script.exists():
|
| return DoctorCheck(
|
| "kaggle_cli_clean_config",
|
| False,
|
| f"Kaggle auth preflight script not found: {script}",
|
| {"credential_path": str(credentials_path), "tokens_printed": False},
|
| )
|
| command = [
|
| sys.executable,
|
| str(script),
|
| "--kaggle-json",
|
| str(credentials_path),
|
| "--check-cli",
|
| ]
|
| try:
|
| result = subprocess.run(
|
| command,
|
| cwd=_project_root(),
|
| capture_output=True,
|
| text=True,
|
| encoding="utf-8",
|
| errors="replace",
|
| timeout=90,
|
| check=False,
|
| )
|
| except subprocess.TimeoutExpired:
|
| return DoctorCheck(
|
| "kaggle_cli_clean_config",
|
| False,
|
| "Kaggle CLI clean-config OAuth preflight timed out.",
|
| {"credential_path": str(credentials_path), "tokens_printed": False},
|
| )
|
| ok = result.returncode == 0
|
| detail = (
|
| "Kaggle CLI read-only command succeeded with clean KAGGLE_CONFIG_DIR."
|
| if ok
|
| else "Kaggle CLI clean-config OAuth preflight failed."
|
| )
|
| return DoctorCheck(
|
| "kaggle_cli_clean_config",
|
| ok,
|
| detail,
|
| {
|
| "credential_path": str(credentials_path),
|
| "command": " ".join(command),
|
| "tokens_printed": False,
|
| },
|
| )
|
|
|
|
|
| def _check_cloudflare() -> DoctorCheck:
|
| npx = shutil.which("npx") or shutil.which("npx.cmd")
|
| if npx is None:
|
| return DoctorCheck("cloudflare", False, "npx/wrangler not available on PATH.", {})
|
| env_file_path = ""
|
| try:
|
| with tempfile.NamedTemporaryFile(delete=False) as env_file:
|
| env_file_path = env_file.name
|
| command = [npx, "wrangler", "whoami", "--env-file", env_file_path]
|
| try:
|
| result = subprocess.run(
|
| command,
|
| capture_output=True,
|
| text=True,
|
| encoding="utf-8",
|
| errors="replace",
|
| timeout=60,
|
| check=False,
|
| )
|
| finally:
|
| if env_file_path:
|
| Path(env_file_path).unlink(missing_ok=True)
|
| except FileNotFoundError:
|
| return DoctorCheck("cloudflare", False, "npx/wrangler not available on PATH.", {})
|
| except subprocess.TimeoutExpired:
|
| return DoctorCheck("cloudflare", False, "wrangler whoami timed out.", {})
|
|
|
| output = result.stdout + result.stderr
|
| ok = result.returncode == 0 and "logged in" in output.lower()
|
| has_route_scope = "workers_routes (write)" in output
|
| return DoctorCheck(
|
| "cloudflare",
|
| ok and has_route_scope,
|
| "Wrangler OAuth is logged in with Workers route scope."
|
| if ok and has_route_scope
|
| else "Wrangler is not logged in with the required Workers route scope.",
|
| {
|
| "wrangler_available": result.returncode == 0,
|
| "logged_in": ok,
|
| "workers_routes_write": has_route_scope,
|
| "command": f"{npx} wrangler whoami --env-file <empty-env-file>",
|
| "env_file_override": "empty",
|
| },
|
| )
|
|
|
|
|
| def _project_root() -> Path:
|
| """Return the repository root for local core release checks."""
|
| return Path(__file__).resolve().parents[2]
|
|
|
|
|
| def _check_package_boundary() -> DoctorCheck:
|
| """Verify the core wheel only includes the public DataForge namespace."""
|
| pyproject_path = _project_root() / "pyproject.toml"
|
| if not pyproject_path.exists():
|
| try:
|
| distribution = importlib_metadata.distribution(CORE_DISTRIBUTION)
|
| top_level = distribution.read_text("top_level.txt") or ""
|
| except importlib_metadata.PackageNotFoundError as exc:
|
| return DoctorCheck(
|
| "core_package_boundary",
|
| False,
|
| f"Could not read installed package metadata: {exc}",
|
| {"distribution": CORE_DISTRIBUTION},
|
| )
|
| top_level_packages = sorted(line.strip() for line in top_level.splitlines() if line.strip())
|
| expected_top_level = ["dataforge"]
|
| return DoctorCheck(
|
| "core_package_boundary",
|
| top_level_packages == expected_top_level,
|
| "Installed wheel exposes only the dataforge top-level package."
|
| if top_level_packages == expected_top_level
|
| else (
|
| "Installed wheel exposes top-level packages "
|
| f"{top_level_packages!r}, expected {expected_top_level!r}."
|
| ),
|
| {"top_level": top_level_packages, "expected": expected_top_level},
|
| )
|
|
|
| try:
|
| pyproject = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
| include = pyproject["tool"]["setuptools"]["packages"]["find"]["include"]
|
| except Exception as exc:
|
| return DoctorCheck(
|
| "core_package_boundary",
|
| False,
|
| f"Could not read package include contract: {exc}",
|
| {"pyproject": str(pyproject_path)},
|
| )
|
| expected = ["dataforge", "dataforge.*"]
|
| return DoctorCheck(
|
| "core_package_boundary",
|
| include == expected,
|
| "Core wheel includes only the dataforge namespace."
|
| if include == expected
|
| else f"Core wheel package include list is {include!r}, expected {expected!r}.",
|
| {"include": include, "expected": expected},
|
| )
|
|
|
|
|
| def _check_packaged_core_files() -> DoctorCheck:
|
| """Verify files required by source-install and wheel smoke commands exist."""
|
| root = _project_root()
|
| required = [
|
| root / "dataforge" / "py.typed",
|
| root / "dataforge" / "fixtures" / "hospital_10rows.csv",
|
| root / "dataforge" / "fixtures" / "hospital_schema.yaml",
|
| root / "dataforge" / "safety" / "constitutions" / "default.yaml",
|
| ]
|
| missing = [str(path.relative_to(root)) for path in required if not path.exists()]
|
| return DoctorCheck(
|
| "core_packaged_files",
|
| not missing,
|
| "Core package data files required by smoke commands are present."
|
| if not missing
|
| else "Missing core package data files: " + ", ".join(missing),
|
| {"missing": missing},
|
| )
|
|
|
|
|
| def _core_checks() -> list[DoctorCheck]:
|
| """Return local OSS release checks that do not require personal accounts."""
|
| return [_check_package_boundary(), _check_packaged_core_files()]
|
|
|
|
|
| def _maintainer_deploy_checks(kaggle_credentials: Path) -> list[DoctorCheck]:
|
| """Return maintainer-specific deploy/auth checks."""
|
| return [
|
| _check_hugging_face(),
|
| _check_kaggle_oauth(kaggle_credentials),
|
| _check_kaggle_cli_clean_config(kaggle_credentials),
|
| _check_cloudflare(),
|
| ]
|
|
|
|
|
| def run_doctor(
|
| *,
|
| kaggle_credentials: Path = DEFAULT_KAGGLE_CREDENTIALS,
|
| core: bool = True,
|
| maintainer_deploy: bool = False,
|
| ) -> DoctorReport:
|
| """Run selected release doctor checks."""
|
| checks: list[DoctorCheck] = []
|
| scopes: list[str] = []
|
| if core:
|
| checks.extend(_core_checks())
|
| scopes.append("core")
|
| if maintainer_deploy:
|
| checks.extend(_maintainer_deploy_checks(kaggle_credentials))
|
| scopes.append("maintainer_deploy")
|
| return DoctorReport(
|
| ok=all(check.ok for check in checks),
|
| checks=checks,
|
| scopes=scopes,
|
| )
|
|
|
|
|
| def main(argv: list[str] | None = None) -> int:
|
| """Script entrypoint used by CI and local release work."""
|
| import argparse
|
|
|
| parser = argparse.ArgumentParser(description=__doc__)
|
| parser.add_argument("--json", action="store_true", help="Print JSON instead of text.")
|
| parser.add_argument("--kaggle-credentials", type=Path, default=DEFAULT_KAGGLE_CREDENTIALS)
|
| parser.add_argument("--core", action="store_true", help="Run OSS core release checks.")
|
| parser.add_argument(
|
| "--maintainer-deploy",
|
| action="store_true",
|
| help="Run maintainer-specific deploy/auth checks.",
|
| )
|
| args = parser.parse_args(argv)
|
| core = args.core or not args.maintainer_deploy
|
| report = run_doctor(
|
| kaggle_credentials=args.kaggle_credentials,
|
| core=core,
|
| maintainer_deploy=args.maintainer_deploy,
|
| )
|
| if args.json:
|
| print(json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
| else:
|
| for check in report.checks:
|
| status = "ok" if check.ok else "fail"
|
| print(f"{status:4} {check.name}: {check.detail}")
|
| return 0 if report.ok else 2
|
|
|
|
|
| if __name__ == "__main__":
|
| raise SystemExit(main(sys.argv[1:]))
|
|
|