From 8a81f16d7ddbcf7035a23cdc2c5de943337ad6a0 Mon Sep 17 00:00:00 2001 From: Mathew Sir Guest the best Date: Tue, 7 Jul 2026 21:39:11 -0600 Subject: [PATCH] feat: JSON SessionStore with stale eviction (replaces pickle persistence) --- src/wabot/sessions.py | 88 +++++++++++++++++++++++++++++++++++++ tests/unit/test_sessions.py | 69 +++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/wabot/sessions.py create mode 100644 tests/unit/test_sessions.py diff --git a/src/wabot/sessions.py b/src/wabot/sessions.py new file mode 100644 index 0000000..155f0f2 --- /dev/null +++ b/src/wabot/sessions.py @@ -0,0 +1,88 @@ +"""Persistence of reattachable browser sessions as plain JSON. + +Replaces the legacy pickle/dill approach: instead of serializing a live +webdriver object, we record only what is needed to reattach — the +executor URL and session id — plus bookkeeping for managed services. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import platformdirs + +LOGGER = logging.getLogger("wabot") + + +@dataclass +class SessionRecord: + """Everything needed to find a saved browser session again.""" + + name: str + executor_url: str + session_id: str + browser: str + created_at: str # ISO-8601, UTC + service_pid: int | None = None + service_port: int | None = None + + +def default_store_path() -> Path: + return Path(platformdirs.user_data_dir("wabot")) / "sessions.json" + + +class SessionStore: + """A JSON file mapping session name -> SessionRecord.""" + + def __init__(self, path: Path | None = None, max_age: timedelta = timedelta(days=3)): + self.path = Path(path) if path is not None else default_store_path() + self.max_age = max_age + + def load(self) -> dict[str, SessionRecord]: + """Read all records, evicting stale ones and surviving a corrupt file.""" + if not self.path.exists(): + return {} + try: + raw = json.loads(self.path.read_text()) + records = {name: SessionRecord(**data) for name, data in raw.items()} + except (json.JSONDecodeError, TypeError, AttributeError): + bad = self.path.with_name(self.path.name + ".bad") + self.path.rename(bad) + LOGGER.warning("corrupt session store moved to %s; starting fresh", bad) + return {} + fresh = {} + for name, record in records.items(): + created = datetime.fromisoformat(record.created_at) + if datetime.now(timezone.utc) - created >= self.max_age: + LOGGER.info("evicting stale session %r (created %s)", name, record.created_at) + continue + fresh[name] = record + if len(fresh) != len(records): + self._write(fresh) + return fresh + + def get(self, name: str) -> SessionRecord | None: + return self.load().get(name) + + def save(self, record: SessionRecord) -> None: + records = self.load() + records[record.name] = record + self._write(records) + + def remove(self, name: str) -> None: + records = self.load() + if records.pop(name, None) is not None: + self._write(records) + + def names(self) -> list[str]: + return sorted(self.load()) + + def _write(self, records: dict[str, SessionRecord]) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = {name: dataclasses.asdict(rec) for name, rec in records.items()} + self.path.write_text(json.dumps(payload, indent=2)) diff --git a/tests/unit/test_sessions.py b/tests/unit/test_sessions.py new file mode 100644 index 0000000..194f20b --- /dev/null +++ b/tests/unit/test_sessions.py @@ -0,0 +1,69 @@ +from datetime import datetime, timedelta, timezone + +from wabot.sessions import SessionRecord, SessionStore + + +def make_record(name="scraper1", created_at=None): + return SessionRecord( + name=name, + executor_url="http://127.0.0.1:9515", + session_id="abc123", + browser="chromium", + created_at=(created_at or datetime.now(timezone.utc)).isoformat(), + service_pid=4242, + service_port=9515, + ) + + +class TestSessionStore: + def test_get_missing_returns_none(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + assert store.get("nope") is None + + def test_save_and_get_round_trip(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + store.save(make_record()) + rec = store.get("scraper1") + assert rec == make_record(created_at=datetime.fromisoformat(rec.created_at)) + assert rec.session_id == "abc123" + assert rec.service_pid == 4242 + + def test_save_is_readable_by_a_fresh_store_instance(self, tmp_path): + path = tmp_path / "sessions.json" + SessionStore(path=path).save(make_record()) + assert SessionStore(path=path).get("scraper1") is not None + + def test_names_lists_sessions_sorted(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + store.save(make_record(name="zeta")) + store.save(make_record(name="alpha")) + assert store.names() == ["alpha", "zeta"] + + def test_remove(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json") + store.save(make_record()) + store.remove("scraper1") + assert store.get("scraper1") is None + store.remove("scraper1") # removing twice must not raise + + def test_stale_records_are_evicted_on_load(self, tmp_path): + store = SessionStore(path=tmp_path / "sessions.json", max_age=timedelta(days=3)) + old = datetime.now(timezone.utc) - timedelta(days=4) + store.save(make_record(name="old", created_at=old)) + store.save(make_record(name="fresh")) + assert store.names() == ["fresh"] + + def test_corrupt_file_is_moved_aside_not_crashed_on(self, tmp_path): + path = tmp_path / "sessions.json" + path.write_text("{ this is not json") + store = SessionStore(path=path) + assert store.load() == {} + assert (tmp_path / "sessions.json.bad").exists() + store.save(make_record()) # store is usable again + assert store.get("scraper1") is not None + + def test_default_path_is_under_platformdirs(self): + from wabot.sessions import default_store_path + + assert default_store_path().name == "sessions.json" + assert "wabot" in str(default_store_path())