Reflect the final architecture (Browser facade in _browser.py, JSON session persistence, ManagedService/ExternalServer hosts, pacing policies), correct the stale "no test suite" claim now that uv + pytest (140 unit + 9 integration) + ruff + Sphinx are in place, and round out .gitignore with .venv/, .pytest_cache/, .ruff_cache/.
5.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
WABot is a Python library wrapping Selenium for stateful browser automation (login flows, multi-page form filling, scraping). Its two distinguishing features:
- Page-object automation with state carried between pages — consumers model each website page as a
Pagesubclass and drive them through a singleBrowserfacade. - Browser sessions that survive the Python process — session identity (executor URL + session id) is persisted to disk so a different Python process can reattach to the same still-open browser, whether it's running under an external WebDriver server or a wabot-managed detached driver service.
Tooling: uv (uv sync), pytest (uv run pytest; real-browser suite via
uv run pytest -m integration), ruff, Sphinx (uv run sphinx-build -M html docs docs/_build).
Selenium 4 (pinned <5: session reattach relies on stable 4.x internals).
Architecture
Consumer code talks to one object, Browser (src/wabot/_browser.py — underscored so the module doesn't shadow the wabot.browser() factory function), which composes everything else:
Browserholds the webdriver plus a current page object and acts as a facade.__getattr__delegates unknown attributes to the current page, sobot.login()invokesbot.page.login();bot[key]resolves page elements.set_page(PageClass)swaps the current page and calls itsverify()gate.perform(name, ...)invokes a page method with exception trapping — any failure flipsself.good = False, after which all further delegated actions are refused, forcing an explicitreset().quit()tears down the driver and, for a wabot-managed service, stops the detached process too (a plaindriver.quit()would leave it running); external servers are left alone.Page(src/wabot/page.py) is the base class consumers subclass. Pages declare a class-levelelements = {key: (type, accessors)}map; lookup walks the MRO, so subclasses inherit and override parent element maps.page[key]returns a typed field wrapper, never a raw WebElement.- Field wrappers (
src/wabot/fields.py):TextField,SelectField,CheckFieldwrap elements withget_value/set_value; unknown attribute access falls through to the underlying selenium element.NullFieldis a falsy null-object returned for missing elements so callers canif el:instead of catching exceptions. - Pacing policies (
src/wabot/pacing.py):HumanPacing(default) andNoPacing. Field wrappers andPageask the active policy for a per-action delay and a click offset instead of hardcoding stealth behavior, so speed/stealth is swappable perBrowserinstance. - Sessions (
src/wabot/sessions.py):SessionRecord/SessionStorepersist only what's needed to reattach —executor_urlandsession_id, plusservice_pid/service_portbookkeeping for managed services — as JSON atplatformdirs.user_data_dir("wabot")/sessions.json, keyed by session name. Records older thanmax_age(default 3 days) are evicted on load; a corrupt store is quarantined tosessions.json.badrather than crashing the caller. - Driver hosts (
src/wabot/hosts.py): two ways to obtain a WebDriver server URL.ExternalServerpoints at a Selenium Grid or bare driver the user already runs (validated viaGET /status).ManagedServicespawnschromedriver/geckodriveritself, detached (start_new_session=True) so it survives this Python process exiting;stop_service(pid)is howBrowser.quit()/destroy()shut it back down.ReattachingRemote.start_sessionskipsNEW_SESSIONentirely and adopts a savedsession_id;attach()builds one and probesdriver.current_urlto confirm the session is still live before handing it back. wabot.browser()(src/wabot/__init__.py) is the public entry point tying it together: nosession=gives an ephemeral local-or-remote browser; a named session looks up its saved record, reattaches if the host answers, and otherwise spins up a host (external or managed) and saves a fresh record.wabot.sessions()/wabot.destroy()round out the public API.- Screenshots (
src/wabot/screenshot.py): Firefox exposes a rawmoz/screenshot/fullcommand over Remote for full-page capture; Chromium has no Remote equivalent, so viewport tiles are captured and stitched with Pillow instead.
Human-like behavior is intentional, not accidental slowness: with the default HumanPacing, clicks land at Gaussian-random offsets from the element's in-view center (Selenium 4 measures click offsets from the center, not top-left, unlike Selenium 3) instead of a plain click, and field setters sleep a random interval between actions. Pass pacing=wabot.NoPacing() for instant, exact behavior (what the test suite uses). Don't "optimize" these away.
Gotchas
- External hosts (
ExternalServer) must outlive the Python process — wabot never starts or stops them. OnlyManagedService-spawned drivers are wabot's to stop. - geckodriver reports
ready: falseonGET /statuswhenever its single session is in use; chromedriver reportsready: trueeven with an active session. Liveness checks (service_alive) therefore accept any HTTP 200 with valid JSON and deliberately ignore thereadyflag — requiring it would work for chromedriver and break for geckodriver. - Selenium is pinned
<5:ReattachingRemoterelies on internals that have been stable across the 4.x line but aren't a public API contract (thatstart_sessionis the only place a new session gets created, and thatsession_idis injected into every subsequent command).ReattachingRemote.__init__raisesRuntimeErroron adoption mismatch as a tripwire if a future selenium release changes this.
Git
Remote is Gitea (gitea@git.zavage.net:Zavage-Software/wabot.git); default branch is master. Do feature work on branches — pushing is done manually by the user (no SSH auth from inside the Claude shell).