mirror of
https://git.zavage.net/Zavage-Software/wabot.git
synced 2026-07-21 13:06:08 -06:00
feat: public API — wabot.browser()/sessions()/destroy() with reattach flow
This commit is contained in:
parent
3d0e00e88f
commit
5245757cc8
@ -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
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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user