"""Portable, cross-process browser profile ownership.""" from __future__ import annotations import re from contextlib import contextmanager from dataclasses import dataclass from pathlib import Path from typing import Any, Iterator from gyxx_flow.core.artifacts import atomic_write_json from gyxx_flow.core.layout import DataLayout from gyxx_flow.core.locks import LockManager _PROFILE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") @dataclass(frozen=True, slots=True) class BrowserProfileLease: profile_id: str profile_path: Path class BrowserProfileManager: def __init__( self, data_root: Path, *, lock_manager: LockManager | None = None ) -> None: self._layout = DataLayout(data_root) self._locks = lock_manager or LockManager( self._layout.root / "state" / "locks" ) @contextmanager def acquire( self, profile_id: str, *, owner: str, timeout_seconds: float = 30.0, ) -> Iterator[BrowserProfileLease]: if not isinstance(profile_id, str) or not _PROFILE_ID.fullmatch(profile_id): raise ValueError("invalid browser profile id") path = self._layout.state("browser_profiles", profile_id) with self._locks.acquire( f"browser:{profile_id}", owner=owner, timeout_seconds=timeout_seconds ): path.mkdir(parents=True, exist_ok=True) yield BrowserProfileLease(profile_id, path) @dataclass(frozen=True, slots=True) class BrowserCookieStore: """Playwright-compatible cookie/storage state persisted with atomic replace.""" cookie_file: Path storage_state_file: Path def __post_init__(self) -> None: object.__setattr__(self, "cookie_file", Path(self.cookie_file).expanduser().resolve()) object.__setattr__( self, "storage_state_file", Path(self.storage_state_file).expanduser().resolve(), ) def load_cookies(self) -> list[dict[str, Any]]: payload = self._read(self.cookie_file, default=[]) if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload): raise ValueError("browser cookie file must contain a list of objects") return payload def save_cookies(self, cookies: list[dict[str, Any]]) -> Path: if not isinstance(cookies, list) or not all(isinstance(item, dict) for item in cookies): raise ValueError("browser cookies must be a list of objects") return atomic_write_json(self.cookie_file, cookies) def load_storage_state(self) -> dict[str, Any] | None: payload = self._read(self.storage_state_file, default=None) if payload is not None and not isinstance(payload, dict): raise ValueError("browser storage state must contain an object") return payload def save_storage_state(self, state: dict[str, Any]) -> Path: if not isinstance(state, dict): raise ValueError("browser storage state must be an object") return atomic_write_json(self.storage_state_file, state) @staticmethod def _read(path: Path, *, default: Any) -> Any: if not path.exists(): return default import json try: return json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ValueError(f"cannot read browser state file: {path.name}") from exc