"""Elmhurst RdSAP entry-tool browser session (POC, step 1: login + navigate). The slow part of the `validate-cert-sap-accuracy` loop is the manual step: a human keys ~50 fields from `elmhurst_inputs.md` into Elmhurst's web RdSAP entry tool, then exports the Input Summary + SAP Worksheets PDFs. This module is the foundation for automating that with Playwright. Design (agreed with the user): * **Persistent browser session.** We launch a *headed* Chromium against a persistent profile dir (`.elmhurst-session/`, gitignored). You log in to Elmhurst once by hand — MFA/captcha included — and every later run reuses that session, so no credentials live in the repo. * **Iterative.** This first cut only proves login + session reuse + navigation. Form-filling and PDF export land on top of `launch_session()` in later steps (selectors to come from a recorded `playwright codegen` session, since the Elmhurst DOM is login-gated). Usage: # First run — a real browser opens; log in by hand, then press Enter here. python scripts/hyde/elmhurst_session.py login --url https:// # Later runs — session is reused; this just proves you're still logged in. python scripts/hyde/elmhurst_session.py open --url https:// python scripts/hyde/elmhurst_session.py status The tool URL can also come from the ELMHURST_URL env var instead of --url. """ from __future__ import annotations import argparse import logging import os import sys from contextlib import contextmanager from pathlib import Path from typing import Generator, Optional from playwright.sync_api import BrowserContext, Page, sync_playwright HERE = Path(__file__).parent SESSION_DIR = HERE / ".elmhurst-session" DOWNLOADS_DIR = HERE / "elmhurst_downloads" logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s" ) logger = logging.getLogger("elmhurst") def _resolve_url(cli_url: Optional[str]) -> str: """Tool URL from --url, else ELMHURST_URL env. We never hardcode it: the Elmhurst entry-tool URL isn't in the repo and may differ per account.""" url = cli_url or os.environ.get("ELMHURST_URL") if not url: logger.error( "No Elmhurst URL given. Pass --url or set ELMHURST_URL. " "(Open the entry tool in your browser and copy the address.)" ) sys.exit(2) return url @contextmanager def launch_session(headless: bool = False) -> Generator[BrowserContext, None, None]: """Open the persistent Elmhurst profile as a Playwright context. Headed by default — Elmhurst login needs a human the first time. The persistent ``user_data_dir`` is what makes the session survive across runs: cookies and local storage are written into ``SESSION_DIR`` on disk, so a later launch is already authenticated. ``accept_downloads`` is on so the later PDF-export step can capture Input Summary / SAP Worksheets. """ SESSION_DIR.mkdir(parents=True, exist_ok=True) with sync_playwright() as p: context = p.chromium.launch_persistent_context( user_data_dir=str(SESSION_DIR), headless=headless, accept_downloads=True, viewport={"width": 1400, "height": 1000}, ) try: yield context finally: context.close() def _active_page(context: BrowserContext, url: Optional[str]) -> Page: """Reuse the profile's existing tab (persistent contexts open one) instead of stacking new blank tabs on every run; navigate it if a URL is given.""" page = context.pages[0] if context.pages else context.new_page() if url: logger.info("Navigating to %s", url) page.goto(url, wait_until="domcontentloaded", timeout=60_000) return page def _wait_for_operator(prompt: str) -> None: """Block until the human is done in the browser. Headed/interactive only.""" try: input(f"\n>>> {prompt}\n>>> Press Enter here when done... ") except EOFError: # Non-interactive (piped) run: nothing to wait on. logger.warning("No interactive stdin; not waiting.") def cmd_login(args: argparse.Namespace) -> int: """First-run login: open the tool, let the human authenticate, persist it.""" url = _resolve_url(args.url) with launch_session(headless=False) as context: page = _active_page(context, url) _wait_for_operator( "Log in to Elmhurst in the opened browser (MFA included). " "Leave the browser on a logged-in page." ) logger.info("Final URL: %s", page.url) logger.info("Session saved to %s", SESSION_DIR) return 0 def cmd_open(args: argparse.Namespace) -> int: """Reuse the saved session and navigate — proves login survived a restart.""" if not _session_exists(): logger.error("No saved session. Run `login` first.") return 1 url = _resolve_url(args.url) with launch_session(headless=False) as context: page = _active_page(context, url) logger.info("Landed on: %s (title: %r)", page.url, page.title()) _wait_for_operator("Inspect the page. This confirms the session reused.") return 0 def _session_exists() -> bool: return SESSION_DIR.exists() and any(SESSION_DIR.iterdir()) def cmd_status(_args: argparse.Namespace) -> int: if _session_exists(): logger.info("Session present at %s", SESSION_DIR) else: logger.info("No session yet. Run `login`.") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="elmhurst_session", description="Elmhurst RdSAP entry-tool session (login + navigate POC).", ) sub = parser.add_subparsers(dest="command", required=True) p_login = sub.add_parser("login", help="open browser, log in by hand, save session") p_login.add_argument("--url", help="Elmhurst tool URL (or set ELMHURST_URL)") p_login.set_defaults(func=cmd_login) p_open = sub.add_parser("open", help="reuse session and navigate to a URL") p_open.add_argument("--url", help="Elmhurst tool URL (or set ELMHURST_URL)") p_open.set_defaults(func=cmd_open) p_status = sub.add_parser("status", help="report whether a session is saved") p_status.set_defaults(func=cmd_status) return parser def main(argv: Optional[list[str]] = None) -> int: args = build_parser().parse_args(argv) func = getattr(args, "func") return int(func(args)) if __name__ == "__main__": raise SystemExit(main())