mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: JSON SessionStore with stale eviction (replaces pickle persistence)
This commit is contained in:
parent
f5e358393d
commit
8a81f16d7d
88
src/wabot/sessions.py
Normal file
88
src/wabot/sessions.py
Normal file
@ -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))
|
||||
69
tests/unit/test_sessions.py
Normal file
69
tests/unit/test_sessions.py
Normal file
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user