Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """Single entry point — verifies dependencies, then launches the app.""" | |
| # guardrails: allow-runtime-pip | |
| # guardrails: allow-runtime-package-install | |
| import argparse | |
| import importlib | |
| import importlib.util | |
| import os | |
| import signal | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from pathlib import Path | |
| if sys.platform == 'darwin': | |
| import multiprocessing | |
| multiprocessing.set_start_method('spawn', force=True) | |
| _VERSION = "1.0.0" | |
| _REQUIRED_PKGS = { | |
| "webview": "pywebview>=4.4", | |
| "sympy": "sympy>=1.12", | |
| "numpy": "numpy>=1.24", | |
| "pygame": "pygame>=2.5", | |
| "antlr4": "antlr4-python3-runtime==4.11.1", | |
| } | |
| _MIN_VERSIONS = {"sympy": (1, 12), "numpy": (1, 24)} | |
| # The project logger is initialised inside ``main`` after ``ensure_deps`` | |
| # verifies the third-party stack. Helpers reference this module-level handle; | |
| # they're only invoked from ``main`` so the lazy init is safe. | |
| _logger = None | |
| def _build_arg_parser() -> argparse.ArgumentParser: | |
| parser = argparse.ArgumentParser( | |
| prog="run.py", | |
| description=( | |
| "Calculus Animator — desktop shell + AI tutor backend launcher." | |
| ), | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=( | |
| "Environment:\n" | |
| " CALCULUS_ANIMATOR_AUTO_INSTALL=1 Auto-install missing pip deps\n" | |
| " LLM_PROVIDER, OPENAI_API_KEY, ... See .env.example for the full list\n" | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--version", | |
| action="version", | |
| version=f"calculus-animator {_VERSION}", | |
| ) | |
| return parser | |
| def _warn_old_version(name: str, ver_str: str, min_ver: tuple) -> None: | |
| parts = tuple(int(x) for x in ver_str.split(".")[:2] if x.isdigit()) | |
| if parts and parts < min_ver: | |
| print( | |
| f"[warn] {name} {ver_str} is older than required " | |
| f"{'.'.join(str(v) for v in min_ver)}. Some features may not " | |
| f"work correctly.", | |
| file=sys.stderr, | |
| ) | |
| def _check_versions() -> None: | |
| try: | |
| import sympy | |
| ver = getattr(sympy, "__version__", "") or "" | |
| _warn_old_version("sympy", ver, _MIN_VERSIONS["sympy"]) | |
| except (ImportError, AttributeError, ValueError) as exc: | |
| if _logger is not None: | |
| _logger.debug("sympy version-check skipped: %s", exc) | |
| try: | |
| import numpy | |
| ver = getattr(numpy, "__version__", "") or "" | |
| _warn_old_version("numpy", ver, _MIN_VERSIONS["numpy"]) | |
| except (ImportError, AttributeError, ValueError) as exc: | |
| if _logger is not None: | |
| _logger.debug("numpy version-check skipped: %s", exc) | |
| def _module_installed(name: str) -> bool: | |
| try: | |
| return importlib.util.find_spec(name) is not None | |
| except (ImportError, ValueError): | |
| return False | |
| def ensure_deps() -> None: | |
| missing = [pkg for mod, pkg in _REQUIRED_PKGS.items() if not _module_installed(mod)] | |
| if missing: | |
| if os.environ.get("CALCULUS_ANIMATOR_AUTO_INSTALL") != "1": | |
| print( | |
| f"Missing required packages: {', '.join(missing)}\n" | |
| "Install them manually with:\n" | |
| f" pip install {' '.join(missing)}\n" | |
| "Or set CALCULUS_ANIMATOR_AUTO_INSTALL=1 to auto-install.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(1) | |
| print(f"Installing missing packages: {', '.join(missing)}") | |
| # guardrails: allow-runtime-pip | |
| # guardrails: allow-runtime-package-install | |
| subprocess.check_call([sys.executable, "-m", "pip", "install", *missing, "-q"]) | |
| _check_versions() | |
| _PROJECT_ROOT = Path(__file__).parent.resolve() | |
| _LOCAL_ENV = (_PROJECT_ROOT / ".env").resolve() | |
| def _load_env() -> None: | |
| if _LOCAL_ENV.exists(): | |
| for candidate in (_LOCAL_ENV,): | |
| try: | |
| candidate.relative_to(_PROJECT_ROOT) | |
| except ValueError: | |
| if _logger is not None: | |
| _logger.warning( | |
| "Skipping .env outside project root: %s", candidate | |
| ) | |
| continue | |
| with open(candidate) as f: | |
| for raw in f: | |
| line = raw.strip() | |
| if line and not line.startswith("#") and "=" in line: | |
| k, _, v = line.partition("=") | |
| os.environ.setdefault(k.strip(), v.strip()) | |
| if os.environ.get("DEEPSEEK_API_KEY"): | |
| os.environ.setdefault("LLM_PROVIDER", "deepseek") | |
| elif os.environ.get("GOOGLE_API_KEY"): | |
| os.environ.setdefault("LLM_PROVIDER", "google") | |
| else: | |
| import shutil | |
| if shutil.which("gemini"): | |
| os.environ.setdefault("LLM_PROVIDER", "gemini_cli") | |
| else: | |
| os.environ.setdefault("LLM_PROVIDER", "local") | |
| def _drain_stream_to_logger(stream, label: str) -> None: | |
| """Forward backend subprocess stream lines to the project logger. | |
| Mirrors ``api.bridge._drain_stream_to_logger``. Prevents the OS pipe | |
| buffer from filling and silently masking startup failures. | |
| """ | |
| if stream is None: | |
| return | |
| try: | |
| for line in iter(stream.readline, ""): | |
| text = line.rstrip() | |
| if text and _logger is not None: | |
| _logger.warning("[%s] %s", label, text) | |
| except (OSError, ValueError) as exc: | |
| if _logger is not None: | |
| _logger.debug("%s drain ended: %s", label, exc) | |
| finally: | |
| try: | |
| stream.close() | |
| except (OSError, ValueError) as exc: | |
| if _logger is not None: | |
| _logger.debug("%s close failed: %s", label, exc) | |
| def _start_backend() -> subprocess.Popen: | |
| proc = subprocess.Popen( | |
| [sys.executable, "-m", "uvicorn", "ai_tutor.main:app", | |
| "--host", "127.0.0.1", "--port", "8000", "--log-level", "warning"], | |
| cwd=_PROJECT_ROOT, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, | |
| text=True, | |
| bufsize=1, | |
| ) | |
| threading.Thread( | |
| target=_drain_stream_to_logger, | |
| args=(proc.stderr, "ai-tutor"), | |
| name="ai-tutor-stderr-drain", | |
| daemon=True, | |
| ).start() | |
| return proc | |
| def _wait_for_backend(url: str = "http://127.0.0.1:8000/health", timeout: float = 120) -> bool: | |
| import urllib.request | |
| start = time.time() | |
| while time.time() - start < timeout: | |
| try: | |
| urllib.request.urlopen(url, timeout=2) | |
| return True | |
| except (OSError, ValueError) as exc: | |
| if _logger is not None: | |
| _logger.debug("backend not ready yet: %s", exc) | |
| time.sleep(0.5) | |
| print("[warn] AI Tutor backend did not respond in time — chat may not work.") | |
| return False | |
| def main(argv=None) -> None: | |
| """Launch the desktop shell. | |
| Argparse runs first so ``--help`` and ``--version`` short-circuit | |
| before any side effect (no dep install, no backend spawn, no | |
| PyWebView launch). | |
| """ | |
| _build_arg_parser().parse_args(argv) | |
| ensure_deps() | |
| # config is stdlib-only and lives at the project root next to this | |
| # launcher; deferred until ``ensure_deps`` has verified the rest of | |
| # the third-party stack. | |
| from config import get_logger | |
| global _logger | |
| _logger = get_logger("run") | |
| _load_env() | |
| backend_process = _start_backend() | |
| _wait_for_backend() | |
| def _shutdown(sig=None, frame=None) -> None: | |
| backend_process.terminate() | |
| backend_process.wait() | |
| signal.signal(signal.SIGINT, _shutdown) | |
| signal.signal(signal.SIGTERM, _shutdown) | |
| from api.bridge import CalculusAPI | |
| from window import launch | |
| try: | |
| launch(CalculusAPI()) | |
| finally: | |
| _shutdown() | |
| if __name__ == "__main__": | |
| if sys.platform == 'darwin': | |
| import multiprocessing | |
| multiprocessing.freeze_support() | |
| main() | |