feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user