feat: public API — wabot.browser()/sessions()/destroy() with reattach flow

This commit is contained in:
Mathew Sir Guest the best 2026-07-08 22:13:01 -06:00
parent 3d0e00e88f
commit 5245757cc8
3 changed files with 303 additions and 3 deletions

@ -1 +1,147 @@
"""wabot — stateful Selenium browser automation with reattachable sessions."""
"""wabot — stateful Selenium browser automation with reattachable sessions.
Quickstart::
import wabot
bot = wabot.browser(session="scraper1", browser="firefox")
# ... process exits; the browser stays open ...
bot = wabot.browser(session="scraper1", browser="firefox") # reattaches
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from .browser import Browser
from .fields import CheckField, NullField, PageObject, SelectField, TextField
from .hosts import (
ExternalServer,
ManagedService,
attach,
build_options,
normalize_browser,
service_alive,
stop_service,
)
from .pacing import HumanPacing, NoPacing
from .page import Page
from .sessions import SessionRecord, SessionStore
__all__ = [
"Browser",
"CheckField",
"HumanPacing",
"NoPacing",
"NullField",
"Page",
"PageObject",
"SelectField",
"SessionRecord",
"SessionStore",
"TextField",
"browser",
"destroy",
"sessions",
]
LOGGER = logging.getLogger("wabot")
def _new_remote(url: str, options):
return webdriver.Remote(command_executor=url, options=options)
def _new_local(browser_name: str, options):
if browser_name == "chromium":
return webdriver.Chrome(options=options)
return webdriver.Firefox(options=options)
def browser(
session: str | None = None,
browser: str = "firefox",
host: str | None = None,
*,
headless: bool = False,
user_agent: str | None = None,
pacing=None,
store: SessionStore | None = None,
) -> Browser:
"""Create a Browser, reattaching to a saved session when one exists.
Args:
session: Persistence name. None (default) = ephemeral: the browser
is not saved and (without ``host``) dies with this process.
browser: "firefox" or "chromium" ("chrome" is an alias).
host: URL of an external WebDriver server (Selenium Grid or a bare
driver). Without it, persistent sessions get a wabot-managed
detached driver service.
headless: Run the browser headless.
user_agent: Override the browser's user agent (default: browser's own).
pacing: A pacing policy; defaults to HumanPacing (stealth delays).
store: SessionStore override, mainly for tests.
"""
browser_name = normalize_browser(browser)
options = build_options(browser_name, headless=headless, user_agent=user_agent)
if session is None:
if host:
return Browser(_new_remote(host, options), pacing=pacing)
return Browser(_new_local(browser_name, options), pacing=pacing)
store = store if store is not None else SessionStore()
record = store.get(session)
if record is not None:
if service_alive(record.executor_url):
driver = attach(record.executor_url, record.session_id, record.browser)
if driver is not None:
LOGGER.info("reattached to session %r", session)
return Browser(driver, pacing=pacing, session_name=session, store=store)
LOGGER.warning("saved session %r is dead; creating a fresh browser", session)
store.remove(session)
host_obj = ExternalServer(host) if host else ManagedService(browser_name)
url = host_obj.ensure_running()
driver = _new_remote(url, options)
store.save(
SessionRecord(
name=session,
executor_url=url,
session_id=driver.session_id,
browser=browser_name,
created_at=datetime.now(timezone.utc).isoformat(),
service_pid=getattr(host_obj, "pid", None),
service_port=getattr(host_obj, "port", None),
)
)
LOGGER.info("created persistent session %r on %s", session, url)
return Browser(driver, pacing=pacing, session_name=session, store=store)
def sessions(store: SessionStore | None = None) -> list[str]:
"""Names of saved (non-stale) sessions."""
return (store if store is not None else SessionStore()).names()
def destroy(name: str, store: SessionStore | None = None) -> bool:
"""Quit a saved session's browser, stop its managed service, forget it."""
store = store if store is not None else SessionStore()
record = store.get(name)
if record is None:
return False
if service_alive(record.executor_url):
driver = attach(record.executor_url, record.session_id, record.browser)
if driver is not None:
try:
driver.quit()
except WebDriverException as ex:
LOGGER.warning("quit failed while destroying %r: %s", name, ex)
if record.service_pid:
stop_service(record.service_pid)
store.remove(name)
return True

143
tests/unit/test_api.py Normal file

