Source code for gamesheet_sdk.common.auth.storage

# Copyright (c) 2026 bdperkin
# SPDX-License-Identifier: MIT

"""Browser storage state file manipulation.

This module provides utilities for reading and writing Playwright browser
storage state files, which store cookies, localStorage, and sessionStorage
data in JSON format.

The storage state file format is:

.. code-block:: json

    {
        "cookies": [...],
        "origins": [
            {
                "origin": "https://example.com",
                "localStorage": [
                    {"name": "key", "value": "value"}
                ]
            }
        ]
    }

**Example usage:**
.. code-block:: python
    from pathlib import Path
    from gamesheet_sdk.common.config import Config
    from gamesheet_sdk.common.auth.storage import (
        load_local_storage_value,
        read_state_or_empty,
        origin_entry_for,
        apply_local_storage_updates,
    )

    # Load a localStorage value
    config = Config()
    access_token = load_local_storage_value(config, "access_token")
    # Create/update localStorage entries
    state_path = Path.home() / ".gamesheet" / "browser_state.json"
    state = read_state_or_empty(state_path)
    origin = origin_entry_for(state, APP_GAMESHEET_COM)
    apply_local_storage_updates(
        origin["localStorage"],
        {
            "access_token": "new_token",
            "refresh_token": "new_refresh",
        },
    )
    state_path.write_text(json.dumps(state, indent=2))
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

from gamesheet_sdk.common.config import Config

_LOGGER = logging.getLogger(__name__)


[docs] def read_state_file(path: Path) -> dict[str, Any] | None: """Parse the browser storage state JSON, or return None on miss/error. Reads and parses a Playwright browser storage state file. Returns ``None`` if the file does not exist or cannot be parsed. .. note:: This function logs a warning (not an error) if the file exists but cannot be read or parsed, allowing graceful fallback to fresh login flows. :param path: Path to the browser storage state JSON file. :type path: Path :returns: Parsed storage state dictionary, or ``None`` if file is missing or cannot be parsed. :rtype: dict[str, Any] | None """ if not path.exists(): return None try: loaded: dict[str, Any] = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): _LOGGER.warning("Failed to read browser storage state from %s.", path) return None return loaded
[docs] def lookup_local_storage(state: dict[str, Any], base_url: str, name: str) -> Any: """Return the named localStorage value for base_url, or None. Searches the storage state dictionary for a localStorage entry matching the given origin (base_url) and key name. .. note:: The value returned is untyped (``Any``) — callers should validate the type before use. See :func:`load_local_storage_value` for a string-validated variant. :param state: Parsed browser storage state dictionary (from :func:`read_state_file` or :func:`read_state_or_empty`). :type state: dict[str, Any] :param base_url: Origin URL to search for (e.g., ``APP_GAMESHEET_COM``). :type base_url: str :param name: localStorage key name to look up. :type name: str :returns: The value associated with the key, or ``None`` if the origin or key is not found. :rtype: Any """ for origin in state.get("origins", []): if origin.get("origin") != base_url: continue for kv in origin.get("localStorage", []): if kv.get("name") == name: return kv.get("value") return None
[docs] def load_local_storage_value(config: Config, name: str) -> str | None: """Read one localStorage entry for config.base_url from the saved state. High-level helper that reads the browser storage state file from ``config.browser_state_path``, looks up the localStorage value for ``config.base_url``, and validates that it is a non-empty string. .. note:: Returns ``None`` if the file does not exist, cannot be parsed, the origin is not found, the key is not found, or the value is not a non-empty string. **Example:** .. code-block:: python from gamesheet_sdk.common.config import Config from gamesheet_sdk.common.auth.storage import load_local_storage_value config = Config() access_token = load_local_storage_value(config, "access_token") if access_token: print(f"Found token: {access_token[:10]}...") else: print("No token found, need to log in") :param config: Configuration object containing the browser state path and base URL. :type config: Config :param name: localStorage key name to retrieve (e.g., ``"access_token"``). :type name: str :returns: The string value if found and non-empty, otherwise None. :rtype: str | None """ state = read_state_file(config.browser_state_path) if state is None: return None value = lookup_local_storage(state, config.base_url, name) return value if isinstance(value, str) and value else None
[docs] def read_state_or_empty(path: Path) -> dict[str, Any]: """Like read_state_file but returns an empty skeleton on miss/error. Reads and parses a browser storage state file. Unlike :func:`read_state_file`, this function never returns ``None`` — if the file does not exist or cannot be parsed, it returns a minimal valid state structure. .. note:: This function does not log warnings on parse failures (unlike :func:`read_state_file`), making it suitable for write-path helpers where missing state is expected. :param path: Path to the browser storage state JSON file. :type path: Path :returns: Parsed storage state dictionary, or ``{"cookies": [], "origins": []}`` if the file is missing or cannot be parsed. :rtype: dict[str, Any] """ empty: dict[str, Any] = {"cookies": [], "origins": []} if not path.exists(): return empty try: loaded: dict[str, Any] = json.loads(path.read_text()) except json.JSONDecodeError: return empty return loaded
[docs] def origin_entry_for(state: dict[str, Any], base_url: str) -> dict[str, Any]: """Return the origin entry for base_url, creating it if absent. Searches the ``origins`` list in the storage state for an entry matching the given URL. If not found, creates a new origin entry with an empty localStorage array and appends it to the list. .. warning:: This function **mutates** the ``state`` dictionary by adding a new origin entry if one does not exist. Ensure you write the modified state back to disk after calling this function. :param state: Browser storage state dictionary (typically from :func:`read_state_or_empty`). :type state: dict[str, Any] :param base_url: Origin URL (e.g., ``APP_GAMESHEET_COM``). :type base_url: str :returns: The origin entry dictionary containing ``"origin"`` and ``"localStorage"`` keys. The returned dict is a **live reference** — modifications to it will update the state. :rtype: dict[str, Any] """ origins: list[dict[str, Any]] = state.setdefault("origins", []) for origin in origins: if origin.get("origin") == base_url: return origin new_entry: dict[str, Any] = {"origin": base_url, "localStorage": []} origins.append(new_entry) return new_entry
[docs] def apply_local_storage_updates( ls: list[dict[str, str]], updates: dict[str, str], ) -> None: """Upsert each name → value pair into the localStorage list. Updates existing localStorage entries in place or appends new entries for keys that do not exist. .. warning:: This function **mutates** the ``ls`` list in place. Ensure you write the parent state back to disk after calling this function. **Example:** .. code-block:: python from pathlib import Path import json from gamesheet_sdk.common.auth.storage import ( read_state_or_empty, origin_entry_for, apply_local_storage_updates, ) state_path = Path.home() / ".gamesheet" / "browser_state.json" state = read_state_or_empty(state_path) origin = origin_entry_for(state, APP_GAMESHEET_COM) # Upsert tokens apply_local_storage_updates( origin["localStorage"], { "access_token": "eyJ...", "refresh_token": "dGh...", }, ) # Write back to disk state_path.write_text(json.dumps(state, indent=2)) :param ls: The ``localStorage`` array from an origin entry (typically ``origin["localStorage"]``). This list is **mutated in place**. :type ls: list[dict[str, str]] :param updates: Dictionary of key-value pairs to upsert. :type updates: dict[str, str] """ by_name = {kv.get("name"): kv for kv in ls} for name, value in updates.items(): existing = by_name.get(name) if existing is not None: existing["value"] = value else: ls.append({"name": name, "value": value})