# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT
"""Reusable Playwright browser session for the JS-heavy WebUI path.
Sibling of :mod:`gamesheet_sdk.common.session` with matching shape:
- One context owning cookies and localStorage.
- Storage state persisted via :attr:`Config.browser_state_path`.
- Base-URL resolution against :attr:`Config.base_url`.
- Context-manager that saves state on exit.
Browsers are heavyweight, so :class:`BrowserSession` starts Playwright
lazily on first reach for the browser; bare construction is free.
"""
from __future__ import annotations
import json
import logging
from types import TracebackType
from typing import Any
from urllib.parse import urljoin
from playwright.sync_api import (
Browser,
BrowserContext,
Page,
Playwright,
StorageState,
sync_playwright,
)
from gamesheet_sdk.common.config import Config
_LOGGER = logging.getLogger(__name__)
[docs]
class BrowserSession:
"""A Playwright-driven session for the JavaScript-heavy code path.
Mirror of :class:`gamesheet_sdk.Session` for flows where ``requests``
is not enough (single-page apps, anti-bot challenges, anything that
needs a real engine to render).
Example::
from gamesheet_sdk import BrowserSession, Config
with BrowserSession(Config()) as bs:
page = bs.goto("/login")
page.fill("input[name='email']", "...")
"""
[docs]
def __init__(self: BrowserSession, config: Config | None = None) -> None:
"""Initialize a browser session without starting Playwright.
Stores the configuration and sets up internal state for lazy browser initialization. Playwright and
Chromium are only launched when :attr:`context` or :meth:`goto` is first accessed. This makes
construction cheap and allows sessions that never reach for the browser (e.g. configuration-only runs)
to avoid the startup overhead.
:param config: Optional configuration object. If ``None``, a default
:class:`~gamesheet_sdk.common.config.Config` is created.
:type config: Config | None
"""
self.config = config or Config()
self._playwright: Playwright | None = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._closed = False
def _load_storage_state(self: BrowserSession) -> StorageState | None:
"""Load browser storage state from disk if it exists.
:returns: A dictionary containing cookies and localStorage data, or ``None`` if the file does not
exist or cannot be parsed.
"""
path = self.config.browser_state_path
if not path.exists():
return None
try:
loaded: StorageState = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
_LOGGER.warning(
"Failed to load browser storage state from %s: %s",
path,
exc,
)
return None
return loaded
# -- internals --------------------------------------------------------
def _start(self: BrowserSession) -> None:
"""Launch Playwright + Chromium + a context, possibly restoring state.
:returns: None
:rtype: None
"""
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(
headless=self.config.browser_headless,
)
storage_state = self._load_storage_state()
if storage_state is not None:
# storage_state is read back from the JSON Playwright itself
# wrote; matches the StorageState TypedDict structurally.
self._context = self._browser.new_context(
storage_state=storage_state,
)
else:
self._context = self._browser.new_context()
# -- public attribute access ------------------------------------------
@property
def context(self: BrowserSession) -> BrowserContext:
"""The underlying Playwright BrowserContext.
Starts Playwright and launches Chromium on first access, so a :class:`BrowserSession` that never
reaches for the browser is effectively free.
:returns: The active browser context.
:rtype: BrowserContext
:raises RuntimeError: If the session has already been closed.
:raises ValueError: If the browser failed to start (defensive check).
"""
if self._closed:
_err_msg = "BrowserSession has been closed"
raise RuntimeError(_err_msg)
if self._context is None:
self._start()
if self._context is None:
# Defensive check: _start() either succeeds (sets _context) or raises
_err_msg = "BrowserSession did not start"
raise ValueError(_err_msg)
return self._context
[docs]
def new_page(self: BrowserSession) -> Page:
"""Open a fresh tab in the session's context and return it.
Starts Playwright and Chromium on first call if not already running.
:returns: Return value.
:rtype: Page
"""
return self.context.new_page()
def _resolve(self: BrowserSession, url: str) -> str:
"""Resolve a URL against the configured base URL.
Absolute URLs (http://, https://, data:, file:, about:) are returned unchanged. Relative URLs are
joined to :attr:`Config.base_url`.
:param url: An absolute or relative URL.
:type url: str
:returns: String result.
:rtype: str
"""
if url.startswith(("http://", "https://", "data:", "file:", "about:")):
return url
return urljoin(self.config.base_url.rstrip("/") + "/", url.lstrip("/"))
[docs]
def goto(self: BrowserSession, url: str, **kwargs: Any) -> Page:
"""Open a fresh tab navigated to ``url``.
``url`` may be absolute or a path relative to :attr:`Config.base_url`.
Starts Playwright and Chromium on first call if not already running.
Example::
with BrowserSession() as bs:
page = bs.goto("/login", wait_until="networkidle")
page.fill("input[name='email']", "test@example.com")
:param url: An absolute or relative URL to navigate to.
:type url: str
:param kwargs: Additional keyword arguments passed to :meth:`playwright.sync_api.Page.goto`.
:type kwargs: Any
:returns: A :class:`~playwright.sync_api.Page` navigated to the resolved URL.
:rtype: Page
"""
page = self.new_page()
page.goto(self._resolve(url), **kwargs)
return page
# -- lifecycle --------------------------------------------------------
[docs]
def save(self: BrowserSession) -> None:
"""Persist the current storage state to :attr:`Config.browser_state_path`.
No-op if the browser has not been started yet (there is nothing to save) or if :meth:`close` has
already been called.
"""
if self._context is None:
return
path = self.config.browser_state_path
path.parent.mkdir(parents=True, exist_ok=True)
state = self._context.storage_state()
path.write_text(json.dumps(state, indent=2, sort_keys=True))
def _safe_save(self: BrowserSession) -> None:
"""Persist storage state, demoting disk errors to a warning.
Calls :meth:`save` and logs any :exc:`OSError` as a warning instead of propagating it, ensuring
cleanup can proceed even if state persistence fails.
:returns: None
:rtype: None
"""
try:
self.save()
except OSError as exc:
_LOGGER.warning("Failed to save browser storage state: %s", exc)
def _release_playwright(self: BrowserSession) -> None:
"""Close the context/browser/playwright handles and drop our refs.
Closes the browser context, browser instance, and stops the Playwright driver in that order. Resets
all internal references to ``None``. Safe to call when some or all handles are already ``None``.
:returns: None
:rtype: None
"""
if self._context is not None:
self._context.close()
if self._browser is not None:
self._browser.close()
if self._playwright is not None:
self._playwright.stop()
self._context = None
self._browser = None
self._playwright = None
[docs]
def close(self: BrowserSession) -> None:
"""Persist storage state and shut Playwright down.
Idempotent: calling :meth:`close` more than once is safe.
"""
if self._closed:
return
self._safe_save()
self._release_playwright()
self._closed = True
def __enter__(self: BrowserSession) -> BrowserSession:
"""Enter the context manager.
:returns: Return value.
:rtype: BrowserSession
"""
return self
def __exit__(
self: BrowserSession,
_exc_type: type[BaseException] | None,
_exc_val: BaseException | None,
_exc_tb: TracebackType | None,
) -> None:
"""Exit the context manager and close the session.
Persists storage state and shuts down Playwright resources.
:returns: None
:rtype: None
"""
self.close()