@ -0,0 +1,143 @@
from unittest.mock import MagicMock
import pytest
import wabot
from wabot.sessions import SessionRecord, SessionStore
@pytest.fixture
def store(tmp_path):
return SessionStore(path=tmp_path / "sessions.json")
@pytest.fixture
def fake_selenium(monkeypatch):
"""Patch every constructor that would talk to a real browser."""
fakes = MagicMock(name="fakes")
fakes.Remote.return_value.session_id = "new-session-id"
monkeypatch.setattr(wabot, "_new_remote", fakes.Remote)
monkeypatch.setattr(wabot, "_new_local", fakes.Local)
monkeypatch.setattr(wabot, "attach", fakes.attach)
monkeypatch.setattr(wabot, "service_alive", fakes.service_alive)
fakes.managed = MagicMock(name="ManagedService_instance")
fakes.managed.ensure_running.return_value = "http://127.0.0.1:7777"
fakes.managed.pid, fakes.managed.port = 4321, 7777
monkeypatch.setattr(wabot, "ManagedService", MagicMock(return_value=fakes.managed))
return fakes
class TestEphemeral:
def test_no_session_no_host_uses_local_driver(self, fake_selenium, store):
bot = wabot.browser(store=store)
fake_selenium.Local.assert_called_once()
assert bot.driver is fake_selenium.Local.return_value
assert store.names() == [] # nothing persisted
def test_no_session_with_host_uses_remote_without_saving(self, fake_selenium, store):
wabot.browser(host="http://grid:4444", store=store)
fake_selenium.Remote.assert_called_once()
assert fake_selenium.Remote.call_args.args[0] == "http://grid:4444"
assert store.names() == []
class TestPersistentCreate:
def test_managed_service_created_and_recorded(self, fake_selenium, store):
bot = wabot.browser(session="s1", browser="chromium", store=store)
record = store.get("s1")
assert record == SessionRecord(
name="s1",
executor_url="http://127.0.0.1:7777",
session_id="new-session-id",
browser="chromium",
created_at=record.created_at,
service_pid=4321,
service_port=7777,
)
assert bot.session_name == "s1"
def test_external_host_records_no_pid(self, fake_selenium, store, monkeypatch):
external = MagicMock()
external.ensure_running.return_value = "http://grid:4444"
del external.pid # ExternalServer has no pid/port attributes
del external.port
monkeypatch.setattr(wabot, "ExternalServer", MagicMock(return_value=external))
wabot.browser(session="s1", host="http://grid:4444", store=store)
record = store.get("s1")
assert record.executor_url == "http://grid:4444"
assert record.service_pid is None and record.service_port is None
class TestReattach:
def make_record(self, store):
from datetime import datetime, timezone
store.save(
SessionRecord(
name="s1",
executor_url="http://127.0.0.1:7777",
session_id="old-session",
browser="chromium",
created_at=datetime.now(timezone.utc).isoformat(),
)
)
def test_live_record_is_reattached(self, fake_selenium, store):
self.make_record(store)
fake_selenium.service_alive.return_value = True
bot = wabot.browser(session="s1", store=store)
fake_selenium.attach.assert_called_once_with(
"http://127.0.0.1:7777", "old-session", "chromium"
)
assert bot.driver is fake_selenium.attach.return_value
fake_selenium.Remote.assert_not_called()
def test_dead_record_falls_through_to_fresh_creation(self, fake_selenium, store):
self.make_record(store)
fake_selenium.service_alive.return_value = True
fake_selenium.attach.return_value = None # session gone
wabot.browser(session="s1", store=store)
fake_selenium.Remote.assert_called_once()
assert store.get("s1").session_id == "new-session-id"
def test_dead_server_skips_attach_entirely(self, fake_selenium, store):
self.make_record(store)
fake_selenium.service_alive.return_value = False
wabot.browser(session="s1", store=store)
fake_selenium.attach.assert_not_called()
fake_selenium.Remote.assert_called_once()
class TestHousekeeping:
def test_sessions_lists_names(self, store):
assert wabot.sessions(store=store) == []
def test_destroy_quits_kills_and_removes(self, fake_selenium, store, monkeypatch):
from datetime import datetime, timezone
stop = MagicMock()
monkeypatch.setattr(wabot, "stop_service", stop)
store.save(
SessionRecord(
name="s1",
executor_url="http://127.0.0.1:7777",
session_id="old-session",
browser="chromium",
created_at=datetime.now(timezone.utc).isoformat(),
service_pid=4321,
)
)
fake_selenium.service_alive.return_value = True
assert wabot.destroy("s1", store=store) is True
fake_selenium.attach.return_value.quit.assert_called_once_with()
stop.assert_called_once_with(4321)
assert store.get("s1") is None
def test_destroy_missing_returns_false(self, store):
assert wabot.destroy("nope", store=store) is False
class TestPublicSurface:
def test_all_exports_exist(self):
for name in wabot.__all__:
assert hasattr(wabot, name), name

@ -124,7 +124,15 @@ class TestQuit:
driver.quit.assert_called_once_with()
def test_quit_stops_managed_service(self, monkeypatch):
import wabot.browser as browser_mod
import sys
# NOTE(Task 12 wiring fix): `import wabot.browser as browser_mod` would
# resolve to `wabot.browser` the FUNCTION (the public API added in
# Task 12 shadows the submodule attribute of the same name on the
# `wabot` package once `wabot/__init__.py` defines `def browser(...)`).
# sys.modules keeps the real submodule reachable regardless of what
# the package's own `browser` attribute is rebound to.
browser_mod = sys.modules["wabot.browser"]
stopped = []
monkeypatch.setattr(browser_mod, "stop_service", lambda pid: stopped.append(pid))
@ -136,7 +144,10 @@ class TestQuit:
store.remove.assert_called_once_with("s1")
def test_quit_removes_record_even_if_driver_quit_raises(self, monkeypatch):
import wabot.browser as browser_mod
import sys
# see NOTE in test_quit_stops_managed_service above
browser_mod = sys.modules["wabot.browser"]
monkeypatch.setattr(browser_mod, "stop_service", lambda pid: None)
driver, store = MagicMock(), MagicMock()